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
593 lines
18 KiB
TypeScript
593 lines
18 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
|
|
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
|
|
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
|
|
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
|
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
|
|
|
|
const PAGE_SIZE = 3;
|
|
|
|
const DATED_CLOSE_DATES = [
|
|
'2026-01-01T00:00:00.000Z',
|
|
'2026-01-02T00:00:00.000Z',
|
|
'2026-01-03T00:00:00.000Z',
|
|
'2026-01-04T00:00:00.000Z',
|
|
'2026-01-05T00:00:00.000Z',
|
|
'2026-01-06T00:00:00.000Z',
|
|
];
|
|
const NULL_CLOSE_DATE_COUNT = 4;
|
|
const TOTAL_COUNT = DATED_CLOSE_DATES.length + NULL_CLOSE_DATE_COUNT;
|
|
|
|
const datedOpportunityIds = DATED_CLOSE_DATES.map(() => randomUUID());
|
|
const nullCloseDateOpportunityIds = Array.from(
|
|
{ length: NULL_CLOSE_DATE_COUNT },
|
|
() => randomUUID(),
|
|
);
|
|
|
|
type GraphqlResponse = {
|
|
body: {
|
|
errors?: { message: string }[];
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
data: Record<string, any>;
|
|
};
|
|
};
|
|
|
|
type RecordConnection = {
|
|
edges: { node: { id: string } }[];
|
|
pageInfo: {
|
|
hasNextPage: boolean;
|
|
hasPreviousPage: boolean;
|
|
startCursor: string;
|
|
endCursor: string;
|
|
};
|
|
};
|
|
|
|
type PaginateAllParams = {
|
|
orderBy: object;
|
|
objectMetadataSingularName?: string;
|
|
objectMetadataPluralName?: string;
|
|
filter?: object;
|
|
gqlFields?: string;
|
|
first?: number;
|
|
};
|
|
|
|
const paginateForward = async ({
|
|
orderBy,
|
|
objectMetadataSingularName = 'opportunity',
|
|
objectMetadataPluralName = 'opportunities',
|
|
filter,
|
|
gqlFields = 'id',
|
|
first = PAGE_SIZE,
|
|
}: PaginateAllParams) => {
|
|
const ids: string[] = [];
|
|
let after: string | undefined = undefined;
|
|
let pages = 0;
|
|
|
|
// Cap the loop far above the expected page count so a paging bug cannot hang the suite
|
|
for (let iteration = 0; iteration < 20; iteration++) {
|
|
const response: GraphqlResponse = await makeGraphqlAPIRequest(
|
|
findManyOperationFactory({
|
|
objectMetadataSingularName,
|
|
objectMetadataPluralName,
|
|
gqlFields,
|
|
filter,
|
|
orderBy,
|
|
first,
|
|
after,
|
|
}),
|
|
).expect(200);
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
const connection: RecordConnection =
|
|
response.body.data[objectMetadataPluralName];
|
|
|
|
ids.push(...connection.edges.map((edge) => edge.node.id));
|
|
pages++;
|
|
|
|
if (!connection.pageInfo.hasNextPage) {
|
|
break;
|
|
}
|
|
|
|
after = connection.pageInfo.endCursor;
|
|
}
|
|
|
|
return { ids, pages };
|
|
};
|
|
|
|
const paginateBackwardFrom = async ({
|
|
orderBy,
|
|
startingBefore,
|
|
gqlFields = 'id',
|
|
}: PaginateAllParams & { startingBefore: string }) => {
|
|
const ids: string[] = [];
|
|
let before: string | undefined = startingBefore;
|
|
|
|
for (let iteration = 0; iteration < 20; iteration++) {
|
|
const response: GraphqlResponse = await makeGraphqlAPIRequest(
|
|
findManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields,
|
|
orderBy,
|
|
last: PAGE_SIZE,
|
|
before,
|
|
}),
|
|
).expect(200);
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
const connection: RecordConnection = response.body.data.opportunities;
|
|
|
|
ids.unshift(...connection.edges.map((edge) => edge.node.id));
|
|
|
|
if (!connection.pageInfo.hasPreviousPage) {
|
|
break;
|
|
}
|
|
|
|
before = connection.pageInfo.startCursor;
|
|
}
|
|
|
|
return { ids };
|
|
};
|
|
|
|
describe('Cursor pagination exhaustiveness with orderBy (issue #24333)', () => {
|
|
beforeAll(async () => {
|
|
await deleteAllRecords('opportunity');
|
|
|
|
await makeGraphqlAPIRequest(
|
|
createManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields: 'id',
|
|
data: [
|
|
...DATED_CLOSE_DATES.map((closeDate, index) => ({
|
|
id: datedOpportunityIds[index],
|
|
name: `Dated opportunity ${index + 1}`,
|
|
closeDate,
|
|
})),
|
|
...nullCloseDateOpportunityIds.map((id, index) => ({
|
|
id,
|
|
name: `Undated opportunity ${index + 1}`,
|
|
})),
|
|
],
|
|
}),
|
|
).expect(200);
|
|
});
|
|
|
|
it('should return every record when paginating ordered by id (baseline)', async () => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: { id: 'AscNullsLast' },
|
|
});
|
|
|
|
expect(new Set(ids).size).toBe(TOTAL_COUNT);
|
|
});
|
|
|
|
it('should return every record when the orderBy field is not in the selection set', async () => {
|
|
const { ids, pages } = await paginateForward({
|
|
orderBy: { closeDate: 'AscNullsFirst' },
|
|
gqlFields: 'id',
|
|
});
|
|
|
|
expect(ids).toHaveLength(TOTAL_COUNT);
|
|
expect(new Set(ids).size).toBe(TOTAL_COUNT);
|
|
expect(pages).toBe(Math.ceil(TOTAL_COUNT / PAGE_SIZE));
|
|
});
|
|
|
|
it.each(['AscNullsLast', 'DescNullsLast'])(
|
|
'should paginate across the trailing NULL block with %s',
|
|
async (direction) => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: { closeDate: direction },
|
|
});
|
|
|
|
expect(ids).toHaveLength(TOTAL_COUNT);
|
|
expect(new Set(ids).size).toBe(TOTAL_COUNT);
|
|
|
|
// The NULL block sorts last: its records must all be there, at the end
|
|
expect(new Set(ids.slice(DATED_CLOSE_DATES.length))).toEqual(
|
|
new Set(nullCloseDateOpportunityIds),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(['AscNullsFirst', 'DescNullsFirst'])(
|
|
'should paginate across the leading NULL block with %s',
|
|
async (direction) => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: { closeDate: direction },
|
|
});
|
|
|
|
expect(ids).toHaveLength(TOTAL_COUNT);
|
|
expect(new Set(ids).size).toBe(TOTAL_COUNT);
|
|
|
|
expect(new Set(ids.slice(0, NULL_CLOSE_DATE_COUNT))).toEqual(
|
|
new Set(nullCloseDateOpportunityIds),
|
|
);
|
|
},
|
|
);
|
|
|
|
it('should keep the requested global order across pages', async () => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: { closeDate: 'AscNullsLast' },
|
|
});
|
|
|
|
expect(ids.slice(0, DATED_CLOSE_DATES.length)).toEqual(datedOpportunityIds);
|
|
});
|
|
|
|
it('should walk backward across the NULL boundary with before cursors', async () => {
|
|
// Reach the last page forward, then walk back from its end cursor: the
|
|
// backward scan re-crosses the null/non-null boundary in reverse
|
|
const forwardResponse = await makeGraphqlAPIRequest(
|
|
findManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields: 'id',
|
|
orderBy: { closeDate: 'AscNullsLast' },
|
|
first: TOTAL_COUNT,
|
|
}),
|
|
).expect(200);
|
|
|
|
const forwardConnection = forwardResponse.body.data.opportunities;
|
|
const forwardIds = forwardConnection.edges.map(
|
|
(edge: { node: { id: string } }) => edge.node.id,
|
|
);
|
|
|
|
expect(forwardIds).toHaveLength(TOTAL_COUNT);
|
|
|
|
const { ids } = await paginateBackwardFrom({
|
|
orderBy: { closeDate: 'AscNullsLast' },
|
|
startingBefore: forwardConnection.pageInfo.endCursor,
|
|
});
|
|
|
|
// Everything before the last record, in the same order as the forward scan
|
|
expect(ids).toEqual(forwardIds.slice(0, -1));
|
|
});
|
|
|
|
it('should reject a cursor that does not carry the orderBy field value', async () => {
|
|
const staleCursor = Buffer.from(
|
|
JSON.stringify({ id: datedOpportunityIds[0] }),
|
|
).toString('base64');
|
|
|
|
const response: GraphqlResponse = await makeGraphqlAPIRequest(
|
|
findManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields: 'id',
|
|
orderBy: { closeDate: 'AscNullsLast' },
|
|
first: PAGE_SIZE,
|
|
after: staleCursor,
|
|
}),
|
|
).expect(200);
|
|
|
|
expect(response.body.errors).toBeDefined();
|
|
expect(response.body.errors?.[0].message).toContain(
|
|
'Cursor is missing the value for orderBy field "closeDate"',
|
|
);
|
|
});
|
|
|
|
it('should paginate exhaustively when a filter is combined with the cursor', async () => {
|
|
const { ids } = await paginateForward({
|
|
filter: { closeDate: { gte: DATED_CLOSE_DATES[2] } },
|
|
orderBy: { closeDate: 'AscNullsLast' },
|
|
first: 2,
|
|
});
|
|
|
|
expect(ids).toEqual(datedOpportunityIds.slice(2));
|
|
});
|
|
|
|
// Canonical lowercase UUID strings sort the same way in JS and in Postgres,
|
|
// so the expected order can be computed with a plain string sort
|
|
it('should honor an explicit descending id ordering across pages', async () => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: { id: 'DescNullsLast' },
|
|
});
|
|
|
|
expect(ids).toEqual(
|
|
[...datedOpportunityIds, ...nullCloseDateOpportunityIds].sort().reverse(),
|
|
);
|
|
});
|
|
|
|
it('should tie-break with an explicit descending id inside the NULL block', async () => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: [{ closeDate: 'AscNullsLast' }, { id: 'DescNullsLast' }],
|
|
});
|
|
|
|
expect(ids).toEqual([
|
|
...datedOpportunityIds,
|
|
...[...nullCloseDateOpportunityIds].sort().reverse(),
|
|
]);
|
|
});
|
|
|
|
it('should keep the first direction when the same field is ordered twice', async () => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: [{ closeDate: 'AscNullsLast' }, { closeDate: 'DescNullsFirst' }],
|
|
});
|
|
|
|
expect(ids.slice(0, DATED_CLOSE_DATES.length)).toEqual(datedOpportunityIds);
|
|
expect(new Set(ids.slice(DATED_CLOSE_DATES.length))).toEqual(
|
|
new Set(nullCloseDateOpportunityIds),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Cursor pagination with composite orderBy not in the selection set', () => {
|
|
const personIds = Array.from({ length: 5 }, () => randomUUID());
|
|
const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];
|
|
|
|
beforeAll(async () => {
|
|
await deleteAllRecords('person');
|
|
|
|
await makeGraphqlAPIRequest(
|
|
createManyOperationFactory({
|
|
objectMetadataSingularName: 'person',
|
|
objectMetadataPluralName: 'people',
|
|
gqlFields: 'id',
|
|
data: personIds.map((id, index) => ({
|
|
id,
|
|
name: {
|
|
firstName: firstNames[index],
|
|
lastName: 'Test',
|
|
},
|
|
})),
|
|
}),
|
|
).expect(200);
|
|
});
|
|
|
|
it('should return every record when only id is selected', async () => {
|
|
const { ids } = await paginateForward({
|
|
objectMetadataSingularName: 'person',
|
|
objectMetadataPluralName: 'people',
|
|
orderBy: { name: { firstName: 'AscNullsLast' } },
|
|
first: 2,
|
|
});
|
|
|
|
// Global order must hold across pages: Alice..Eve
|
|
expect(ids).toEqual(personIds);
|
|
});
|
|
});
|
|
|
|
describe('Cursor pagination ordered by a nullable foreign key', () => {
|
|
const companyIds = [randomUUID(), randomUUID()];
|
|
const withCompanyOpportunityIds = [randomUUID(), randomUUID(), randomUUID()];
|
|
const withoutCompanyOpportunityIds = [
|
|
randomUUID(),
|
|
randomUUID(),
|
|
randomUUID(),
|
|
];
|
|
const fkTotalCount =
|
|
withCompanyOpportunityIds.length + withoutCompanyOpportunityIds.length;
|
|
|
|
beforeAll(async () => {
|
|
await deleteAllRecords('opportunity');
|
|
await deleteAllRecords('company');
|
|
|
|
await makeGraphqlAPIRequest(
|
|
createManyOperationFactory({
|
|
objectMetadataSingularName: 'company',
|
|
objectMetadataPluralName: 'companies',
|
|
gqlFields: 'id',
|
|
data: companyIds.map((id, index) => ({
|
|
id,
|
|
name: `Cursor FK company ${index + 1}`,
|
|
})),
|
|
}),
|
|
).expect(200);
|
|
|
|
await makeGraphqlAPIRequest(
|
|
createManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields: 'id',
|
|
data: [
|
|
...withCompanyOpportunityIds.map((id, index) => ({
|
|
id,
|
|
name: `FK opportunity ${index + 1}`,
|
|
companyId: companyIds[index % companyIds.length],
|
|
})),
|
|
...withoutCompanyOpportunityIds.map((id, index) => ({
|
|
id,
|
|
name: `FK-less opportunity ${index + 1}`,
|
|
})),
|
|
],
|
|
}),
|
|
).expect(200);
|
|
});
|
|
|
|
it('should return every record when ordering by the foreign key column', async () => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: { companyId: 'AscNullsLast' },
|
|
first: 2,
|
|
});
|
|
|
|
expect(ids).toHaveLength(fkTotalCount);
|
|
expect(new Set(ids).size).toBe(fkTotalCount);
|
|
expect(new Set(ids.slice(withCompanyOpportunityIds.length))).toEqual(
|
|
new Set(withoutCompanyOpportunityIds),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Cursor pagination ordered by a nullable composite sub-field', () => {
|
|
const withAmountOpportunityIds = [
|
|
randomUUID(),
|
|
randomUUID(),
|
|
randomUUID(),
|
|
randomUUID(),
|
|
];
|
|
const withoutAmountOpportunityIds = [
|
|
randomUUID(),
|
|
randomUUID(),
|
|
randomUUID(),
|
|
];
|
|
const amountTotalCount =
|
|
withAmountOpportunityIds.length + withoutAmountOpportunityIds.length;
|
|
|
|
beforeAll(async () => {
|
|
await deleteAllRecords('opportunity');
|
|
|
|
await makeGraphqlAPIRequest(
|
|
createManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields: 'id',
|
|
data: [
|
|
...withAmountOpportunityIds.map((id, index) => ({
|
|
id,
|
|
name: `Amount opportunity ${index + 1}`,
|
|
amount: {
|
|
amountMicros: (index + 1) * 1_000_000,
|
|
currencyCode: 'USD',
|
|
},
|
|
})),
|
|
...withoutAmountOpportunityIds.map((id, index) => ({
|
|
id,
|
|
name: `Amount-less opportunity ${index + 1}`,
|
|
})),
|
|
],
|
|
}),
|
|
).expect(200);
|
|
});
|
|
|
|
it('should paginate across the NULL block of a currency sub-field', async () => {
|
|
const { ids } = await paginateForward({
|
|
orderBy: { amount: { amountMicros: 'AscNullsLast' } },
|
|
first: 2,
|
|
});
|
|
|
|
expect(ids).toHaveLength(amountTotalCount);
|
|
expect(new Set(ids).size).toBe(amountTotalCount);
|
|
// Non-null amounts keep their micros order across pages
|
|
expect(ids.slice(0, withAmountOpportunityIds.length)).toEqual(
|
|
withAmountOpportunityIds,
|
|
);
|
|
expect(new Set(ids.slice(withAmountOpportunityIds.length))).toEqual(
|
|
new Set(withoutAmountOpportunityIds),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Cursor pagination with duplicate sort values', () => {
|
|
const duplicatedCloseDates = [
|
|
'2026-02-01T00:00:00.000Z',
|
|
'2026-02-01T00:00:00.000Z',
|
|
'2026-02-02T00:00:00.000Z',
|
|
'2026-02-02T00:00:00.000Z',
|
|
'2026-02-03T00:00:00.000Z',
|
|
'2026-02-03T00:00:00.000Z',
|
|
];
|
|
const duplicateDatedOpportunityIds = duplicatedCloseDates.map(() =>
|
|
randomUUID(),
|
|
);
|
|
const duplicateNullOpportunityIds = [randomUUID(), randomUUID()];
|
|
const duplicatesTotalCount =
|
|
duplicateDatedOpportunityIds.length + duplicateNullOpportunityIds.length;
|
|
|
|
beforeAll(async () => {
|
|
await deleteAllRecords('opportunity');
|
|
|
|
await makeGraphqlAPIRequest(
|
|
createManyOperationFactory({
|
|
objectMetadataSingularName: 'opportunity',
|
|
objectMetadataPluralName: 'opportunities',
|
|
gqlFields: 'id',
|
|
data: [
|
|
...duplicatedCloseDates.map((closeDate, index) => ({
|
|
id: duplicateDatedOpportunityIds[index],
|
|
name: `Duplicate dated opportunity ${index + 1}`,
|
|
closeDate,
|
|
})),
|
|
...duplicateNullOpportunityIds.map((id, index) => ({
|
|
id,
|
|
name: `Duplicate undated opportunity ${index + 1}`,
|
|
})),
|
|
],
|
|
}),
|
|
).expect(200);
|
|
});
|
|
|
|
it('should neither skip nor repeat records when the sort value has duplicates', async () => {
|
|
// Page size 2 lands page boundaries inside the duplicate groups
|
|
const { ids } = await paginateForward({
|
|
orderBy: { closeDate: 'AscNullsLast' },
|
|
first: 2,
|
|
});
|
|
|
|
expect(ids).toHaveLength(duplicatesTotalCount);
|
|
expect(new Set(ids).size).toBe(duplicatesTotalCount);
|
|
expect(new Set(ids.slice(duplicatedCloseDates.length))).toEqual(
|
|
new Set(duplicateNullOpportunityIds),
|
|
);
|
|
});
|
|
});
|
|
|
|
// Empty TEXT values are stored as SQL NULL (write-side normalization) and
|
|
// presented as '' by the API: the scan's NULL block holds every empty row, and
|
|
// cursors read the raw SQL values so the continuation follows the scan exactly
|
|
describe('Cursor pagination ordered by a TEXT field with empty values', () => {
|
|
const namedCompanyIds = {
|
|
ALPHA: '20202020-eeee-4000-8000-000000000001',
|
|
BETA: '20202020-eeee-4000-8000-000000000002',
|
|
};
|
|
const emptyNameCompanyIds = [
|
|
'20202020-eeee-4000-8000-000000000003',
|
|
'20202020-eeee-4000-8000-000000000004',
|
|
'20202020-eeee-4000-8000-000000000005',
|
|
'20202020-eeee-4000-8000-000000000006',
|
|
];
|
|
const allCompanyIds = [
|
|
...Object.values(namedCompanyIds),
|
|
...emptyNameCompanyIds,
|
|
];
|
|
|
|
beforeAll(async () => {
|
|
await makeGraphqlAPIRequest(
|
|
createManyOperationFactory({
|
|
objectMetadataSingularName: 'company',
|
|
objectMetadataPluralName: 'companies',
|
|
gqlFields: 'id',
|
|
data: [
|
|
{ id: namedCompanyIds.ALPHA, name: 'Alpha Corp' },
|
|
{ id: namedCompanyIds.BETA, name: 'Beta Inc' },
|
|
// One explicitly empty, the others without the field: both store NULL
|
|
{ id: emptyNameCompanyIds[0], name: '' },
|
|
...emptyNameCompanyIds.slice(1).map((id) => ({ id })),
|
|
],
|
|
upsert: true,
|
|
}),
|
|
).expect(200);
|
|
});
|
|
|
|
it('should paginate the empty block exhaustively with AscNullsLast', async () => {
|
|
const { ids } = await paginateForward({
|
|
objectMetadataSingularName: 'company',
|
|
objectMetadataPluralName: 'companies',
|
|
filter: { id: { in: allCompanyIds } },
|
|
orderBy: { name: 'AscNullsLast' },
|
|
first: 2,
|
|
});
|
|
|
|
expect(ids).toEqual([
|
|
namedCompanyIds.ALPHA,
|
|
namedCompanyIds.BETA,
|
|
...emptyNameCompanyIds,
|
|
]);
|
|
});
|
|
|
|
it('should paginate the empty block exhaustively with DescNullsLast', async () => {
|
|
const { ids } = await paginateForward({
|
|
objectMetadataSingularName: 'company',
|
|
objectMetadataPluralName: 'companies',
|
|
filter: { id: { in: allCompanyIds } },
|
|
orderBy: { name: 'DescNullsLast' },
|
|
first: 2,
|
|
});
|
|
|
|
expect(ids).toEqual([
|
|
namedCompanyIds.BETA,
|
|
namedCompanyIds.ALPHA,
|
|
...emptyNameCompanyIds,
|
|
]);
|
|
});
|
|
});
|