Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions pgpm/export/__tests__/export-granularity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,50 @@ relocatable = false
`);
expect(fk.rows).toHaveLength(1);
});

it('generates populated revert/verify scripts for each change', () => {
const moduleDir = join(exportWorkspaceDir, 'packages', EXTENSION_NAME);

const ownersVerify = readFileSync(
join(moduleDir, 'verify', 'schemas', 'pets_consolidated_public', 'tables', 'owners', 'table.sql'),
'utf-8'
);
expect(ownersVerify).toContain("to_regclass('pets_consolidated_public.owners')");

const ownersRevert = readFileSync(
join(moduleDir, 'revert', 'schemas', 'pets_consolidated_public', 'tables', 'owners', 'table.sql'),
'utf-8'
);
expect(ownersRevert).toContain('DROP TABLE pets_consolidated_public.owners;');
expect(ownersRevert).not.toContain('CASCADE');

const schemaRevert = readFileSync(
join(moduleDir, 'revert', 'schemas', 'pets_consolidated_public', 'schema.sql'),
'utf-8'
);
expect(schemaRevert).toContain('DROP SCHEMA pets_consolidated_public;');
});

it('verifies the deployed module with the generated verify scripts', async () => {
const moduleDir = join(exportWorkspaceDir, 'packages', EXTENSION_NAME);
const migrate = new PgpmMigrate(dbConfig);
const result = await migrate.verify({ modulePath: moduleDir });
expect(result.failed).toEqual([]);
expect(result.verified.length).toBeGreaterThan(0);
});

it('reverts the module with the generated revert scripts, leaving the DB clean', async () => {
const moduleDir = join(exportWorkspaceDir, 'packages', EXTENSION_NAME);
const migrate = new PgpmMigrate(dbConfig);
const result = await migrate.revert({ modulePath: moduleDir });
expect(result.failed).toBeUndefined();

const schema = await pg.query(`
SELECT schema_name FROM information_schema.schemata
WHERE schema_name = 'pets_consolidated_public'
`);
expect(schema.rows).toHaveLength(0);
});
});

describe('atomic granularity', () => {
Expand Down
11 changes: 7 additions & 4 deletions pgpm/export/src/restructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ export interface RestructureExportRowsResult {
* alteration convention via `alterationPathFor` (monotonic per-parent
* counter), with the parent added to their deps.
*
* Revert/verify scripts are not carried through: the restructured grouping no
* longer matches the original rows one-to-one, so they are emitted empty.
* Revert/verify scripts are not carried through from the original rows (the
* restructured grouping no longer matches them one-to-one); instead each
* change gets scripts generated from its own statements via
* `revertFor`/`verifyFor` (drops in reverse topological order, one existence
* check per created object).
*/
export const restructureExportRows = async (
rows: PgpmRow[],
Expand Down Expand Up @@ -68,8 +71,8 @@ export const restructureExportRows = async (
deploy,
deps,
content: change.deploy,
revert: '',
verify: ''
revert: change.revert,
verify: change.verify
};
});

Expand Down
47 changes: 47 additions & 0 deletions pgpm/transform/__tests__/granularity-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,53 @@ describe('restructureChanges', () => {
expect(users.deploy.match(/ALTER TABLE/g)!.length).toBeGreaterThanOrEqual(2);
});

it('generates per-change revert scripts (drops in reverse topo order)', () => {
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'consolidated' });

const schema = result.changes.find(c => c.name === 'schemas/app/schema')!;
expect(schema.revert).toEqual('DROP SCHEMA app;');

const users = result.changes.find(c => c.name === 'schemas/app/tables/users/table')!;
expect(users.revert).toEqual('DROP TABLE app.users;');
expect(users.revert).not.toContain('CASCADE');
});

it('generates per-change verify scripts (raise-on-failure existence checks)', () => {
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'consolidated' });

const schema = result.changes.find(c => c.name === 'schemas/app/schema')!;
expect(schema.verify).toEqual(
"SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'app') THEN 1 ELSE 0 END);"
);

const users = result.changes.find(c => c.name === 'schemas/app/tables/users/table')!;
expect(users.verify).toEqual(
"SELECT 1/(CASE WHEN to_regclass('app.users') IS NOT NULL THEN 1 ELSE 0 END);"
);
});

it('reverts the atomic shape per command, dependents first', () => {
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'atomic' });
const users = result.changes.find(c => c.name === 'schemas/app/tables/users/table')!;
// Constraint dropped before its column, column before the table.
const drops = users.revert.split('\n\n');
expect(drops[0]).toContain('DROP CONSTRAINT users_pkey');
expect(drops[drops.length - 1]).toEqual('DROP TABLE app.users;');
});

it('prefixes revert/verify warnings with the owning change name', () => {
const result = restructureChanges([
{
name: 'schemas/app/tables/users/table',
dependencies: [],
deploy: 'CREATE TABLE app.users (id int);\nALTER TABLE app.users ADD CHECK (id > 0);'
}
], { granularity: 'atomic' });
expect(result.warnings.some(w =>
w.startsWith('schemas/app/tables/users/table: revert not derivable:')
)).toBe(true);
});

it('supports custom change naming', () => {
const result = restructureChanges(ATOMIC_CHANGES, {
granularity: 'consolidated',
Expand Down
3 changes: 2 additions & 1 deletion pgpm/transform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
},
"dependencies": {
"@pgpmjs/naming-spec": "workspace:^",
"@pgsql/transform": "^18.10.0",
"@pgsql/scripts": "^18.0.0",
"@pgsql/transform": "^18.11.0",
"plpgsql-parser": "^18.2.2"
}
}
48 changes: 40 additions & 8 deletions pgpm/transform/src/granularity-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* - `consolidated` — additionally inlines FKs proven safe by the graph.
*/
import { pathFor } from '@pgpmjs/naming-spec';
import { revertFor, verifyFor } from '@pgsql/scripts';
import type { Granularity, StatementFacts } from '@pgsql/transform';
import {
buildStatementGraph,
Expand All @@ -38,6 +39,22 @@ export interface GranularityChange {
deploy: string;
}

/** A restructured change: deploy plus generated revert/verify scripts. */
export interface RestructuredChange extends GranularityChange {
/**
* Generated revert SQL (headerless): mechanical inverses of the change's
* statements in reverse topological order within the group, via
* `revertFor`. Non-invertible statements leave a `-- revert not
* derivable` comment and a warning.
*/
revert: string;
/**
* Generated verify SQL (headerless): one raise-on-failure existence check
* per created object, via `verifyFor`.
*/
verify: string;
}

export interface RestructureModuleOptions {
granularity: Granularity;
/**
Expand All @@ -50,7 +67,7 @@ export interface RestructureModuleOptions {

export interface RestructureModuleResult {
/** Restructured changes in deploy order, dependencies recomputed. */
changes: GranularityChange[];
changes: RestructuredChange[];
/** Non-fatal notes (folds rejected to preserve ordering, etc.). */
warnings: string[];
}
Expand Down Expand Up @@ -145,14 +162,17 @@ export function restructureChanges(
}
});

// Slice statement text per group, in the emitted (topological) order.
// Slice statement text per group, in the emitted (topological) order,
// and collect each group's facts for revert/verify generation.
const groupSql: string[][] = groupNames.map((): string[] => []);
const groupStatements: StatementFacts[][] = groupNames.map((): StatementFacts[] => []);

facts.forEach((f, i) => {
const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
const g = groupOf[i];
if (g !== -1 && text) {
groupSql[g].push(text.endsWith(';') ? text : `${text};`);
groupStatements[g].push(f);
}
});

Expand Down Expand Up @@ -185,13 +205,25 @@ export function restructureChanges(
return firstA - firstB;
});

const result: GranularityChange[] = order
const result: RestructuredChange[] = order
.filter(g => groupSql[g].length > 0)
.map(g => ({
name: groupNames[g],
dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
deploy: groupSql[g].join('\n\n')
}));
.map(g => {
const revert = revertFor(groupStatements[g]);
const verify = verifyFor(groupStatements[g]);
for (const warning of revert.warnings) {
warnings.push(`${groupNames[g]}: ${warning}`);
}
for (const warning of verify.warnings) {
warnings.push(`${groupNames[g]}: ${warning}`);
}
return {
name: groupNames[g],
dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
deploy: groupSql[g].join('\n\n'),
revert: revert.sql,
verify: verify.sql
};
});

return { changes: result, warnings };
}
1 change: 1 addition & 0 deletions pgpm/transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
} from './bundle-driver';
export type {
GranularityChange,
RestructuredChange,
RestructureModuleOptions,
RestructureModuleResult,
} from './granularity-driver';
Expand Down
Loading