fix: REST cursor encoding for nested order_by composite fields (#20974)

## Summary

fix: REST cursor encoding for nested order_by composite fields

Closes #20109

---
AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-06-02 00:10:20 -07:00
committed by GitHub
parent 3d6bcc3102
commit 6ac797a69c
9 changed files with 363 additions and 24 deletions
@@ -252,6 +252,51 @@ describe('computeCursorArgFilter', () => {
]);
});
it('should handle dotted composite cursor keys', () => {
const cursor = {
'fullName.firstName': 'John',
'fullName.lastName': 'Doe',
} as any;
const orderBy = [
{
fullName: {
firstName: OrderByDirection.AscNullsLast,
lastName: OrderByDirection.AscNullsLast,
},
},
];
const result = computeCursorArgFilter(
cursor,
orderBy,
flatObjectMetadata,
flatFieldMetadataMaps,
true,
);
expect(result).toEqual([
{
fullName: {
firstName: { gt: 'John' },
},
},
{
and: [
{
fullName: {
firstName: { eq: 'John' },
},
},
{
fullName: {
lastName: { gt: 'Doe' },
},
},
],
},
]);
});
it('should handle composite field with backward pagination', () => {
const cursor = {
fullName: { firstName: 'John', lastName: 'Doe' },
@@ -24,7 +24,7 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
type BuildCursorWhereConditionParams = {
cursorKey: keyof ObjectRecord;
cursorKey: string;
cursorValue:
| ObjectRecordCursorLeafScalarValue
| ObjectRecordCursorLeafCompositeValue;
@@ -49,7 +49,10 @@ export const buildCursorWhereCondition = ({
flatObjectMetadata,
);
const fieldMetadataId = fieldIdByName[cursorKey];
const [fieldKey, ...subFieldPath] = cursorKey.split('.');
const compositeSubFieldKey = subFieldPath.join('.');
const fieldMetadataKey = fieldKey as keyof ObjectRecord;
const fieldMetadataId = fieldIdByName[fieldMetadataKey];
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
@@ -58,16 +61,29 @@ export const buildCursorWhereCondition = ({
if (!fieldMetadata) {
throw new GraphqlQueryRunnerException(
`Field metadata not found for key: ${cursorKey}`,
`Field metadata not found for key: ${String(cursorKey)}`,
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
if (compositeSubFieldKey.length > 0) {
return buildCursorCompositeFieldWhereCondition({
fieldType: fieldMetadata.type,
fieldKey: fieldMetadataKey,
orderBy,
cursorValue: {
[compositeSubFieldKey]: cursorValue,
} as ObjectRecordCursorLeafCompositeValue,
isForwardPagination,
isEqualityCondition,
});
}
return buildCursorCompositeFieldWhereCondition({
fieldType: fieldMetadata.type,
fieldKey: cursorKey,
fieldKey: fieldMetadataKey,
orderBy,
cursorValue: cursorValue as ObjectRecordCursorLeafCompositeValue,
isForwardPagination,
@@ -76,11 +92,14 @@ export const buildCursorWhereCondition = ({
}
if (isEqualityCondition) {
return { [cursorKey]: { eq: cursorValue } };
return { [fieldMetadataKey]: { eq: cursorValue } };
}
const keyOrderBy = validateAndGetOrderByForScalarField(cursorKey, orderBy);
const orderByDirection = keyOrderBy[cursorKey];
const keyOrderBy = validateAndGetOrderByForScalarField(
fieldMetadataKey,
orderBy,
);
const orderByDirection = keyOrderBy[fieldMetadataKey];
if (!isDefined(orderByDirection)) {
throw new GraphqlQueryRunnerException(
@@ -93,5 +112,5 @@ export const buildCursorWhereCondition = ({
const isAscending = isAscendingOrder(orderByDirection);
const computedOperator = computeOperator(isAscending, isForwardPagination);
return { [cursorKey]: { [computedOperator]: cursorValue } };
return { [fieldMetadataKey]: { [computedOperator]: cursorValue } };
};
@@ -75,9 +75,9 @@ export const validateAndGetOrderByForCompositeField = (
key: keyof ObjectRecord,
orderBy: ObjectRecordOrderBy,
): ObjectRecordOrderByForCompositeField => {
const keyOrderBy = orderBy.find((order) => key in order);
const matchingOrderBys = orderBy.filter((order) => key in order);
if (!isDefined(keyOrderBy)) {
if (matchingOrderBys.length === 0) {
throw new GraphqlQueryRunnerException(
'Invalid cursor',
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
@@ -85,7 +85,20 @@ export const validateAndGetOrderByForCompositeField = (
);
}
if (!isOrderByForCompositeField(keyOrderBy, key)) {
// Merge all orderBy entries for the same composite field key so that
// separate { fullName: { firstName } } and { fullName: { lastName } } entries
// are treated as a single composite orderBy
const mergedValue = matchingOrderBys.reduce(
(acc, orderByEntry) => ({
...acc,
...(orderByEntry[key as string] as Record<string, OrderByDirection>),
}),
{} as Record<string, OrderByDirection>,
);
const mergedOrderBy = { [key as string]: mergedValue };
if (!isOrderByForCompositeField(mergedOrderBy, key)) {
throw new GraphqlQueryRunnerException(
'Expected composite field order by',
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
@@ -93,7 +106,7 @@ export const validateAndGetOrderByForCompositeField = (
);
}
return keyOrderBy;
return mergedOrderBy;
};
export const countRelationFieldsInOrderBy = (