From 1a5675d63e56568fc42f46e3f247452fae2d3640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 10 Jan 2026 11:02:16 +0100 Subject: [PATCH] feat: add sorting on relation fields (Many-to-One) (#17021) ## Summary This PR enables sorting records by fields of related objects. For example, sorting **People by their Company's name**. ### Before Only scalar and composite fields could be sorted. Relation fields showed in the sort dropdown but produced errors. ### After Many-to-One relation fields can now be sorted using the related object's **label identifier field** (e.g., Company's `name`). --- ## Changes ### Frontend - Added `RELATION` to sortable field types (restricted to `MANY_TO_ONE` relations) - New `getOrderByForRelationField()` generates nested orderBy structures using the related object's label identifier - Updated `turnSortsIntoOrderBy()` to handle relation fields by looking up related object metadata ### Backend - Extended `GraphqlQueryOrderFieldParser.parse()` to detect nested relation ordering like `{ company: { name: 'AscNullsLast' } }` - Returns `ParseOrderByResult` containing both `orderBy` conditions and `relationJoins` info - Added LEFT JOINs for relation ordering in `applyOrderToBuilder()` - Added `addRelationOrderColumnsToBuilder()` for TypeORM DISTINCT compatibility ### Tests - Added unit tests for `filterSortableFieldMetadataItems`, `getOrderByForRelationField`, and `turnSortsIntoOrderBy` - Added integration tests covering ascending/descending order and composite label identifiers --- ## TypeORM Bug Workaround We encountered a significant TypeORM limitation when implementing this feature. When using `getMany()` with `ORDER BY` on joined relation columns, TypeORM generates a DISTINCT subquery that has specific requirements: ### Issue 1: Alias Parsing TypeORM's `orderBy()` method fails with **"alias not found"** when using quoted SQL identifiers like `"company"."name"`. TypeORM internally expects unquoted property paths (e.g., `company.name`) for its alias resolution mechanism. ### Issue 2: setFindOptions Clears addSelect `setFindOptions({ select })` **clears any previously added `addSelect()` columns**. This caused `"column distinctAlias.company_name does not exist"` errors because the relation columns needed for ORDER BY were being removed. ### Solution We split the logic into two methods: 1. `applyOrderToBuilder()` - adds JOINs and ORDER BY (before `setFindOptions`) 2. `addRelationOrderColumnsToBuilder()` - adds relation columns for SELECT (AFTER `setFindOptions`) This ensures the relation columns are present in the final SQL query's SELECT clause with the proper underscore aliases (`company_name`) that TypeORM's DISTINCT subquery expects. **Related TypeORM issue**: https://github.com/typeorm/typeorm/issues/9921 --- ## Screenshots/Demo _Add screenshots if applicable_ --- .../utils/sortCachedObjectEdges.ts | 66 +- .../filterSortableFieldMetadataItems.test.ts | 100 +++ .../getOrderByForRelationField.test.ts | 153 ++++ .../utils/filterSortableFieldMetadataItems.ts | 21 +- .../utils/getOrderByForFieldMetadataType.ts | 26 + .../types/RecordGqlOperationOrderBy.ts | 10 +- .../__tests__/turnSortsIntoOrderBy.test.ts | 141 ++++ .../utils/turnSortsIntoOrderBy.ts | 31 +- .../useFindManyRecordIndexTableParams.ts | 8 +- ...useRecordIndexGroupCommonQueryVariables.ts | 8 +- .../hooks/useQueryVariablesFromParentView.ts | 4 + .../getQueryVariablesFromFiltersAndSorts.ts | 8 +- .../WorkflowEditActionFindRecords.tsx | 6 +- .../common-base-query-runner.service.ts | 9 +- .../common-find-many-query-runner.service.ts | 75 +- .../common-group-by-query-runner.service.ts | 1 + .../graphql-query-order-group-by.parser.ts | 660 +++++++++++++++ .../graphql-query-order.parser.ts | 779 ++++-------------- .../types/order-by-condition.type.ts | 4 + .../types/parse-order-by-result.type.ts | 7 + .../types/relation-join-info.type.ts | 3 + .../convert-order-by-to-find-options-order.ts | 4 +- .../get-optional-order-by-casting.util.ts | 16 + .../utils/is-order-by-direction.util.ts | 11 + .../parse-composite-field-for-order.util.ts | 23 +- .../graphql-query.parser.ts | 61 +- ...bject-metadata-gql-input-type.generator.ts | 1 + ...adata-order-by-gql-input-type.generator.ts | 4 + .../utils/validate-and-get-order-by.utils.ts | 31 + ...rder-by-relation-field.integration-spec.ts | 428 ++++++++++ 30 files changed, 2011 insertions(+), 688 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/__tests__/filterSortableFieldMetadataItems.test.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/__tests__/getOrderByForRelationField.test.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/parse-order-by-result.type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/relation-join-info.type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts index 739cc4d342..3242d84c2a 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts @@ -1,11 +1,27 @@ import { type Reference, type StoreObject } from '@apollo/client'; import { type ReadFieldFunction } from '@apollo/client/cache/core/types/common'; +import { isNonEmptyString } from '@sniptt/guards'; import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { type OrderBy } from '@/types/OrderBy'; -import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort'; import { isDefined } from 'twenty-shared/utils'; +import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort'; + +// Extracts the OrderBy direction from a nested orderBy structure +// Handles up to 3 levels: field, field.subField, or relation.compositeField.subField +const extractOrderByDirection = (value: unknown): OrderBy | null => { + if (isNonEmptyString(value)) { + return value as OrderBy; + } + if (typeof value === 'object' && value !== null) { + const entries = Object.entries(value); + if (entries.length > 0) { + return extractOrderByDirection(entries[0][1]); + } + } + return null; +}; export const sortCachedObjectEdges = ({ edges, @@ -16,11 +32,20 @@ export const sortCachedObjectEdges = ({ orderBy: RecordGqlOperationOrderBy; readCacheField: ReadFieldFunction; }) => { - const [orderByFieldName, orderByFieldValue] = Object.entries(orderBy[0])[0]; - const [orderBySubFieldName, orderBySubFieldValue] = - typeof orderByFieldValue === 'string' + const [orderByFieldName, orderByFieldValueOrDirection] = Object.entries( + orderBy[0], + )[0]; + const [orderBySubFieldName, orderBySubFieldValueOrDirection] = + isNonEmptyString(orderByFieldValueOrDirection) ? [] - : Object.entries(orderByFieldValue)[0]; + : Object.entries(orderByFieldValueOrDirection)[0]; + + // For relation fields with composite nested fields (e.g., accountOwner.name.firstName) + const [orderBySubSubFieldNameOrDirection] = + isNonEmptyString(orderBySubFieldValueOrDirection) || + !isDefined(orderBySubFieldValueOrDirection) + ? [] + : Object.entries(orderBySubFieldValueOrDirection)[0]; const readFieldValueToSort = ( edge: RecordGqlRefEdge, @@ -36,18 +61,39 @@ export const sortCachedObjectEdges = ({ if (!isSubFieldFilter) return fieldValue as string | number | null; const subFieldValue = - readCacheField( + readCacheField( orderBySubFieldName, fieldValue as Reference | StoreObject, ) ?? null; - return subFieldValue; + // Handle 3-level nesting: relation -> composite field -> sub-field + // e.g., accountOwner (relation) -> name (composite FULL_NAME) -> firstName + if ( + isDefined(subFieldValue) && + isDefined(orderBySubSubFieldNameOrDirection) + ) { + const subSubFieldValue = + readCacheField( + orderBySubSubFieldNameOrDirection, + // At this level, subFieldValue is the composite object (not a Reference) + subFieldValue as Reference | StoreObject, + ) ?? null; + return subSubFieldValue; + } + + return subFieldValue as string | number | null; }; - const orderByValue = orderBySubFieldValue || (orderByFieldValue as OrderBy); + const orderByDirection = + extractOrderByDirection(orderBySubFieldValueOrDirection) || + extractOrderByDirection(orderByFieldValueOrDirection); - const isAsc = orderByValue.startsWith('Asc'); - const isNullsFirst = orderByValue.endsWith('NullsFirst'); + if (!orderByDirection) { + return edges; + } + + const isAsc = orderByDirection.startsWith('Asc'); + const isNullsFirst = orderByDirection.endsWith('NullsFirst'); return [...edges].sort((edgeA, edgeB) => { const fieldValueA = readFieldValueToSort(edgeA); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/filterSortableFieldMetadataItems.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/filterSortableFieldMetadataItems.test.ts new file mode 100644 index 0000000000..563d5b5436 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/filterSortableFieldMetadataItems.test.ts @@ -0,0 +1,100 @@ +import { filterSortableFieldMetadataItems } from '@/object-metadata/utils/filterSortableFieldMetadataItems'; +import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; + +describe('filterSortableFieldMetadataItems', () => { + it('should allow TEXT field type', () => { + const field = { + type: FieldMetadataType.TEXT, + isSystem: false, + isActive: true, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(true); + }); + + it('should allow NUMBER field type', () => { + const field = { + type: FieldMetadataType.NUMBER, + isSystem: false, + isActive: true, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(true); + }); + + it('should allow DATE_TIME field type', () => { + const field = { + type: FieldMetadataType.DATE_TIME, + isSystem: false, + isActive: true, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(true); + }); + + it('should allow MANY_TO_ONE relation field type', () => { + const field = { + type: FieldMetadataType.RELATION, + isSystem: false, + isActive: true, + relation: { + type: RelationType.MANY_TO_ONE, + }, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(true); + }); + + it('should NOT allow ONE_TO_MANY relation field type', () => { + const field = { + type: FieldMetadataType.RELATION, + isSystem: false, + isActive: true, + relation: { + type: RelationType.ONE_TO_MANY, + }, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(false); + }); + + it('should NOT allow RELATION field without relation property', () => { + const field = { + type: FieldMetadataType.RELATION, + isSystem: false, + isActive: true, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(false); + }); + + it('should NOT allow system fields', () => { + const field = { + type: FieldMetadataType.TEXT, + isSystem: true, + isActive: true, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(false); + }); + + it('should NOT allow inactive fields', () => { + const field = { + type: FieldMetadataType.TEXT, + isSystem: false, + isActive: false, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(false); + }); + + it('should NOT allow unsortable field types like RICH_TEXT', () => { + const field = { + type: FieldMetadataType.RICH_TEXT, + isSystem: false, + isActive: true, + }; + + expect(filterSortableFieldMetadataItems(field)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getOrderByForRelationField.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getOrderByForRelationField.test.ts new file mode 100644 index 0000000000..590ef95934 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getOrderByForRelationField.test.ts @@ -0,0 +1,153 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getOrderByForRelationField } from '@/object-metadata/utils/getOrderByForFieldMetadataType'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +describe('getOrderByForRelationField', () => { + it('should generate nested orderBy for relation with TEXT label identifier', () => { + const field: Pick = { + name: 'company', + }; + + const relatedObjectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'labelIdentifierFieldMetadataId' + > = { + labelIdentifierFieldMetadataId: 'name-field-id', + fields: [ + { + id: 'name-field-id', + name: 'name', + type: FieldMetadataType.TEXT, + } as FieldMetadataItem, + ], + }; + + const result = getOrderByForRelationField( + field, + relatedObjectMetadataItem, + 'AscNullsLast', + ); + + // Should produce nested structure: { company: { name: 'AscNullsLast' } } + expect(result).toEqual([{ company: { name: 'AscNullsLast' } }]); + }); + + it('should generate nested orderBy for relation with FULL_NAME label identifier', () => { + const field: Pick = { + name: 'person', + }; + + const relatedObjectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'labelIdentifierFieldMetadataId' + > = { + labelIdentifierFieldMetadataId: 'name-field-id', + fields: [ + { + id: 'name-field-id', + name: 'name', + type: FieldMetadataType.FULL_NAME, + } as FieldMetadataItem, + ], + }; + + const result = getOrderByForRelationField( + field, + relatedObjectMetadataItem, + 'DescNullsLast', + ); + + // Should produce nested structure with composite field + expect(result).toEqual([ + { + person: { + name: { + firstName: 'DescNullsLast', + lastName: 'DescNullsLast', + }, + }, + }, + ]); + }); + + it('should fallback to FK when no label identifier field is found', () => { + const field: Pick = { + name: 'company', + }; + + const relatedObjectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'labelIdentifierFieldMetadataId' + > = { + labelIdentifierFieldMetadataId: 'non-existent-field-id', + fields: [], + }; + + const result = getOrderByForRelationField( + field, + relatedObjectMetadataItem, + 'AscNullsLast', + ); + + expect(result).toEqual([{ companyId: 'AscNullsLast' }]); + }); + + it('should use default "name" field when labelIdentifierFieldMetadataId is not set', () => { + const field: Pick = { + name: 'company', + }; + + const relatedObjectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'labelIdentifierFieldMetadataId' + > = { + labelIdentifierFieldMetadataId: undefined as unknown as string, + fields: [ + { + id: 'name-field-id', + name: 'name', + type: FieldMetadataType.TEXT, + } as FieldMetadataItem, + ], + }; + + const result = getOrderByForRelationField( + field, + relatedObjectMetadataItem, + 'AscNullsLast', + ); + + // When labelIdentifierFieldMetadataId is not set, isLabelIdentifierField + // falls back to checking for a field named 'name' + expect(result).toEqual([{ company: { name: 'AscNullsLast' } }]); + }); + + it('should handle descending direction', () => { + const field: Pick = { + name: 'company', + }; + + const relatedObjectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'labelIdentifierFieldMetadataId' + > = { + labelIdentifierFieldMetadataId: 'name-field-id', + fields: [ + { + id: 'name-field-id', + name: 'name', + type: FieldMetadataType.TEXT, + } as FieldMetadataItem, + ], + }; + + const result = getOrderByForRelationField( + field, + relatedObjectMetadataItem, + 'DescNullsLast', + ); + + expect(result).toEqual([{ company: { name: 'DescNullsLast' } }]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts index 35a12320bb..8561382326 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts @@ -1,7 +1,14 @@ import { SORTABLE_FIELD_METADATA_TYPES } from '@/object-metadata/constants/SortableFieldMetadataTypes'; -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; -export const filterSortableFieldMetadataItems = (field: FieldMetadataItem) => { +type SortableFieldInput = { + isSystem?: boolean | null; + isActive?: boolean | null; + type: FieldMetadataType; + relation?: { type: RelationType } | null; +}; + +export const filterSortableFieldMetadataItems = (field: SortableFieldInput) => { const isSystemField = field.isSystem; const isFieldActive = field.isActive; @@ -9,5 +16,13 @@ export const filterSortableFieldMetadataItems = (field: FieldMetadataItem) => { field.type, ); - return !isSystemField && isFieldActive && isFieldTypeSortable; + const isRelationFieldSortable = + field.type === FieldMetadataType.RELATION && + field.relation?.type === RelationType.MANY_TO_ONE; + + return ( + !isSystemField && + isFieldActive && + (isFieldTypeSortable || isRelationFieldSortable) + ); }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts b/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts index 6813f72d6e..2eeaa33910 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts @@ -1,4 +1,6 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { @@ -71,3 +73,27 @@ export const getOrderByForFieldMetadataType = ( ]; } }; + +export const getOrderByForRelationField = ( + field: Pick, + relatedObjectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'labelIdentifierFieldMetadataId' + >, + direction: OrderBy, +): RecordGqlOperationOrderBy => { + const labelIdentifierField = getLabelIdentifierFieldMetadataItem( + relatedObjectMetadataItem, + ); + + if (!labelIdentifierField) { + return [{ [`${field.name}Id`]: direction }]; + } + + const labelFieldOrderBy = getOrderByForFieldMetadataType( + labelIdentifierField, + direction, + ); + + return [{ [field.name]: labelFieldOrderBy[0] }]; +}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationOrderBy.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationOrderBy.ts index f686023564..783e0d63e1 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationOrderBy.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationOrderBy.ts @@ -1,5 +1,13 @@ import { type OrderBy } from '@/types/OrderBy'; +// Recursive type for nested orderBy values +// Supports: OrderBy | { field: OrderBy } | { field: { subField: OrderBy } } | ... +type OrderByValue = OrderBy | { [fieldName: string]: OrderByValue }; + +// Supports nested ordering for: +// - Scalar fields: { field: OrderBy } +// - Composite fields: { field: { subField: OrderBy } } +// - Relation + composite: { relation: { compositeLabel: { subField: OrderBy } } } export type RecordGqlOperationOrderBy = Array<{ - [fieldName: string]: OrderBy | { [subFieldName: string]: OrderBy }; + [fieldName: string]: OrderByValue; }>; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts index e218bc54c0..92401e2c66 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts @@ -6,6 +6,7 @@ import { type RecordSort } from '@/object-record/record-sort/types/RecordSort'; import { type EachTestingContext } from 'twenty-shared/testing'; import { FieldMetadataType, + RelationType, ViewSortDirection, } from '~/generated-metadata/graphql'; @@ -165,4 +166,144 @@ describe('turnSortsIntoOrderBy', () => { ).toEqual(expected); }, ); + + describe('relation field sorting', () => { + const companyObjectMetadataItem: ObjectMetadataItem = { + id: 'company-object-id', + fields: [ + { + id: 'company-name-field-id', + name: 'name', + type: FieldMetadataType.TEXT, + label: 'Name', + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + isActive: true, + } as FieldMetadataItem, + ], + readableFields: [], + updatableFields: [], + indexMetadatas: [], + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + nameSingular: 'company', + namePlural: 'companies', + labelIdentifierFieldMetadataId: 'company-name-field-id', + icon: 'IconBuildingSkyscraper', + isActive: true, + isSystem: false, + isUIReadOnly: false, + isCustom: false, + isRemote: false, + isSearchable: false, + labelPlural: 'Companies', + labelSingular: 'Company', + isLabelSyncedWithName: true, + }; + + const personObjectMetadataItem: ObjectMetadataItem = { + id: 'person-object-id', + fields: [ + { + id: 'company-relation-field-id', + name: 'company', + type: FieldMetadataType.RELATION, + label: 'Company', + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + isActive: true, + relation: { + type: RelationType.MANY_TO_ONE, + targetObjectMetadata: { + nameSingular: 'company', + }, + }, + } as unknown as FieldMetadataItem, + { + id: 'position-field-id', + name: 'position', + type: FieldMetadataType.POSITION, + label: 'Position', + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + } as FieldMetadataItem, + ], + readableFields: [], + updatableFields: [], + indexMetadatas: [], + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + nameSingular: 'person', + namePlural: 'people', + labelIdentifierFieldMetadataId: 'person-name-field-id', + icon: 'IconUser', + isActive: true, + isSystem: false, + isUIReadOnly: false, + isCustom: false, + isRemote: false, + isSearchable: false, + labelPlural: 'People', + labelSingular: 'Person', + isLabelSyncedWithName: true, + }; + + it('should sort by relation field using label identifier', () => { + const sorts: RecordSort[] = [ + { + id: 'sort-1', + fieldMetadataId: 'company-relation-field-id', + direction: ViewSortDirection.ASC, + }, + ]; + + const result = turnSortsIntoOrderBy(personObjectMetadataItem, sorts, [ + companyObjectMetadataItem, + ]); + + // Should produce nested structure for GraphQL: { company: { name: 'AscNullsFirst' } } + expect(result).toEqual([ + { company: { name: 'AscNullsFirst' } }, + { position: 'AscNullsFirst' }, + ]); + }); + + it('should sort by relation field descending', () => { + const sorts: RecordSort[] = [ + { + id: 'sort-1', + fieldMetadataId: 'company-relation-field-id', + direction: ViewSortDirection.DESC, + }, + ]; + + const result = turnSortsIntoOrderBy(personObjectMetadataItem, sorts, [ + companyObjectMetadataItem, + ]); + + // Should produce nested structure for GraphQL: { company: { name: 'DescNullsLast' } } + expect(result).toEqual([ + { company: { name: 'DescNullsLast' } }, + { position: 'AscNullsFirst' }, + ]); + }); + + it('should fallback to FK when related object not found', () => { + const sorts: RecordSort[] = [ + { + id: 'sort-1', + fieldMetadataId: 'company-relation-field-id', + direction: ViewSortDirection.ASC, + }, + ]; + + // Pass empty objectMetadataItems array - related object not found + const result = turnSortsIntoOrderBy(personObjectMetadataItem, sorts, []); + + expect(result).toEqual([ + { companyId: 'AscNullsFirst' }, + { position: 'AscNullsFirst' }, + ]); + }); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts index 694f313c02..91197ae997 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts @@ -4,20 +4,22 @@ import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/Re import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType'; +import { + getOrderByForFieldMetadataType, + getOrderByForRelationField, +} from '@/object-metadata/utils/getOrderByForFieldMetadataType'; import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField'; import { type RecordSort } from '@/object-record/record-sort/types/RecordSort'; import { type OrderBy } from '@/types/OrderBy'; import { isDefined } from 'twenty-shared/utils'; -import { ViewSortDirection } from '~/generated/graphql'; +import { FieldMetadataType, ViewSortDirection } from '~/generated/graphql'; export const turnSortsIntoOrderBy = ( objectMetadataItem: ObjectMetadataItem, sorts: RecordSort[], + objectMetadataItems: ObjectMetadataItem[] = [], ): RecordGqlOperationOrderBy => { - const fields: Pick[] = - objectMetadataItem?.fields ?? []; + const fields = objectMetadataItem?.fields ?? []; const fieldsById = mapArrayToObject(fields, ({ id }) => id); @@ -34,6 +36,25 @@ export const turnSortsIntoOrderBy = ( ? 'AscNullsFirst' : 'DescNullsLast'; + // Handle RELATION fields by looking up related object metadata + if (correspondingField.type === FieldMetadataType.RELATION) { + const relatedObjectName = + correspondingField.relation?.targetObjectMetadata?.nameSingular; + const relatedObjectMetadata = objectMetadataItems.find( + (item) => item.nameSingular === relatedObjectName, + ); + + if (isDefined(relatedObjectMetadata)) { + return getOrderByForRelationField( + correspondingField, + relatedObjectMetadata, + direction, + ); + } + // Fallback if related object not found - sort by FK + return [{ [`${correspondingField.name}Id`]: direction }]; + } + return getOrderByForFieldMetadataType(correspondingField, direction); }) .filter(isDefined); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts index 5fa9ad4f39..d27fc2a631 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts @@ -1,4 +1,5 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; @@ -20,6 +21,7 @@ export const useFindManyRecordIndexTableParams = ( const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); + const { objectMetadataItems } = useObjectMetadataItems(); const { recordGroupFilter } = useRecordGroupFilter( objectMetadataItem?.fields, @@ -58,7 +60,11 @@ export const useFindManyRecordIndexTableParams = ( filterValue: anyFieldFilterValue, }); - const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts); + const orderBy = turnSortsIntoOrderBy( + objectMetadataItem, + currentRecordSorts, + objectMetadataItems, + ); return { objectNameSingular, diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts index 2ab4eca048..7506b449e9 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts @@ -1,3 +1,4 @@ +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; @@ -18,6 +19,7 @@ import { export const useRecordIndexGroupCommonQueryVariables = () => { const { objectMetadataItem } = useRecordIndexContextOrThrow(); + const { objectMetadataItems } = useObjectMetadataItems(); const currentRecordFilterGroups = useRecoilComponentValue( currentRecordFilterGroupsComponentState, @@ -50,7 +52,11 @@ export const useRecordIndexGroupCommonQueryVariables = () => { filterValue: anyFieldFilterValue, }); - const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts); + const orderBy = turnSortsIntoOrderBy( + objectMetadataItem, + currentRecordSorts, + objectMetadataItems, + ); const recordGroupFieldMetadata = useRecoilComponentValue( recordIndexGroupFieldMetadataItemComponentState, diff --git a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromParentView.ts b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromParentView.ts index 6de737a3da..b4916b71e5 100644 --- a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromParentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromParentView.ts @@ -1,5 +1,6 @@ import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; @@ -10,6 +11,8 @@ export const useQueryVariablesFromParentView = ({ }: { objectMetadataItem: ObjectMetadataItem; }) => { + const { objectMetadataItems } = useObjectMetadataItems(); + const recordShowParentView = useRecoilComponentValue( contextStoreRecordShowParentViewComponentState, MAIN_CONTEXT_STORE_INSTANCE_ID, @@ -22,6 +25,7 @@ export const useQueryVariablesFromParentView = ({ recordFilters: recordShowParentView?.parentViewFilters ?? [], recordSorts: recordShowParentView?.parentViewSorts ?? [], objectMetadataItem, + objectMetadataItems, filterValueDependencies, }); diff --git a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromFiltersAndSorts.ts b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromFiltersAndSorts.ts index 17a1bda720..a92a7e6eb0 100644 --- a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromFiltersAndSorts.ts +++ b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromFiltersAndSorts.ts @@ -11,12 +11,14 @@ export const getQueryVariablesFromFiltersAndSorts = ({ recordFilters, recordSorts, objectMetadataItem, + objectMetadataItems = [], filterValueDependencies, }: { recordFilterGroups: RecordFilterGroup[]; recordFilters: RecordFilter[]; recordSorts: RecordSort[]; objectMetadataItem: ObjectMetadataItem; + objectMetadataItems?: ObjectMetadataItem[]; filterValueDependencies: RecordFilterValueDependencies; }) => { const filter = computeRecordGqlOperationFilter({ @@ -26,7 +28,11 @@ export const getQueryVariablesFromFiltersAndSorts = ({ recordFilters, }); - const orderBy = turnSortsIntoOrderBy(objectMetadataItem, recordSorts); + const orderBy = turnSortsIntoOrderBy( + objectMetadataItem, + recordSorts, + objectMetadataItems, + ); return { filter, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx index c7ea8be4d8..cc24f02980 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx @@ -273,7 +273,11 @@ export const WorkflowEditActionFindRecords = ({ const gqlOperationOrderBy = sorts.length > 0 - ? turnSortsIntoOrderBy(selectedObjectMetadataItem, sorts) + ? turnSortsIntoOrderBy( + selectedObjectMetadataItem, + sorts, + objectMetadataItems, + ) : undefined; const newFormData: FindRecordsFormData = { diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts index 23332234d4..32f29b5c4d 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts @@ -129,7 +129,11 @@ export abstract class CommonBaseQueryRunnerService< args.selectedFields, ); - this.validateQueryComplexity(selectedFieldsResult, args); + this.validateQueryComplexity( + selectedFieldsResult, + args, + queryRunnerContext, + ); const processedArgs = { ...(await this.processArgs(args, queryRunnerContext, this.operationName)), @@ -174,6 +178,7 @@ export abstract class CommonBaseQueryRunnerService< protected computeQueryComplexity( selectedFieldsResult: CommonSelectedFieldsResult, _args: CommonInput, + _queryRunnerContext: CommonBaseQueryRunnerContext, ): number { const simpleFieldsComplexity = 1; const selectedFieldsComplexity = @@ -406,6 +411,7 @@ export abstract class CommonBaseQueryRunnerService< private validateQueryComplexity( selectedFieldsResult: CommonSelectedFieldsResult, args: CommonInput, + queryRunnerContext: CommonBaseQueryRunnerContext, ) { const maximumComplexity = this.twentyConfigService.get( 'COMMON_QUERY_COMPLEXITY_LIMIT', @@ -424,6 +430,7 @@ export abstract class CommonBaseQueryRunnerService< const queryComplexity = this.computeQueryComplexity( selectedFieldsResult, args, + queryRunnerContext, ); if (queryComplexity > maximumComplexity) { diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts index 14d5806fad..42ab2962db 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts @@ -29,13 +29,23 @@ import { CommonQueryNames, FindManyQueryArgs, } from 'src/engine/api/common/types/common-query-args.type'; +import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; import { getPageInfo } from 'src/engine/api/common/utils/get-page-info.util'; +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { getCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils'; +import { + countRelationFieldsInOrderBy, + hasRelationFieldInOrderBy, +} from 'src/engine/api/utils/validate-and-get-order-by.utils'; import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; @Injectable() @@ -90,6 +100,25 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi const cursor = getCursor(args); if (cursor) { + const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata( + flatFieldMetadataMaps, + flatObjectMetadata, + ); + + if ( + hasRelationFieldInOrderBy( + args.orderBy ?? [], + flatFieldMetadataMaps, + fieldIdByName, + ) + ) { + throw new GraphqlQueryRunnerException( + 'Cursor-based pagination is not supported with relation field ordering. Use offset pagination instead.', + GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + const cursorArgFilter = computeCursorArgFilter( cursor, orderByWithIdCondition, @@ -111,7 +140,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi appliedFilters, ); - commonQueryParser.applyOrderToBuilder( + const parsedOrderBy = commonQueryParser.applyOrderToBuilder( queryBuilder, orderByWithIdCondition, flatObjectMetadata.nameSingular, @@ -140,12 +169,17 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi queryBuilder.skip(args.offset); } - const objectRecords = (await queryBuilder - .setFindOptions({ - select: columnsToSelect, - }) - .take(limit + 1) - .getMany()) as ObjectRecord[]; + queryBuilder.setFindOptions({ select: columnsToSelect }); + queryBuilder.take(limit + 1); + + // Add relation order columns AFTER setFindOptions (setFindOptions clears addSelect) + commonQueryParser.addRelationOrderColumnsToBuilder( + queryBuilder, + parsedOrderBy, + flatObjectMetadata.nameSingular, + ); + + const objectRecords = (await queryBuilder.getMany()) as ObjectRecord[]; const pageInfo = getPageInfo( objectRecords, @@ -275,4 +309,31 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi ); } } + + protected override computeQueryComplexity( + selectedFieldsResult: CommonSelectedFieldsResult, + args: CommonInput, + queryRunnerContext: CommonBaseQueryRunnerContext, + ): number { + const baseComplexity = super.computeQueryComplexity( + selectedFieldsResult, + args, + queryRunnerContext, + ); + + const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext; + + const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata( + flatFieldMetadataMaps, + flatObjectMetadata, + ); + + const orderByRelationCount = countRelationFieldsInOrderBy( + args.orderBy ?? [], + flatFieldMetadataMaps, + fieldIdByName, + ); + + return baseComplexity + orderByRelationCount; + } } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index 4f0fa78a78..19baa8ded2 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -410,6 +410,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic protected override computeQueryComplexity( selectedFieldsResult: CommonSelectedFieldsResult, args: CommonInput, + _queryRunnerContext: CommonBaseQueryRunnerContext, ): number { const groupByQueryComplexity = 1; const simpleFieldsComplexity = 1; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts new file mode 100644 index 0000000000..5971949341 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts @@ -0,0 +1,660 @@ +import { isObject } from 'class-validator'; +import { + type AggregateOrderByWithGroupByField, + FieldMetadataType, + ObjectRecordGroupByDateGranularity, + type ObjectRecordOrderByForCompositeField, + type ObjectRecordOrderByForRelationField, + type ObjectRecordOrderByForScalarField, + type ObjectRecordOrderByWithGroupByDateField, + OrderByDirection, + type OrderByWithGroupBy, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; +import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util'; +import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; +import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util'; +import { + type GroupByDateField, + type GroupByField, + type GroupByRegularField, +} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; +import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; +import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; +import { + type AggregationField, + getAvailableAggregationsFromObjectFields, +} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; +import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; + +import { type OrderByClause } from './types/order-by-condition.type'; + +export class GraphqlQueryOrderGroupByParser { + private flatObjectMetadata: FlatObjectMetadata; + private flatObjectMetadataMaps: FlatEntityMaps; + private flatFieldMetadataMaps: FlatEntityMaps; + private fieldIdByName: Record; + + constructor( + flatObjectMetadata: FlatObjectMetadata, + flatObjectMetadataMaps: FlatEntityMaps, + flatFieldMetadataMaps: FlatEntityMaps, + ) { + this.flatObjectMetadata = flatObjectMetadata; + this.flatObjectMetadataMaps = flatObjectMetadataMaps; + this.flatFieldMetadataMaps = flatFieldMetadataMaps; + + const fieldMaps = buildFieldMapsFromFlatObjectMetadata( + flatFieldMetadataMaps, + flatObjectMetadata, + ); + + this.fieldIdByName = fieldMaps.fieldIdByName; + } + + parse({ + orderBy, + groupByFields, + }: { + orderBy: OrderByWithGroupBy; + groupByFields: GroupByField[]; + }): Record[] { + const parsedOrderBy: Record[] = []; + + const fields = this.flatObjectMetadata.fieldMetadataIds + .map((id) => this.flatFieldMetadataMaps.byId[id]) + .filter(isDefined); + + const availableAggregations: Record = + getAvailableAggregationsFromObjectFields(fields); + + for (const orderByArg of orderBy) { + if (this.isAggregateOrderByArg(orderByArg)) { + const parsedAggregateOrderBy = this.parseAggregateOrderByArg( + availableAggregations, + orderByArg, + this.flatObjectMetadata, + ); + + parsedOrderBy.push(parsedAggregateOrderBy); + continue; + } + + if (Object.keys(orderByArg).length > 1) { + throw new UserInputError( + 'Please provide orderBy field criteria one by one in orderBy array', + ); + } + + const fieldName = Object.keys(orderByArg)[0]; + const fieldMetadataId = this.fieldIdByName[fieldName]; + const fieldMetadata = this.flatFieldMetadataMaps.byId[fieldMetadataId]; + + if (!isDefined(fieldMetadata)) { + throw new UserInputError(`Cannot orderBy unknown field: ${fieldName}.`); + } + + if (this.isObjectRecordOrderByForScalarField(orderByArg)) { + const parsedOrderByForScalarField = + this.parseObjectRecordOrderByForScalarField({ + groupByFields, + orderByArg, + flatObjectMetadata: this.flatObjectMetadata, + fieldMetadata, + }); + + if (!isDefined(parsedOrderByForScalarField)) { + continue; + } + + parsedOrderBy.push(parsedOrderByForScalarField); + continue; + } + + if ( + this.isObjectRecordOrderByWithGroupByDateField( + orderByArg, + fieldMetadata.type, + ) + ) { + const parsedOrderByForGroupByDateField = + this.parseObjectRecordOrderByWithGroupByDateField({ + groupByFields, + orderByArg, + fieldMetadataId, + }); + + if (!isDefined(parsedOrderByForGroupByDateField)) { + continue; + } + + parsedOrderBy.push(parsedOrderByForGroupByDateField); + continue; + } + + if ( + this.isObjectRecordOrderByForRelationField(orderByArg, fieldMetadata) + ) { + const parsedOrderByForRelationField = + this.parseObjectRecordOrderByForRelationField({ + groupByFields, + orderByArg, + fieldMetadata, + }); + + if (!isDefined(parsedOrderByForRelationField)) { + continue; + } + + parsedOrderBy.push(parsedOrderByForRelationField); + continue; + } + + if (this.isObjectRecordOrderByForCompositeField(orderByArg)) { + const parsedOrderByForCompositeField = + this.parseObjectRecordOrderByForCompositeField({ + groupByFields, + orderByArg, + flatObjectMetadata: this.flatObjectMetadata, + fieldMetadata, + }); + + if (!isDefined(parsedOrderByForCompositeField)) { + continue; + } + + parsedOrderBy.push(parsedOrderByForCompositeField); + continue; + } + + throw new UserInputError(`Unknown orderBy value: ${orderByArg}`); + } + + return parsedOrderBy; + } + + private isAggregateOrderByArg = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | AggregateOrderByWithGroupByField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, + ): orderByArg is AggregateOrderByWithGroupByField => { + return isDefined(orderByArg.aggregate); + }; + + private isObjectRecordOrderByForScalarField = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, + ): orderByArg is ObjectRecordOrderByForScalarField => { + if (Object.keys(orderByArg).length > 1) { + throw new UserInputError( + 'Please provide orderBy field criteria one by one in orderBy array', + ); + } + + const scalarFieldOrCompositeFieldOrderByValue = + Object.values(orderByArg)[0]; + + if ( + Object.values(OrderByDirection).includes( + scalarFieldOrCompositeFieldOrderByValue, + ) + ) { + return true; + } + + return false; + }; + + private isObjectRecordOrderByForCompositeField = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, + ): orderByArg is ObjectRecordOrderByForCompositeField => { + const compositeFieldOrderByValue = Object.values(orderByArg)[0]; + + if (!isObject(compositeFieldOrderByValue)) { + throw new UserInputError( + `Unknown orderBy value: ${compositeFieldOrderByValue}`, + ); + } + + if (Object.values(compositeFieldOrderByValue).length > 1) { + throw new UserInputError( + 'Please provide orderBy field criteria one by one in orderBy array', + ); + } + + const compositeFieldOrderByDirection = Object.values( + compositeFieldOrderByValue, + )[0]; + + if ( + Object.values(OrderByDirection).includes( + compositeFieldOrderByDirection as OrderByDirection, + ) + ) { + return true; + } + + return false; + }; + + private isObjectRecordOrderByWithGroupByDateField = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | AggregateOrderByWithGroupByField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, + fieldMetadataType: FieldMetadataType, + ): orderByArg is ObjectRecordOrderByWithGroupByDateField => { + if ( + fieldMetadataType !== FieldMetadataType.DATE && + fieldMetadataType !== FieldMetadataType.DATE_TIME + ) { + return false; + } + + if (Object.keys(orderByArg).length > 1) { + throw new UserInputError( + 'Please provide orderBy field criteria one by one in orderBy array', + ); + } + + const dateFieldOrderByValue = Object.values(orderByArg)[0]; + + if (!isDefined(dateFieldOrderByValue)) { + return false; + } + + if (!isDefined(dateFieldOrderByValue.orderBy)) { + return false; + } + + if ( + !Object.values(OrderByDirection).includes(dateFieldOrderByValue.orderBy) + ) { + return false; + } + + if (!isDefined(dateFieldOrderByValue.granularity)) { + return false; + } + + if ( + !Object.values(ObjectRecordGroupByDateGranularity).includes( + dateFieldOrderByValue.granularity, + ) + ) { + return false; + } + + return true; + }; + + private isObjectRecordOrderByForRelationField = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, + fieldMetadata: FlatFieldMetadata, + ): orderByArg is ObjectRecordOrderByForRelationField => { + if (!isMorphOrRelationFlatFieldMetadata(fieldMetadata)) { + return false; + } + + const relationFieldOrderByValue = Object.values(orderByArg)[0]; + + if (!isObject(relationFieldOrderByValue)) { + return false; + } + + return Object.keys(relationFieldOrderByValue).length > 0; + }; + + private parseAggregateOrderByArg = ( + availableAggregations: Record, + orderByArg: AggregateOrderByWithGroupByField, + flatObjectMetadata: FlatObjectMetadata, + ): Record => { + const aggregate = orderByArg.aggregate; + + if (Object.keys(aggregate).length > 1) { + throw new UserInputError( + 'Please provide aggregate criteria one by one in orderBy array', + ); + } + + const aggregateField = availableAggregations[Object.keys(aggregate)[0]]; + + if (!aggregateField) { + throw new UserInputError( + `Unknown aggregate field: ${Object.keys(aggregate)[0]}`, + ); + } + + const aggregateExpression = ProcessAggregateHelper.getAggregateExpression( + aggregateField, + flatObjectMetadata.nameSingular, + ); + + if (!isDefined(aggregateExpression)) { + throw new UserInputError( + `Cannot find expression for aggregate field: ${Object.keys(aggregate)[0]}`, + ); + } + const orderByDirection = Object.values(aggregate)[0]; + const convertedDirection = + convertOrderByToFindOptionsOrder(orderByDirection); + + return { + [aggregateExpression]: convertedDirection, + }; + }; + + private parseObjectRecordOrderByForScalarField = ({ + groupByFields, + orderByArg, + flatObjectMetadata, + fieldMetadata, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByForScalarField; + flatObjectMetadata: FlatObjectMetadata; + fieldMetadata: FlatFieldMetadata; + }): Record | null => { + const fieldIsInGroupBy = groupByFields.some( + (groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id, + ); + + if (!fieldIsInGroupBy) { + throw new UserInputError( + `Cannot order by a field that is not an aggregate nor in groupBy criteria: ${fieldMetadata.name}.`, + ); + } + + const orderByCasting = getOptionalOrderByCasting(fieldMetadata); + const orderByDirection = Object.values(orderByArg)[0]; + + if (!isDefined(orderByDirection)) { + return null; + } + + return { + [`"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]: + convertOrderByToFindOptionsOrder(orderByDirection), + }; + }; + + private parseObjectRecordOrderByForCompositeField = ({ + groupByFields, + orderByArg, + flatObjectMetadata, + fieldMetadata, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByForCompositeField; + flatObjectMetadata: FlatObjectMetadata; + fieldMetadata: FlatFieldMetadata; + }): Record | null => { + const fieldName = Object.keys(orderByArg)[0]; + const orderBySubField = orderByArg[fieldName]; + + if (!isDefined(orderBySubField)) { + return null; + } + + if (Object.keys(orderBySubField).length > 1) { + throw new UserInputError( + `Subfields must be provided one by one in orderBy array.`, + ); + } + + const subFieldName = Object.keys(orderBySubField)[0]; + + if ( + !groupByFields.some( + (groupByField) => + groupByField.fieldMetadata.id === fieldMetadata.id && + (groupByField as GroupByRegularField).subFieldName === subFieldName, + ) + ) { + throw new UserInputError( + `Cannot order by a field that is not in groupBy or that is not an aggregate field: ${subFieldName}`, + ); + } + + return parseCompositeFieldForOrder( + fieldMetadata, + orderBySubField, + flatObjectMetadata.nameSingular, + ); + }; + + private parseObjectRecordOrderByWithGroupByDateField = ({ + groupByFields, + orderByArg, + fieldMetadataId, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByWithGroupByDateField; + fieldMetadataId: string; + }): Record | null => { + const orderByDirection = Object.values(orderByArg)[0]?.orderBy; + + if (!isDefined(orderByDirection)) { + return null; + } + + const granularity = Object.values(orderByArg)[0]?.granularity; + + if (!isDefined(granularity)) { + throw new UserInputError( + `Missing date granularity for field ${Object.keys(orderByArg)[0]}`, + ); + } + + const associatedGroupByField = groupByFields.find( + (groupByField) => + groupByField.fieldMetadata.id === fieldMetadataId && + (groupByField as GroupByDateField).dateGranularity === granularity, + ) as GroupByDateField | undefined; + + if (!isDefined(associatedGroupByField)) { + throw new UserInputError( + `Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`, + ); + } + + const columnNameWithQuotes = `"${ + formatColumnNamesFromCompositeFieldAndSubfields( + associatedGroupByField.fieldMetadata.name, + associatedGroupByField.subFieldName + ? [associatedGroupByField.subFieldName] + : undefined, + )[0] + }"`; + + const expression = getGroupByExpression({ + groupByField: associatedGroupByField, + columnNameWithQuotes, + }); + + return { + [expression]: convertOrderByToFindOptionsOrder(orderByDirection), + }; + }; + + private parseObjectRecordOrderByForRelationField = ({ + groupByFields, + orderByArg, + fieldMetadata, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByForRelationField; + fieldMetadata: FlatFieldMetadata; + }): Record | null => { + const { + associatedGroupByField, + nestedFieldMetadata, + nestedFieldOrderByValue, + } = prepareForOrderByRelationFieldParsing({ + orderByArg, + fieldMetadata, + flatObjectMetadataMaps: this.flatObjectMetadataMaps, + flatFieldMetadataMaps: this.flatFieldMetadataMaps, + groupByFields, + }); + + if ( + !isDefined(associatedGroupByField) || + !isDefined(nestedFieldMetadata) || + !isDefined(nestedFieldOrderByValue) + ) { + return null; + } + + // Handle composite fields + if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) { + if (!isObject(nestedFieldOrderByValue)) { + throw new UserInputError( + `Composite field "${nestedFieldMetadata.name}" requires a subfield to be specified`, + ); + } + + const compositeSubFields = Object.keys(nestedFieldOrderByValue); + + if (compositeSubFields.length > 1) { + throw new UserInputError( + 'Please provide composite subfield criteria one by one in orderBy array', + ); + } + + const nestedSubFieldName = compositeSubFields[0]; + const orderByDirection = ( + nestedFieldOrderByValue as Record + )[nestedSubFieldName]; + + if (!isDefined(orderByDirection)) { + return null; + } + + if ( + !isDefined(associatedGroupByField.nestedSubFieldName) || + associatedGroupByField.nestedSubFieldName !== nestedSubFieldName + ) { + throw new UserInputError( + `Cannot order by a composite subfield that is not in groupBy criteria: ${nestedSubFieldName}`, + ); + } + + const joinAlias = fieldMetadata.name; + const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( + nestedFieldMetadata.name, + [nestedSubFieldName], + )[0]; + + const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; + + return { + [columnNameWithQuotes]: + convertOrderByToFindOptionsOrder(orderByDirection), + }; + } + + const isGroupByDateField = + (nestedFieldMetadata.type === FieldMetadataType.DATE || + nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) && + isObject(nestedFieldOrderByValue) && + 'orderBy' in nestedFieldOrderByValue && + 'granularity' in nestedFieldOrderByValue; + + if (isGroupByDateField) { + const orderByDirection = ( + nestedFieldOrderByValue as { + orderBy: OrderByDirection; + granularity: ObjectRecordGroupByDateGranularity; + } + ).orderBy; + const granularity = ( + nestedFieldOrderByValue as { + orderBy: OrderByDirection; + granularity: ObjectRecordGroupByDateGranularity; + } + ).granularity; + + if ( + !isDefined(associatedGroupByField.dateGranularity) || + associatedGroupByField.dateGranularity !== granularity + ) { + throw new UserInputError( + `Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`, + ); + } + + const joinAlias = fieldMetadata.name; + const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( + nestedFieldMetadata.name, + associatedGroupByField.nestedSubFieldName + ? [associatedGroupByField.nestedSubFieldName] + : undefined, + )[0]; + + const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; + + const expression = getGroupByExpression({ + groupByField: associatedGroupByField, + columnNameWithQuotes, + }); + + return { + [expression]: convertOrderByToFindOptionsOrder(orderByDirection), + }; + } + + // Handle regular nested fields + if ( + typeof nestedFieldOrderByValue === 'string' && + Object.values(OrderByDirection).includes( + nestedFieldOrderByValue as OrderByDirection, + ) + ) { + const orderByDirection = nestedFieldOrderByValue as OrderByDirection; + + const joinAlias = fieldMetadata.name; + const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( + nestedFieldMetadata.name, + associatedGroupByField.nestedSubFieldName + ? [associatedGroupByField.nestedSubFieldName] + : undefined, + )[0]; + + const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; + + return { + [columnNameWithQuotes]: + convertOrderByToFindOptionsOrder(orderByDirection), + }; + } + + return null; + }; +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts index 6070e8d3d9..bbeb1bea33 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts @@ -1,15 +1,4 @@ import { isObject } from 'class-validator'; -import { - type AggregateOrderByWithGroupByField, - FieldMetadataType, - ObjectRecordGroupByDateGranularity, - type ObjectRecordOrderByForCompositeField, - type ObjectRecordOrderByForRelationField, - type ObjectRecordOrderByForScalarField, - type ObjectRecordOrderByWithGroupByDateField, - OrderByDirection, - type OrderByWithGroupBy, -} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -20,33 +9,22 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; +import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util'; +import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util'; import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; -import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util'; -import { - type GroupByDateField, - type GroupByField, - type GroupByRegularField, -} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; -import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; -import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; -import { - type AggregationField, - getAvailableAggregationsFromObjectFields, -} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; -import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; -import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util'; -import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; -export type OrderByCondition = { - order: 'ASC' | 'DESC'; - nulls?: 'NULLS FIRST' | 'NULLS LAST'; -}; +import { type OrderByClause } from './types/order-by-condition.type'; +import { type ParseOrderByResult } from './types/parse-order-by-result.type'; +import { type RelationJoinInfo } from './types/relation-join-info.type'; + +// Re-export types for backward compatibility +export { OrderByClause, ParseOrderByResult, RelationJoinInfo }; export class GraphqlQueryOrderFieldParser { private flatObjectMetadata: FlatObjectMetadata; @@ -77,665 +55,202 @@ export class GraphqlQueryOrderFieldParser { orderBy: ObjectRecordOrderBy, objectNameSingular: string, isForwardPagination = true, - ): Record { - return orderBy.reduce( - (acc, item) => { - Object.entries(item).forEach(([fieldName, orderByDirection]) => { - const fieldMetadataId = - this.fieldIdByName[fieldName] || - this.fieldIdByJoinColumnName[fieldName]; - const fieldMetadata = - this.flatFieldMetadataMaps.byId[fieldMetadataId]; + ): ParseOrderByResult { + const orderByConditions: Record = {}; + const relationJoins: RelationJoinInfo[] = []; + const addedJoinAliases = new Set(); - if (!fieldMetadata || orderByDirection === undefined) { + for (const item of orderBy) { + for (const [fieldName, orderByDirection] of Object.entries(item)) { + // Check if accessed by relation name (company) vs FK name (companyId) + const isAccessedByRelationName = !!this.fieldIdByName[fieldName]; + const fieldMetadataId = + this.fieldIdByName[fieldName] || + this.fieldIdByJoinColumnName[fieldName]; + const fieldMetadata = this.flatFieldMetadataMaps.byId[fieldMetadataId]; + + if (!fieldMetadata || orderByDirection === undefined) { + throw new GraphqlQueryRunnerException( + `Field "${fieldName}" does not exist or is not sortable`, + GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + // Only treat as relation if accessed by relation name (not FK like companyId) + if ( + isAccessedByRelationName && + isMorphOrRelationFlatFieldMetadata(fieldMetadata) + ) { + if (!isObject(orderByDirection)) { throw new GraphqlQueryRunnerException( - `Field "${fieldName}" does not exist or is not sortable`, - GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND, + `Relation field "${fieldName}" requires nested field ordering (e.g., { ${fieldName}: { fieldName: 'AscNullsFirst' } })`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, ); } - if (isCompositeFieldMetadataType(fieldMetadata.type)) { - const compositeOrder = parseCompositeFieldForOrder( - fieldMetadata, - orderByDirection, - objectNameSingular, - isForwardPagination, - ); + const relationOrderResult = this.parseRelationFieldOrder({ + fieldMetadata, + orderByDirection: orderByDirection as Record, + isForwardPagination, + }); - Object.assign(acc, compositeOrder); - } else { - const orderByCasting = - this.getOptionalOrderByCasting(fieldMetadata); + if (relationOrderResult) { + Object.assign(orderByConditions, relationOrderResult.orderBy); - const columnName = isMorphOrRelationFlatFieldMetadata(fieldMetadata) - ? formatColumnNameForRelationField( - fieldMetadata.name, - fieldMetadata.settings, - ) - : fieldName; - - acc[`"${objectNameSingular}"."${columnName}"${orderByCasting}`] = - convertOrderByToFindOptionsOrder( - orderByDirection as OrderByDirection, - isForwardPagination, - ); + if (!addedJoinAliases.has(relationOrderResult.joinInfo.joinAlias)) { + relationJoins.push(relationOrderResult.joinInfo); + addedJoinAliases.add(relationOrderResult.joinInfo.joinAlias); + } + } + } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { + if (!isObject(orderByDirection)) { + throw new GraphqlQueryRunnerException( + `Composite field "${fieldName}" requires subfield ordering (e.g., { ${fieldName}: { subFieldName: 'AscNullsFirst' } })`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); } - }); - return acc; - }, - {} as Record, - ); - } - - parseForGroupBy({ - orderBy, - groupByFields, - }: { - orderBy: OrderByWithGroupBy; - groupByFields: GroupByField[]; - }): Record[] { - let parsedOrderBy: Record[] = []; - - const fields = this.flatObjectMetadata.fieldMetadataIds - .map((id) => this.flatFieldMetadataMaps.byId[id]) - .filter(isDefined); - - const availableAggregations: Record = - getAvailableAggregationsFromObjectFields(fields); - - for (const orderByArg of orderBy) { - if (this.isAggregateOrderByArg(orderByArg)) { - const parsedAggregateOrderBy = this.parseAggregateOrderByArg( - availableAggregations, - orderByArg, - this.flatObjectMetadata, - ); - - parsedOrderBy.push(parsedAggregateOrderBy); - continue; - } - - if (Object.keys(orderByArg).length > 1) { - throw new UserInputError( - 'Please provide orderBy field criteria one by one in orderBy array', - ); - } - - const fieldName = Object.keys(orderByArg)[0]; - const fieldMetadataId = this.fieldIdByName[fieldName]; - const fieldMetadata = this.flatFieldMetadataMaps.byId[fieldMetadataId]; - - if (!isDefined(fieldMetadata)) { - throw new UserInputError(`Cannot orderBy unknown field: ${fieldName}.`); - } - - if (this.isObjectRecordOrderByForScalarField(orderByArg)) { - const parsedOrderByForScalarField = - this.parseObjectRecordOrderByForScalarField({ - groupByFields, - orderByArg, - flatObjectMetadata: this.flatObjectMetadata, + const compositeOrder = parseCompositeFieldForOrder( fieldMetadata, - }); + orderByDirection as Record, + objectNameSingular, + isForwardPagination, + ); - if (!isDefined(parsedOrderByForScalarField)) { - continue; + Object.assign(orderByConditions, compositeOrder); + } else { + if (!isOrderByDirection(orderByDirection)) { + throw new GraphqlQueryRunnerException( + `Scalar field "${fieldName}" requires a direction value (AscNullsFirst, AscNullsLast, DescNullsFirst, DescNullsLast)`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const orderByCasting = getOptionalOrderByCasting(fieldMetadata); + + orderByConditions[ + `${objectNameSingular}.${fieldName}${orderByCasting}` + ] = convertOrderByToFindOptionsOrder( + orderByDirection, + isForwardPagination, + ); } - - parsedOrderBy.push(parsedOrderByForScalarField); - continue; } - - if ( - this.isObjectRecordOrderByWithGroupByDateField( - orderByArg, - fieldMetadata.type, - ) - ) { - const parsedOrderByForGroupByDateField = - this.parseObjectRecordOrderByWithGroupByDateField({ - groupByFields, - orderByArg, - fieldMetadataId, - }); - - if (!isDefined(parsedOrderByForGroupByDateField)) { - continue; - } - - parsedOrderBy.push(parsedOrderByForGroupByDateField); - continue; - } - - if ( - this.isObjectRecordOrderByForRelationField(orderByArg, fieldMetadata) - ) { - const parsedOrderByForRelationField = - this.parseObjectRecordOrderByForRelationField({ - groupByFields, - orderByArg, - fieldMetadata, - }); - - if (!isDefined(parsedOrderByForRelationField)) { - continue; - } - - parsedOrderBy.push(parsedOrderByForRelationField); - continue; - } - - if (this.isObjectRecordOrderByForCompositeField(orderByArg)) { - const parsedOrderByForCompositeField = - this.parseObjectRecordOrderByForCompositeField({ - groupByFields, - orderByArg, - flatObjectMetadata: this.flatObjectMetadata, - fieldMetadata, - }); - - if (!isDefined(parsedOrderByForCompositeField)) { - continue; - } - - parsedOrderBy.push(parsedOrderByForCompositeField); - continue; - } - - throw new UserInputError(`Unknown orderBy value: ${orderByArg}`); - } - - return parsedOrderBy; - } - - getOptionalOrderByCasting( - fieldMetadata: Pick, - ): string { - if ( - fieldMetadata.type === FieldMetadataType.SELECT || - fieldMetadata.type === FieldMetadataType.MULTI_SELECT - ) { - return '::text'; - } - - return ''; - } - - isAggregateOrderByArg = ( - orderByArg: - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByForCompositeField - | AggregateOrderByWithGroupByField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForRelationField, - ): orderByArg is AggregateOrderByWithGroupByField => { - return isDefined(orderByArg.aggregate); - }; - - isObjectRecordOrderByForScalarField = ( - orderByArg: - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForRelationField, - ): orderByArg is ObjectRecordOrderByForScalarField => { - if (Object.keys(orderByArg).length > 1) { - throw new UserInputError( - 'Please provide orderBy field criteria one by one in orderBy array', - ); - } - - const scalarFieldOrCompositeFieldOrderByValue = - Object.values(orderByArg)[0]; - - if ( - Object.values(OrderByDirection).includes( - scalarFieldOrCompositeFieldOrderByValue, - ) - ) { - return true; - } - - return false; - }; - - isObjectRecordOrderByForCompositeField = ( - orderByArg: - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForRelationField, - ): orderByArg is ObjectRecordOrderByForCompositeField => { - const compositeFieldOrderByValue = Object.values(orderByArg)[0]; - - if (!isObject(compositeFieldOrderByValue)) { - throw new UserInputError( - `Unknown orderBy value: ${compositeFieldOrderByValue}`, - ); - } - - if (Object.values(compositeFieldOrderByValue).length > 1) { - throw new UserInputError( - 'Please provide orderBy field criteria one by one in orderBy array', - ); - } - - const compositeFieldOrderByDirection = Object.values( - compositeFieldOrderByValue, - )[0]; - - if ( - Object.values(OrderByDirection).includes( - compositeFieldOrderByDirection as OrderByDirection, - ) - ) { - return true; - } - - return false; - }; - - isObjectRecordOrderByWithGroupByDateField = ( - orderByArg: - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByForCompositeField - | AggregateOrderByWithGroupByField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForRelationField, - fieldMetadataType: FieldMetadataType, - ): orderByArg is ObjectRecordOrderByWithGroupByDateField => { - if ( - fieldMetadataType !== FieldMetadataType.DATE && - fieldMetadataType !== FieldMetadataType.DATE_TIME - ) { - return false; - } - - if (Object.keys(orderByArg).length > 1) { - throw new UserInputError( - 'Please provide orderBy field criteria one by one in orderBy array', - ); - } - - const dateFieldOrderByValue = Object.values(orderByArg)[0]; - - if (!isDefined(dateFieldOrderByValue)) { - return false; - } - - if (!isDefined(dateFieldOrderByValue.orderBy)) { - return false; - } - - if ( - !Object.values(OrderByDirection).includes(dateFieldOrderByValue.orderBy) - ) { - return false; - } - - if (!isDefined(dateFieldOrderByValue.granularity)) { - return false; - } - - if ( - !Object.values(ObjectRecordGroupByDateGranularity).includes( - dateFieldOrderByValue.granularity, - ) - ) { - return false; - } - - return true; - }; - - parseAggregateOrderByArg = ( - availableAggregations: Record, - orderByArg: AggregateOrderByWithGroupByField, - flatObjectMetadata: FlatObjectMetadata, - ): Record => { - const aggregate = orderByArg.aggregate; - - if (Object.keys(aggregate).length > 1) { - throw new UserInputError( - 'Please provide aggregate criteria one by one in orderBy array', - ); - } - - const aggregateField = availableAggregations[Object.keys(aggregate)[0]]; - - if (!aggregateField) { - throw new UserInputError( - `Unknown aggregate field: ${Object.keys(aggregate)[0]}`, - ); - } - - const aggregateExpression = ProcessAggregateHelper.getAggregateExpression( - aggregateField, - flatObjectMetadata.nameSingular, - ); - - if (!isDefined(aggregateExpression)) { - throw new UserInputError( - `Cannot find expression for aggregate field: ${Object.keys(aggregate)[0]}`, - ); - } - const orderByDirection = Object.values(aggregate)[0]; - const convertedDirection = - convertOrderByToFindOptionsOrder(orderByDirection); - - return { - [aggregateExpression]: convertedDirection, - }; - }; - - parseObjectRecordOrderByForScalarField = ({ - groupByFields, - orderByArg, - flatObjectMetadata, - fieldMetadata, - }: { - groupByFields: GroupByField[]; - orderByArg: ObjectRecordOrderByForScalarField; - flatObjectMetadata: FlatObjectMetadata; - fieldMetadata: FlatFieldMetadata; - }): Record | null => { - const fieldIsInGroupBy = groupByFields.some( - (groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id, - ); - - if (!fieldIsInGroupBy) { - throw new UserInputError( - `Cannot order by a field that is not an aggregate nor in groupBy criteria: ${fieldMetadata.name}.`, - ); - } - - const orderByCasting = this.getOptionalOrderByCasting(fieldMetadata); - const orderByDirection = Object.values(orderByArg)[0]; - - if (!isDefined(orderByDirection)) { - return null; } return { - [`"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]: - convertOrderByToFindOptionsOrder(orderByDirection), + orderBy: orderByConditions, + relationJoins, }; - }; + } - parseObjectRecordOrderByForCompositeField = ({ - groupByFields, - orderByArg, - flatObjectMetadata, + private parseRelationFieldOrder({ fieldMetadata, + orderByDirection, + isForwardPagination, }: { - groupByFields: GroupByField[]; - orderByArg: ObjectRecordOrderByForCompositeField; - flatObjectMetadata: FlatObjectMetadata; fieldMetadata: FlatFieldMetadata; - }): Record | null => { - const fieldName = Object.keys(orderByArg)[0]; - const orderBySubField = orderByArg[fieldName]; - - if (!isDefined(orderBySubField)) { + orderByDirection: Record; + isForwardPagination: boolean; + }): { + orderBy: Record; + joinInfo: RelationJoinInfo; + } | null { + if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) { return null; } - if (Object.keys(orderBySubField).length > 1) { - throw new UserInputError( - `Subfields must be provided one by one in orderBy array.`, - ); - } + const targetObjectMetadata = + this.flatObjectMetadataMaps.byId[ + fieldMetadata.relationTargetObjectMetadataId + ]; - const subFieldName = Object.keys(orderBySubField)[0]; - - if ( - !groupByFields.some( - (groupByField) => - groupByField.fieldMetadata.id === fieldMetadata.id && - (groupByField as GroupByRegularField).subFieldName === subFieldName, - ) - ) { - throw new UserInputError( - `Cannot order by a field that is not in groupBy or that is not an aggregate field: ${subFieldName}`, - ); - } - - return parseCompositeFieldForOrder( - fieldMetadata, - orderBySubField, - flatObjectMetadata.nameSingular, - ); - }; - - parseObjectRecordOrderByWithGroupByDateField = ({ - groupByFields, - orderByArg, - fieldMetadataId, - }: { - groupByFields: GroupByField[]; - orderByArg: ObjectRecordOrderByWithGroupByDateField; - fieldMetadataId: string; - }): Record | null => { - const orderByDirection = Object.values(orderByArg)[0]?.orderBy; - - if (!isDefined(orderByDirection)) { + if (!isDefined(targetObjectMetadata)) { return null; } - const granularity = Object.values(orderByArg)[0]?.granularity; + const nestedFieldName = Object.keys(orderByDirection)[0]; + const nestedFieldOrderByValue = orderByDirection[nestedFieldName]; - if (!isDefined(granularity)) { - throw new UserInputError( - `Missing date granularity for field ${Object.keys(orderByArg)[0]}`, + if (!isDefined(nestedFieldOrderByValue)) { + return null; + } + + const { fieldIdByName: targetFieldIdByName } = + buildFieldMapsFromFlatObjectMetadata( + this.flatFieldMetadataMaps, + targetObjectMetadata, + ); + + const nestedFieldMetadataId = targetFieldIdByName[nestedFieldName]; + + if (!isDefined(nestedFieldMetadataId)) { + throw new GraphqlQueryRunnerException( + `Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`, + GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, ); } - const associatedGroupByField = groupByFields.find( - (groupByField) => - groupByField.fieldMetadata.id === fieldMetadataId && - (groupByField as GroupByDateField).dateGranularity === granularity, - ) as GroupByDateField | undefined; + const nestedFieldMetadata = + this.flatFieldMetadataMaps.byId[nestedFieldMetadataId]; - if (!isDefined(associatedGroupByField)) { - throw new UserInputError( - `Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`, - ); + if (!isDefined(nestedFieldMetadata)) { + return null; } - const columnNameWithQuotes = `"${ - formatColumnNamesFromCompositeFieldAndSubfields( - associatedGroupByField.fieldMetadata.name, - associatedGroupByField.subFieldName - ? [associatedGroupByField.subFieldName] - : undefined, - )[0] - }"`; + const joinAlias = fieldMetadata.name; - const expression = getGroupByExpression({ - groupByField: associatedGroupByField, - columnNameWithQuotes, - }); - - return { - [expression]: convertOrderByToFindOptionsOrder(orderByDirection), + const joinInfo: RelationJoinInfo = { + joinAlias, }; - }; - parseObjectRecordOrderByForRelationField = ({ - groupByFields, - orderByArg, - fieldMetadata, - }: { - groupByFields: GroupByField[]; - orderByArg: ObjectRecordOrderByForRelationField; - fieldMetadata: FlatFieldMetadata; - }): Record | null => { - const { - associatedGroupByField, - nestedFieldMetadata, - nestedFieldOrderByValue, - } = prepareForOrderByRelationFieldParsing({ - orderByArg, - fieldMetadata, - flatObjectMetadataMaps: this.flatObjectMetadataMaps, - flatFieldMetadataMaps: this.flatFieldMetadataMaps, - groupByFields, - }); - - if ( - !isDefined(associatedGroupByField) || - !isDefined(nestedFieldMetadata) || - !isDefined(nestedFieldOrderByValue) - ) { - return null; - } - - // Handle composite fields if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) { if (!isObject(nestedFieldOrderByValue)) { - throw new UserInputError( + throw new GraphqlQueryRunnerException( `Composite field "${nestedFieldMetadata.name}" requires a subfield to be specified`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, ); } - const compositeSubFields = Object.keys(nestedFieldOrderByValue); + const compositeOrder = parseCompositeFieldForOrder( + nestedFieldMetadata, + nestedFieldOrderByValue as Record, + joinAlias, + isForwardPagination, + ); - if (compositeSubFields.length > 1) { - throw new UserInputError( - 'Please provide composite subfield criteria one by one in orderBy array', - ); - } - - const nestedSubFieldName = compositeSubFields[0]; - const orderByDirection = ( - nestedFieldOrderByValue as Record - )[nestedSubFieldName]; - - if (!isDefined(orderByDirection)) { + if (Object.keys(compositeOrder).length === 0) { return null; } - if ( - !isDefined(associatedGroupByField.nestedSubFieldName) || - associatedGroupByField.nestedSubFieldName !== nestedSubFieldName - ) { - throw new UserInputError( - `Cannot order by a composite subfield that is not in groupBy criteria: ${nestedSubFieldName}`, - ); - } - - const joinAlias = fieldMetadata.name; - const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( - nestedFieldMetadata.name, - [nestedSubFieldName], - )[0]; - - const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; - return { - [columnNameWithQuotes]: - convertOrderByToFindOptionsOrder(orderByDirection), + orderBy: compositeOrder, + joinInfo, }; } - const isGroupByDateField = - (nestedFieldMetadata.type === FieldMetadataType.DATE || - nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) && - isObject(nestedFieldOrderByValue) && - 'orderBy' in nestedFieldOrderByValue && - 'granularity' in nestedFieldOrderByValue; - - if (isGroupByDateField) { - const orderByDirection = ( - nestedFieldOrderByValue as { - orderBy: OrderByDirection; - granularity: ObjectRecordGroupByDateGranularity; - } - ).orderBy; - const granularity = ( - nestedFieldOrderByValue as { - orderBy: OrderByDirection; - granularity: ObjectRecordGroupByDateGranularity; - } - ).granularity; - - if ( - !isDefined(associatedGroupByField.dateGranularity) || - associatedGroupByField.dateGranularity !== granularity - ) { - throw new UserInputError( - `Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`, - ); - } - - const joinAlias = fieldMetadata.name; - const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( - nestedFieldMetadata.name, - associatedGroupByField.nestedSubFieldName - ? [associatedGroupByField.nestedSubFieldName] - : undefined, - )[0]; - - const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; - - const expression = getGroupByExpression({ - groupByField: associatedGroupByField, - columnNameWithQuotes, - }); + if (isOrderByDirection(nestedFieldOrderByValue)) { + const nestedColumnName = nestedFieldMetadata.name; + const orderByCasting = getOptionalOrderByCasting(nestedFieldMetadata); return { - [expression]: convertOrderByToFindOptionsOrder(orderByDirection), - }; - } - - // Handle regular nested fields - if ( - typeof nestedFieldOrderByValue === 'string' && - Object.values(OrderByDirection).includes( - nestedFieldOrderByValue as OrderByDirection, - ) - ) { - const orderByDirection = nestedFieldOrderByValue as OrderByDirection; - - const joinAlias = fieldMetadata.name; - const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( - nestedFieldMetadata.name, - associatedGroupByField.nestedSubFieldName - ? [associatedGroupByField.nestedSubFieldName] - : undefined, - )[0]; - - const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; - - return { - [columnNameWithQuotes]: - convertOrderByToFindOptionsOrder(orderByDirection), + orderBy: { + [`${joinAlias}.${nestedColumnName}${orderByCasting}`]: + convertOrderByToFindOptionsOrder( + nestedFieldOrderByValue, + isForwardPagination, + ), + }, + joinInfo, }; } return null; - }; - - isObjectRecordOrderByForRelationField = ( - orderByArg: - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForRelationField, - fieldMetadata: FlatFieldMetadata, - ): orderByArg is ObjectRecordOrderByForRelationField => { - if (!isMorphOrRelationFlatFieldMetadata(fieldMetadata)) { - return false; - } - - const relationFieldOrderByValue = Object.values(orderByArg)[0]; - - if (!isObject(relationFieldOrderByValue)) { - return false; - } - - return Object.keys(relationFieldOrderByValue).length > 0; - }; + } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts new file mode 100644 index 0000000000..70c0431ec9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts @@ -0,0 +1,4 @@ +export type OrderByClause = { + order: 'ASC' | 'DESC'; + nulls?: 'NULLS FIRST' | 'NULLS LAST'; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/parse-order-by-result.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/parse-order-by-result.type.ts new file mode 100644 index 0000000000..ed92134f92 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/parse-order-by-result.type.ts @@ -0,0 +1,7 @@ +import { type OrderByClause } from './order-by-condition.type'; +import { type RelationJoinInfo } from './relation-join-info.type'; + +export type ParseOrderByResult = { + orderBy: Record; + relationJoins: RelationJoinInfo[]; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/relation-join-info.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/relation-join-info.type.ts new file mode 100644 index 0000000000..535121936a --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/relation-join-info.type.ts @@ -0,0 +1,3 @@ +export type RelationJoinInfo = { + joinAlias: string; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order.ts index 2b0d19b2e5..6e205c4a34 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order.ts @@ -5,12 +5,12 @@ import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; -import { type OrderByCondition } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; +import { type OrderByClause } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; export const convertOrderByToFindOptionsOrder = ( direction: OrderByDirection, isForwardPagination = true, -): OrderByCondition => { +): OrderByClause => { switch (direction) { case OrderByDirection.AscNullsFirst: return { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util.ts new file mode 100644 index 0000000000..a7bc9f335a --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util.ts @@ -0,0 +1,16 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +export const getOptionalOrderByCasting = ( + fieldMetadata: Pick, +): string => { + if ( + fieldMetadata.type === FieldMetadataType.SELECT || + fieldMetadata.type === FieldMetadataType.MULTI_SELECT + ) { + return '::text'; + } + + return ''; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util.ts new file mode 100644 index 0000000000..f613f5a158 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util.ts @@ -0,0 +1,11 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { OrderByDirection } from 'twenty-shared/types'; + +export const isOrderByDirection = ( + value: unknown, +): value is OrderByDirection => { + return ( + isNonEmptyString(value) && + Object.values(OrderByDirection).includes(value as OrderByDirection) + ); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts index 0baef38a1f..aada0cedbb 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts @@ -1,21 +1,18 @@ -import { - OrderByDirection, - compositeTypeDefinitions, -} from 'twenty-shared/types'; +import { compositeTypeDefinitions } from 'twenty-shared/types'; import { capitalize } from 'twenty-shared/utils'; -import { type OrderByCondition } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; +import { type OrderByClause } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; +import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util'; import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; export const parseCompositeFieldForOrder = ( fieldMetadata: FlatFieldMetadata, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any, - objectNameSingular: string, + value: Record, + prefix: string, isForwardPagination = true, -): Record => { +): Record => { const compositeType = compositeTypeDefinitions.get( fieldMetadata.type as CompositeFieldMetadataType, ); @@ -38,7 +35,7 @@ export const parseCompositeFieldForOrder = ( ); } - const fullFieldName = `"${objectNameSingular}"."${fieldMetadata.name}${capitalize(subFieldKey)}"`; + const fullFieldName = `${prefix}.${fieldMetadata.name}${capitalize(subFieldKey)}`; if (!isOrderByDirection(subFieldValue)) { throw new Error( @@ -52,10 +49,6 @@ export const parseCompositeFieldForOrder = ( return acc; }, - {} as Record, + {} as Record, ); }; - -const isOrderByDirection = (value: unknown): value is OrderByDirection => { - return Object.values(OrderByDirection).includes(value as OrderByDirection); -}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index 27d953065a..d5332b5be5 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -9,7 +9,11 @@ import { } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser'; -import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; +import { GraphqlQueryOrderGroupByParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser'; +import { + GraphqlQueryOrderFieldParser, + type OrderByClause, +} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; import { GraphqlQuerySelectedFieldsParser, type GraphqlQuerySelectedFieldsResult, @@ -26,6 +30,7 @@ export class GraphqlQueryParser { private flatFieldMetadataMaps: FlatEntityMaps; private filterConditionParser: GraphqlQueryFilterConditionParser; private orderFieldParser: GraphqlQueryOrderFieldParser; + private orderGroupByParser: GraphqlQueryOrderGroupByParser; constructor( flatObjectMetadata: FlatObjectMetadata, @@ -45,6 +50,11 @@ export class GraphqlQueryParser { this.flatObjectMetadataMaps, this.flatFieldMetadataMaps, ); + this.orderGroupByParser = new GraphqlQueryOrderGroupByParser( + this.flatObjectMetadata, + this.flatObjectMetadataMaps, + this.flatFieldMetadataMaps, + ); } public applyFilterToBuilder( @@ -106,15 +116,50 @@ export class GraphqlQueryParser { orderBy: ObjectRecordOrderBy | OrderByWithGroupBy, objectNameSingular: string, isForwardPagination = true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): WorkspaceSelectQueryBuilder { - const parsedOrderBys = this.orderFieldParser.parse( + ): Record { + const parseResult = this.orderFieldParser.parse( orderBy as ObjectRecordOrderBy, objectNameSingular, isForwardPagination, ); - return queryBuilder.orderBy(parsedOrderBys); + // Add LEFT JOINs for relation ordering + for (const joinInfo of parseResult.relationJoins) { + queryBuilder.leftJoin( + `${objectNameSingular}.${joinInfo.joinAlias}`, + joinInfo.joinAlias, + ); + } + + queryBuilder.orderBy(parseResult.orderBy); + + // Return parsed orderBy so caller can add relation columns after setFindOptions + return parseResult.orderBy; + } + + public addRelationOrderColumnsToBuilder( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryBuilder: WorkspaceSelectQueryBuilder, + parsedOrderBy: Record, + objectNameSingular: string, + ): void { + // Add relation ORDER BY columns with underscore alias for DISTINCT compatibility + // This must be called AFTER setFindOptions because setFindOptions clears addSelect + for (const orderByKey of Object.keys(parsedOrderBy)) { + const parts = orderByKey.split('.'); + + if (parts.length === 2) { + const [alias, column] = parts; + + // Only add select for joined relation columns, not main entity columns + if (alias !== objectNameSingular) { + queryBuilder.addSelect( + `"${alias}"."${column}"`, + `${alias}_${column}`, + ); + } + } + } } public getOrderByRawSQL( @@ -122,13 +167,13 @@ export class GraphqlQueryParser { objectNameSingular: string, isForwardPagination = true, ): string { - const parsedOrderBys = this.orderFieldParser.parse( + const parseResult = this.orderFieldParser.parse( orderBy as ObjectRecordOrderBy, objectNameSingular, isForwardPagination, ); - const orderByRawSQLClauseArray = Object.entries(parsedOrderBys).map( + const orderByRawSQLClauseArray = Object.entries(parseResult.orderBy).map( ([orderByField, orderByCondition]) => { const nullsCondition = isDefined(orderByCondition.nulls) ? ` ${orderByCondition.nulls}` @@ -154,7 +199,7 @@ export class GraphqlQueryParser { groupByFields: GroupByField[], // eslint-disable-next-line @typescript-eslint/no-explicit-any ): WorkspaceSelectQueryBuilder { - const parsedOrderBys = this.orderFieldParser.parseForGroupBy({ + const parsedOrderBys = this.orderGroupByParser.parse({ orderBy, groupByFields, }); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts index 765d1a8150..ae4b12ed85 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts @@ -43,6 +43,7 @@ export class ObjectMetadataGqlInputTypeGenerator { this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({ flatObjectMetadata, fields, + context, }); this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({ flatObjectMetadata, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts index 17aaba5d6e..e2582860c6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts @@ -5,6 +5,7 @@ import { GraphQLInputObjectType } from 'graphql'; import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; import { ObjectMetadataOrderByBaseGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; +import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-schema-builder/types/schema-generation-context.type'; import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; @@ -23,9 +24,11 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator { public buildAndStore({ flatObjectMetadata, fields, + context, }: { flatObjectMetadata: FlatObjectMetadata; fields: FlatFieldMetadata[]; + context: SchemaGenerationContext; }) { const inputType = new GraphQLInputObjectType({ name: `${pascalCase(flatObjectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderBy.toString()}Input`, @@ -34,6 +37,7 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator { this.objectMetadataOrderByBaseGenerator.generateFields({ fields, logger: this.logger, + context, }), }) as GraphQLInputObjectType; 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 index 4a12485620..1a85d9d5a6 100644 --- 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 @@ -1,4 +1,5 @@ import { + FieldMetadataType, type ObjectRecord, type ObjectRecordOrderByForCompositeField, type ObjectRecordOrderByForScalarField, @@ -13,6 +14,8 @@ import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; const isOrderByDirection = (value: unknown): value is OrderByDirection => { return Object.values(OrderByDirection).includes(value as OrderByDirection); @@ -91,3 +94,31 @@ export const validateAndGetOrderByForCompositeField = ( return keyOrderBy; }; + +export const countRelationFieldsInOrderBy = ( + orderBy: ObjectRecordOrderBy, + flatFieldMetadataMaps: FlatEntityMaps, + fieldIdByName: Record, +): number => { + return orderBy.filter((orderByItem) => { + const fieldName = Object.keys(orderByItem)[0]; + const fieldMetadataId = fieldIdByName[fieldName]; + const fieldMetadata = flatFieldMetadataMaps.byId[fieldMetadataId]; + + return fieldMetadata?.type === FieldMetadataType.RELATION; + }).length; +}; + +export const hasRelationFieldInOrderBy = ( + orderBy: ObjectRecordOrderBy, + flatFieldMetadataMaps: FlatEntityMaps, + fieldIdByName: Record, +): boolean => { + return ( + countRelationFieldsInOrderBy( + orderBy, + flatFieldMetadataMaps, + fieldIdByName, + ) > 0 + ); +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts new file mode 100644 index 0000000000..26060a338e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts @@ -0,0 +1,428 @@ +import gql from 'graphql-tag'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +const TEST_COMPANY_IDS = { + ALPHA: '20202020-aaaa-4000-8000-000000000001', + BETA: '20202020-aaaa-4000-8000-000000000002', + GAMMA: '20202020-aaaa-4000-8000-000000000003', +}; + +const TEST_PERSON_IDS = [ + '20202020-bbbb-4000-8000-000000000001', + '20202020-bbbb-4000-8000-000000000002', + '20202020-bbbb-4000-8000-000000000003', + '20202020-bbbb-4000-8000-000000000004', + '20202020-bbbb-4000-8000-000000000005', + '20202020-bbbb-4000-8000-000000000006', + '20202020-bbbb-4000-8000-000000000007', + '20202020-bbbb-4000-8000-000000000008', + '20202020-bbbb-4000-8000-000000000009', + '20202020-bbbb-4000-8000-000000000010', +]; + +describe('Order by relation field (e2e)', () => { + beforeAll(async () => { + // Create test companies with distinct names for sorting verification + const createCompanies = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: 'id name', + data: [ + { id: TEST_COMPANY_IDS.ALPHA, name: 'Alpha Corp' }, + { id: TEST_COMPANY_IDS.BETA, name: 'Beta Inc' }, + { id: TEST_COMPANY_IDS.GAMMA, name: 'Gamma LLC' }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createCompanies); + + // Create test people with company relations and some without (for null testing) + const createPeople = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + data: [ + // People with companies (for testing sorting) + { id: TEST_PERSON_IDS[0], companyId: TEST_COMPANY_IDS.ALPHA }, + { id: TEST_PERSON_IDS[1], companyId: TEST_COMPANY_IDS.ALPHA }, + { id: TEST_PERSON_IDS[2], companyId: TEST_COMPANY_IDS.BETA }, + { id: TEST_PERSON_IDS[3], companyId: TEST_COMPANY_IDS.BETA }, + { id: TEST_PERSON_IDS[4], companyId: TEST_COMPANY_IDS.GAMMA }, + { id: TEST_PERSON_IDS[5], companyId: TEST_COMPANY_IDS.GAMMA }, + // People without companies (for testing NULLS LAST) + { id: TEST_PERSON_IDS[6], companyId: null }, + { id: TEST_PERSON_IDS[7], companyId: null }, + { id: TEST_PERSON_IDS[8], companyId: null }, + { id: TEST_PERSON_IDS[9], companyId: null }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createPeople); + }); + + it('should sort people by company name ascending', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 10) { + edges { + node { + id + name { + firstName + lastName + } + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: TEST_PERSON_IDS } }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const edges = response.body.data.people.edges; + + expect(Array.isArray(edges)).toBe(true); + expect(edges.length).toBeGreaterThan(0); + + // Verify company names are in ascending order (excluding nulls at the end) + const companyNames = edges + .map( + (edge: { node: { company?: { name: string } } }) => + edge.node.company?.name, + ) + .filter(Boolean); + + const sortedCompanyNames = [...companyNames].sort((a, b) => + a.localeCompare(b), + ); + + expect(companyNames).toEqual(sortedCompanyNames); + }); + + it('should sort people by company name descending', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 10) { + edges { + node { + id + name { + firstName + lastName + } + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'DescNullsLast' } }], + filter: { id: { in: TEST_PERSON_IDS } }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const edges = response.body.data.people.edges; + + expect(Array.isArray(edges)).toBe(true); + expect(edges.length).toBeGreaterThan(0); + + // Verify company names are in descending order (excluding nulls at the end) + const companyNames = edges + .map( + (edge: { node: { company?: { name: string } } }) => + edge.node.company?.name, + ) + .filter(Boolean); + + const sortedCompanyNames = [...companyNames].sort((a, b) => + b.localeCompare(a), + ); + + expect(companyNames).toEqual(sortedCompanyNames); + }); + + it('should handle null relations with NULLS LAST', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 50) { + edges { + node { + id + name { + firstName + lastName + } + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: TEST_PERSON_IDS } }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const edges = response.body.data.people.edges; + + expect(Array.isArray(edges)).toBe(true); + expect(edges.length).toBeGreaterThan(0); + + // Check that null companies appear at the end + let seenNull = false; + + for (const edge of edges) { + if (edge.node.company === null) { + seenNull = true; + } else if (seenNull) { + // If we already saw a null, subsequent non-nulls mean order is wrong + throw new Error('Records with null company should appear at the end'); + } + } + }); + + it('should work with offset pagination', async () => { + // First request to get initial data + const firstQueryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + $limit: Int + ) { + people(orderBy: $orderBy, filter: $filter, first: $limit) { + edges { + node { + id + company { + name + } + } + } + totalCount + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: TEST_PERSON_IDS } }, + limit: 3, + }, + }; + + const firstResponse = await makeGraphqlAPIRequest(firstQueryData); + + expect(firstResponse.body.data).toBeDefined(); + expect(firstResponse.body.errors).toBeUndefined(); + + const firstPageEdges = firstResponse.body.data.people.edges; + const totalCount = firstResponse.body.data.people.totalCount; + + expect(Array.isArray(firstPageEdges)).toBe(true); + expect(firstPageEdges.length).toBeGreaterThan(0); + expect(totalCount).toBeGreaterThan(3); + + // Second request using offset (matching frontend behavior) + const secondQueryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + $limit: Int + $offset: Int + ) { + people( + orderBy: $orderBy + filter: $filter + first: $limit + offset: $offset + ) { + edges { + node { + id + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: TEST_PERSON_IDS } }, + limit: 3, + offset: 3, + }, + }; + + const secondResponse = await makeGraphqlAPIRequest(secondQueryData); + + expect(secondResponse.body.data).toBeDefined(); + expect(secondResponse.body.errors).toBeUndefined(); + + const secondPageEdges = secondResponse.body.data.people.edges; + + expect(Array.isArray(secondPageEdges)).toBe(true); + + // Verify different records are returned (no overlap) + const firstPageIds = firstPageEdges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + const secondPageIds = secondPageEdges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + const overlap = firstPageIds.filter((id: string) => + secondPageIds.includes(id), + ); + + expect(overlap.length).toBe(0); + }); + + it('should return clear error when using cursor pagination with relation orderBy', async () => { + // First get a cursor by fetching records + const firstQueryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 3) { + edges { + node { + id + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: TEST_PERSON_IDS } }, + }, + }; + + const firstResponse = await makeGraphqlAPIRequest(firstQueryData); + + expect(firstResponse.body.data).toBeDefined(); + expect(firstResponse.body.errors).toBeUndefined(); + + const pageInfo = firstResponse.body.data.people.pageInfo; + + // Assert we have enough data for pagination test + expect(pageInfo.hasNextPage).toBe(true); + expect(pageInfo.endCursor).toBeDefined(); + + // Try to use cursor with relation orderBy - should fail with clear error + const secondQueryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + $after: String + ) { + people(orderBy: $orderBy, filter: $filter, first: 3, after: $after) { + edges { + node { + id + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: TEST_PERSON_IDS } }, + after: pageInfo.endCursor, + }, + }; + + const secondResponse = await makeGraphqlAPIRequest(secondQueryData); + + expect(secondResponse.body.errors).toBeDefined(); + expect(secondResponse.body.errors[0].message).toContain( + 'Cursor-based pagination is not supported with relation field ordering', + ); + }); + + it('should allow sorting by relation FK (backward compatibility)', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 10) { + edges { + node { + id + companyId + } + } + } + } + `, + variables: { + orderBy: [{ companyId: 'AscNullsLast' }], + filter: { id: { in: TEST_PERSON_IDS } }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const edges = response.body.data.people.edges; + + expect(Array.isArray(edges)).toBe(true); + expect(edges.length).toBeGreaterThan(0); + }); +});