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.
This commit is contained in:
Raphaël Bosi
2026-08-04 13:06:59 +02:00
committed by GitHub
parent 0408816781
commit 453f3479ab
2 changed files with 55 additions and 3 deletions
@@ -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<string, unknown>) => {
return recorder.calls;
};
const withNormalizedParameterKeys = (calls: RecordedWhereCall[]): unknown[] =>
calls.map(({ method, node }) =>
node.kind === 'sql'
? {
method,
sql: node.sql.replace(/(?<!:):[A-Za-z0-9_]+/g, ':parameter'),
parameterValues: Object.values(node.parameters ?? {}),
}
: {
method,
kind: node.kind,
children: withNormalizedParameterKeys(node.children),
},
);
describe('GraphqlQueryFilterConditionParser', () => {
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%' } } });
@@ -94,13 +94,15 @@ const applyFilterEntry = (
const applyLogicalGroup = (
whereExpression: WhereExpressionBuilder,
filters: Record<string, unknown>[],
filters: Record<string, unknown>[] | Record<string, unknown>,
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);
});