Add filter on array and jsonb field types (#7839)

This PR was created by [GitStart](https://gitstart.com/) to address the
requirements from this ticket:
[TWNTY-6784](https://clients.gitstart.com/twenty/5449/tickets/TWNTY-6784).
This ticket was imported from:
[TWNTY-6784](https://github.com/twentyhq/twenty/issues/6784)

 --- 

### Description

- Add filter on array and jsonb field types
- We did not implement the contains any filter for arrays on the
frontend because we would need to change the UI design since this should
be an array of values, and now we have only one input

### Demo


<https://www.loom.com/share/0facf752b63f4120b5d4ea4ee9772d35?sid=d7bde469-e6a9-4298-a637-d81d40695a86>

Fixes #6784

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
gitstart-app[bot] 2024-10-21 18:11:02 +02:00 committed by GitHub
parent 3f2751ef6c
commit 7b10bfa7d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 277 additions and 17 deletions

View File

@ -1,16 +1,35 @@
import { renderHook } from '@testing-library/react';
import { Nullable } from 'twenty-ui';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
onInitializeRecoilSnapshot: ({ set }) => {
set(currentWorkspaceState, {
id: '1',
featureFlags: [],
allowImpersonation: false,
activationStatus: WorkspaceActivationStatus.Active,
metadataVersion: 1,
});
},
});
describe('useColumnDefinitionsFromFieldMetadata', () => {
it('should return empty definitions if no object is passed', () => {
const { result } = renderHook(
(objectMetadataItem?: Nullable<ObjectMetadataItem>) => {
return useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
},
{
wrapper: Wrapper,
},
);
expect(Array.isArray(result.current.columnDefinitions)).toBe(true);
@ -32,6 +51,7 @@ describe('useColumnDefinitionsFromFieldMetadata', () => {
},
{
initialProps: companyObjectMetadata,
wrapper: Wrapper,
},
);

View File

@ -6,6 +6,7 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { formatFieldMetadataItemAsColumnDefinition } from '../utils/formatFieldMetadataItemAsColumnDefinition';
import { formatFieldMetadataItemsAsFilterDefinitions } from '../utils/formatFieldMetadataItemsAsFilterDefinitions';
import { formatFieldMetadataItemsAsSortDefinitions } from '../utils/formatFieldMetadataItemsAsSortDefinitions';
@ -23,8 +24,13 @@ export const useColumnDefinitionsFromFieldMetadata = (
[objectMetadataItem],
);
const isArrayAndJsonFilterEnabled = useIsFeatureEnabled(
'IS_ARRAY_AND_JSON_FILTER_ENABLED',
);
const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({
fields: activeFieldMetadataItems,
isArrayAndJsonFilterEnabled,
});
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({

View File

@ -8,10 +8,12 @@ import { ObjectMetadataItem } from '../types/ObjectMetadataItem';
export const formatFieldMetadataItemsAsFilterDefinitions = ({
fields,
isArrayAndJsonFilterEnabled,
}: {
fields: Array<ObjectMetadataItem['fields'][0]>;
}): FilterDefinition[] =>
fields.reduce((acc, field) => {
isArrayAndJsonFilterEnabled: boolean;
}): FilterDefinition[] => {
return fields.reduce((acc, field) => {
if (
field.type === FieldMetadataType.Relation &&
field.relationDefinition?.direction !==
@ -37,6 +39,9 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Rating,
FieldMetadataType.Actor,
FieldMetadataType.Phones,
...(isArrayAndJsonFilterEnabled
? [FieldMetadataType.Array, FieldMetadataType.RawJson]
: []),
].includes(field.type)
) {
return acc;
@ -44,6 +49,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
return [...acc, formatFieldMetadataItemAsFilterDefinition({ field })];
}, [] as FilterDefinition[]);
};
export const formatFieldMetadataItemAsFilterDefinition = ({
field,
@ -92,6 +98,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
return 'ACTOR';
case FieldMetadataType.Array:
return 'ARRAY';
case FieldMetadataType.RawJson:
return 'RAW_JSON';
default:
return 'TEXT';
}

View File

@ -104,6 +104,17 @@ export type PhonesFilter = {
primaryPhoneCountryCode?: StringFilter;
};
export type ArrayFilter = {
contains?: string[];
not_contains?: string[];
is?: IsFilter;
};
export type RawJsonFilter = {
like?: string;
is?: IsFilter;
};
export type LeafFilter =
| UUIDFilter
| StringFilter
@ -117,6 +128,8 @@ export type LeafFilter =
| LinksFilter
| ActorFilter
| PhonesFilter
| ArrayFilter
| RawJsonFilter
| undefined;
export type AndObjectRecordFilter = {

View File

@ -93,6 +93,7 @@ export const ObjectFilterDropdownFilterInput = ({
'ADDRESS',
'ACTOR',
'ARRAY',
'RAW_JSON',
'PHONES',
].includes(filterDefinitionUsedInDropdown.type) &&
!isActorSourceCompositeFilter(filterDefinitionUsedInDropdown) && (

View File

@ -19,4 +19,5 @@ export type FilterableFieldType = PickLiteral<
| 'MULTI_SELECT'
| 'ACTOR'
| 'ARRAY'
| 'RAW_JSON'
>;

View File

@ -18,7 +18,6 @@ export const getOperandsForFilterDefinition = (
case 'FULL_NAME':
case 'ADDRESS':
case 'LINKS':
case 'ARRAY':
case 'PHONES':
return [
ViewFilterOperand.Contains,
@ -32,6 +31,12 @@ export const getOperandsForFilterDefinition = (
ViewFilterOperand.LessThan,
...emptyOperands,
];
case 'RAW_JSON':
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
];
case 'DATE_TIME':
case 'DATE':
return [
@ -70,6 +75,12 @@ export const getOperandsForFilterDefinition = (
...emptyOperands,
];
}
case 'ARRAY':
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
];
default:
return [];
}

View File

@ -1,10 +1,12 @@
import {
ActorFilter,
AddressFilter,
ArrayFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
RawJsonFilter,
RecordGqlOperationFilter,
RelationFilter,
StringFilter,
@ -290,6 +292,24 @@ export const applyEmptyFilters = (
],
};
break;
case 'ARRAY':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: { is: 'NULL' } as ArrayFilter,
},
],
};
break;
case 'RAW_JSON':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: { is: 'NULL' } as RawJsonFilter,
},
],
};
break;
case 'EMAILS':
emptyRecordFilter = {
or: [

View File

@ -0,0 +1,34 @@
import { ArrayFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
export const isMatchingArrayFilter = ({
arrayFilter,
value,
}: {
arrayFilter: ArrayFilter;
value: string[];
}) => {
if (value === null || !Array.isArray(value)) {
return false;
}
switch (true) {
case arrayFilter.contains !== undefined: {
return arrayFilter.contains.every((item) => value.includes(item));
}
case arrayFilter.not_contains !== undefined: {
return !arrayFilter.not_contains.some((item) => value.includes(item));
}
case arrayFilter.is !== undefined: {
if (arrayFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
default: {
throw new Error(
`Unexpected value for array filter: ${JSON.stringify(arrayFilter)}`,
);
}
}
};

View File

@ -0,0 +1,32 @@
import { RawJsonFilter } from '../../graphql/types/RecordGqlOperationFilter';
export const isMatchingRawJsonFilter = ({
rawJsonFilter,
value,
}: {
rawJsonFilter: RawJsonFilter;
value: string;
}) => {
switch (true) {
case rawJsonFilter.like !== undefined: {
const regexPattern = rawJsonFilter.like.replace(/%/g, '.*');
const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'i');
const stringValue = JSON.stringify(value);
return regexCaseInsensitive.test(stringValue);
}
case rawJsonFilter.is !== undefined: {
if (rawJsonFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
default: {
throw new Error(
`Unexpected value for string filter : ${JSON.stringify(rawJsonFilter)}`,
);
}
}
};

View File

@ -5,6 +5,7 @@ import {
ActorFilter,
AddressFilter,
AndObjectRecordFilter,
ArrayFilter,
BooleanFilter,
CurrencyFilter,
DateFilter,
@ -16,14 +17,17 @@ import {
NotObjectRecordFilter,
OrObjectRecordFilter,
PhonesFilter,
RawJsonFilter,
RecordGqlOperationFilter,
StringFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { isMatchingArrayFilter } from '@/object-record/record-filter/utils/isMatchingArrayFilter';
import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter';
import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter';
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter';
import { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isMatchingRawJsonFilter';
import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -165,6 +169,18 @@ export const isRecordMatchingFilter = ({
value: record[filterKey],
});
}
case FieldMetadataType.Array: {
return isMatchingArrayFilter({
arrayFilter: filterValue as ArrayFilter,
value: record[filterKey],
});
}
case FieldMetadataType.RawJson: {
return isMatchingRawJsonFilter({
rawJsonFilter: filterValue as RawJsonFilter,
value: record[filterKey],
});
}
case FieldMetadataType.FullName: {
const fullNameFilter = filterValue as FullNameFilter;
@ -302,6 +318,7 @@ export const isRecordMatchingFilter = ({
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
);
}
default: {
throw new Error(
`Not implemented yet for field type "${objectMetadataField.type}"`,

View File

@ -3,10 +3,12 @@ import { isNonEmptyString } from '@sniptt/guards';
import {
ActorFilter,
AddressFilter,
ArrayFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
RawJsonFilter,
RecordGqlOperationFilter,
RelationFilter,
StringFilter,
@ -98,6 +100,39 @@ export const turnFiltersIntoQueryFilter = (
);
}
break;
case 'RAW_JSON':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
[correspondingField.name]: {
like: `%${rawUIFilter.value}%`,
} as RawJsonFilter,
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
not: {
[correspondingField.name]: {
like: `%${rawUIFilter.value}%`,
} as RawJsonFilter,
},
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'DATE':
case 'DATE_TIME': {
const resolvedFilterValue = resolveFilterValue(rawUIFilter);
@ -835,6 +870,40 @@ export const turnFiltersIntoQueryFilter = (
}
break;
}
case 'ARRAY': {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains: {
objectRecordFilters.push({
[correspondingField.name]: {
contains: [`${rawUIFilter.value}`],
} as ArrayFilter,
});
break;
}
case ViewFilterOperand.DoesNotContain: {
objectRecordFilters.push({
[correspondingField.name]: {
not_contains: [`${rawUIFilter.value}`],
} as ArrayFilter,
});
break;
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.label} filter`,
);
}
break;
}
default:
throw new Error('Unknown filter type');
}

View File

@ -2,6 +2,7 @@ import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFi
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews';
import { getQueryVariablesFromView } from '@/views/utils/getQueryVariablesFromView';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({
objectMetadataItem,
@ -19,10 +20,15 @@ export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({
objectMetadataItem,
});
const isArrayAndJsonFilterEnabled = useIsFeatureEnabled(
'IS_ARRAY_AND_JSON_FILTER_ENABLED',
);
const { filter, orderBy } = getQueryVariablesFromView({
fieldMetadataItems: activeFieldMetadataItems,
objectMetadataItem,
view,
isArrayAndJsonFilterEnabled,
});
return {

View File

@ -13,10 +13,12 @@ export const getQueryVariablesFromView = ({
view,
fieldMetadataItems,
objectMetadataItem,
isArrayAndJsonFilterEnabled,
}: {
view: View | null | undefined;
fieldMetadataItems: FieldMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
isArrayAndJsonFilterEnabled: boolean;
}) => {
if (!isDefined(view)) {
return {
@ -29,6 +31,7 @@ export const getQueryVariablesFromView = ({
const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({
fields: fieldMetadataItems,
isArrayAndJsonFilterEnabled,
});
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({

View File

@ -13,4 +13,5 @@ export type FeatureFlagKey =
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
| 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED'
| 'IS_ANALYTICS_V2_ENABLED'
| 'IS_UNIQUE_INDEXES_ENABLED';
| 'IS_UNIQUE_INDEXES_ENABLED'
| 'IS_ARRAY_AND_JSON_FILTER_ENABLED';

View File

@ -13,6 +13,8 @@ import { FieldMetadataMap } from 'src/engine/metadata-modules/utils/generate-obj
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { capitalize } from 'src/utils/capitalize';
const ARRAY_OPERATORS = ['in', 'contains', 'not_contains'];
export class GraphqlQueryFilterFieldParser {
private fieldMetadataMap: FieldMetadataMap;
@ -44,13 +46,14 @@ export class GraphqlQueryFilterFieldParser {
}
const [[operator, value]] = Object.entries(filterValue);
if (operator === 'in') {
if (!Array.isArray(value) || value.length === 0) {
throw new GraphqlQueryRunnerException(
`Invalid filter value for field ${key}. Expected non-empty array`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (
ARRAY_OPERATORS.includes(operator) &&
(!Array.isArray(value) || value.length === 0)
) {
throw new GraphqlQueryRunnerException(
`Invalid filter value for field ${key}. Expected non-empty array`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const { sql, params } = computeWhereConditionParts(

View File

@ -61,24 +61,36 @@ export const computeWhereConditionParts = (
};
case 'like':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'ilike':
return {
sql: `"${objectNameSingular}"."${key}" ILIKE :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'startsWith':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'endsWith':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'contains':
return {
sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`,
params: { [`${key}${uuid}`]: value },
};
case 'not_contains':
return {
sql: `NOT ("${objectNameSingular}"."${key}" && ARRAY[:...${key}${uuid}])`,
params: { [`${key}${uuid}`]: value },
};
default:
throw new GraphqlQueryRunnerException(
`Operator "${operator}" is not supported`,

View File

@ -1,10 +1,12 @@
import { GraphQLInputObjectType, GraphQLList, GraphQLString } from 'graphql';
import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
export const ArrayFilterType = new GraphQLInputObjectType({
name: 'ArrayFilter',
fields: {
contains: { type: new GraphQLList(GraphQLString) },
contains_any: { type: new GraphQLList(GraphQLString) },
not_contains: { type: new GraphQLList(GraphQLString) },
is: { type: FilterIs },
},
});

View File

@ -1,4 +1,4 @@
import { GraphQLInputObjectType } from 'graphql';
import { GraphQLInputObjectType, GraphQLString } from 'graphql';
import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
@ -6,5 +6,6 @@ export const RawJsonFilterType = new GraphQLInputObjectType({
name: 'RawJsonFilter',
fields: {
is: { type: FilterIs },
like: { type: GraphQLString },
},
});