Files
twenty/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts
T
Félix Malfait 57363c2127 Fix orderBy columns missing from SELECT in DISTINCT subquery (#17079)
## Description

Fixes a bug where queries with relation field + scalar field ordering
would fail with:
```
column distinctAlias.person_position does not exist
```

## Root Cause

When a GraphQL query orders by columns that are **not in the selected
fields**, TypeORM's DISTINCT subquery fails because it expects those
columns in the inner SELECT with alias format (e.g., `person_position`).

The issue only manifests when:
1. A filter is applied (triggers DISTINCT path in TypeORM)
2. OrderBy includes columns NOT in the GraphQL selection
3. Example: query selects only `id`, but orders by `position`

## The Fix

`addRelationOrderColumnsToBuilder` now accepts `columnsToSelect` and
adds orderBy columns via `addSelect()` only if they're **NOT** already
in the selected columns:

- **Relation orderBy columns**: Always added (never in columnsToSelect)
- **Main entity orderBy columns**: Added only when not already selected

This ensures all orderBy columns are present in TypeORM's inner SELECT
for the DISTINCT subquery.

## Test Plan

Added integration test for the exact failing scenario:
- Filter with `neq` (triggers DISTINCT path)
- Multiple orderBy: relation field + scalar field  
- Minimal field selection (only `id`, not `position`)

## Related

Regression introduced in #17021 which added nested sort support.
2026-01-11 14:19:05 +01:00

624 lines
18 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.skip('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);
}
});
it('should work with filter + relation orderBy + scalar orderBy with minimal fields selected', async () => {
// This test reproduces a bug where TypeORM's DISTINCT subquery failed
// when orderBy included columns not in the SELECT clause.
// The bug manifested as: "column distinctAlias.person_position does not exist"
const queryData = {
query: gql`
query People(
$orderBy: [PersonOrderByInput]
$filter: PersonFilterInput
$limit: Int
) {
people(orderBy: $orderBy, filter: $filter, first: $limit) {
edges {
node {
id
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`,
variables: {
// Filter excludes one record - key to triggering the DISTINCT subquery path
filter: { id: { neq: TEST_PERSON_IDS[0] } },
// Multiple orderBy: relation field + scalar field (position not in SELECT)
orderBy: [
{ company: { name: 'DescNullsLast' } },
{ position: 'AscNullsFirst' },
],
limit: 60,
},
};
const response = await makeGraphqlAPIRequest(queryData);
// Should succeed without "column distinctAlias.person_position does not exist" error
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
expect(response.body.data.people).toBeDefined();
const edges = response.body.data.people.edges;
expect(Array.isArray(edges)).toBe(true);
// Verify the filtered record is not in the results
const resultIds = edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(resultIds).not.toContain(TEST_PERSON_IDS[0]);
});
});