diff --git a/pgpm/transform/__tests__/granularity-driver.test.ts b/pgpm/transform/__tests__/granularity-driver.test.ts new file mode 100644 index 000000000..ffbbb2778 --- /dev/null +++ b/pgpm/transform/__tests__/granularity-driver.test.ts @@ -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']); + }); +}); diff --git a/pgpm/transform/package.json b/pgpm/transform/package.json index 3f4252a06..40a0c907a 100644 --- a/pgpm/transform/package.json +++ b/pgpm/transform/package.json @@ -43,7 +43,7 @@ "makage": "^0.3.0" }, "dependencies": { - "@pgsql/transform": "^18.8.0", + "@pgsql/transform": "^18.9.0", "plpgsql-parser": "^18.2.2" } } diff --git a/pgpm/transform/src/granularity-driver.ts b/pgpm/transform/src/granularity-driver.ts new file mode 100644 index 000000000..298d9bfab --- /dev/null +++ b/pgpm/transform/src/granularity-driver.ts @@ -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/` / `schemas//tables/` 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> = { + 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/` for schemas, `schemas///` for + * objects, `misc/` 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(); + + 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(); + 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[] = groupNames.map((): Set => new Set()); + 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(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 }; +} diff --git a/pgpm/transform/src/index.ts b/pgpm/transform/src/index.ts index 497eee272..f5526dc19 100644 --- a/pgpm/transform/src/index.ts +++ b/pgpm/transform/src/index.ts @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dee59ee1..78c318f74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3097,8 +3097,8 @@ importers: pgpm/transform: dependencies: '@pgsql/transform': - specifier: ^18.8.0 - version: 18.8.0 + specifier: ^18.9.0 + version: 18.9.0 plpgsql-parser: specifier: ^18.2.2 version: 18.2.2 @@ -4169,20 +4169,6 @@ packages: } engines: { node: '>=18.0.0' } - '@babel/code-frame@7.27.1': - resolution: - { - integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==, - } - engines: { node: '>=6.9.0' } - - '@babel/code-frame@7.28.6': - resolution: - { - integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==, - } - engines: { node: '>=6.9.0' } - '@babel/code-frame@7.29.0': resolution: { @@ -6457,10 +6443,10 @@ packages: integrity: sha512-wZcMP1QHiQbWWKOw4nfvZ74xUSz/9jqeSUXVnoR1saI5M0D+iYbiuOlfNDyYmziLwgEkCuSJLZK1dZ+eQCwgAA==, } - '@pgsql/transform@18.8.0': + '@pgsql/transform@18.9.0': resolution: { - integrity: sha512-orYvqoD34XPwpp4KgJNiXZFOmze2dSZOd3gwHHyy2fICf+5XXN5IBX14tJxiwon2k8NpPfpjQ5xSQjDhK5uRzQ==, + integrity: sha512-VqkupUcHLvvUcc0Sqhwq7tevmnLjAree2k215z2kJJtmeALfLx6gFBu8pQaI6HedBo5aqy2ORlGp/HAYLW2h/w==, } '@pgsql/traverse@18.1.0': @@ -15926,18 +15912,6 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/code-frame@7.28.6': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -15948,7 +15922,7 @@ snapshots: '@babel/core@7.28.6': dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) @@ -17763,7 +17737,7 @@ snapshots: '@pgsql/quotes@18.1.0': {} - '@pgsql/transform@18.8.0': + '@pgsql/transform@18.9.0': dependencies: '@pgsql/quotes': 18.1.0 '@pgsql/traverse': 18.4.0 @@ -18431,7 +18405,7 @@ snapshots: '@testing-library/dom@7.31.2': dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@babel/runtime': 7.28.4 '@types/aria-query': 4.2.2 aria-query: 4.2.2 @@ -21157,7 +21131,7 @@ snapshots: jest-message-util@30.2.0: dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@jest/types': 30.2.0 '@types/stack-utils': 2.0.3 chalk: 4.1.2