From 050f7dcf859ca628274de9587c0732ec2747b8f8 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sat, 13 Jun 2026 08:52:21 +0200 Subject: [PATCH] fix(server): allow ordering operators on SELECT and RATING fields (#21506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem On a record **show page**, prev/next navigation fails with toasts like: ``` Invalid filter : Operator "lt" is not valid for this "severity" SELECT field ``` …but only when the record is reached via in-app navigation from an index view sorted by a SELECT field (a hard reload is clean). ## Root cause The record show page paginates with **keyset (cursor) pagination**. `useRecordShowPagePagination` builds `before`/`after` filters via `computeCursorArgFilter`, which emits `{ field: { gt|lt: value } }` for **every** field in the parent view's `orderBy`, regardless of type. When a view is sorted by a **SELECT** field (e.g. the *All Bugs* view sorts by `severity`), the keyset filter becomes `{ severity: { gt: "HIGH" } }` / `{ severity: { lt: "HIGH" } }`. SELECT/RATING fields use `ENUM_FILTER_OPERATORS` (`eq, neq, in, containsAny, is, isEmptyArray`) — no ordering operators — so filter validation rejects them, breaking both the neighbour queries and the rank-in-view ("X of Y") count query. A hard reload has no parent-view `orderBy`, so neighbour queries are skipped → no error. This isn't a technical limitation: the column is comparable, `ORDER BY severity` works, and the backend already emits the same comparison for its own cursor path (`buildCursorWhereCondition`) — that path just bypasses the user-filter allowlist. Ordering operators were omitted from `ENUM_FILTER_OPERATORS` because they aren't meaningful *user* filters for categorical fields, but keyset pagination legitimately needs them. ## Fix Add `gt/gte/lt/lte` to `ENUM_FILTER_OPERATORS`, so SELECT/RATING keyset filters are accepted — exactly as `UUID_FILTER_OPERATORS` already carries these operators for the `id` cursor tiebreaker. The filter UI never offers these operands for SELECT, so this only enables the keyset/cursor use case. ## Tests - `get-operators-for-field-type.util.spec.ts`: SELECT/RATING now include `gt/gte/lt/lte`. - `validate-operator-for-field-type-or-throw.util.spec.ts`: regression — ordering operators on a SELECT field no longer throw. --- .../filter-arg-processor.service.spec.ts.snap | 8 ++++---- ...lter-inputs-by-field-metadata-type.constant.ts | 8 ++++++++ .../constants/filter-operators.constant.ts | 4 ++++ .../get-operators-for-field-type.util.spec.ts | 14 ++++++++++++++ ...-operator-for-field-type-or-throw.util.spec.ts | 15 +++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/__snapshots__/filter-arg-processor.service.spec.ts.snap b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/__snapshots__/filter-arg-processor.service.spec.ts.snap index 49cba9e02d..7cabfea883 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/__snapshots__/filter-arg-processor.service.spec.ts.snap +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/__snapshots__/filter-arg-processor.service.spec.ts.snap @@ -1,8 +1,8 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #1: {"actorField":{"invalidSubField":{"eq":"test"}}} 1`] = `"Sub field "invalidSubField" not found for composite type: ACTOR"`; -exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #2: {"actorField":{"source":{"invalidOperator":"test"}}} 1`] = `"Operator "invalidOperator" is not valid for field "actorField.source" of type SELECT - Allowed operators: eq, neq, in, containsAny, is, isEmptyArray"`; +exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #2: {"actorField":{"source":{"invalidOperator":"test"}}} 1`] = `"Operator "invalidOperator" is not valid for field "actorField.source" of type SELECT - Allowed operators: eq, neq, gt, gte, lt, lte, in, containsAny, is, isEmptyArray"`; exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #3: {"actorField":{"workspaceMemberId":{"eq":"invalid-uuid"}}} 1`] = `"Invalid UUID value 'invalid-uuid' for field "actorField.workspaceMemberId""`; @@ -124,7 +124,7 @@ exports[`FilterArgProcessorService failing filter inputs validation POSITION sho exports[`FilterArgProcessorService failing filter inputs validation POSITION should throw for invalid filter #4: {"position":{}} 1`] = `"Filter for field "position" must have exactly one operator"`; -exports[`FilterArgProcessorService failing filter inputs validation RATING should throw for invalid filter #1: {"ratingField":{"invalidOperator":"RATING_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "ratingField" of type RATING - Allowed operators: eq, neq, in, containsAny, is, isEmptyArray"`; +exports[`FilterArgProcessorService failing filter inputs validation RATING should throw for invalid filter #1: {"ratingField":{"invalidOperator":"RATING_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "ratingField" of type RATING - Allowed operators: eq, neq, gt, gte, lt, lte, in, containsAny, is, isEmptyArray"`; exports[`FilterArgProcessorService failing filter inputs validation RATING should throw for invalid filter #2: {"ratingField":{}} 1`] = `"Filter for field "ratingField" must have exactly one operator"`; @@ -144,7 +144,7 @@ exports[`FilterArgProcessorService failing filter inputs validation RICH_TEXT sh exports[`FilterArgProcessorService failing filter inputs validation RICH_TEXT should throw for invalid filter #2: {"richTextField":{"markdown":{"invalidOperator":"test"}}} 1`] = `"Operator "invalidOperator" is not valid for field "richTextField.markdown" of type TEXT - Allowed operators: eq, neq, gt, gte, lt, lte, in, is, like, ilike, startsWith, endsWith"`; -exports[`FilterArgProcessorService failing filter inputs validation SELECT should throw for invalid filter #1: {"selectField":{"invalidOperator":"OPTION_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "selectField" of type SELECT - Allowed operators: eq, neq, in, containsAny, is, isEmptyArray"`; +exports[`FilterArgProcessorService failing filter inputs validation SELECT should throw for invalid filter #1: {"selectField":{"invalidOperator":"OPTION_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "selectField" of type SELECT - Allowed operators: eq, neq, gt, gte, lt, lte, in, containsAny, is, isEmptyArray"`; exports[`FilterArgProcessorService failing filter inputs validation SELECT should throw for invalid filter #2: {"selectField":{}} 1`] = `"Filter for field "selectField" must have exactly one operator"`; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/constants/successful-filter-inputs-by-field-metadata-type.constant.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/constants/successful-filter-inputs-by-field-metadata-type.constant.ts index 6b11ee5715..bd5cd2bb2c 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/constants/successful-filter-inputs-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/constants/successful-filter-inputs-by-field-metadata-type.constant.ts @@ -110,6 +110,10 @@ export const successfulFilterInputsByFieldMetadataType: { { filter: { selectField: { in: ['OPTION_1', 'OPTION_2'] } } }, { filter: { selectField: { is: 'NULL' } } }, { filter: { selectField: { is: 'NOT_NULL' } } }, + { filter: { selectField: { gt: 'OPTION_1' } } }, + { filter: { selectField: { gte: 'OPTION_1' } } }, + { filter: { selectField: { lt: 'OPTION_1' } } }, + { filter: { selectField: { lte: 'OPTION_1' } } }, ], [FieldMetadataType.RATING]: [ { filter: { ratingField: { eq: 'RATING_1' } } }, @@ -117,6 +121,10 @@ export const successfulFilterInputsByFieldMetadataType: { { filter: { ratingField: { in: ['RATING_1', 'RATING_2'] } } }, { filter: { ratingField: { is: 'NULL' } } }, { filter: { ratingField: { is: 'NOT_NULL' } } }, + { filter: { ratingField: { gt: 'RATING_1' } } }, + { filter: { ratingField: { gte: 'RATING_1' } } }, + { filter: { ratingField: { lt: 'RATING_1' } } }, + { filter: { ratingField: { lte: 'RATING_1' } } }, ], [FieldMetadataType.MULTI_SELECT]: [ { filter: { multiSelectField: { containsAny: ['OPTION_1'] } } }, diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/filter-operators.constant.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/filter-operators.constant.ts index 1baada3880..cec02355c6 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/filter-operators.constant.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/filter-operators.constant.ts @@ -65,6 +65,10 @@ export const MULTI_SELECT_FILTER_OPERATORS: FilterOperator[] = [ export const ENUM_FILTER_OPERATORS: FilterOperator[] = [ 'eq', 'neq', + 'gt', + 'gte', + 'lt', + 'lte', 'in', 'containsAny', 'is', diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/get-operators-for-field-type.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/get-operators-for-field-type.util.spec.ts index 4a054bd22f..942c377fc2 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/get-operators-for-field-type.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/get-operators-for-field-type.util.spec.ts @@ -31,4 +31,18 @@ describe('getOperatorsForFieldType', () => { expect(result).toContain('containsIlike'); expect(result).toContain('isEmptyArray'); }); + + it.each([FieldMetadataType.SELECT, FieldMetadataType.RATING])( + 'should allow enum operators and ordering operators for %s', + (fieldType) => { + const result = getOperatorsForFieldType(fieldType); + + expect(result).toContain('eq'); + expect(result).toContain('in'); + expect(result).toContain('gt'); + expect(result).toContain('gte'); + expect(result).toContain('lt'); + expect(result).toContain('lte'); + }, + ); }); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/validate-operator-for-field-type-or-throw.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/validate-operator-for-field-type-or-throw.util.spec.ts index cffa6b1b5f..89b0e2e5da 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/validate-operator-for-field-type-or-throw.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/utils/__tests__/validate-operator-for-field-type-or-throw.util.spec.ts @@ -46,4 +46,19 @@ describe('validateOperatorForFieldTypeOrThrow', () => { validateOperatorForFieldTypeOrThrow('like', fieldMetadata, 'testField'), ).toThrow(CommonQueryRunnerException); }); + + it.each(['gt', 'gte', 'lt', 'lte'] as const)( + 'should not throw when ordering operator "%s" is used on a SELECT field', + (operator) => { + const fieldMetadata = createFieldMetadata(FieldMetadataType.SELECT); + + expect(() => + validateOperatorForFieldTypeOrThrow( + operator, + fieldMetadata, + 'severity', + ), + ).not.toThrow(); + }, + ); });