From d4995ab54eb02de5f32b0b851d8f2ec76539f390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:48:03 +0200 Subject: [PATCH] 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 Co-authored-by: Charles Bochet --- .../interfaces/object-record.interface.ts | 30 +- ...e-field-where-condition.utils.spec.ts.snap | 343 +++++++++++++++++ ...posite-field-where-condition.utils.spec.ts | 358 ++++++++++++++++++ .../compute-cursor-arg-filter.utils.spec.ts | 98 ++++- ...r-composite-field-where-condition.utils.ts | 139 +++++++ ...ursor-cumulative-where-conditions.utils.ts | 74 ++++ .../build-cursor-where-condition.utils.ts | 78 ++++ .../utils/compute-cursor-arg-filter.utils.ts | 205 ++-------- .../api/utils/compute-operator.utils.ts | 12 + .../api/utils/is-ascending-order.utils.ts | 5 + .../utils/validate-and-get-order-by.utils.ts | 88 +++++ .../constants/test-person-ids.constants.ts | 1 + ...osite-field-pagination.integration-spec.ts | 263 +++++++++++++ .../utils/find-many-operation-factory.util.ts | 29 +- ...est-api-core-find-many.integration-spec.ts | 170 ++++++++- 15 files changed, 1710 insertions(+), 183 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/utils/__tests__/__snapshots__/build-cursor-composite-field-where-condition.utils.spec.ts.snap create mode 100644 packages/twenty-server/src/engine/api/utils/__tests__/build-cursor-composite-field-where-condition.utils.spec.ts create mode 100644 packages/twenty-server/src/engine/api/utils/build-cursor-composite-field-where-condition.utils.ts create mode 100644 packages/twenty-server/src/engine/api/utils/build-cursor-cumulative-where-conditions.utils.ts create mode 100644 packages/twenty-server/src/engine/api/utils/build-cursor-where-condition.utils.ts create mode 100644 packages/twenty-server/src/engine/api/utils/compute-operator.utils.ts create mode 100644 packages/twenty-server/src/engine/api/utils/is-ascending-order.utils.ts create mode 100644 packages/twenty-server/src/engine/api/utils/validate-and-get-order-by.utils.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/composite-field-pagination.integration-spec.ts diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts index 206eb2d62..70d8b6ab2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts @@ -7,10 +7,10 @@ export interface ObjectRecord { deletedAt: string | null; } -export type ObjectRecordFilter = { +export type ObjectRecordFilter = Partial<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any [Property in keyof ObjectRecord]: any; -}; +}>; export enum OrderByDirection { AscNullsFirst = 'AscNullsFirst', @@ -19,11 +19,29 @@ export enum OrderByDirection { DescNullsLast = 'DescNullsLast', } -export type ObjectRecordOrderBy = Array<{ +export type ObjectRecordOrderBy = Array< + ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField +>; + +export type ObjectRecordOrderByForScalarField = { + [Property in keyof ObjectRecord]?: OrderByDirection; +}; + +export type ObjectRecordOrderByForCompositeField = { + [Property in keyof ObjectRecord]?: Record; +}; + +export type ObjectRecordCursorLeafScalarValue = string | number | boolean; +export type ObjectRecordCursorLeafCompositeValue = Record< + string, + ObjectRecordCursorLeafScalarValue +>; + +export type ObjectRecordCursor = { [Property in keyof ObjectRecord]?: - | OrderByDirection - | Record; -}>; + | ObjectRecordCursorLeafScalarValue + | ObjectRecordCursorLeafCompositeValue; +}; export interface ObjectRecordDuplicateCriteria { objectName: string; diff --git a/packages/twenty-server/src/engine/api/utils/__tests__/__snapshots__/build-cursor-composite-field-where-condition.utils.spec.ts.snap b/packages/twenty-server/src/engine/api/utils/__tests__/__snapshots__/build-cursor-composite-field-where-condition.utils.spec.ts.snap new file mode 100644 index 000000000..21478abb7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/__tests__/__snapshots__/build-cursor-composite-field-where-condition.utils.spec.ts.snap @@ -0,0 +1,343 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`buildCompositeFieldWhereCondition multiple properties cases should match snapshots for address composite field - both ascending, forward pagination: multiple properties - address composite field - both ascending, forward pagination 1`] = ` +{ + "or": [ + { + "address": { + "addressStreet1": { + "gt": "123 Main St", + }, + }, + }, + { + "and": [ + { + "address": { + "addressStreet1": { + "eq": "123 Main St", + }, + }, + }, + { + "address": { + "addressStreet2": { + "gt": "Apt 4B", + }, + }, + }, + ], + }, + { + "and": [ + { + "address": { + "addressStreet1": { + "eq": "123 Main St", + }, + }, + }, + { + "address": { + "addressStreet2": { + "eq": "Apt 4B", + }, + }, + }, + { + "address": { + "addressCity": { + "gt": "New York", + }, + }, + }, + ], + }, + { + "and": [ + { + "address": { + "addressStreet1": { + "eq": "123 Main St", + }, + }, + }, + { + "address": { + "addressStreet2": { + "eq": "Apt 4B", + }, + }, + }, + { + "address": { + "addressCity": { + "eq": "New York", + }, + }, + }, + { + "address": { + "addressPostcode": { + "gt": "10001", + }, + }, + }, + ], + }, + { + "and": [ + { + "address": { + "addressStreet1": { + "eq": "123 Main St", + }, + }, + }, + { + "address": { + "addressStreet2": { + "eq": "Apt 4B", + }, + }, + }, + { + "address": { + "addressCity": { + "eq": "New York", + }, + }, + }, + { + "address": { + "addressPostcode": { + "eq": "10001", + }, + }, + }, + { + "address": { + "addressState": { + "gt": "NY", + }, + }, + }, + ], + }, + { + "and": [ + { + "address": { + "addressStreet1": { + "eq": "123 Main St", + }, + }, + }, + { + "address": { + "addressStreet2": { + "eq": "Apt 4B", + }, + }, + }, + { + "address": { + "addressCity": { + "eq": "New York", + }, + }, + }, + { + "address": { + "addressPostcode": { + "eq": "10001", + }, + }, + }, + { + "address": { + "addressState": { + "eq": "NY", + }, + }, + }, + { + "address": { + "addressCountry": { + "gt": "USA", + }, + }, + }, + ], + }, + ], +} +`; + +exports[`buildCompositeFieldWhereCondition multiple properties cases should match snapshots for two properties - both ascending, backward pagination: multiple properties - two properties - both ascending, backward pagination 1`] = ` +{ + "or": [ + { + "name": { + "firstName": { + "lt": "John", + }, + }, + }, + { + "and": [ + { + "name": { + "firstName": { + "eq": "John", + }, + }, + }, + { + "name": { + "lastName": { + "lt": "Doe", + }, + }, + }, + ], + }, + ], +} +`; + +exports[`buildCompositeFieldWhereCondition multiple properties cases should match snapshots for two properties - both ascending, forward pagination: multiple properties - two properties - both ascending, forward pagination 1`] = ` +{ + "or": [ + { + "name": { + "firstName": { + "gt": "John", + }, + }, + }, + { + "and": [ + { + "name": { + "firstName": { + "eq": "John", + }, + }, + }, + { + "name": { + "lastName": { + "gt": "Doe", + }, + }, + }, + ], + }, + ], +} +`; + +exports[`buildCompositeFieldWhereCondition multiple properties cases should match snapshots for two properties - both descending, forward pagination: multiple properties - two properties - both descending, forward pagination 1`] = ` +{ + "or": [ + { + "name": { + "firstName": { + "lt": "John", + }, + }, + }, + { + "and": [ + { + "name": { + "firstName": { + "eq": "John", + }, + }, + }, + { + "name": { + "lastName": { + "lt": "Doe", + }, + }, + }, + ], + }, + ], +} +`; + +exports[`buildCompositeFieldWhereCondition multiple properties cases should match snapshots for two properties - mixed ordering, forward pagination: multiple properties - two properties - mixed ordering, forward pagination 1`] = ` +{ + "or": [ + { + "name": { + "firstName": { + "gt": "John", + }, + }, + }, + { + "and": [ + { + "name": { + "firstName": { + "eq": "John", + }, + }, + }, + { + "name": { + "lastName": { + "lt": "Doe", + }, + }, + }, + ], + }, + ], +} +`; + +exports[`buildCompositeFieldWhereCondition single property cases should match snapshot for ascending order with backward pagination: single property - ascending order with backward pagination 1`] = ` +{ + "person": { + "firstName": { + "lt": "John", + }, + }, +} +`; + +exports[`buildCompositeFieldWhereCondition single property cases should match snapshot for ascending order with forward pagination: single property - ascending order with forward pagination 1`] = ` +{ + "person": { + "firstName": { + "gt": "John", + }, + }, +} +`; + +exports[`buildCompositeFieldWhereCondition single property cases should match snapshot for descending order with backward pagination: single property - descending order with backward pagination 1`] = ` +{ + "person": { + "firstName": { + "gt": "John", + }, + }, +} +`; + +exports[`buildCompositeFieldWhereCondition single property cases should match snapshot for descending order with forward pagination: single property - descending order with forward pagination 1`] = ` +{ + "person": { + "firstName": { + "lt": "John", + }, + }, +} +`; diff --git a/packages/twenty-server/src/engine/api/utils/__tests__/build-cursor-composite-field-where-condition.utils.spec.ts b/packages/twenty-server/src/engine/api/utils/__tests__/build-cursor-composite-field-where-condition.utils.spec.ts new file mode 100644 index 000000000..a75d9e567 --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/__tests__/build-cursor-composite-field-where-condition.utils.spec.ts @@ -0,0 +1,358 @@ +import { EachTestingContext } from 'twenty-shared/testing'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { + ObjectRecordOrderBy, + OrderByDirection, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { buildCursorCompositeFieldWhereCondition } from 'src/engine/api/utils/build-cursor-composite-field-where-condition.utils'; + +describe('buildCompositeFieldWhereCondition', () => { + describe('eq operator cases', () => { + it('should handle eq operator', () => { + const result = buildCursorCompositeFieldWhereCondition({ + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'name', + orderBy: [ + { + name: { + firstName: OrderByDirection.AscNullsLast, + lastName: OrderByDirection.AscNullsLast, + }, + }, + ], + cursorValue: { firstName: 'John', lastName: 'Doe' }, + isForwardPagination: true, + isEqualityCondition: true, + }); + + expect(result).toEqual({ + name: { + firstName: { + eq: 'John', + }, + lastName: { + eq: 'Doe', + }, + }, + }); + }); + }); + + describe('single property cases', () => { + const singlePropertyTestCases: EachTestingContext<{ + description: string; + fieldType: FieldMetadataType; + fieldKey: string; + orderBy: ObjectRecordOrderBy; + value: { [key: string]: any }; + isForwardPagination: boolean; + operator?: string; + expectedOperator: string; + }>[] = [ + { + title: 'ascending order with forward pagination', + context: { + description: 'ascending order with forward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'person', + orderBy: [{ person: { firstName: OrderByDirection.AscNullsLast } }], + value: { firstName: 'John' }, + isForwardPagination: true, + operator: undefined, + expectedOperator: 'gt', + }, + }, + { + title: 'ascending order with backward pagination', + context: { + description: 'ascending order with backward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'person', + orderBy: [{ person: { firstName: OrderByDirection.AscNullsLast } }], + value: { firstName: 'John' }, + isForwardPagination: false, + operator: undefined, + expectedOperator: 'lt', + }, + }, + { + title: 'descending order with forward pagination', + context: { + description: 'descending order with forward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'person', + orderBy: [{ person: { firstName: OrderByDirection.DescNullsLast } }], + value: { firstName: 'John' }, + isForwardPagination: true, + operator: undefined, + expectedOperator: 'lt', + }, + }, + { + title: 'descending order with backward pagination', + context: { + description: 'descending order with backward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'person', + orderBy: [{ person: { firstName: OrderByDirection.DescNullsLast } }], + value: { firstName: 'John' }, + isForwardPagination: false, + operator: undefined, + expectedOperator: 'gt', + }, + }, + ]; + + test.each(singlePropertyTestCases)( + 'should handle $description', + ({ context }) => { + const { + fieldType, + fieldKey, + orderBy, + value, + isForwardPagination, + expectedOperator, + } = context; + + const result = buildCursorCompositeFieldWhereCondition({ + fieldType, + fieldKey, + orderBy, + cursorValue: value, + isForwardPagination, + }); + + expect(result).toEqual({ + [fieldKey]: { + firstName: { + [expectedOperator]: value.firstName, + }, + }, + }); + }, + ); + + test.each(singlePropertyTestCases)( + 'should match snapshot for $title', + ({ context }) => { + const { + fieldType, + fieldKey, + orderBy, + value, + isForwardPagination, + description, + } = context; + + const result = buildCursorCompositeFieldWhereCondition({ + fieldType, + fieldKey, + orderBy, + cursorValue: value, + isForwardPagination, + }); + + expect(result).toMatchSnapshot(`single property - ${description}`); + }, + ); + }); + + describe('multiple properties cases', () => { + const multiplePropertiesTestCases: EachTestingContext<{ + description: string; + fieldType: FieldMetadataType; + fieldKey: string; + orderBy: ObjectRecordOrderBy; + value: { [key: string]: any }; + isForwardPagination: boolean; + }>[] = [ + { + title: 'two properties - both ascending, forward pagination', + context: { + description: 'two properties - both ascending, forward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'name', + orderBy: [ + { + name: { + firstName: OrderByDirection.AscNullsLast, + lastName: OrderByDirection.AscNullsLast, + }, + }, + ], + value: { firstName: 'John', lastName: 'Doe' }, + isForwardPagination: true, + }, + }, + { + title: 'two properties - both ascending, backward pagination', + context: { + description: 'two properties - both ascending, backward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'name', + orderBy: [ + { + name: { + firstName: OrderByDirection.AscNullsLast, + lastName: OrderByDirection.AscNullsLast, + }, + }, + ], + value: { firstName: 'John', lastName: 'Doe' }, + isForwardPagination: false, + }, + }, + { + title: 'two properties - mixed ordering, forward pagination', + context: { + description: 'two properties - mixed ordering, forward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'name', + orderBy: [ + { + name: { + firstName: OrderByDirection.AscNullsLast, + lastName: OrderByDirection.DescNullsLast, + }, + }, + ], + value: { firstName: 'John', lastName: 'Doe' }, + isForwardPagination: true, + }, + }, + { + title: 'two properties - both descending, forward pagination', + context: { + description: 'two properties - both descending, forward pagination', + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'name', + orderBy: [ + { + name: { + firstName: OrderByDirection.DescNullsLast, + lastName: OrderByDirection.DescNullsLast, + }, + }, + ], + value: { firstName: 'John', lastName: 'Doe' }, + isForwardPagination: true, + }, + }, + { + title: 'address composite field - both ascending, forward pagination', + context: { + description: + 'address composite field - both ascending, forward pagination', + fieldType: FieldMetadataType.ADDRESS, + fieldKey: 'address', + orderBy: [ + { + address: { + addressStreet1: OrderByDirection.AscNullsLast, + addressStreet2: OrderByDirection.AscNullsLast, + addressCity: OrderByDirection.AscNullsLast, + addressState: OrderByDirection.AscNullsLast, + addressCountry: OrderByDirection.AscNullsLast, + addressPostcode: OrderByDirection.AscNullsLast, + }, + }, + ], + value: { + addressStreet1: '123 Main St', + addressStreet2: 'Apt 4B', + addressCity: 'New York', + addressState: 'NY', + addressCountry: 'USA', + addressPostcode: '10001', + }, + isForwardPagination: true, + }, + }, + ]; + + test.each(multiplePropertiesTestCases)( + 'should handle $title', + ({ context }) => { + const { fieldType, fieldKey, orderBy, value, isForwardPagination } = + context; + const result = buildCursorCompositeFieldWhereCondition({ + fieldType, + fieldKey, + orderBy, + cursorValue: value, + isForwardPagination, + }); + + expect(result).toHaveProperty('or'); + const orConditions = result.or; + + expect(Array.isArray(orConditions)).toBe(true); + + const propertiesWithValues = Object.keys(value).length; + + expect(orConditions).toHaveLength(propertiesWithValues); + + expect(orConditions[0]).toHaveProperty(fieldKey); + + for (const [index, orCondition] of orConditions.slice(1).entries()) { + expect(orCondition).toHaveProperty('and'); + expect(Array.isArray(orCondition.and)).toBe(true); + expect(orCondition.and).toHaveLength(index + 2); + } + }, + ); + + test.each(multiplePropertiesTestCases)( + 'should match snapshots for $title', + ({ context }) => { + const { + fieldType, + fieldKey, + orderBy, + value, + isForwardPagination, + description, + } = context; + + const result = buildCursorCompositeFieldWhereCondition({ + fieldType, + fieldKey, + orderBy, + cursorValue: value, + isForwardPagination, + }); + + expect(result).toMatchSnapshot(`multiple properties - ${description}`); + }, + ); + }); + + describe('error cases', () => { + it('should throw error for invalid composite type', () => { + expect(() => + buildCursorCompositeFieldWhereCondition({ + fieldType: FieldMetadataType.TEXT, + fieldKey: 'person', + orderBy: [{ person: { firstName: OrderByDirection.AscNullsLast } }], + cursorValue: { firstName: 'John' }, + isForwardPagination: true, + }), + ).toThrow('Composite type definition not found for type: TEXT'); + }); + + it('should throw error for invalid cursor with missing order by', () => { + expect(() => + buildCursorCompositeFieldWhereCondition({ + fieldType: FieldMetadataType.FULL_NAME, + fieldKey: 'person', + orderBy: [], + cursorValue: { firstName: 'John' }, + isForwardPagination: true, + }), + ).toThrow('Invalid cursor'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/utils/__tests__/compute-cursor-arg-filter.utils.spec.ts b/packages/twenty-server/src/engine/api/utils/__tests__/compute-cursor-arg-filter.utils.spec.ts index b1d8957af..ebb0bf42f 100644 --- a/packages/twenty-server/src/engine/api/utils/__tests__/compute-cursor-arg-filter.utils.spec.ts +++ b/packages/twenty-server/src/engine/api/utils/__tests__/compute-cursor-arg-filter.utils.spec.ts @@ -83,13 +83,13 @@ describe('computeCursorArgFilter', () => { expect(result).toEqual([ { name: { gt: 'John' } }, - { name: { eq: 'John' }, age: { lt: 30 } }, + { and: [{ name: { eq: 'John' } }, { age: { lt: 30 } }] }, ]); }); }); describe('composite field handling', () => { - it('should handle fullName composite field', () => { + it('should handle fullName composite field with proper ordering', () => { const cursor = { fullName: { firstName: 'John', lastName: 'Doe' }, }; @@ -109,15 +109,107 @@ describe('computeCursorArgFilter', () => { true, ); + expect(result).toEqual([ + { + or: [ + { + fullName: { + firstName: { gt: 'John' }, + }, + }, + { + and: [ + { + fullName: { + firstName: { eq: 'John' }, + }, + }, + { + fullName: { + lastName: { gt: 'Doe' }, + }, + }, + ], + }, + ], + }, + ]); + }); + + it('should handle single property composite field', () => { + const cursor = { + fullName: { firstName: 'John' }, + }; + const orderBy = [ + { + fullName: { + firstName: OrderByDirection.AscNullsLast, + }, + }, + ]; + + const result = computeCursorArgFilter( + cursor, + orderBy, + mockFieldMetadataMap, + true, + ); + expect(result).toEqual([ { fullName: { firstName: { gt: 'John' }, - lastName: { gt: 'Doe' }, }, }, ]); }); + + it('should handle composite field with backward pagination', () => { + const cursor = { + fullName: { firstName: 'John', lastName: 'Doe' }, + }; + const orderBy = [ + { + fullName: { + firstName: OrderByDirection.AscNullsLast, + lastName: OrderByDirection.AscNullsLast, + }, + }, + ]; + + const result = computeCursorArgFilter( + cursor, + orderBy, + mockFieldMetadataMap, + false, + ); + + expect(result).toEqual([ + { + or: [ + { + fullName: { + firstName: { lt: 'John' }, + }, + }, + { + and: [ + { + fullName: { + firstName: { eq: 'John' }, + }, + }, + { + fullName: { + lastName: { lt: 'Doe' }, + }, + }, + ], + }, + ], + }, + ]); + }); }); describe('error handling', () => { diff --git a/packages/twenty-server/src/engine/api/utils/build-cursor-composite-field-where-condition.utils.ts b/packages/twenty-server/src/engine/api/utils/build-cursor-composite-field-where-condition.utils.ts new file mode 100644 index 000000000..3a4021c6e --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/build-cursor-composite-field-where-condition.utils.ts @@ -0,0 +1,139 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { + ObjectRecord, + ObjectRecordCursorLeafCompositeValue, + ObjectRecordFilter, + ObjectRecordOrderBy, +} 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'; +import { buildCursorCumulativeWhereCondition } from 'src/engine/api/utils/build-cursor-cumulative-where-conditions.utils'; +import { computeOperator } from 'src/engine/api/utils/compute-operator.utils'; +import { isAscendingOrder } from 'src/engine/api/utils/is-ascending-order.utils'; +import { validateAndGetOrderByForCompositeField } from 'src/engine/api/utils/validate-and-get-order-by.utils'; +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; + +type BuildCursorCompositeFieldWhereConditionParams = { + fieldType: FieldMetadataType; + fieldKey: keyof ObjectRecord; + orderBy: ObjectRecordOrderBy; + cursorValue: ObjectRecordCursorLeafCompositeValue; + isForwardPagination: boolean; + isEqualityCondition?: boolean; +}; + +export const buildCursorCompositeFieldWhereCondition = ({ + fieldType, + fieldKey, + orderBy, + cursorValue, + isForwardPagination, + isEqualityCondition = false, +}: BuildCursorCompositeFieldWhereConditionParams): Record< + string, + ObjectRecordFilter +> => { + const compositeType = compositeTypeDefinitions.get(fieldType); + + if (!compositeType) { + throw new GraphqlQueryRunnerException( + `Composite type definition not found for type: ${fieldType}`, + GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, + ); + } + + const fieldOrderBy = validateAndGetOrderByForCompositeField( + fieldKey, + orderBy, + ); + + const compositeFieldProperties = compositeType.properties.filter( + (property) => + property.type !== FieldMetadataType.RAW_JSON && + cursorValue[property.name] !== undefined, + ); + + if (compositeFieldProperties.length === 0) { + return {}; + } + + const cursorEntries = compositeFieldProperties + .map((property) => { + if (cursorValue[property.name] === undefined) { + return null; + } + + return { + [property.name]: cursorValue[property.name], + }; + }) + .filter(isDefined); + + if (isEqualityCondition) { + const result = cursorEntries.reduce>( + (acc, cursorEntry) => { + const [cursorKey, cursorValue] = Object.entries(cursorEntry)[0]; + + return { + ...acc, + [cursorKey]: { + eq: cursorValue, + }, + }; + }, + {}, + ); + + return { + [fieldKey]: result, + }; + } + + const orConditions = buildCursorCumulativeWhereCondition({ + cursorEntries, + buildEqualityCondition: ({ cursorKey, cursorValue }) => ({ + [fieldKey]: { + [cursorKey]: { + eq: cursorValue, + }, + }, + }), + buildMainCondition: ({ cursorKey, cursorValue }) => { + const orderByDirection = fieldOrderBy[fieldKey]?.[cursorKey]; + + if (!isDefined(orderByDirection)) { + throw new GraphqlQueryRunnerException( + 'Invalid cursor', + GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, + ); + } + + const isAscending = isAscendingOrder(orderByDirection); + const computedOperator = computeOperator( + isAscending, + isForwardPagination, + ); + + return { + [fieldKey]: { + [cursorKey]: { + [computedOperator]: cursorValue, + }, + }, + }; + }, + }); + + if (orConditions.length === 1) { + return orConditions[0]; + } + + return { + or: orConditions, + }; +}; diff --git a/packages/twenty-server/src/engine/api/utils/build-cursor-cumulative-where-conditions.utils.ts b/packages/twenty-server/src/engine/api/utils/build-cursor-cumulative-where-conditions.utils.ts new file mode 100644 index 000000000..52027c5e5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/build-cursor-cumulative-where-conditions.utils.ts @@ -0,0 +1,74 @@ +import { + ObjectRecordCursorLeafCompositeValue, + ObjectRecordCursorLeafScalarValue, + ObjectRecordFilter, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +type BuildCursorConditionParams = { + cursorKey: string; + cursorValue: CursorValue; +}; + +type ReturnType = Array; + +type CursorEntry = Record; + +type BuildCursorCumulativeWhereConditionsParams = { + cursorEntries: CursorEntry[]; + buildEqualityCondition: ({ + cursorKey, + cursorValue, + }: BuildCursorConditionParams) => ObjectRecordFilter; + buildMainCondition: ({ + cursorKey, + cursorValue, + }: BuildCursorConditionParams) => ObjectRecordFilter; +}; + +export const buildCursorCumulativeWhereCondition = < + CursorValue extends + | ObjectRecordCursorLeafCompositeValue + | ObjectRecordCursorLeafScalarValue, +>({ + cursorEntries, + buildEqualityCondition, + buildMainCondition, +}: BuildCursorCumulativeWhereConditionsParams): ReturnType => { + return cursorEntries.map((cursorEntry, index) => { + const [currentCursorKey, currentCursorValue] = + Object.entries(cursorEntry)[0]; + const andConditions: ObjectRecordFilter[] = []; + + for ( + let subConditionIndex = 0; + subConditionIndex < index; + subConditionIndex++ + ) { + const previousCursorEntry = cursorEntries[subConditionIndex]; + const [previousCursorKey, previousCursorValue] = + Object.entries(previousCursorEntry)[0]; + + andConditions.push( + buildEqualityCondition({ + cursorKey: previousCursorKey, + cursorValue: previousCursorValue, + }), + ); + } + + andConditions.push( + buildMainCondition({ + cursorKey: currentCursorKey, + cursorValue: currentCursorValue, + }), + ); + + if (andConditions.length === 1) { + return andConditions[0]; + } + + return { + and: andConditions, + }; + }); +}; diff --git a/packages/twenty-server/src/engine/api/utils/build-cursor-where-condition.utils.ts b/packages/twenty-server/src/engine/api/utils/build-cursor-where-condition.utils.ts new file mode 100644 index 000000000..a1f5fb249 --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/build-cursor-where-condition.utils.ts @@ -0,0 +1,78 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + ObjectRecord, + ObjectRecordCursorLeafCompositeValue, + ObjectRecordCursorLeafScalarValue, + ObjectRecordOrderBy, +} 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'; +import { computeOperator } from 'src/engine/api/utils/compute-operator.utils'; +import { isAscendingOrder } from 'src/engine/api/utils/is-ascending-order.utils'; +import { validateAndGetOrderByForScalarField } from 'src/engine/api/utils/validate-and-get-order-by.utils'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; +import { buildCursorCompositeFieldWhereCondition } from 'src/engine/api/utils/build-cursor-composite-field-where-condition.utils'; + +type BuildCursorWhereConditionParams = { + cursorKey: keyof ObjectRecord; + cursorValue: + | ObjectRecordCursorLeafScalarValue + | ObjectRecordCursorLeafCompositeValue; + fieldMetadataMapByName: FieldMetadataMap; + orderBy: ObjectRecordOrderBy; + isForwardPagination: boolean; + isEqualityCondition?: boolean; +}; + +export const buildCursorWhereCondition = ({ + cursorKey, + cursorValue, + fieldMetadataMapByName, + orderBy, + isForwardPagination, + isEqualityCondition = false, +}: BuildCursorWhereConditionParams): Record => { + const fieldMetadata = fieldMetadataMapByName[cursorKey]; + + if (!fieldMetadata) { + throw new GraphqlQueryRunnerException( + `Field metadata not found for key: ${cursorKey}`, + GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, + ); + } + + if (isCompositeFieldMetadataType(fieldMetadata.type)) { + return buildCursorCompositeFieldWhereCondition({ + fieldType: fieldMetadata.type, + fieldKey: cursorKey, + orderBy, + cursorValue: cursorValue as ObjectRecordCursorLeafCompositeValue, + isForwardPagination, + isEqualityCondition, + }); + } + + if (isEqualityCondition) { + return { [cursorKey]: { eq: cursorValue } }; + } + + const keyOrderBy = validateAndGetOrderByForScalarField(cursorKey, orderBy); + const orderByDirection = keyOrderBy[cursorKey]; + + if (!isDefined(orderByDirection)) { + throw new GraphqlQueryRunnerException( + 'Invalid cursor', + GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, + ); + } + + const isAscending = isAscendingOrder(orderByDirection); + const computedOperator = computeOperator(isAscending, isForwardPagination); + + return { [cursorKey]: { [computedOperator]: cursorValue } }; +}; diff --git a/packages/twenty-server/src/engine/api/utils/compute-cursor-arg-filter.utils.ts b/packages/twenty-server/src/engine/api/utils/compute-cursor-arg-filter.utils.ts index 42f85efc6..c41a31faf 100644 --- a/packages/twenty-server/src/engine/api/utils/compute-cursor-arg-filter.utils.ts +++ b/packages/twenty-server/src/engine/api/utils/compute-cursor-arg-filter.utils.ts @@ -1,190 +1,59 @@ -import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { + ObjectRecordCursor, + ObjectRecordCursorLeafCompositeValue, + ObjectRecordCursorLeafScalarValue, ObjectRecordFilter, ObjectRecordOrderBy, - 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'; -import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { buildCursorCumulativeWhereCondition } from 'src/engine/api/utils/build-cursor-cumulative-where-conditions.utils'; import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; - -const computeOperator = ( - isAscending: boolean, - isForwardPagination: boolean, - defaultOperator?: string, -): string => { - if (defaultOperator) return defaultOperator; - - return isAscending - ? isForwardPagination - ? 'gt' - : 'lt' - : isForwardPagination - ? 'lt' - : 'gt'; -}; - -const validateAndGetOrderBy = ( - key: string, - orderBy: ObjectRecordOrderBy, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Record => { - const keyOrderBy = orderBy.find((order) => key in order); - - if (!keyOrderBy) { - throw new GraphqlQueryRunnerException( - 'Invalid cursor', - GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, - ); - } - - return keyOrderBy; -}; - -const isAscendingOrder = (direction: OrderByDirection): boolean => - direction === OrderByDirection.AscNullsFirst || - direction === OrderByDirection.AscNullsLast; +import { buildCursorWhereCondition } from 'src/engine/api/utils/build-cursor-where-condition.utils'; export const computeCursorArgFilter = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - cursor: Record, + cursor: ObjectRecordCursor, orderBy: ObjectRecordOrderBy, fieldMetadataMapByName: FieldMetadataMap, isForwardPagination = true, ): ObjectRecordFilter[] => { - const cursorKeys = Object.keys(cursor ?? {}); - const cursorValues = Object.values(cursor ?? {}); + const cursorEntries = Object.entries(cursor) + .map(([key, value]) => { + if (value === undefined) { + return null; + } - if (cursorKeys.length === 0) { + return { + [key]: value, + }; + }) + .filter(isDefined); + + if (cursorEntries.length === 0) { return []; } - return Object.entries(cursor ?? {}).map(([key, value], index) => { - let whereCondition = {}; - - for ( - let subConditionIndex = 0; - subConditionIndex < index; - subConditionIndex++ - ) { - whereCondition = { - ...whereCondition, - ...buildWhereCondition( - cursorKeys[subConditionIndex], - cursorValues[subConditionIndex], - fieldMetadataMapByName, - orderBy, - isForwardPagination, - 'eq', - ), - }; - } - - return { - ...whereCondition, - ...buildWhereCondition( - key, - value, + return buildCursorCumulativeWhereCondition< + ObjectRecordCursorLeafCompositeValue | ObjectRecordCursorLeafScalarValue + >({ + cursorEntries, + buildEqualityCondition: ({ cursorKey, cursorValue }) => + buildCursorWhereCondition({ + cursorKey, + cursorValue, + fieldMetadataMapByName, + orderBy, + isForwardPagination: true, + isEqualityCondition: true, + }), + buildMainCondition: ({ cursorKey, cursorValue }) => + buildCursorWhereCondition({ + cursorKey, + cursorValue, fieldMetadataMapByName, orderBy, isForwardPagination, - ), - } as ObjectRecordFilter; + }), }); }; - -const buildWhereCondition = ( - key: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any, - fieldMetadataMapByName: FieldMetadataMap, - orderBy: ObjectRecordOrderBy, - isForwardPagination: boolean, - operator?: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Record => { - const fieldMetadata = fieldMetadataMapByName[key]; - - if (!fieldMetadata) { - throw new GraphqlQueryRunnerException( - `Field metadata not found for key: ${key}`, - GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, - ); - } - - if (isCompositeFieldMetadataType(fieldMetadata.type)) { - return buildCompositeWhereCondition( - key, - value, - fieldMetadata.type, - orderBy, - isForwardPagination, - operator, - ); - } - - const keyOrderBy = validateAndGetOrderBy(key, orderBy); - const isAscending = isAscendingOrder(keyOrderBy[key]); - const computedOperator = computeOperator( - isAscending, - isForwardPagination, - operator, - ); - - return { [key]: { [computedOperator]: value } }; -}; - -const buildCompositeWhereCondition = ( - key: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any, - fieldType: FieldMetadataType, - orderBy: ObjectRecordOrderBy, - isForwardPagination: boolean, - operator?: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Record => { - const compositeType = compositeTypeDefinitions.get(fieldType); - - if (!compositeType) { - throw new GraphqlQueryRunnerException( - `Composite type definition not found for type: ${fieldType}`, - GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, - ); - } - - const keyOrderBy = validateAndGetOrderBy(key, orderBy); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: Record = {}; - - compositeType.properties.forEach((property) => { - if ( - property.type === FieldMetadataType.RAW_JSON || - value[property.name] === undefined - ) { - return; - } - - const isAscending = isAscendingOrder(keyOrderBy[key][property.name]); - const computedOperator = computeOperator( - isAscending, - isForwardPagination, - operator, - ); - - result[key] = { - ...result[key], - [property.name]: { - [computedOperator]: value[property.name], - }, - }; - }); - - return result; -}; diff --git a/packages/twenty-server/src/engine/api/utils/compute-operator.utils.ts b/packages/twenty-server/src/engine/api/utils/compute-operator.utils.ts new file mode 100644 index 000000000..288d7491d --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/compute-operator.utils.ts @@ -0,0 +1,12 @@ +export const computeOperator = ( + isAscending: boolean, + isForwardPagination: boolean, +): string => { + return isAscending + ? isForwardPagination + ? 'gt' + : 'lt' + : isForwardPagination + ? 'lt' + : 'gt'; +}; diff --git a/packages/twenty-server/src/engine/api/utils/is-ascending-order.utils.ts b/packages/twenty-server/src/engine/api/utils/is-ascending-order.utils.ts new file mode 100644 index 000000000..196f33720 --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/is-ascending-order.utils.ts @@ -0,0 +1,5 @@ +import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +export const isAscendingOrder = (direction: OrderByDirection): boolean => + direction === OrderByDirection.AscNullsFirst || + direction === OrderByDirection.AscNullsLast; diff --git a/packages/twenty-server/src/engine/api/utils/validate-and-get-order-by.utils.ts b/packages/twenty-server/src/engine/api/utils/validate-and-get-order-by.utils.ts new file mode 100644 index 000000000..a340e6d6b --- /dev/null +++ b/packages/twenty-server/src/engine/api/utils/validate-and-get-order-by.utils.ts @@ -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, + key: keyof ObjectRecord, +): orderByLeaf is ObjectRecordOrderByForScalarField => { + const value = orderByLeaf[key as string]; + + return isDefined(value) && isOrderByDirection(value); +}; + +const isOrderByForCompositeField = ( + orderByLeaf: Record, + 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).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; +}; diff --git a/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts index 9d1805dee..bf38621b7 100644 --- a/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts +++ b/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts @@ -2,5 +2,6 @@ export const TEST_PERSON_1_ID = '777a8457-eb2d-40ac-a707-551b615b6980'; export const TEST_PERSON_2_ID = '777a8457-eb2d-40ac-a707-551b615b6981'; export const TEST_PERSON_3_ID = '777a8457-eb2d-40ac-a707-551b615b6982'; export const TEST_PERSON_4_ID = '777a8457-eb2d-40ac-a707-551b615b6983'; +export const TEST_PERSON_5_ID = '777a8457-eb2d-40ac-a707-551b615b6984'; export const NOT_EXISTING_TEST_PERSON_ID = '777a8457-eb2d-40ac-a707-551b615b6990'; diff --git a/packages/twenty-server/test/integration/graphql/suites/composite-field-pagination.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/composite-field-pagination.integration-spec.ts new file mode 100644 index 000000000..78b99e886 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/composite-field-pagination.integration-spec.ts @@ -0,0 +1,263 @@ +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { + TEST_PERSON_1_ID, + TEST_PERSON_2_ID, + TEST_PERSON_3_ID, + TEST_PERSON_4_ID, + TEST_PERSON_5_ID, +} from 'test/integration/constants/test-person-ids.constants'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; + +describe('GraphQL People Pagination with Composite Field Sorting', () => { + beforeAll(async () => { + await deleteAllRecords('person'); + + const testPeople = [ + { + id: TEST_PERSON_1_ID, + firstName: 'Alice', + lastName: 'Brown', + }, + { + id: TEST_PERSON_2_ID, + firstName: 'Alice', + lastName: 'Smith', + }, + { + id: TEST_PERSON_3_ID, + firstName: 'Bob', + lastName: 'Johnson', + }, + { + id: TEST_PERSON_4_ID, + firstName: 'Bob', + lastName: 'Williams', + }, + { + id: TEST_PERSON_5_ID, + firstName: 'Charlie', + lastName: 'Davis', + }, + ]; + + for (const person of testPeople) { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: person.id, + name: { + firstName: person.firstName, + lastName: person.lastName, + }, + }, + }); + + await makeGraphqlAPIRequest(graphqlOperation).expect(200); + } + }); + + it('should support pagination with fullName composite field in ascending order', async () => { + const firstPageOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + orderBy: { + name: { + firstName: 'AscNullsLast', + lastName: 'AscNullsLast', + }, + }, + first: 2, + }); + + const firstPageResponse = + await makeGraphqlAPIRequest(firstPageOperation).expect(200); + + const firstPagePeople = firstPageResponse.body.data.people.edges.map( + (edge: any) => edge.node, + ); + const firstPageCursor = + firstPageResponse.body.data.people.pageInfo.endCursor; + + expect(firstPagePeople).toHaveLength(2); + expect(firstPageResponse.body.data.people.pageInfo.hasNextPage).toBe(true); + + expect(firstPagePeople[0].name.firstName).toBe('Alice'); + expect(firstPagePeople[0].name.lastName).toBe('Brown'); + expect(firstPagePeople[1].name.firstName).toBe('Alice'); + expect(firstPagePeople[1].name.lastName).toBe('Smith'); + + const secondPageOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + orderBy: { + name: { + firstName: 'AscNullsLast', + lastName: 'AscNullsLast', + }, + }, + first: 2, + after: firstPageCursor, + }); + + const secondPageResponse = + await makeGraphqlAPIRequest(secondPageOperation).expect(200); + + const secondPagePeople = secondPageResponse.body.data.people.edges.map( + (edge: any) => edge.node, + ); + + expect(secondPagePeople).toHaveLength(2); + + expect(secondPagePeople[0].name.firstName).toBe('Bob'); + expect(secondPagePeople[0].name.lastName).toBe('Johnson'); + expect(secondPagePeople[1].name.firstName).toBe('Bob'); + expect(secondPagePeople[1].name.lastName).toBe('Williams'); + + const firstPageIds = firstPagePeople.map((p: { id: string }) => p.id); + const secondPageIds = secondPagePeople.map((p: { id: string }) => p.id); + const intersection = firstPageIds.filter((id: string) => + secondPageIds.includes(id), + ); + + expect(intersection).toHaveLength(0); + + const thirdPageOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + orderBy: { + name: { + firstName: 'AscNullsLast', + lastName: 'AscNullsLast', + }, + }, + first: 2, + after: secondPageResponse.body.data.people.pageInfo.endCursor, + }); + + const thirdPageResponse = + await makeGraphqlAPIRequest(thirdPageOperation).expect(200); + + const thirdPagePeople = thirdPageResponse.body.data.people.edges.map( + (edge: any) => edge.node, + ); + + expect(thirdPagePeople).toHaveLength(1); + expect(thirdPagePeople[0].name.firstName).toBe('Charlie'); + expect(thirdPagePeople[0].name.lastName).toBe('Davis'); + expect(thirdPageResponse.body.data.people.pageInfo.hasNextPage).toBe(false); + }); + + it('should support cursor-based pagination with fullName in descending order', async () => { + const firstPageOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + orderBy: { + name: { + firstName: 'DescNullsLast', + lastName: 'DescNullsLast', + }, + }, + first: 2, + }); + + const firstPageResponse = + await makeGraphqlAPIRequest(firstPageOperation).expect(200); + + const firstPagePeople = firstPageResponse.body.data.people.edges.map( + (edge: any) => edge.node, + ); + + expect(firstPagePeople).toHaveLength(2); + + expect(firstPagePeople[0].name.firstName).toBe('Charlie'); + expect(firstPagePeople[0].name.lastName).toBe('Davis'); + expect(firstPagePeople[1].name.firstName).toBe('Bob'); + expect(firstPagePeople[1].name.lastName).toBe('Williams'); + + const secondPageOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + orderBy: { + name: { + firstName: 'DescNullsLast', + lastName: 'DescNullsLast', + }, + }, + first: 2, + after: firstPageResponse.body.data.people.pageInfo.endCursor, + }); + + const secondPageResponse = + await makeGraphqlAPIRequest(secondPageOperation).expect(200); + + const secondPagePeople = secondPageResponse.body.data.people.edges.map( + (edge: any) => edge.node, + ); + + expect(secondPagePeople).toHaveLength(2); + + expect(secondPagePeople[0].name.firstName).toBe('Bob'); + expect(secondPagePeople[0].name.lastName).toBe('Johnson'); + expect(secondPagePeople[1].name.firstName).toBe('Alice'); + expect(secondPagePeople[1].name.lastName).toBe('Smith'); + }); + + it('should support backward pagination with fullName composite field in ascending order', async () => { + const allPeopleOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + orderBy: { + name: { + firstName: 'AscNullsLast', + lastName: 'AscNullsLast', + }, + }, + }); + + const allPeopleResponse = + await makeGraphqlAPIRequest(allPeopleOperation).expect(200); + + const allPeople = allPeopleResponse.body.data.people.edges.map( + (edge: any) => edge.node, + ); + const lastPersonCursor = + allPeopleResponse.body.data.people.pageInfo.endCursor; + + const backwardPageOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + orderBy: { + name: { + firstName: 'AscNullsLast', + lastName: 'AscNullsLast', + }, + }, + last: 2, + before: lastPersonCursor, + }); + + const backwardPageResponse = await makeGraphqlAPIRequest( + backwardPageOperation, + ).expect(200); + + const backwardPagePeople = backwardPageResponse.body.data.people.edges.map( + (edge: any) => edge.node, + ); + + expect(backwardPagePeople).toHaveLength(2); + + expect(backwardPagePeople[0].id).toBe(allPeople.at(-2)?.id); + expect(backwardPagePeople[1].id).toBe(allPeople.at(-3)?.id); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-many-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-many-operation-factory.util.ts index 6f7842cc6..b6bf2f8bb 100644 --- a/packages/twenty-server/test/integration/graphql/utils/find-many-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/find-many-operation-factory.util.ts @@ -6,6 +6,12 @@ type FindManyOperationFactoryParams = { objectMetadataPluralName: string; gqlFields: string; filter?: object; + orderBy?: object; + limit?: number; + after?: string; + before?: string; + first?: number; + last?: number; }; export const findManyOperationFactory = ({ @@ -13,19 +19,38 @@ export const findManyOperationFactory = ({ objectMetadataPluralName, gqlFields, filter = {}, + orderBy = {}, + limit, + after, + before, + first, + last, }: FindManyOperationFactoryParams) => ({ query: gql` - query ${capitalize(objectMetadataPluralName)}($filter: ${capitalize(objectMetadataSingularName)}FilterInput) { - ${objectMetadataPluralName}(filter: $filter) { + query ${capitalize(objectMetadataPluralName)}($filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByInput], $limit: Int, $after: String, $before: String, $first: Int, $last: Int) { + ${objectMetadataPluralName}(filter: $filter, orderBy: $orderBy, limit: $limit, after: $after, before: $before, first: $first, last: $last) { edges { node { ${gqlFields} } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor } } } `, variables: { filter, + orderBy, + limit, + after, + before, + first, + last, }, }); diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts index 75e311545..0f3a0ecf6 100644 --- a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts @@ -1,14 +1,14 @@ +import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; import { TEST_PERSON_1_ID, TEST_PERSON_2_ID, TEST_PERSON_3_ID, TEST_PERSON_4_ID, } from 'test/integration/constants/test-person-ids.constants'; -import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; -import { generateRecordName } from 'test/integration/utils/generate-record-name'; -import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; -import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; describe('Core REST API Find Many endpoint', () => { const testPersonIds = [ @@ -262,6 +262,168 @@ describe('Core REST API Find Many endpoint', () => { expect(people).toEqual([...people].sort((a, b) => a.city - b.city)); }); + it('should should throw an error when trying to order by a composite field', async () => { + await makeRestAPIRequest({ + method: 'get', + path: '/people?order_by=name[AscNullsLast]', + }).expect(400); + }); + + // TODO: Uncomment this test when we support composite fields ordering in the rest api + + // it('should support pagination with fullName composite field ordering', async () => { + // await deleteAllRecords('person'); + + // const testPeople = [ + // { + // id: TEST_PERSON_1_ID, + // firstName: 'Alice', + // lastName: 'Brown', + // position: 0, + // }, + // { + // id: TEST_PERSON_2_ID, + // firstName: 'Alice', + // lastName: 'Smith', + // position: 1, + // }, + // { + // id: TEST_PERSON_3_ID, + // firstName: 'Bob', + // lastName: 'Johnson', + // position: 2, + // }, + // { + // id: TEST_PERSON_4_ID, + // firstName: 'Bob', + // lastName: 'Williams', + // position: 3, + // }, + // { + // id: TEST_PERSON_5_ID, + // firstName: 'Charlie', + // lastName: 'Davis', + // position: 4, + // }, + // ]; + + // for (const person of testPeople) { + // await makeRestAPIRequest({ + // method: 'post', + // path: '/people', + // body: { + // id: person.id, + // name: { + // firstName: person.firstName, + // lastName: person.lastName, + // }, + // }, + // }); + // } + + // const firstPageResponse = await makeRestAPIRequest({ + // method: 'get', + // path: '/people?order_by=name[AscNullsLast]&limit=2', + // }).expect(200); + + // const firstPagePeople = firstPageResponse.body.data.people; + // const firstPageCursor = firstPageResponse.body.pageInfo.endCursor; + + // expect(firstPagePeople).toHaveLength(2); + // expect(firstPageResponse.body.pageInfo.hasNextPage).toBe(true); + + // expect(firstPagePeople[0].name.firstName).toBe('Alice'); + // expect(firstPagePeople[0].name.lastName).toBe('Brown'); + // expect(firstPagePeople[1].name.firstName).toBe('Alice'); + // expect(firstPagePeople[1].name.lastName).toBe('Smith'); + + // const secondPageResponse = await makeRestAPIRequest({ + // method: 'get', + // path: `/people?order_by=name[AscNullsLast]&limit=2&starting_after=${firstPageCursor}`, + // }).expect(200); + + // const secondPagePeople = secondPageResponse.body.data.people; + + // expect(secondPagePeople).toHaveLength(2); + + // expect(secondPagePeople[0].name.firstName).toBe('Bob'); + // expect(secondPagePeople[0].name.lastName).toBe('Johnson'); + // expect(secondPagePeople[1].name.firstName).toBe('Bob'); + // expect(secondPagePeople[1].name.lastName).toBe('Williams'); + + // const firstPageIds = firstPagePeople.map((p: { id: string }) => p.id); + // const secondPageIds = secondPagePeople.map((p: { id: string }) => p.id); + // const intersection = firstPageIds.filter((id: string) => + // secondPageIds.includes(id), + // ); + + // expect(intersection).toHaveLength(0); + + // const thirdPageResponse = await makeRestAPIRequest({ + // method: 'get', + // path: `/people?order_by=name[AscNullsLast]&limit=2&starting_after=${secondPageResponse.body.pageInfo.endCursor}`, + // }).expect(200); + + // const thirdPagePeople = thirdPageResponse.body.data.people; + + // expect(thirdPagePeople).toHaveLength(1); + // expect(thirdPagePeople[0].name.firstName).toBe('Charlie'); + // expect(thirdPagePeople[0].name.lastName).toBe('Davis'); + // expect(thirdPageResponse.body.pageInfo.hasNextPage).toBe(false); + // }); + + // it('should support cursor-based pagination with fullName descending order', async () => { + // const firstPageResponse = await makeRestAPIRequest({ + // method: 'get', + // path: '/people?order_by=name[DescNullsLast]&limit=2', + // }).expect(200); + + // const firstPagePeople = firstPageResponse.body.data.people; + + // expect(firstPagePeople).toHaveLength(2); + + // expect(firstPagePeople[0].name.firstName).toBe('Charlie'); + // expect(firstPagePeople[0].name.lastName).toBe('Davis'); + // expect(firstPagePeople[1].name.firstName).toBe('Bob'); + // expect(firstPagePeople[1].name.lastName).toBe('Williams'); + + // const secondPageResponse = await makeRestAPIRequest({ + // method: 'get', + // path: `/people?order_by=name[DescNullsLast]&limit=2&starting_after=${firstPageResponse.body.pageInfo.endCursor}`, + // }).expect(200); + + // const secondPagePeople = secondPageResponse.body.data.people; + + // expect(secondPagePeople).toHaveLength(2); + + // expect(secondPagePeople[0].name.firstName).toBe('Bob'); + // expect(secondPagePeople[0].name.lastName).toBe('Johnson'); + // expect(secondPagePeople[1].name.firstName).toBe('Alice'); + // expect(secondPagePeople[1].name.lastName).toBe('Smith'); + // }); + + // it('should support backward pagination with fullName composite field', async () => { + // const allPeopleResponse = await makeRestAPIRequest({ + // method: 'get', + // path: '/people?order_by=name.firstName[AscNullsLast],name.lastName[AscNullsLast]', + // }).expect(200); + + // const allPeople = allPeopleResponse.body.data.people; + // const lastPersonCursor = allPeopleResponse.body.pageInfo.endCursor; + + // const backwardPageResponse = await makeRestAPIRequest({ + // method: 'get', + // path: `/people?order_by=name[AscNullsLast]&limit=2&ending_before=${lastPersonCursor}`, + // }).expect(200); + + // const backwardPagePeople = backwardPageResponse.body.data.people; + + // expect(backwardPagePeople).toHaveLength(2); + + // expect(backwardPagePeople[0].id).toBe(allPeople[allPeople.length - 3].id); + // expect(backwardPagePeople[1].id).toBe(allPeople[allPeople.length - 2].id); + // }); + it('should support depth 0 parameter', async () => { const response = await makeRestAPIRequest({ method: 'get',