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
87 changes: 87 additions & 0 deletions pgpm/transform/__tests__/granularity-driver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { loadModule } from 'plpgsql-parser';

import { restructureChanges } from '../src/granularity-driver';

beforeAll(async () => {
await loadModule();
});

const ATOMIC_CHANGES = [
{
name: 'schemas/app',
dependencies: [],
deploy: 'CREATE SCHEMA app;'
},
{
name: 'schemas/app/tables/users',
dependencies: ['schemas/app'],
deploy: [
'CREATE TABLE app.users ();',
'ALTER TABLE app.users ADD COLUMN id uuid;',
'ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);'
].join('\n')
},
{
name: 'schemas/app/tables/orders',
dependencies: ['schemas/app'],
deploy: [
'CREATE TABLE app.orders ();',
'ALTER TABLE app.orders ADD COLUMN id uuid;',
'ALTER TABLE app.orders ADD COLUMN user_id uuid;'
].join('\n')
},
{
name: 'schemas/app/tables/orders_fk',
dependencies: ['schemas/app/tables/orders', 'schemas/app/tables/users'],
deploy: 'ALTER TABLE app.orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES app.users (id);'
}
];

describe('restructureChanges', () => {
it('consolidates a module into fully-baked per-object changes', () => {
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'consolidated' });
expect(result.warnings).toEqual([]);

const names = result.changes.map(c => c.name);
expect(names).toEqual([
'schemas/app',
'schemas/app/tables/users',
'schemas/app/tables/orders'
]);

const users = result.changes.find(c => c.name === 'schemas/app/tables/users')!;
expect(users.deploy).not.toContain('ALTER TABLE');
expect(users.deploy).toContain('PRIMARY KEY');
expect(users.dependencies).toContain('schemas/app');

const orders = result.changes.find(c => c.name === 'schemas/app/tables/orders')!;
expect(orders.deploy).toContain('FOREIGN KEY');
expect(orders.dependencies).toContain('schemas/app/tables/users');
});

it('object granularity keeps cross-table FKs as separate statements', () => {
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'object' });
const all = result.changes.map(c => c.deploy).join('\n');
expect(all).toContain('ALTER TABLE');
expect(all).toContain('FOREIGN KEY');
// Columns still folded into the creates.
const users = result.changes.find(c => c.name === 'schemas/app/tables/users')!;
expect(users.deploy).toContain('id uuid');
});

it('atomize explodes consolidated changes back to per-statement shape', () => {
const consolidated = restructureChanges(ATOMIC_CHANGES, { granularity: 'consolidated' });
const atomic = restructureChanges(consolidated.changes, { granularity: 'atomic' });
const users = atomic.changes.find(c => c.name === 'schemas/app/tables/users')!;
expect(users.deploy).toContain('ADD COLUMN');
expect(users.deploy.match(/ALTER TABLE/g)!.length).toBeGreaterThanOrEqual(2);
});

it('supports custom change naming', () => {
const result = restructureChanges(ATOMIC_CHANGES, {
granularity: 'consolidated',
changeName: f => `obj/${f.creates[0]?.name ?? 'misc'}`
});
expect(result.changes.map(c => c.name)).toEqual(['obj/app', 'obj/users', 'obj/orders']);
});
});
2 changes: 1 addition & 1 deletion pgpm/transform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"makage": "^0.3.0"
},
"dependencies": {
"@pgsql/transform": "^18.8.0",
"@pgsql/transform": "^18.9.0",
"plpgsql-parser": "^18.2.2"
}
}
201 changes: 201 additions & 0 deletions pgpm/transform/src/granularity-driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* Granularity driver: restructure a pgpm module's deploy surface between the
* atomic, object, and consolidated shapes.
*
* The upstream pass (`restructureSql` in `@pgsql/transform`) rewrites one SQL
* script between equivalent shapes, guarded by the statement dependency
* graph. This driver lifts that to the pgpm change model: it flattens a
* module's deploy scripts in plan order into one program, restructures it to
* the target granularity, then re-slices the result into changes — one change
* per created object — with change dependencies recomputed from the statement
* graph. Like the other drivers in this package it is structurally typed on
* the bundle seams: no dependency on `@pgpmjs/bundle` or `@pgpmjs/core`.
*
* - `atomic` — the machine-emitted shape: bare CREATE TABLE plus one
* ALTER per column/constraint.
* - `object` — each table fully baked; cross-object statements
* (FKs, indexes, triggers, policies) stay separate.
* - `consolidated` — additionally inlines FKs proven safe by the graph.
*/
import type { Granularity, StatementFacts } from '@pgsql/transform';
import {
buildStatementGraph,
classifyStatements,
restructureSql
} from '@pgsql/transform';

export type { Granularity } from '@pgsql/transform';

/** A change's deploy surface going into or out of the restructure. */
export interface GranularityChange {
/** Change name (plan token, e.g. `schemas/app/tables/users`). */
name: string;
/** Change names this change requires (within the same module). */
dependencies: string[];
/** Deploy SQL (headerless — the caller owns pgpm headers). */
deploy: string;
}

export interface RestructureModuleOptions {
granularity: Granularity;
/**
* Derive a change name for a statement group from the facts of its primary
* (creating) statement. Defaults to {@link defaultChangeName}: pgpm-style
* `schemas/<schema>` / `schemas/<schema>/tables/<name>` paths.
*/
changeName?: (facts: StatementFacts) => string;
}

export interface RestructureModuleResult {
/** Restructured changes in deploy order, dependencies recomputed. */
changes: GranularityChange[];
/** Non-fatal notes (folds rejected to preserve ordering, etc.). */
warnings: string[];
}

const KIND_DIRS: Partial<Record<StatementFacts['kind'], string>> = {
table: 'tables',
view: 'views',
index: 'indexes',
type: 'types',
function: 'procedures',
trigger: 'triggers',
policy: 'policies',
seed_dml: 'fixtures'
};

/**
* Default pgpm-style change name for a statement group:
* `schemas/<schema>` for schemas, `schemas/<schema>/<kind>/<name>` for
* objects, `misc/<n>` when nothing better is known.
*/
export function defaultChangeName(facts: StatementFacts): string {
const created = facts.creates[0];
if (facts.kind === 'schema' && created) return `schemas/${created.name}`;
if (created) {
const dir = KIND_DIRS[facts.kind] ?? 'objects';
const schema = created.schema ?? 'public';
// Trigger/policy names are table-qualified (`table.trigger`).
const name = created.name.replace(/\./g, '/');
return `schemas/${schema}/${dir}/${name}`;
}
return 'misc/statements';
}

/**
* Restructure a module's deploy changes to the target granularity.
*
* The flattened program is restructured as one script, then re-sliced: each
* emitted statement joins the group of the object it creates (statements
* creating nothing attach to the previous group), groups become changes named
* by `changeName`, and change dependencies are the statement-graph edges
* mapped onto owning groups. Requires `loadModule()` from `plpgsql-parser`.
*/
export function restructureChanges(
changes: GranularityChange[],
options: RestructureModuleOptions
): RestructureModuleResult {
const nameFor = options.changeName ?? defaultChangeName;

const flattened = changes
.map(c => c.deploy.trim())
.filter(Boolean)
.join('\n\n');

const { sql, warnings } = restructureSql(flattened, {
granularity: options.granularity
});

// Re-classify the emitted script; group statements by the object they
// target (creates[0]), so a table's CREATE and its remaining ALTERs land
// in the same change regardless of statement kind.
const facts = classifyStatements(sql);
const graph = buildStatementGraph(facts);

const groupOf: number[] = new Array(facts.length).fill(-1);
const groupKeys: string[] = [];
const groupFacts: StatementFacts[] = [];
const groupKeyToIndex = new Map<string, number>();

facts.forEach((f, i) => {
const created = f.creates[0];
if (!created) {
// Statements creating nothing (grants, comments) ride with the
// previous statement's change.
if (i > 0 && groupOf[i - 1] !== -1) groupOf[i] = groupOf[i - 1];
return;
}
const key = `${created.schema ?? ''}.${created.name}`;
let g = groupKeyToIndex.get(key);
if (g === undefined) {
g = groupKeys.length;
groupKeys.push(key);
groupFacts.push(f);
groupKeyToIndex.set(key, g);
} else if (!(groupFacts[g].kind in KIND_DIRS) && groupFacts[g].kind !== 'schema' && (f.kind in KIND_DIRS || f.kind === 'schema')) {
// Prefer naming the group after its creating statement over an ALTER.
groupFacts[g] = f;
}
groupOf[i] = g;
});

const groupNames = groupFacts.map(nameFor);

// Schema producers, for schema-level change dependencies.
const schemaGroup = new Map<string, number>();
facts.forEach((f, i) => {
if (f.kind === 'schema' && f.creates[0] && groupOf[i] !== -1) {
schemaGroup.set(f.creates[0].name, groupOf[i]);
}
});

// Slice statement text per group, in the emitted (topological) order.
const groupSql: string[][] = groupNames.map((): string[] => []);

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};`);
}
});

// Change dependencies = statement edges projected onto groups, plus
// schema references (an object change depends on its schema's change).
const groupDeps: Set<number>[] = groupNames.map((): Set<number> => new Set<number>());
for (const edge of graph.edges) {
if (edge.kind === 'late') continue;
const from = groupOf[edge.from];
const to = groupOf[edge.to];
if (from !== -1 && to !== -1 && from !== to) groupDeps[from].add(to);
}
facts.forEach((f, i) => {
const from = groupOf[i];
if (from === -1) return;
const schemas = new Set<string>(f.referencedSchemas);
for (const created of f.creates) {
if (created.schema) schemas.add(created.schema);
}
for (const schema of schemas) {
const to = schemaGroup.get(schema);
if (to !== undefined && to !== from) groupDeps[from].add(to);
}
});

// Emit groups in first-statement order (already topological).
const order = [...groupNames.keys()].sort((a, b) => {
const firstA = groupOf.indexOf(a);
const firstB = groupOf.indexOf(b);
return firstA - firstB;
});

const result: GranularityChange[] = 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')
}));

return { changes: result, warnings };
}
9 changes: 9 additions & 0 deletions pgpm/transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export {
makeNamespaceValidator,
makeSchemaTranspiler,
} from './bundle-driver';
export type {
GranularityChange,
RestructureModuleOptions,
RestructureModuleResult,
} from './granularity-driver';
export {
defaultChangeName,
restructureChanges,
} from './granularity-driver';
export type {
CategoryProfile,
ChangeCategory,
Expand Down
Loading
Loading