fix(server): allow ordering operators on SELECT and RATING fields (#21506)

## 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.
This commit is contained in:
Charles Bochet
2026-06-13 08:52:21 +02:00
committed by GitHub
parent 1efa3567ef
commit 050f7dcf85
5 changed files with 45 additions and 4 deletions
@@ -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"`;
@@ -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'] } } },
@@ -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',
@@ -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');
},
);
});
@@ -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();
},
);
});