From 453f3479ab58857d4dadf869c3cc93ce082b3f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:06:59 +0200 Subject: [PATCH] Accept a singleton or filter in the GraphQL filter walker (#23738) `RecordGqlOperationFilter` types `or` as `RecordGqlOperationFilter[] | RecordGqlOperationFilter`, so both `{ or: [{ name: { ilike: '%acme%' } }] }` and `{ or: { name: { ilike: '%acme%' } } }` are valid. `applyLogicalGroup` went straight to `filters.forEach(...)`, so the non-array form threw `TypeError: filters.forEach is not a function` instead of returning records. Only `or` is affected: `and` is always an array and `not` is always a single object. This is not a new bug. The same assumption existed before the walker was extracted, when `parseKeyFilter` did the `value.forEach` inline. Sentry surfaced it on #23369, and it was left out of that PR to keep it scoped. The fix mirrors `renderLogicalGroup` in the RLS SQL renderer, which already normalizes a singleton to an array on its first line, so the two walkers over this filter format now accept the same shapes. --- ...phql-query-filter-condition.parser.spec.ts | 52 ++++++++++++++++++- ...filter-entries-to-where-expression.util.ts | 6 ++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/__tests__/graphql-query-filter-condition.parser.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/__tests__/graphql-query-filter-condition.parser.spec.ts index 74836b32f5..f2216e36e6 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/__tests__/graphql-query-filter-condition.parser.spec.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/__tests__/graphql-query-filter-condition.parser.spec.ts @@ -1,4 +1,7 @@ -import { createWhereExpressionRecorder } from 'test/utils/create-where-expression-recorder.util'; +import { + createWhereExpressionRecorder, + type RecordedWhereCall, +} from 'test/utils/create-where-expression-recorder.util'; import { FieldMetadataType } from 'twenty-shared/types'; import { type ObjectLiteral } from 'typeorm'; @@ -74,6 +77,21 @@ const recordFilterEntries = (filter: Record) => { return recorder.calls; }; +const withNormalizedParameterKeys = (calls: RecordedWhereCall[]): unknown[] => + calls.map(({ method, node }) => + node.kind === 'sql' + ? { + method, + sql: node.sql.replace(/(? { describe('applyFilterEntriesToWhereBrackets', () => { it('emits the first entry with where and later entries with andWhere', () => { @@ -170,6 +188,38 @@ describe('GraphqlQueryFilterConditionParser', () => { ]); }); + it('applies an or group that is not wrapped in an array', () => { + const calls = recordFilterEntries({ or: { name: { ilike: '%acme%' } } }); + + expect(withNormalizedParameterKeys(calls)).toEqual( + withNormalizedParameterKeys( + recordFilterEntries({ or: [{ name: { ilike: '%acme%' } }] }), + ), + ); + expect(calls).toEqual([ + { + method: 'where', + node: { + kind: 'brackets', + children: [ + { + method: 'where', + node: { + kind: 'brackets', + children: [ + { + method: 'where', + node: expect.objectContaining({ kind: 'sql' }), + }, + ], + }, + }, + ], + }, + }, + ]); + }); + it('emits a not group as notBrackets', () => { const calls = recordFilterEntries({ not: { name: { ilike: '%a%' } } }); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/apply-filter-entries-to-where-expression.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/apply-filter-entries-to-where-expression.util.ts index 15864b5817..024d58a3ab 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/apply-filter-entries-to-where-expression.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/apply-filter-entries-to-where-expression.util.ts @@ -94,13 +94,15 @@ const applyFilterEntry = ( const applyLogicalGroup = ( whereExpression: WhereExpressionBuilder, - filters: Record[], + filters: Record[] | Record, logicalOperator: 'and' | 'or', isFirst: boolean, context: FilterWalkContext, ): void => { + const filterList = Array.isArray(filters) ? filters : [filters]; + const groupCondition = new Brackets((groupWhereExpression) => { - filters.forEach((filter, index) => { + filterList.forEach((filter, index) => { const elementCondition = new Brackets((elementWhereExpression) => { applyFilterEntries(elementWhereExpression, filter, context); });