diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 05c87497dcc6..62846c8fbab5 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -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) => { return useColumnDefinitionsFromFieldMetadata(objectMetadataItem); }, + { + wrapper: Wrapper, + }, ); expect(Array.isArray(result.current.columnDefinitions)).toBe(true); @@ -32,6 +51,7 @@ describe('useColumnDefinitionsFromFieldMetadata', () => { }, { initialProps: companyObjectMetadata, + wrapper: Wrapper, }, ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts index b7764673e991..d7155f3d7166 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts @@ -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({ diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index a110acdceba4..42734cb92f6f 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -8,10 +8,12 @@ import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; export const formatFieldMetadataItemsAsFilterDefinitions = ({ fields, + isArrayAndJsonFilterEnabled, }: { fields: Array; -}): 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'; } diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts index 6c1615b17afa..9393d98b841a 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts @@ -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 = { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx index a630286ffadb..35f20b4a52fd 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx @@ -93,6 +93,7 @@ export const ObjectFilterDropdownFilterInput = ({ 'ADDRESS', 'ACTOR', 'ARRAY', + 'RAW_JSON', 'PHONES', ].includes(filterDefinitionUsedInDropdown.type) && !isActorSourceCompositeFilter(filterDefinitionUsedInDropdown) && ( diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts index 0624fe937ef7..b2bf87102729 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts @@ -19,4 +19,5 @@ export type FilterableFieldType = PickLiteral< | 'MULTI_SELECT' | 'ACTOR' | 'ARRAY' + | 'RAW_JSON' >; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index 688aa02b6c79..3634a7baefd5 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -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 []; } diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts index e004288ceba0..03ea135cebd0 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts @@ -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: [ diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts new file mode 100644 index 000000000000..7578a04aac49 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts @@ -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)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts new file mode 100644 index 000000000000..8251bca722a5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts @@ -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)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index 929956d77128..c2a5da47fdd8 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -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}"`, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts index 0e3c69d7c0b8..9829d957e498 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts @@ -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'); } diff --git a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts index f5431bc45a58..d40afd1064a9 100644 --- a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts @@ -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 { diff --git a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts index 8206f52b3a95..fc685af7efff 100644 --- a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts +++ b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts @@ -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({ diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index 5471c5d4d59e..f0346d505548 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index 9b1d1020b7bd..5d35ebf5ecba 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -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( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts index ef8d4680ebb3..aae3f9b0015c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts @@ -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`, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts index 37b3ba8293fb..3cd24cbbaac7 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts @@ -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 }, }, }); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts index 5b06437dd695..75f40b7e5916 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts @@ -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 }, }, });