Fix cursor-based pagination with lexicographic ordering for composite fields (#12467)

# Fix cursor-based pagination with lexicographic ordering for composite
fields

## Bug

The existing cursor-based pagination implementation had a bug when
handling composite fields.
When paginating through results sorted by composite fields (like
`fullName` with sub-properties `firstName` and`lastName`), the WHERE
conditions generated for cursor positioning were incorrect, leading to
records being skipped.

The previous implementation was generating wrong WHERE conditions:

For example, when paginating with a cursor like `{ firstName: 'John',
lastName: 'Doe' }`, it would generate:

```sql
WHERE firstName > 'John' AND lastName > 'Doe'
```

This is incorrect because it would miss records like `{ firstName:
'John', lastName: 'Smith' }` which should be included in forward
pagination.

## Fix

Create a new util to use proper lexicographic order when sorting a
composite field.

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2025-06-11 16:48:03 +02:00
committed by GitHub
parent 4cea354838
commit d4995ab54e
15 changed files with 1710 additions and 183 deletions
@@ -0,0 +1,88 @@
import { isDefined } from 'twenty-shared/utils';
import {
ObjectRecord,
ObjectRecordOrderBy,
ObjectRecordOrderByForCompositeField,
ObjectRecordOrderByForScalarField,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
const isOrderByDirection = (value: unknown): value is OrderByDirection => {
return Object.values(OrderByDirection).includes(value as OrderByDirection);
};
const isOrderByForScalarField = (
orderByLeaf: Record<string, unknown>,
key: keyof ObjectRecord,
): orderByLeaf is ObjectRecordOrderByForScalarField => {
const value = orderByLeaf[key as string];
return isDefined(value) && isOrderByDirection(value);
};
const isOrderByForCompositeField = (
orderByLeaf: Record<string, unknown>,
key: keyof ObjectRecord,
): orderByLeaf is ObjectRecordOrderByForCompositeField => {
const value = orderByLeaf[key as string];
return (
isDefined(value) &&
typeof value === 'object' &&
value !== null &&
!isOrderByDirection(value) &&
Object.values(value as Record<string, unknown>).every(isOrderByDirection)
);
};
export const validateAndGetOrderByForScalarField = (
key: keyof ObjectRecord,
orderBy: ObjectRecordOrderBy,
): ObjectRecordOrderByForScalarField => {
const keyOrderBy = orderBy.find((order) => key in order);
if (!isDefined(keyOrderBy)) {
throw new GraphqlQueryRunnerException(
'Invalid cursor',
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
);
}
if (!isOrderByForScalarField(keyOrderBy, key)) {
throw new GraphqlQueryRunnerException(
'Expected non-composite field order by',
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
);
}
return keyOrderBy;
};
export const validateAndGetOrderByForCompositeField = (
key: keyof ObjectRecord,
orderBy: ObjectRecordOrderBy,
): ObjectRecordOrderByForCompositeField => {
const keyOrderBy = orderBy.find((order) => key in order);
if (!isDefined(keyOrderBy)) {
throw new GraphqlQueryRunnerException(
'Invalid cursor',
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
);
}
if (!isOrderByForCompositeField(keyOrderBy, key)) {
throw new GraphqlQueryRunnerException(
'Expected composite field order by',
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
);
}
return keyOrderBy;
};