Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
1021 lines
29 KiB
TypeScript
1021 lines
29 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',
|
|
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' },
|
|
{ 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);
|
|
|
|
const createPeople = createManyOperationFactory({
|
|
objectMetadataSingularName: 'person',
|
|
objectMetadataPluralName: 'people',
|
|
gqlFields: 'id',
|
|
data: [
|
|
{ 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 },
|
|
{ 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 },
|
|
{
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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 () => {
|
|
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);
|
|
|
|
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 paginate exhaustively with cursors when the ordered relation field is selected', async () => {
|
|
const collectedIds: string[] = [];
|
|
const collectedCompanyNames: (string | null)[] = [];
|
|
let after: string | undefined = undefined;
|
|
|
|
for (let iteration = 0; iteration < 10; iteration++) {
|
|
const response = await makeGraphqlAPIRequest({
|
|
query: gql`
|
|
query People(
|
|
$orderBy: [PersonOrderByInput]
|
|
$filter: PersonFilterInput
|
|
$after: String
|
|
) {
|
|
people(
|
|
orderBy: $orderBy
|
|
filter: $filter
|
|
first: 3
|
|
after: $after
|
|
) {
|
|
edges {
|
|
node {
|
|
id
|
|
company {
|
|
name
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
|
filter: { id: { in: TEST_PERSON_IDS } },
|
|
after,
|
|
},
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
const connection = response.body.data.people;
|
|
|
|
for (const edge of connection.edges) {
|
|
collectedIds.push(edge.node.id);
|
|
collectedCompanyNames.push(edge.node.company?.name ?? null);
|
|
}
|
|
|
|
if (!connection.pageInfo.hasNextPage) {
|
|
break;
|
|
}
|
|
|
|
after = connection.pageInfo.endCursor;
|
|
}
|
|
|
|
expect(collectedIds).toHaveLength(TEST_PERSON_IDS.length);
|
|
expect(new Set(collectedIds).size).toBe(TEST_PERSON_IDS.length);
|
|
|
|
// Company names must be globally non-decreasing across pages, nulls last
|
|
const companyNames = collectedCompanyNames.filter(
|
|
(name): name is string => name !== null,
|
|
);
|
|
|
|
expect(companyNames).toEqual([...companyNames].sort());
|
|
expect(collectedCompanyNames.slice(companyNames.length)).toEqual(
|
|
Array(TEST_PERSON_IDS.length - companyNames.length).fill(null),
|
|
);
|
|
});
|
|
|
|
it('should walk backward across the missing-relation boundary with before cursors', async () => {
|
|
const forwardResponse = await makeGraphqlAPIRequest({
|
|
query: gql`
|
|
query People(
|
|
$orderBy: [PersonOrderByInput]
|
|
$filter: PersonFilterInput
|
|
$first: Int
|
|
) {
|
|
people(orderBy: $orderBy, filter: $filter, first: $first) {
|
|
edges {
|
|
node {
|
|
id
|
|
company {
|
|
name
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
|
filter: { id: { in: TEST_PERSON_IDS } },
|
|
first: TEST_PERSON_IDS.length,
|
|
},
|
|
});
|
|
|
|
expect(forwardResponse.body.errors).toBeUndefined();
|
|
|
|
const forwardConnection = forwardResponse.body.data.people;
|
|
const forwardIds = forwardConnection.edges.map(
|
|
(edge: { node: { id: string } }) => edge.node.id,
|
|
);
|
|
|
|
expect(forwardIds).toHaveLength(TEST_PERSON_IDS.length);
|
|
|
|
const backwardIds: string[] = [];
|
|
let before: string | undefined = forwardConnection.pageInfo.endCursor;
|
|
|
|
for (let iteration = 0; iteration < 10; iteration++) {
|
|
const response = await makeGraphqlAPIRequest({
|
|
query: gql`
|
|
query People(
|
|
$orderBy: [PersonOrderByInput]
|
|
$filter: PersonFilterInput
|
|
$before: String
|
|
) {
|
|
people(
|
|
orderBy: $orderBy
|
|
filter: $filter
|
|
last: 3
|
|
before: $before
|
|
) {
|
|
edges {
|
|
node {
|
|
id
|
|
company {
|
|
name
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasPreviousPage
|
|
startCursor
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
|
filter: { id: { in: TEST_PERSON_IDS } },
|
|
before,
|
|
},
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
const connection = response.body.data.people;
|
|
|
|
backwardIds.unshift(
|
|
...connection.edges.map(
|
|
(edge: { node: { id: string } }) => edge.node.id,
|
|
),
|
|
);
|
|
|
|
if (!connection.pageInfo.hasPreviousPage) {
|
|
break;
|
|
}
|
|
|
|
before = connection.pageInfo.startCursor;
|
|
}
|
|
|
|
// Everything before the last record, in the same order as the forward scan
|
|
expect(backwardIds).toEqual(forwardIds.slice(0, -1));
|
|
});
|
|
|
|
// Cursors read the relation orderBy values from the ordering join itself,
|
|
// so pagination must not depend on the selection set (issue #24333)
|
|
it('should paginate exhaustively without the ordered relation field selected', async () => {
|
|
const collectedIds: string[] = [];
|
|
let after: string | undefined = undefined;
|
|
|
|
for (let iteration = 0; iteration < 10; iteration++) {
|
|
const response = await makeGraphqlAPIRequest({
|
|
query: gql`
|
|
query People(
|
|
$orderBy: [PersonOrderByInput]
|
|
$filter: PersonFilterInput
|
|
$after: String
|
|
) {
|
|
people(
|
|
orderBy: $orderBy
|
|
filter: $filter
|
|
first: 3
|
|
after: $after
|
|
) {
|
|
edges {
|
|
node {
|
|
id
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
|
filter: { id: { in: TEST_PERSON_IDS } },
|
|
after,
|
|
},
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
const connection = response.body.data.people;
|
|
|
|
collectedIds.push(
|
|
...connection.edges.map((edge: { node: { id: string } }) => edge.node.id),
|
|
);
|
|
|
|
if (!connection.pageInfo.hasNextPage) {
|
|
break;
|
|
}
|
|
|
|
after = connection.pageInfo.endCursor;
|
|
}
|
|
|
|
// Companies sort Alpha < Beta < Gamma with id tie-breaks, then the
|
|
// missing-relation block in id order: exactly the seeded id order
|
|
expect(collectedIds).toEqual(TEST_PERSON_IDS);
|
|
});
|
|
|
|
it('should paginate from an edge cursor without the ordered relation field selected', async () => {
|
|
const collectedIds: string[] = [];
|
|
let after: string | undefined = undefined;
|
|
|
|
for (let iteration = 0; iteration < 20; iteration++) {
|
|
const response = await makeGraphqlAPIRequest({
|
|
query: gql`
|
|
query People(
|
|
$orderBy: [PersonOrderByInput]
|
|
$filter: PersonFilterInput
|
|
$after: String
|
|
) {
|
|
people(
|
|
orderBy: $orderBy
|
|
filter: $filter
|
|
first: 2
|
|
after: $after
|
|
) {
|
|
edges {
|
|
node {
|
|
id
|
|
}
|
|
cursor
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
|
filter: { id: { in: TEST_PERSON_IDS } },
|
|
after,
|
|
},
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
const connection = response.body.data.people;
|
|
|
|
collectedIds.push(
|
|
...connection.edges.map((edge: { node: { id: string } }) => edge.node.id),
|
|
);
|
|
|
|
if (!connection.pageInfo.hasNextPage) {
|
|
break;
|
|
}
|
|
|
|
after = connection.edges[connection.edges.length - 1].cursor;
|
|
}
|
|
|
|
expect(collectedIds).toEqual(TEST_PERSON_IDS);
|
|
});
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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]);
|
|
});
|
|
});
|
|
|
|
const COMPOSITE_TEST_PERSON_IDS = {
|
|
ADA_LOVELACE: '20202020-cccc-4000-8000-000000000001',
|
|
ADA_ZUSE: '20202020-cccc-4000-8000-000000000002',
|
|
GRACE_HOPPER: '20202020-cccc-4000-8000-000000000003',
|
|
LINUS_TORVALDS: '20202020-cccc-4000-8000-000000000004',
|
|
};
|
|
|
|
const COMPOSITE_TEST_OPPORTUNITY_IDS = [
|
|
'20202020-dddd-4000-8000-000000000001',
|
|
'20202020-dddd-4000-8000-000000000002',
|
|
'20202020-dddd-4000-8000-000000000003',
|
|
'20202020-dddd-4000-8000-000000000004',
|
|
'20202020-dddd-4000-8000-000000000005',
|
|
'20202020-dddd-4000-8000-000000000006',
|
|
];
|
|
|
|
// The web app sorts a relation column by the target's label identifier; for a
|
|
// person target that is the FULL_NAME composite, sent as one orderBy entry per
|
|
// property: [{ pointOfContact: { name: { firstName } } }, { ...lastName... }]
|
|
describe('Order by a composite field through a relation (e2e)', () => {
|
|
const orderBy = [
|
|
{ pointOfContact: { name: { firstName: 'AscNullsLast' } } },
|
|
{ pointOfContact: { name: { lastName: 'AscNullsLast' } } },
|
|
];
|
|
|
|
// Opportunities ordered by their contact's (firstName, lastName), the two
|
|
// Adas separated by lastName, then the two without a contact in id order
|
|
const expectedOpportunityIds = COMPOSITE_TEST_OPPORTUNITY_IDS;
|
|
|
|
beforeAll(async () => {
|
|
const createPeople = createManyOperationFactory({
|
|
objectMetadataSingularName: 'person',
|
|
objectMetadataPluralName: 'people',
|
|
gqlFields: 'id',
|
|
data: [
|
|
{
|
|
id: COMPOSITE_TEST_PERSON_IDS.ADA_LOVELACE,
|
|
name: { firstName: 'Ada', lastName: 'Lovelace' },
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_PERSON_IDS.ADA_ZUSE,
|
|
name: { firstName: 'Ada', lastName: 'Zuse' },
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_PERSON_IDS.GRACE_HOPPER,
|
|
name: { firstName: 'Grace', lastName: 'Hopper' },
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_PERSON_IDS.LINUS_TORVALDS,
|
|
name: { firstName: 'Linus', lastName: 'Torvalds' },
|
|
},
|
|
],
|
|
upsert: true,
|
|
});
|
|
|
|
await makeGraphqlAPIRequest(createPeople);
|
|
|
|
const createOpportunities = createManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields: 'id',
|
|
data: [
|
|
{
|
|
id: COMPOSITE_TEST_OPPORTUNITY_IDS[0],
|
|
name: 'Deal Ada Lovelace',
|
|
pointOfContactId: COMPOSITE_TEST_PERSON_IDS.ADA_LOVELACE,
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_OPPORTUNITY_IDS[1],
|
|
name: 'Deal Ada Zuse',
|
|
pointOfContactId: COMPOSITE_TEST_PERSON_IDS.ADA_ZUSE,
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_OPPORTUNITY_IDS[2],
|
|
name: 'Deal Grace',
|
|
pointOfContactId: COMPOSITE_TEST_PERSON_IDS.GRACE_HOPPER,
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_OPPORTUNITY_IDS[3],
|
|
name: 'Deal Linus',
|
|
pointOfContactId: COMPOSITE_TEST_PERSON_IDS.LINUS_TORVALDS,
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_OPPORTUNITY_IDS[4],
|
|
name: 'Deal without contact 1',
|
|
pointOfContactId: null,
|
|
},
|
|
{
|
|
id: COMPOSITE_TEST_OPPORTUNITY_IDS[5],
|
|
name: 'Deal without contact 2',
|
|
pointOfContactId: null,
|
|
},
|
|
],
|
|
upsert: true,
|
|
});
|
|
|
|
await makeGraphqlAPIRequest(createOpportunities);
|
|
});
|
|
|
|
const fetchPage = async (variables: Record<string, unknown>) => {
|
|
const response = await makeGraphqlAPIRequest({
|
|
query: gql`
|
|
query Opportunities(
|
|
$orderBy: [OpportunityOrderByInput]
|
|
$filter: OpportunityFilterInput
|
|
$first: Int
|
|
$last: Int
|
|
$after: String
|
|
$before: String
|
|
) {
|
|
opportunities(
|
|
orderBy: $orderBy
|
|
filter: $filter
|
|
first: $first
|
|
last: $last
|
|
after: $after
|
|
before: $before
|
|
) {
|
|
edges {
|
|
node {
|
|
id
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
hasPreviousPage
|
|
startCursor
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
orderBy,
|
|
filter: { id: { in: COMPOSITE_TEST_OPPORTUNITY_IDS } },
|
|
...variables,
|
|
},
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
return response.body.data.opportunities;
|
|
};
|
|
|
|
it('should paginate exhaustively in the label order with only id selected', async () => {
|
|
const collectedIds: string[] = [];
|
|
let after: string | undefined = undefined;
|
|
let pages = 0;
|
|
|
|
for (let iteration = 0; iteration < 10; iteration++) {
|
|
const connection = await fetchPage({ first: 2, after });
|
|
|
|
collectedIds.push(
|
|
...connection.edges.map(
|
|
(edge: { node: { id: string } }) => edge.node.id,
|
|
),
|
|
);
|
|
pages++;
|
|
|
|
if (!connection.pageInfo.hasNextPage) {
|
|
break;
|
|
}
|
|
|
|
after = connection.pageInfo.endCursor;
|
|
}
|
|
|
|
expect(collectedIds).toEqual(expectedOpportunityIds);
|
|
expect(pages).toBe(3);
|
|
});
|
|
|
|
it('should walk backward across the missing-contact boundary', async () => {
|
|
const forwardConnection = await fetchPage({
|
|
first: COMPOSITE_TEST_OPPORTUNITY_IDS.length,
|
|
});
|
|
const endCursor = forwardConnection.pageInfo.endCursor;
|
|
|
|
const collectedIds: string[] = [];
|
|
let before: string | undefined = endCursor;
|
|
|
|
for (let iteration = 0; iteration < 10; iteration++) {
|
|
const connection = await fetchPage({ last: 2, before });
|
|
|
|
collectedIds.unshift(
|
|
...connection.edges.map(
|
|
(edge: { node: { id: string } }) => edge.node.id,
|
|
),
|
|
);
|
|
|
|
if (!connection.pageInfo.hasPreviousPage) {
|
|
break;
|
|
}
|
|
|
|
before = connection.pageInfo.startCursor;
|
|
}
|
|
|
|
// Everything before the last record, in forward order
|
|
expect(collectedIds).toEqual(expectedOpportunityIds.slice(0, -1));
|
|
});
|
|
});
|