From a8331dc43e07fc5774f0cbf1d9bbf4e7c3e12d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 10 Jan 2026 14:05:56 +0100 Subject: [PATCH] feat: add case-insensitive sorting for text fields (#17023) ## Context Text fields were being sorted case-sensitively on the backend (e.g., 'Apple', 'apple', 'Banana' would sort as 'Apple', 'Banana', 'apple'). This resulted in unexpected sorting behavior that differed between the frontend Apollo cache sorting and backend database sorting. ## Changes ### Backend (packages/twenty-server) - **`graphql-query-order.parser.ts`**: - Added `shouldUseCaseInsensitiveOrder()` helper that returns `true` for TEXT, SELECT, and MULTI_SELECT fields - Added `buildOrderByColumnExpression()` method that wraps column expressions with `LOWER()` for case-insensitive sorting - Updated `parse()` and `parseObjectRecordOrderByForScalarField()` to use the new helpers - Updated `parseObjectRecordOrderByForRelationField()` to apply LOWER() to nested text fields - **`parse-composite-field-for-order.util.ts`**: - Added `shouldUseCaseInsensitiveOrder()` helper for composite subfields - Updated composite field parsing to apply `LOWER()` to subfields of type TEXT (e.g., `name.firstName`, `name.lastName`) ### Frontend (packages/twenty-front) - **`sort.ts`**: - Updated `sortAsc()` to use case-insensitive comparison (`toLowerCase()`) for string values - `sortDesc()` automatically benefits from this since it delegates to `sortAsc()` - Ensures Apollo cache sorting matches backend behavior ## Example Before: ```sql ORDER BY "person"."name" ASC ``` Result: ['Apple', 'Banana', 'apple', 'cherry'] After: ```sql ORDER BY LOWER("person"."name") ASC ``` Result: ['apple', 'Apple', 'Banana', 'cherry'] ## Testing - Manual testing of sorting on People and Companies views - Verified frontend cache sorting matches backend results --- packages/twenty-front/src/utils/sort.ts | 13 +- .../__tests__/build-order-by-key.spec.ts | 87 +++++++++ ...rse-composite-field-for-order.util.spec.ts | 184 ++++++++++++++++++ .../graphql-query-order.parser.ts | 38 ++-- .../types/order-by-condition.type.ts | 2 + .../build-order-by-column-expression.util.ts | 27 +++ .../parse-composite-field-for-order.util.ts | 20 +- .../graphql-query.parser.ts | 19 +- ...rder-by-relation-field.integration-spec.ts | 135 +++++++++++++ 9 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/build-order-by-key.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/parse-composite-field-for-order.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util.ts diff --git a/packages/twenty-front/src/utils/sort.ts b/packages/twenty-front/src/utils/sort.ts index ef9055a8b2d..c09f245f2aa 100644 --- a/packages/twenty-front/src/utils/sort.ts +++ b/packages/twenty-front/src/utils/sort.ts @@ -1,3 +1,5 @@ +import { isNonEmptyString } from '@sniptt/guards'; + import { type Maybe } from '~/generated/graphql'; export const sortNullsFirst = ( @@ -20,7 +22,16 @@ export const sortNullsLast = ( export const sortAsc = ( fieldValueA: string | number, fieldValueB: string | number, -) => (fieldValueA === fieldValueB ? 0 : fieldValueA < fieldValueB ? -1 : 1); +) => { + const valueA = isNonEmptyString(fieldValueA) + ? fieldValueA.toLowerCase() + : fieldValueA; + const valueB = isNonEmptyString(fieldValueB) + ? fieldValueB.toLowerCase() + : fieldValueB; + + return valueA === valueB ? 0 : valueA < valueB ? -1 : 1; +}; export const sortDesc = ( fieldValueA: string | number, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/build-order-by-key.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/build-order-by-key.spec.ts new file mode 100644 index 00000000000..8b06a88639b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/build-order-by-key.spec.ts @@ -0,0 +1,87 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { + buildOrderByColumnExpression, + shouldCastToText, + shouldUseCaseInsensitiveOrder, +} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util'; + +describe('buildOrderByColumnExpression', () => { + describe('returns unquoted column expressions for TypeORM', () => { + it('should return unquoted alias.column format', () => { + const result = buildOrderByColumnExpression('company', 'name'); + + expect(result).toBe('company.name'); + }); + + it('should work with different prefixes', () => { + const result = buildOrderByColumnExpression('assignee', 'email'); + + expect(result).toBe('assignee.email'); + }); + + it('should handle composite column names (e.g., nameFirstName)', () => { + const result = buildOrderByColumnExpression('person', 'nameFirstName'); + + expect(result).toBe('person.nameFirstName'); + }); + }); +}); + +describe('shouldUseCaseInsensitiveOrder', () => { + it('should return true for TEXT fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.TEXT)).toBe(true); + }); + + it('should return true for SELECT fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.SELECT)).toBe(true); + }); + + it('should return true for MULTI_SELECT fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.MULTI_SELECT)).toBe( + true, + ); + }); + + it('should return false for NUMBER fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.NUMBER)).toBe(false); + }); + + it('should return false for DATE_TIME fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.DATE_TIME)).toBe( + false, + ); + }); + + it('should return false for UUID fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.UUID)).toBe(false); + }); + + it('should return false for BOOLEAN fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.BOOLEAN)).toBe( + false, + ); + }); +}); + +describe('shouldCastToText', () => { + it('should return true for SELECT fields', () => { + expect(shouldCastToText(FieldMetadataType.SELECT)).toBe(true); + }); + + it('should return true for MULTI_SELECT fields', () => { + expect(shouldCastToText(FieldMetadataType.MULTI_SELECT)).toBe(true); + }); + + it('should return false for TEXT fields', () => { + expect(shouldCastToText(FieldMetadataType.TEXT)).toBe(false); + }); + + it('should return false for NUMBER fields', () => { + expect(shouldCastToText(FieldMetadataType.NUMBER)).toBe(false); + }); + + it('should return false for DATE_TIME fields', () => { + expect(shouldCastToText(FieldMetadataType.DATE_TIME)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/parse-composite-field-for-order.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/parse-composite-field-for-order.util.spec.ts new file mode 100644 index 00000000000..cc763598fcd --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/parse-composite-field-for-order.util.spec.ts @@ -0,0 +1,184 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +describe('parseCompositeFieldForOrder', () => { + describe('case-insensitive sorting for composite subfields', () => { + it('should set useLower: true for TEXT subfields in FULL_NAME composite', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { firstName: 'AscNullsFirst' }, + 'person', + true, + ); + + expect(result).toEqual({ + 'person.nameFirstName': { + order: 'ASC', + nulls: 'NULLS FIRST', + useLower: true, + castToText: false, + }, + }); + }); + + it('should set useLower: true for TEXT subfields in LINKS composite', () => { + const fieldMetadata = { + type: FieldMetadataType.LINKS, + name: 'linkedinLink', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { primaryLinkLabel: 'DescNullsLast' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.linkedinLinkPrimaryLinkLabel': { + order: 'DESC', + nulls: 'NULLS LAST', + useLower: true, + castToText: false, + }, + }); + }); + + it('should set useLower: true for TEXT subfields in ADDRESS composite', () => { + const fieldMetadata = { + type: FieldMetadataType.ADDRESS, + name: 'address', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { addressCity: 'AscNullsLast' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.addressAddressCity': { + order: 'ASC', + nulls: 'NULLS LAST', + useLower: true, + castToText: false, + }, + }); + }); + }); + + describe('case-sensitive sorting for non-text composite subfields', () => { + it('should set useLower: false for non-TEXT subfields in CURRENCY composite', () => { + const fieldMetadata = { + type: FieldMetadataType.CURRENCY, + name: 'annualRevenue', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { amountMicros: 'DescNullsFirst' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.annualRevenueAmountMicros': { + order: 'DESC', + nulls: 'NULLS FIRST', + useLower: false, + castToText: false, + }, + }); + }); + + it('should set useLower: false for non-TEXT subfields in ADDRESS lat/lng', () => { + const fieldMetadata = { + type: FieldMetadataType.ADDRESS, + name: 'address', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { addressLat: 'AscNullsFirst' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.addressAddressLat': { + order: 'ASC', + nulls: 'NULLS FIRST', + useLower: false, + castToText: false, + }, + }); + }); + }); + + describe('pagination direction handling', () => { + it('should reverse order direction for backward pagination', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { firstName: 'AscNullsFirst' }, + 'person', + false, + ); + + expect(result).toEqual({ + 'person.nameFirstName': { + order: 'DESC', + nulls: 'NULLS FIRST', + useLower: true, + castToText: false, + }, + }); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid subfield name', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + expect(() => + parseCompositeFieldForOrder( + fieldMetadata, + { invalidSubField: 'AscNullsFirst' }, + 'person', + true, + ), + ).toThrow('Sub field metadata not found'); + }); + + it('should throw error for invalid order direction', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + expect(() => + parseCompositeFieldForOrder( + fieldMetadata, + { firstName: 'InvalidDirection' }, + 'person', + true, + ), + ).toThrow('Sub field order by value must be of type OrderByDirection'); + }); + }); +}); 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 bbeb1bea338..1520798949d 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 @@ -8,8 +8,12 @@ import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { + buildOrderByColumnExpression, + shouldCastToText, + shouldUseCaseInsensitiveOrder, +} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util'; 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 { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; @@ -130,14 +134,19 @@ export class GraphqlQueryOrderFieldParser { ); } - const orderByCasting = getOptionalOrderByCasting(fieldMetadata); - - orderByConditions[ - `${objectNameSingular}.${fieldName}${orderByCasting}` - ] = convertOrderByToFindOptionsOrder( - orderByDirection, - isForwardPagination, + const columnExpression = buildOrderByColumnExpression( + objectNameSingular, + fieldName, ); + + orderByConditions[columnExpression] = { + ...convertOrderByToFindOptionsOrder( + orderByDirection, + isForwardPagination, + ), + useLower: shouldUseCaseInsensitiveOrder(fieldMetadata.type), + castToText: shouldCastToText(fieldMetadata.type), + }; } } } @@ -236,16 +245,21 @@ export class GraphqlQueryOrderFieldParser { } if (isOrderByDirection(nestedFieldOrderByValue)) { - const nestedColumnName = nestedFieldMetadata.name; - const orderByCasting = getOptionalOrderByCasting(nestedFieldMetadata); + const columnExpression = buildOrderByColumnExpression( + joinAlias, + nestedFieldMetadata.name, + ); return { orderBy: { - [`${joinAlias}.${nestedColumnName}${orderByCasting}`]: - convertOrderByToFindOptionsOrder( + [columnExpression]: { + ...convertOrderByToFindOptionsOrder( nestedFieldOrderByValue, isForwardPagination, ), + useLower: shouldUseCaseInsensitiveOrder(nestedFieldMetadata.type), + castToText: shouldCastToText(nestedFieldMetadata.type), + }, }, joinInfo, }; 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 index 70c0431ec98..36169bd8f4e 100644 --- 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 @@ -1,4 +1,6 @@ export type OrderByClause = { order: 'ASC' | 'DESC'; nulls?: 'NULLS FIRST' | 'NULLS LAST'; + useLower?: boolean; + castToText?: boolean; // For SELECT/MULTI_SELECT fields that need ::text before LOWER }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util.ts new file mode 100644 index 00000000000..264347d4be2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util.ts @@ -0,0 +1,27 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +export const shouldUseCaseInsensitiveOrder = ( + fieldType: FieldMetadataType, +): boolean => { + return ( + fieldType === FieldMetadataType.TEXT || + fieldType === FieldMetadataType.SELECT || + fieldType === FieldMetadataType.MULTI_SELECT + ); +}; + +export const shouldCastToText = (fieldType: FieldMetadataType): boolean => { + return ( + fieldType === FieldMetadataType.SELECT || + fieldType === FieldMetadataType.MULTI_SELECT + ); +}; + +// Returns unquoted column expression for TypeORM's orderBy (e.g., "company.name") +// Quoting and LOWER() wrapping is handled in getOrderByRawSQL +export const buildOrderByColumnExpression = ( + prefix: string, + columnName: string, +): string => { + return `${prefix}.${columnName}`; +}; 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 aada0cedbb5..9eda397c539 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 @@ -2,6 +2,11 @@ import { compositeTypeDefinitions } from 'twenty-shared/types'; import { capitalize } from 'twenty-shared/utils'; import { type OrderByClause } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; +import { + buildOrderByColumnExpression, + shouldCastToText, + shouldUseCaseInsensitiveOrder, +} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util'; 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'; @@ -35,17 +40,22 @@ export const parseCompositeFieldForOrder = ( ); } - const fullFieldName = `${prefix}.${fieldMetadata.name}${capitalize(subFieldKey)}`; + const orderByKey = buildOrderByColumnExpression( + prefix, + `${fieldMetadata.name}${capitalize(subFieldKey)}`, + ); if (!isOrderByDirection(subFieldValue)) { throw new Error( `Sub field order by value must be of type OrderByDirection, but got: ${subFieldValue}`, ); } - acc[fullFieldName] = convertOrderByToFindOptionsOrder( - subFieldValue, - isForwardPagination, - ); + + acc[orderByKey] = { + ...convertOrderByToFindOptionsOrder(subFieldValue, isForwardPagination), + useLower: shouldUseCaseInsensitiveOrder(subFieldMetadata.type), + castToText: shouldCastToText(subFieldMetadata.type), + }; return acc; }, 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 d5332b5be5f..3b75510a97f 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 @@ -179,7 +179,24 @@ export class GraphqlQueryParser { ? ` ${orderByCondition.nulls}` : ''; - return `${orderByField} ${orderByCondition.order}${nullsCondition}`; + // Convert "alias.column" to quoted SQL identifier "alias"."column" + const parts = orderByField.split('.'); + const quotedColumn = + parts.length === 2 + ? `"${parts[0]}"."${parts[1]}"` + : `"${orderByField}"`; + + // Build column expression with optional ::text cast and LOWER() + let columnExpr = quotedColumn; + + if (orderByCondition.castToText) { + columnExpr = `${columnExpr}::text`; + } + if (orderByCondition.useLower) { + columnExpr = `LOWER(${columnExpr})`; + } + + return `${columnExpr} ${orderByCondition.order}${nullsCondition}`; }, ); 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 index 26060a338ed..801b07c837a 100644 --- 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 @@ -6,6 +6,10 @@ const TEST_COMPANY_IDS = { ALPHA: '20202020-aaaa-4000-8000-000000000001', BETA: '20202020-aaaa-4000-8000-000000000002', GAMMA: '20202020-aaaa-4000-8000-000000000003', + // Companies for case-insensitive sorting tests + ACME_LOWER: '20202020-aaaa-4000-8000-000000000004', + ACME_UPPER: '20202020-aaaa-4000-8000-000000000005', + ZEBRA: '20202020-aaaa-4000-8000-000000000006', }; const TEST_PERSON_IDS = [ @@ -21,6 +25,12 @@ const TEST_PERSON_IDS = [ '20202020-bbbb-4000-8000-000000000010', ]; +const CASE_INSENSITIVE_TEST_PERSON_IDS = [ + '20202020-bbbb-4000-8000-000000000011', + '20202020-bbbb-4000-8000-000000000012', + '20202020-bbbb-4000-8000-000000000013', +]; + describe('Order by relation field (e2e)', () => { beforeAll(async () => { // Create test companies with distinct names for sorting verification @@ -32,6 +42,10 @@ describe('Order by relation field (e2e)', () => { { id: TEST_COMPANY_IDS.ALPHA, name: 'Alpha Corp' }, { id: TEST_COMPANY_IDS.BETA, name: 'Beta Inc' }, { id: TEST_COMPANY_IDS.GAMMA, name: 'Gamma LLC' }, + // Companies for case-insensitive sorting tests (lowercase vs uppercase) + { id: TEST_COMPANY_IDS.ACME_LOWER, name: 'acme' }, + { id: TEST_COMPANY_IDS.ACME_UPPER, name: 'ACME' }, + { id: TEST_COMPANY_IDS.ZEBRA, name: 'Zebra' }, ], upsert: true, }); @@ -56,6 +70,19 @@ describe('Order by relation field (e2e)', () => { { id: TEST_PERSON_IDS[7], companyId: null }, { id: TEST_PERSON_IDS[8], companyId: null }, { id: TEST_PERSON_IDS[9], companyId: null }, + // People for case-insensitive sorting tests + { + id: CASE_INSENSITIVE_TEST_PERSON_IDS[0], + companyId: TEST_COMPANY_IDS.ACME_LOWER, + }, + { + id: CASE_INSENSITIVE_TEST_PERSON_IDS[1], + companyId: TEST_COMPANY_IDS.ACME_UPPER, + }, + { + id: CASE_INSENSITIVE_TEST_PERSON_IDS[2], + companyId: TEST_COMPANY_IDS.ZEBRA, + }, ], upsert: true, }); @@ -425,4 +452,112 @@ describe('Order by relation field (e2e)', () => { expect(Array.isArray(edges)).toBe(true); expect(edges.length).toBeGreaterThan(0); }); + + it('should sort case-insensitively (acme and ACME should sort together before Zebra)', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 10) { + edges { + node { + id + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: CASE_INSENSITIVE_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; + const companyNames = edges.map( + (edge: { node: { company?: { name: string } } }) => + edge.node.company?.name, + ); + + expect(companyNames.length).toBe(3); + + // Both "acme" and "ACME" should come before "Zebra" in case-insensitive sort + const zebraIndex = companyNames.findIndex( + (name: string) => name.toLowerCase() === 'zebra', + ); + const acmeIndices = companyNames + .map((name: string, index: number) => + name.toLowerCase() === 'acme' ? index : -1, + ) + .filter((index: number) => index !== -1); + + // All ACME variants should appear before Zebra + for (const acmeIndex of acmeIndices) { + expect(acmeIndex).toBeLessThan(zebraIndex); + } + }); + + it('should sort case-insensitively in descending order', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 10) { + edges { + node { + id + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'DescNullsLast' } }], + filter: { id: { in: CASE_INSENSITIVE_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; + const companyNames = edges.map( + (edge: { node: { company?: { name: string } } }) => + edge.node.company?.name, + ); + + expect(companyNames.length).toBe(3); + + // In descending case-insensitive order, "Zebra" should come first + const zebraIndex = companyNames.findIndex( + (name: string) => name.toLowerCase() === 'zebra', + ); + const acmeIndices = companyNames + .map((name: string, index: number) => + name.toLowerCase() === 'acme' ? index : -1, + ) + .filter((index: number) => index !== -1); + + // Zebra should appear before all ACME variants in descending order + for (const acmeIndex of acmeIndices) { + expect(zebraIndex).toBeLessThan(acmeIndex); + } + }); });