Files
twenty/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts
T
Félix Malfait a8331dc43e 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
2026-01-10 14:05:56 +01:00

564 lines
16 KiB
TypeScript

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',
// 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 = [
'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',
];
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
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' },
// 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,
});
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 },
// 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,
});
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);
});
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);
}
});
});