959 api rest startingafter and endingbefore not working properly with orderby (#12012)

Fixes https://github.com/twentyhq/core-team-issues/issues/959
This commit is contained in:
martmull
2025-05-14 10:41:56 +02:00
committed by GitHub
parent e835e0ad64
commit 4d2e431277
6 changed files with 145 additions and 63 deletions

View File

@ -15,21 +15,29 @@ export class RestApiGetManyHandler extends RestApiBaseHandler {
objectMetadataItemWithFieldsMaps, objectMetadataItemWithFieldsMaps,
} = await this.getRepositoryAndMetadataOrFail(request); } = await this.getRepositoryAndMetadataOrFail(request);
const { records, isForwardPagination, hasMoreRecords, totalCount } = const {
await this.findRecords({
request,
repository,
objectMetadata,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
});
return this.formatPaginatedResult(
records, records,
isForwardPagination,
hasMoreRecords,
totalCount,
startCursor,
endCursor,
} = await this.findRecords({
request,
repository,
objectMetadata,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
});
return this.formatPaginatedResult({
finalRecords: records,
objectMetadataNamePlural, objectMetadataNamePlural,
isForwardPagination, isForwardPagination,
hasMoreRecords, hasMoreRecords,
totalCount, totalCount,
); startCursor,
endCursor,
});
} }
} }

View File

@ -7,7 +7,7 @@ import { In, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { import {
ObjectRecord, ObjectRecord,
OrderByDirection, ObjectRecordFilter,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
@ -30,6 +30,8 @@ import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/wo
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util'; import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util';
import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { computeCursorArgFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter';
export interface PageInfo { export interface PageInfo {
hasNextPage?: boolean; hasNextPage?: boolean;
@ -243,13 +245,23 @@ export abstract class RestApiBaseHandler {
}; };
} }
formatPaginatedResult( formatPaginatedResult({
finalRecords: any[], finalRecords,
objectMetadataNamePlural: string, objectMetadataNamePlural,
isForwardPagination: boolean, isForwardPagination,
hasMoreRecords: boolean, hasMoreRecords,
totalCount: number, totalCount,
) { startCursor,
endCursor,
}: {
finalRecords: any[];
objectMetadataNamePlural: string;
isForwardPagination: boolean;
hasMoreRecords: boolean;
totalCount: number;
startCursor: string | null;
endCursor: string | null;
}) {
const hasPreviousPage = !isForwardPagination && hasMoreRecords; const hasPreviousPage = !isForwardPagination && hasMoreRecords;
return this.formatResult({ return this.formatResult({
@ -259,20 +271,8 @@ export abstract class RestApiBaseHandler {
pageInfo: { pageInfo: {
hasNextPage: isForwardPagination && hasMoreRecords, hasNextPage: isForwardPagination && hasMoreRecords,
...(hasPreviousPage ? { hasPreviousPage } : {}), ...(hasPreviousPage ? { hasPreviousPage } : {}),
startCursor: startCursor,
finalRecords.length > 0 endCursor,
? Buffer.from(JSON.stringify({ id: finalRecords[0].id })).toString(
'base64',
)
: null,
endCursor:
finalRecords.length > 0
? Buffer.from(
JSON.stringify({
id: finalRecords[finalRecords.length - 1].id,
}),
).toString('base64')
: null,
}, },
totalCount, totalCount,
}); });
@ -289,7 +289,10 @@ export abstract class RestApiBaseHandler {
request: Request; request: Request;
recordId?: string; recordId?: string;
repository: WorkspaceRepository<ObjectLiteral>; repository: WorkspaceRepository<ObjectLiteral>;
objectMetadata: any; objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
objectMetadataNameSingular: string; objectMetadataNameSingular: string;
objectMetadataItemWithFieldsMaps: objectMetadataItemWithFieldsMaps:
| ObjectMetadataItemWithFieldMaps | ObjectMetadataItemWithFieldMaps
@ -305,16 +308,23 @@ export abstract class RestApiBaseHandler {
const fieldMetadataMapByName = const fieldMetadataMapByName =
objectMetadataItemWithFieldsMaps?.fieldsByName || {}; objectMetadataItemWithFieldsMaps?.fieldsByName || {};
const fieldMetadataMapByJoinColumnName = const fieldMetadataMapByJoinColumnName =
objectMetadataItemWithFieldsMaps?.fieldsByJoinColumnName || {}; objectMetadataItemWithFieldsMaps?.fieldsByJoinColumnName || {};
const isForwardPagination = !inputs.endingBefore;
const graphqlQueryParser = new GraphqlQueryParser( const graphqlQueryParser = new GraphqlQueryParser(
fieldMetadataMapByName, fieldMetadataMapByName,
fieldMetadataMapByJoinColumnName, fieldMetadataMapByJoinColumnName,
objectMetadata.objectMetadataMaps, objectMetadata.objectMetadataMaps,
); );
const filters = this.computeFilters(inputs); const filters = this.computeFilters({
inputs,
objectMetadata,
isForwardPagination,
});
let selectQueryBuilder = isDefined(filters) let selectQueryBuilder = isDefined(filters)
? graphqlQueryParser.applyFilterToBuilder( ? graphqlQueryParser.applyFilterToBuilder(
@ -326,11 +336,9 @@ export abstract class RestApiBaseHandler {
const totalCount = await this.getTotalCount(selectQueryBuilder); const totalCount = await this.getTotalCount(selectQueryBuilder);
const isForwardPagination = !inputs.endingBefore;
selectQueryBuilder = graphqlQueryParser.applyOrderToBuilder( selectQueryBuilder = graphqlQueryParser.applyOrderToBuilder(
selectQueryBuilder, selectQueryBuilder,
[...(inputs.orderBy || []), { id: OrderByDirection.AscNullsFirst }], inputs.orderBy || [],
objectMetadataNameSingular, objectMetadataNameSingular,
isForwardPagination, isForwardPagination,
); );
@ -356,15 +364,29 @@ export abstract class RestApiBaseHandler {
const hasMoreRecords = records.length < totalCount; const hasMoreRecords = records.length < totalCount;
const finalRecords = formatGetManyData<ObjectRecord[]>(
records,
objectMetadataItemWithFieldsMaps,
objectMetadata.objectMetadataMaps,
);
const startCursor =
finalRecords.length > 0
? encodeCursor(finalRecords[0], inputs.orderBy)
: null;
const endCursor =
finalRecords.length > 0
? encodeCursor(finalRecords[finalRecords.length - 1], inputs.orderBy)
: null;
return { return {
records: formatGetManyData<ObjectLiteral[]>( records: finalRecords,
records,
objectMetadataItemWithFieldsMaps as any,
objectMetadata.objectMetadataMaps,
),
totalCount, totalCount,
hasMoreRecords, hasMoreRecords,
isForwardPagination, isForwardPagination,
startCursor,
endCursor,
}; };
} }
@ -376,25 +398,35 @@ export abstract class RestApiBaseHandler {
return await countQuery.getCount(); return await countQuery.getCount();
} }
computeFilters(inputs: QueryVariables) { computeFilters({
inputs,
objectMetadata,
isForwardPagination,
}: {
inputs: QueryVariables;
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
isForwardPagination: boolean;
}) {
let appliedFilters = inputs.filter; let appliedFilters = inputs.filter;
if (inputs.startingAfter) { const cursor = inputs.startingAfter || inputs.endingBefore;
appliedFilters = {
and: [
appliedFilters || {},
{ id: { gt: this.parseCursor(inputs.startingAfter).id } },
],
};
}
if (inputs.endingBefore) { if (cursor) {
appliedFilters = { const cursorArgFilter = computeCursorArgFilter(
and: [ this.parseCursor(cursor),
appliedFilters || {}, inputs.orderBy || [],
{ id: { lt: this.parseCursor(inputs.endingBefore).id } }, objectMetadata.objectMetadataMapItem.fieldsByName,
], isForwardPagination,
}; );
appliedFilters = (inputs.filter
? {
and: [inputs.filter, { or: cursorArgFilter }],
}
: { or: cursorArgFilter }) as unknown as ObjectRecordFilter;
} }
return appliedFilters; return appliedFilters;

View File

@ -37,7 +37,10 @@ describe('OrderByInputFactory', () => {
it('should return default if order by missing', () => { it('should return default if order by missing', () => {
const request: any = { query: {} }; const request: any = { query: {} };
expect(service.create(request, objectMetadata)).toEqual([{}]); expect(service.create(request, objectMetadata)).toEqual([
{},
{ id: OrderByDirection.AscNullsFirst },
]);
}); });
it('should create order by parser properly', () => { it('should create order by parser properly', () => {
@ -50,6 +53,7 @@ describe('OrderByInputFactory', () => {
expect(service.create(request, objectMetadata)).toEqual([ expect(service.create(request, objectMetadata)).toEqual([
{ fieldNumber: OrderByDirection.AscNullsFirst }, { fieldNumber: OrderByDirection.AscNullsFirst },
{ fieldText: OrderByDirection.DescNullsLast }, { fieldText: OrderByDirection.DescNullsLast },
{ id: OrderByDirection.AscNullsFirst },
]); ]);
}); });
@ -62,6 +66,7 @@ describe('OrderByInputFactory', () => {
expect(service.create(request, objectMetadata)).toEqual([ expect(service.create(request, objectMetadata)).toEqual([
{ fieldNumber: OrderByDirection.AscNullsFirst }, { fieldNumber: OrderByDirection.AscNullsFirst },
{ id: OrderByDirection.AscNullsFirst },
]); ]);
}); });
@ -74,6 +79,7 @@ describe('OrderByInputFactory', () => {
expect(service.create(request, objectMetadata)).toEqual([ expect(service.create(request, objectMetadata)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.AscNullsFirst } }, { fieldCurrency: { amountMicros: OrderByDirection.AscNullsFirst } },
{ id: OrderByDirection.AscNullsFirst },
]); ]);
}); });
@ -86,6 +92,7 @@ describe('OrderByInputFactory', () => {
expect(service.create(request, objectMetadata)).toEqual([ expect(service.create(request, objectMetadata)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } }, { fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } },
{ id: OrderByDirection.AscNullsFirst },
]); ]);
}); });
@ -100,6 +107,7 @@ describe('OrderByInputFactory', () => {
expect(service.create(request, objectMetadata)).toEqual([ expect(service.create(request, objectMetadata)).toEqual([
{ fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } }, { fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast } },
{ fieldText: { label: OrderByDirection.AscNullsLast } }, { fieldText: { label: OrderByDirection.AscNullsLast } },
{ id: OrderByDirection.AscNullsFirst },
]); ]);
}); });

View File

@ -25,7 +25,7 @@ export class OrderByInputFactory {
const orderByQuery = request.query.order_by; const orderByQuery = request.query.order_by;
if (typeof orderByQuery !== 'string') { if (typeof orderByQuery !== 'string') {
return [{}]; return this.addDefaultOrderById([{}]);
} }
//orderByQuery = field_1[AscNullsFirst],field_2[DescNullsLast],field_3 //orderByQuery = field_1[AscNullsFirst],field_2[DescNullsLast],field_3
@ -82,6 +82,14 @@ export class OrderByInputFactory {
checkArrayFields(objectMetadata.objectMetadataMapItem, result); checkArrayFields(objectMetadata.objectMetadataMapItem, result);
return result; return this.addDefaultOrderById(result);
}
addDefaultOrderById(orderBy: ObjectRecordOrderBy) {
const hasIdOrder = orderBy.some((o) => Object.keys(o).includes('id'));
return hasIdOrder
? orderBy
: [...orderBy, { id: OrderByDirection.AscNullsFirst }];
} }
} }

View File

@ -17,7 +17,7 @@ import { isValidDate } from 'src/utils/date/isValidDate';
export function formatResult<T>( export function formatResult<T>(
data: any, data: any,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps | undefined,
objectMetadataMaps: ObjectMetadataMaps, objectMetadataMaps: ObjectMetadataMaps,
): T { ): T {
if (!data) { if (!data) {

View File

@ -206,6 +206,32 @@ describe('Core REST API Find Many endpoint', () => {
); );
}); });
it('should support pagination with ordering', async () => {
const descResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?order_by=position[DescNullsLast]&limit=2',
}).expect(200);
const descPeople = descResponse.body.data.people;
const endingBefore = descResponse.body.pageInfo.endCursor;
const lastPosition = descPeople[descPeople.length - 1].position;
expect(descResponse.body.pageInfo.hasNextPage).toBe(true);
expect(descPeople.length).toEqual(2);
expect(lastPosition).toEqual(2);
const descResponseWithPaginationResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?order_by=position[DescNullsLast]&limit=2&starting_after=${endingBefore}`,
}).expect(200);
const descResponseWithPagination =
descResponseWithPaginationResponse.body.data.people;
expect(descResponseWithPagination.length).toEqual(2);
expect(descResponseWithPagination[0].position).toEqual(lastPosition - 1);
});
it('should handle invalid cursor gracefully', async () => { it('should handle invalid cursor gracefully', async () => {
await makeRestAPIRequest({ await makeRestAPIRequest({
method: 'get', method: 'get',