Skip to content

Commit 42c4d07

Browse files
committed
Update test: Multi page behavior. databaseId filter.
1 parent ca260d7 commit 42c4d07

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

pgpm/export/__tests__/export-parity.test.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,13 @@ describe('export parity — SQL vs GraphQL (integration)', () => {
397397
.toEqual({ path: relPath, content: gqlFiltered[relPath] });
398398
}
399399

400+
// Verify the ORM was called with PostGraphile-style filter syntax
401+
expect(mockFindMany).toHaveBeenCalledWith(
402+
expect.objectContaining({
403+
where: { databaseId: { equalTo: DATABASE_ID } }
404+
})
405+
);
406+
400407
// Sanity checks
401408
expect(sqlPaths.some(p => p.includes(EXTENSION_NAME) && p.endsWith('pgpm.plan'))).toBe(true);
402409
expect(sqlPaths.some(p => p.includes(META_EXTENSION_NAME) && p.endsWith('pgpm.plan'))).toBe(true);
@@ -461,4 +468,217 @@ describe('export parity — SQL vs GraphQL (integration)', () => {
461468
// Database module should NOT exist (no sql_actions, no migrateEndpoint)
462469
expect(fs.existsSync(path.join(pkgsDir, EXTENSION_NAME, 'deploy'))).toBe(false);
463470
});
471+
472+
// =========================================================================
473+
// Cursor-based pagination with >100 rows (multi-page + output parity)
474+
// =========================================================================
475+
476+
it('should paginate through >100 sql_actions and produce output identical to single-page', async () => {
477+
mockFindMany.mockReset();
478+
479+
// Generate 105 unique sql_actions — enough to exceed PAGE_SIZE (100)
480+
const PAGINATION_DATABASE_ID = 'a1b2c3d4-e5f6-4708-b250-000000000099';
481+
const TOTAL_ACTIONS = 105;
482+
483+
// Seed >100 sql_actions into the real test DB
484+
const insertValues: string[] = [];
485+
for (let i = 0; i < TOTAL_ACTIONS; i++) {
486+
const padded = String(i).padStart(3, '0');
487+
const schemaPrefix = i < 50 ? 'pets_public' : 'pets_private';
488+
const prevSchemaPrefix = (i - 1) < 50 ? 'pets_public' : 'pets_private';
489+
const depClause = i === 0
490+
? `ARRAY[]::text[]`
491+
: `ARRAY['schemas/${prevSchemaPrefix}/tables/tbl_${String(i - 1).padStart(3, '0')}/table']::text[]`;
492+
insertValues.push(
493+
`('Create table tbl_${padded}', '${PAGINATION_DATABASE_ID}', 'schemas/${schemaPrefix}/tables/tbl_${padded}/table', ` +
494+
`${depClause}, ` +
495+
`'CREATE TABLE ${schemaPrefix}.tbl_${padded} (id uuid PRIMARY KEY);', ` +
496+
`'DROP TABLE ${schemaPrefix}.tbl_${padded};', ` +
497+
`$$SELECT 1 FROM information_schema.tables WHERE table_schema = ''${schemaPrefix}'' AND table_name = ''tbl_${padded}'';$$)`
498+
);
499+
}
500+
501+
// Use a separate pgsql-test connection for the large-dataset pagination test
502+
const { teardown: pagTeardown } = await getConnections({}, [
503+
seed.fn(async ({ pg: pgClient, config }: any) => {
504+
const migrate = new PgpmMigrate(config);
505+
await migrate.initialize();
506+
507+
await pgClient.query(SCHEMA_SHIMS_SQL);
508+
509+
// Seed the database row + schemas (required by exportMigrations)
510+
await pgClient.query(`
511+
INSERT INTO metaschema_public.database (id, owner_id, name, hash) VALUES
512+
('${PAGINATION_DATABASE_ID}', '00000000-0000-0000-0000-000000000001', '${DATABASE_NAME}', 'f1e2d3c4-b5a6-5c2e-9a07-000000000099');
513+
INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description) VALUES
514+
('aaaa0001-0000-0000-0000-000000000099', '${PAGINATION_DATABASE_ID}', 'public', 'pets_public', 'Public-facing tables'),
515+
('aaaa0002-0000-0000-0000-000000000099', '${PAGINATION_DATABASE_ID}', 'private', 'pets_private', 'Internal tables');
516+
`);
517+
518+
// Seed >100 sql_actions
519+
await pgClient.query(
520+
`INSERT INTO db_migrate.sql_actions (name, database_id, deploy, deps, content, revert, verify) VALUES\n` +
521+
insertValues.join(',\n')
522+
);
523+
524+
// Fetch the camelCase rows for the mock
525+
const sqlActionsResult = await pgClient.query(
526+
'SELECT * FROM db_migrate.sql_actions WHERE database_id = $1 ORDER BY id',
527+
[PAGINATION_DATABASE_ID]
528+
);
529+
const pagCamelActions = sqlActionsResult.rows.map(pgRowToCamel);
530+
expect(pagCamelActions.length).toBe(TOTAL_ACTIONS);
531+
532+
const pagSchemas = [
533+
{ name: 'public', schema_name: 'pets_public' },
534+
{ name: 'private', schema_name: 'pets_private' }
535+
];
536+
const pagSchemaNames = pagSchemas.map(s => s.schema_name);
537+
538+
// Fetch meta result from real DB
539+
const pagMetaResult = await exportMeta({ opts: { pg: config }, dbname: config.database, database_id: PAGINATION_DATABASE_ID });
540+
541+
// ---- Create two workspaces: SQL baseline and paginated GraphQL ----
542+
const pagTempDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'pgpm-pagination-'));
543+
const createWorkspace = (label: string) => {
544+
const wsDir = path.join(pagTempDir, label);
545+
const pkgsDir = path.join(wsDir, 'packages');
546+
fs.mkdirSync(path.join(wsDir, 'extensions'), { recursive: true });
547+
fs.mkdirSync(pkgsDir, { recursive: true });
548+
fs.writeFileSync(path.join(wsDir, 'pgpm.json'), JSON.stringify({ packages: ['packages/*', 'extensions/*'] }));
549+
fs.writeFileSync(path.join(wsDir, 'package.json'), JSON.stringify({ name: label, version: '1.0.0', private: true }));
550+
551+
for (const mod of [EXTENSION_NAME, META_EXTENSION_NAME]) {
552+
const modDir = path.join(pkgsDir, mod);
553+
fs.mkdirSync(modDir, { recursive: true });
554+
fs.writeFileSync(path.join(modDir, 'package.json'), JSON.stringify({ name: mod, version: '1.0.0' }));
555+
fs.writeFileSync(path.join(modDir, `${mod}.control`), `comment='test'\ndefault_version='1.0.0'\n`);
556+
fs.writeFileSync(path.join(modDir, 'pgpm.plan'), `%syntax-version=1.0.0\n%project=${mod}\n%uri=${mod}\n`);
557+
}
558+
return wsDir;
559+
};
560+
561+
const sqlWs = createWorkspace('sql-baseline');
562+
const gqlWs = createWorkspace('gql-paginated');
563+
564+
// ---- SQL flow (single query, no pagination) ----
565+
const sqlProject = new PgpmPackage(sqlWs);
566+
await exportMigrations({
567+
project: sqlProject,
568+
options: { pg: config },
569+
dbInfo: {
570+
dbname: config.database,
571+
databaseName: DATABASE_NAME,
572+
database_ids: [PAGINATION_DATABASE_ID]
573+
},
574+
schema_names: pagSchemaNames,
575+
author: 'test <test@test.local>',
576+
outdir: path.join(sqlWs, 'packages'),
577+
extensionName: EXTENSION_NAME,
578+
metaExtensionName: META_EXTENSION_NAME
579+
});
580+
581+
// ---- GraphQL flow (paginated mock returning pages of 100) ----
582+
const PAGE_SIZE = 100;
583+
const pages: Record<string, unknown>[][] = [];
584+
for (let offset = 0; offset < pagCamelActions.length; offset += PAGE_SIZE) {
585+
pages.push(pagCamelActions.slice(offset, offset + PAGE_SIZE));
586+
}
587+
// Should be 2 pages: [100, 5]
588+
expect(pages.length).toBe(2);
589+
expect(pages[0].length).toBe(PAGE_SIZE);
590+
expect(pages[1].length).toBe(TOTAL_ACTIONS - PAGE_SIZE);
591+
592+
// Wire up mockFindMany to return each page with proper cursor info
593+
for (let i = 0; i < pages.length; i++) {
594+
const isLast = i === pages.length - 1;
595+
mockFindMany.mockReturnValueOnce({
596+
unwrap: jest.fn().mockResolvedValue({
597+
sqlActions: {
598+
nodes: pages[i],
599+
totalCount: pagCamelActions.length,
600+
pageInfo: {
601+
hasNextPage: !isLast,
602+
hasPreviousPage: i > 0,
603+
endCursor: isLast ? null : `cursor-after-page-${i + 1}`
604+
}
605+
}
606+
})
607+
});
608+
}
609+
610+
(exportGraphQLMeta as any).mockImplementation(async () => pagMetaResult);
611+
612+
const gqlProject = new PgpmPackage(gqlWs);
613+
await exportGraphQL({
614+
project: gqlProject,
615+
metaEndpoint: 'http://localhost:3002/graphql',
616+
migrateEndpoint: 'http://db_migrate.localhost:3000/graphql',
617+
token: undefined,
618+
headers: { 'X-Meta-Schema': 'true' },
619+
databaseId: PAGINATION_DATABASE_ID,
620+
databaseName: DATABASE_NAME,
621+
schema_names: pagSchemaNames,
622+
schemas: pagSchemas,
623+
author: 'test <test@test.local>',
624+
outdir: path.join(gqlWs, 'packages'),
625+
extensionName: EXTENSION_NAME,
626+
metaExtensionName: META_EXTENSION_NAME
627+
});
628+
629+
// ---- Assertions ----
630+
631+
// findMany must have been called exactly twice (page 1 + page 2)
632+
expect(mockFindMany).toHaveBeenCalledTimes(2);
633+
634+
// First call: no cursor
635+
expect(mockFindMany.mock.calls[0][0]).not.toHaveProperty('after');
636+
637+
// Second call: cursor from page 1
638+
expect(mockFindMany.mock.calls[1][0]).toHaveProperty('after', 'cursor-after-page-1');
639+
640+
// Both calls use the correct filter
641+
for (const call of mockFindMany.mock.calls) {
642+
expect(call[0]).toMatchObject({
643+
where: { databaseId: { equalTo: PAGINATION_DATABASE_ID } }
644+
});
645+
}
646+
647+
// ---- Output parity: paginated GraphQL must match SQL baseline ----
648+
const isScaffold = (p: string) =>
649+
p.endsWith('package.json') || p.endsWith('.control');
650+
const filterScaffold = (files: Record<string, string>) => {
651+
const out: Record<string, string> = {};
652+
for (const [k, v] of Object.entries(files)) {
653+
if (!isScaffold(k)) out[k] = v;
654+
}
655+
return out;
656+
};
657+
658+
const sqlFiles = filterScaffold(collectFiles(path.join(sqlWs, 'packages')));
659+
const gqlFiles = filterScaffold(collectFiles(path.join(gqlWs, 'packages')));
660+
661+
const sqlPaths = Object.keys(sqlFiles).sort();
662+
const gqlPaths = Object.keys(gqlFiles).sort();
663+
664+
// Same set of files
665+
expect(gqlPaths).toEqual(sqlPaths);
666+
667+
// Identical content for every file
668+
for (const relPath of sqlPaths) {
669+
expect({ path: relPath, content: gqlFiles[relPath] })
670+
.toEqual({ path: relPath, content: sqlFiles[relPath] });
671+
}
672+
673+
// Sanity: at least 105 deploy files were produced (one per sql_action)
674+
const deployFiles = sqlPaths.filter(p => p.includes(EXTENSION_NAME) && p.includes('/deploy/'));
675+
expect(deployFiles.length).toBe(TOTAL_ACTIONS);
676+
677+
// Cleanup
678+
fs.rmSync(pagTempDir, { recursive: true, force: true });
679+
})
680+
]);
681+
682+
await pagTeardown();
683+
});
464684
});

0 commit comments

Comments
 (0)