diff --git a/packages/transform/__tests__/__snapshots__/restructure.test.ts.snap b/packages/transform/__tests__/__snapshots__/restructure.test.ts.snap new file mode 100644 index 000000000..4fe6a3d3e --- /dev/null +++ b/packages/transform/__tests__/__snapshots__/restructure.test.ts.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`restructureSql — atomize explodes CREATE TABLE into bare create + per-column/per-constraint alters 1`] = ` +"CREATE TABLE app.users ( + +); + +ALTER TABLE app.users + ADD COLUMN id uuid + DEFAULT gen_random_uuid(); + +ALTER TABLE app.users + ADD COLUMN email text + NOT NULL; + +ALTER TABLE app.users + ADD CONSTRAINT users_email_uniq + UNIQUE (email); + +ALTER TABLE app.users + ADD PRIMARY KEY (id); + +CREATE TABLE app.orders ( + +); + +ALTER TABLE app.orders + ADD COLUMN id uuid; + +ALTER TABLE app.orders + ADD COLUMN user_id uuid; + +ALTER TABLE app.orders + ADD PRIMARY KEY (id); + +ALTER TABLE app.orders + ADD + FOREIGN KEY(user_id) + REFERENCES app.users (id);" +`; + +exports[`restructureSql — fold (consolidated granularity) additionally inlines safe FKs into the table definition 1`] = ` +"CREATE TABLE app.users ( + id uuid DEFAULT gen_random_uuid(), + email text NOT NULL, + CONSTRAINT users_pkey PRIMARY KEY (id) +); + +CREATE TABLE app.orders ( + id uuid, + user_id uuid, + CONSTRAINT orders_pkey PRIMARY KEY (id), + CONSTRAINT orders_user_fk + FOREIGN KEY(user_id) + REFERENCES app.users (id) +);" +`; + +exports[`restructureSql — fold (consolidated granularity) keeps mutually-referencing FKs atomic instead of breaking the cycle 1`] = ` +"CREATE TABLE app.b ( + id uuid, + a_id uuid, + CONSTRAINT b_pkey PRIMARY KEY (id) +); + +CREATE TABLE app.a ( + id uuid, + b_id uuid, + CONSTRAINT a_pkey PRIMARY KEY (id), + CONSTRAINT a_b_fk + FOREIGN KEY(b_id) + REFERENCES app.b (id) +); + +ALTER TABLE app.b + ADD CONSTRAINT b_a_fk + FOREIGN KEY(a_id) + REFERENCES app.a (id);" +`; + +exports[`restructureSql — fold (object granularity) folds columns and same-table constraints into CREATE TABLE, keeps FKs separate 1`] = ` +"CREATE TABLE app.users ( + id uuid DEFAULT gen_random_uuid(), + email text NOT NULL, + CONSTRAINT users_pkey PRIMARY KEY (id) +); + +CREATE TABLE app.orders ( + id uuid, + user_id uuid, + CONSTRAINT orders_pkey PRIMARY KEY (id) +); + +ALTER TABLE app.orders + ADD CONSTRAINT orders_user_fk + FOREIGN KEY(user_id) + REFERENCES app.users (id);" +`; diff --git a/packages/transform/__tests__/graph.test.ts b/packages/transform/__tests__/graph.test.ts new file mode 100644 index 000000000..5b50cbdbd --- /dev/null +++ b/packages/transform/__tests__/graph.test.ts @@ -0,0 +1,79 @@ +import { loadModule } from 'plpgsql-parser'; + +import { classifyStatements } from '../src/facts'; +import { buildStatementGraph } from '../src/graph'; + +beforeAll(async () => { + await loadModule(); +}); + +const graphOf = (sql: string) => buildStatementGraph(classifyStatements(sql)); + +describe('buildStatementGraph', () => { + it('links references to their producers with hard edges', () => { + const g = graphOf(` + CREATE TABLE app.users (id int); + CREATE VIEW app.v_users AS SELECT * FROM app.users; + `); + expect(g.edges).toHaveLength(1); + expect(g.edges[0]).toMatchObject({ from: 1, to: 0, kind: 'hard' }); + expect(g.order).toEqual([0, 1]); + }); + + it('classifies FK targets as fk edges', () => { + const g = graphOf(` + CREATE TABLE app.orders (id int); + CREATE TABLE app.users (id int); + ALTER TABLE app.orders ADD CONSTRAINT fk FOREIGN KEY (id) REFERENCES app.users (id); + `); + const fk = g.edges.find(e => e.kind === 'fk'); + expect(fk).toMatchObject({ from: 2, to: 1 }); + // ALTER also hard-depends on its own table via creates/references dedupe: + // the alter "creates" (targets) app.orders so no self edge exists. + expect(g.order.indexOf(1)).toBeLessThan(g.order.indexOf(2)); + }); + + it('treats PL/pgSQL body references as late edges that allow cycles', () => { + const g = graphOf(` + CREATE FUNCTION app.a() RETURNS int LANGUAGE plpgsql AS $$ BEGIN RETURN app.b(); END $$; + CREATE FUNCTION app.b() RETURNS int LANGUAGE plpgsql AS $$ BEGIN RETURN app.a(); END $$; + `); + expect(g.edges.every(e => e.kind === 'late')).toBe(true); + // Late edges never force multi-member components. + expect(g.components.every(c => c.length === 1)).toBe(true); + expect(g.order).toEqual([0, 1]); + }); + + it('orders mutually-referencing FKs without a cycle at statement granularity', () => { + const g = graphOf(` + CREATE TABLE app.a (id int); + CREATE TABLE app.b (id int); + ALTER TABLE app.a ADD CONSTRAINT fk_ab FOREIGN KEY (id) REFERENCES app.b (id); + ALTER TABLE app.b ADD CONSTRAINT fk_ba FOREIGN KEY (id) REFERENCES app.a (id); + `); + // Atomic statements are exactly what makes mutual FKs deployable: the + // separate ALTERs order after both CREATEs, so no component is bigger + // than one statement. (The cycle only appears when folding — which is + // why restructure keeps such FKs atomic.) + expect(g.components.every(c => c.length === 1)).toBe(true); + expect(g.order.indexOf(2)).toBeGreaterThan(g.order.indexOf(1)); + expect(g.order.indexOf(3)).toBeGreaterThan(g.order.indexOf(0)); + }); + + it('produces a stable topological order (source order for ties)', () => { + const g = graphOf(` + CREATE TABLE app.z (id int); + CREATE TABLE app.a (id int); + CREATE TABLE app.m (id int); + `); + expect(g.order).toEqual([0, 1, 2]); + }); + + it('reorders forward references', () => { + const g = graphOf(` + CREATE VIEW app.v AS SELECT * FROM app.t; + CREATE TABLE app.t (id int); + `); + expect(g.order).toEqual([1, 0]); + }); +}); diff --git a/packages/transform/__tests__/restructure.test.ts b/packages/transform/__tests__/restructure.test.ts new file mode 100644 index 000000000..8fbdca8da --- /dev/null +++ b/packages/transform/__tests__/restructure.test.ts @@ -0,0 +1,122 @@ +import { loadModule } from 'plpgsql-parser'; + +import { restructureSql } from '../src/restructure'; + +beforeAll(async () => { + await loadModule(); +}); + +const ATOMIC = ` +CREATE TABLE app.users (); +ALTER TABLE app.users ADD COLUMN id uuid; +ALTER TABLE app.users ADD COLUMN email text; +ALTER TABLE app.users ALTER COLUMN email SET NOT NULL; +ALTER TABLE app.users ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +CREATE TABLE app.orders (); +ALTER TABLE app.orders ADD COLUMN id uuid; +ALTER TABLE app.orders ADD COLUMN user_id uuid; +ALTER TABLE app.orders ADD CONSTRAINT orders_pkey PRIMARY KEY (id); +ALTER TABLE app.orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES app.users (id); +`; + +describe('restructureSql — fold (object granularity)', () => { + it('folds columns and same-table constraints into CREATE TABLE, keeps FKs separate', () => { + const result = restructureSql(ATOMIC, { granularity: 'object' }); + expect(result.warnings).toEqual([]); + expect(result.sql).toMatchSnapshot(); + // Columns, defaults, not-null, PKs folded; FK stays as ALTER TABLE. + expect(result.sql).toContain('CREATE TABLE app.users'); + expect(result.sql).toContain('DEFAULT gen_random_uuid()'); + expect(result.sql).toContain('NOT NULL'); + expect(result.sql.match(/ALTER TABLE/g) ?? []).toHaveLength(1); + expect(result.sql).toContain('FOREIGN KEY'); + }); +}); + +describe('restructureSql — fold (consolidated granularity)', () => { + it('additionally inlines safe FKs into the table definition', () => { + const result = restructureSql(ATOMIC, { granularity: 'consolidated' }); + expect(result.sql).toMatchSnapshot(); + expect(result.sql).not.toContain('ALTER TABLE'); + // users must be emitted before orders (FK dependency). + expect(result.sql.indexOf('CREATE TABLE app.users')) + .toBeLessThan(result.sql.indexOf('CREATE TABLE app.orders')); + }); + + it('keeps mutually-referencing FKs atomic instead of breaking the cycle', () => { + const cyclic = ` + CREATE TABLE app.a (); + ALTER TABLE app.a ADD COLUMN id uuid; + ALTER TABLE app.a ADD COLUMN b_id uuid; + ALTER TABLE app.a ADD CONSTRAINT a_pkey PRIMARY KEY (id); + CREATE TABLE app.b (); + ALTER TABLE app.b ADD COLUMN id uuid; + ALTER TABLE app.b ADD COLUMN a_id uuid; + ALTER TABLE app.b ADD CONSTRAINT b_pkey PRIMARY KEY (id); + ALTER TABLE app.a ADD CONSTRAINT a_b_fk FOREIGN KEY (b_id) REFERENCES app.b (id); + ALTER TABLE app.b ADD CONSTRAINT b_a_fk FOREIGN KEY (a_id) REFERENCES app.a (id); + `; + const result = restructureSql(cyclic, { granularity: 'consolidated' }); + // At least one FK must remain an ALTER TABLE to break the cycle. + expect(result.sql).toContain('ALTER TABLE'); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.sql).toMatchSnapshot(); + }); + + it('inlines a self-referencing FK', () => { + const selfRef = ` + CREATE TABLE app.tree (); + ALTER TABLE app.tree ADD COLUMN id uuid; + ALTER TABLE app.tree ADD CONSTRAINT tree_pkey PRIMARY KEY (id); + ALTER TABLE app.tree ADD COLUMN parent_id uuid; + ALTER TABLE app.tree ADD CONSTRAINT tree_parent_fk FOREIGN KEY (parent_id) REFERENCES app.tree (id); + `; + const result = restructureSql(selfRef, { granularity: 'consolidated' }); + expect(result.sql).not.toContain('ALTER TABLE'); + expect(result.sql).toContain('FOREIGN KEY'); + }); +}); + +describe('restructureSql — atomize', () => { + const CONSOLIDATED = ` + CREATE TABLE app.users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL, + CONSTRAINT users_email_uniq UNIQUE (email) + ); + CREATE TABLE app.orders ( + id uuid PRIMARY KEY, + user_id uuid REFERENCES app.users (id) + ); + `; + + it('explodes CREATE TABLE into bare create + per-column/per-constraint alters', () => { + const result = restructureSql(CONSOLIDATED, { granularity: 'atomic' }); + expect(result.sql).toMatchSnapshot(); + expect(result.exploded).toBeGreaterThan(0); + expect(result.sql).toMatch(/CREATE TABLE app\.users \(\s*\)/); + expect(result.sql).toContain('ADD COLUMN'); + // Column-level PK/UNIQUE/FK promoted to table-level ADD CONSTRAINT. + expect(result.sql).toContain('PRIMARY KEY (id)'); + expect(result.sql).toMatch(/FOREIGN KEY\s*\(user_id\)/); + // Defaults and NOT NULL stay inline on the column. + expect(result.sql).toMatch(/ADD COLUMN email text\s+NOT NULL/); + expect(result.sql).toMatch(/ADD COLUMN id uuid\s+DEFAULT gen_random_uuid\(\)/); + }); + + it('round-trips: atomize then consolidate returns the baked shape', () => { + const atomic = restructureSql(CONSOLIDATED, { granularity: 'atomic' }); + const back = restructureSql(atomic.sql, { granularity: 'consolidated' }); + expect(back.sql).not.toContain('ALTER TABLE'); + expect(back.sql).toContain('CREATE TABLE app.users'); + expect(back.sql).toContain('CREATE TABLE app.orders'); + expect(back.sql).toContain('FOREIGN KEY'); + }); + + it('leaves partitioned/typed tables intact', () => { + const sql = 'CREATE TABLE app.log_2026 PARTITION OF app.log FOR VALUES FROM (1) TO (2);'; + const result = restructureSql(sql, { granularity: 'atomic' }); + expect(result.exploded).toBe(0); + }); +}); diff --git a/packages/transform/src/graph.ts b/packages/transform/src/graph.ts new file mode 100644 index 000000000..f73a2f414 --- /dev/null +++ b/packages/transform/src/graph.ts @@ -0,0 +1,265 @@ +/** + * Statement-level dependency graph over classified SQL. + * + * {@link classifyStatements} reduces each top-level statement to + * {@link StatementFacts} — what it creates, what it references, which of + * those references live only inside PL/pgSQL bodies. This module turns a + * sequence of facts into a typed dependency graph and computes the orderings + * the granularity passes (consolidate / atomize) need: + * + * - **typed edges**: `hard` edges (the referenced object must exist at + * CREATE time), `fk` edges (foreign-key targets — hard, but singled out + * because they are the edges FK inlining must respect), and `late` edges + * (PL/pgSQL body references, resolved at call time — they never constrain + * deploy order and may legitimately form cycles). + * - **SCC condensation**: strongly connected components over hard+fk edges. + * A well-formed DDL script condenses to singleton components; any larger + * component is a genuine ordering cycle (e.g. mutually referencing FKs) + * that granularity passes must leave in atomic form. + * - **topological order** of the condensation, stable with respect to the + * original statement order (ties keep source order), so re-emission is + * deterministic and minimally surprising. + */ +import { QualifiedName, StatementFacts } from './facts'; + +/** + * How a dependency edge constrains ordering. + * + * - `hard` — name must resolve when the dependent statement executes. + * - `fk` — a hard edge arising from a foreign-key target; distinguished so + * folding passes can decide whether an FK may be inlined into its table. + * - `late` — reached only inside a PL/pgSQL body; resolved at call time, + * so it does not constrain deploy order. + */ +export type EdgeKind = 'hard' | 'fk' | 'late'; + +/** A directed dependency: statement `from` depends on statement `to`. */ +export interface StatementEdge { + from: number; + to: number; + kind: EdgeKind; + /** The referenced object that induced this edge. */ + via: QualifiedName; +} + +/** A node in the statement graph: one top-level statement. */ +export interface StatementNode { + /** Index of the statement in the classified script. */ + index: number; + facts: StatementFacts; + /** Outgoing edges (this statement's dependencies). */ + out: StatementEdge[]; + /** Incoming edges (statements that depend on this one). */ + in: StatementEdge[]; +} + +/** The statement-level dependency graph for one SQL script. */ +export interface StatementGraph { + nodes: StatementNode[]; + edges: StatementEdge[]; + /** + * `schema.name` → indices of the statements that create that object. + * Trigger and policy names are table-qualified (`table.trigger`), matching + * {@link StatementFacts.creates}. + */ + producers: Map; + /** + * Strongly connected components over `hard` + `fk` edges, in topological + * order of the condensation. Singleton components are the common case; + * larger ones are genuine DDL ordering cycles. + */ + components: number[][]; + /** + * A stable topological order of all statements: components in condensation + * order, members of a component in source order. Ties between independent + * components keep source order. + */ + order: number[]; +} + +const keyOf = (q: QualifiedName): string => `${q.schema ?? ''}.${q.name}`; + +/** + * Build the typed statement dependency graph for a classified script. + * + * Edges only exist between statements of the same script: a reference with + * no in-script producer is an external dependency and induces no edge (the + * caller decides what to do with those — pgpm expresses them as + * cross-package requires). + */ +export function buildStatementGraph(facts: StatementFacts[]): StatementGraph { + const nodes: StatementNode[] = facts.map((f, index) => ({ + index, + facts: f, + out: [] as StatementEdge[], + in: [] as StatementEdge[] + })); + + const producers = new Map(); + facts.forEach((f, i) => { + for (const created of f.creates) { + const key = keyOf(created); + const list = producers.get(key) ?? []; + list.push(i); + producers.set(key, list); + } + }); + + // The producer a reference binds to is the closest preceding statement + // that creates the object (redefinitions shadow earlier ones); when the + // reference precedes every producer, it binds to the first one — that is + // exactly the forward edge a reordering pass must satisfy. + const bind = (ref: QualifiedName, from: number): number | undefined => { + const list = producers.get(keyOf(ref)); + if (!list || list.length === 0) return undefined; + let found: number | undefined; + for (const i of list) { + if (i === from) return undefined; // self-dependency: never an edge + if (i < from) found = i; + } + return found ?? list[0]; + }; + + const edges: StatementEdge[] = []; + const addEdge = (from: number, to: number, kind: EdgeKind, via: QualifiedName) => { + if (edges.some(e => e.from === from && e.to === to && e.kind === kind)) return; + const edge: StatementEdge = { from, to, kind, via }; + edges.push(edge); + nodes[from].out.push(edge); + nodes[to].in.push(edge); + }; + + facts.forEach((f, i) => { + const bodyOnly = new Set(f.bodyReferences.map(keyOf)); + const fkKeys = new Set(f.fkTargets.map(keyOf)); + for (const ref of f.references) { + const to = bind(ref, i); + if (to === undefined) continue; + const key = keyOf(ref); + const kind: EdgeKind = fkKeys.has(key) ? 'fk' : bodyOnly.has(key) ? 'late' : 'hard'; + addEdge(i, to, kind, ref); + } + }); + + const components = condense(nodes); + const order = stableTopoOrder(nodes, components); + return { nodes, edges, producers, components, order }; +} + +/** + * Tarjan SCC over `hard` + `fk` edges (`late` edges are ignored — they are + * call-time bindings and legitimately cyclic). Components are returned in + * reverse-topological completion order and then re-sorted topologically with + * source-order tie-breaking by {@link stableTopoOrder}; here we only sort + * each component's members and order components by their smallest member so + * output is deterministic. + */ +function condense(nodes: StatementNode[]): number[][] { + const n = nodes.length; + const indexOf = new Array(n).fill(-1); + const low = new Array(n).fill(0); + const onStack = new Array(n).fill(false); + const stack: number[] = []; + const components: number[][] = []; + let counter = 0; + + const orderingEdges = (v: number): number[] => + nodes[v].out.filter(e => e.kind !== 'late').map(e => e.to); + + // Iterative Tarjan (DDL scripts can be tens of thousands of statements). + const visit = (root: number): void => { + interface Frame { v: number; edges: number[]; i: number } + const frames: Frame[] = [{ v: root, edges: orderingEdges(root), i: 0 }]; + indexOf[root] = low[root] = counter++; + stack.push(root); + onStack[root] = true; + + while (frames.length > 0) { + const frame = frames[frames.length - 1]; + if (frame.i < frame.edges.length) { + const w = frame.edges[frame.i++]; + if (indexOf[w] === -1) { + indexOf[w] = low[w] = counter++; + stack.push(w); + onStack[w] = true; + frames.push({ v: w, edges: orderingEdges(w), i: 0 }); + } else if (onStack[w]) { + low[frame.v] = Math.min(low[frame.v], indexOf[w]); + } + } else { + frames.pop(); + if (frames.length > 0) { + const parent = frames[frames.length - 1]; + low[parent.v] = Math.min(low[parent.v], low[frame.v]); + } + if (low[frame.v] === indexOf[frame.v]) { + const component: number[] = []; + for (;;) { + const w = stack.pop()!; + onStack[w] = false; + component.push(w); + if (w === frame.v) break; + } + component.sort((a, b) => a - b); + components.push(component); + } + } + } + }; + + for (let v = 0; v < n; v++) { + if (indexOf[v] === -1) visit(v); + } + components.sort((a, b) => a[0] - b[0]); + return components; +} + +/** + * Topologically order the condensation with source order breaking ties + * (Kahn's algorithm over components, min-heap on smallest member index), + * then flatten: members of each component stay in source order. + */ +function stableTopoOrder(nodes: StatementNode[], components: number[][]): number[] { + const componentOf = new Array(nodes.length).fill(0); + components.forEach((members, c) => { + for (const m of members) componentOf[m] = c; + }); + + const succ: Set[] = components.map(() => new Set()); + const indegree = new Array(components.length).fill(0); + for (const node of nodes) { + for (const e of node.out) { + if (e.kind === 'late') continue; + const from = componentOf[e.from]; + const to = componentOf[e.to]; + if (from === to) continue; + // Dependency edge from → to means `to` must come first. + if (!succ[to].has(from)) { + succ[to].add(from); + indegree[from]++; + } + } + } + + const ready: number[] = []; + for (let c = 0; c < components.length; c++) { + if (indegree[c] === 0) ready.push(c); + } + const takeMin = (): number => { + let best = 0; + for (let i = 1; i < ready.length; i++) { + if (components[ready[i]][0] < components[ready[best]][0]) best = i; + } + return ready.splice(best, 1)[0]; + }; + + const order: number[] = []; + while (ready.length > 0) { + const c = takeMin(); + order.push(...components[c]); + for (const next of succ[c]) { + if (--indegree[next] === 0) ready.push(next); + } + } + return order; +} diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index b4024487c..4609a6acf 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -6,6 +6,19 @@ export type { StatementKind, } from './facts'; export { classifyStatements } from './facts'; +export type { + EdgeKind, + StatementEdge, + StatementGraph, + StatementNode, +} from './graph'; +export { buildStatementGraph } from './graph'; +export type { + Granularity, + RestructureOptions, + RestructureResult, +} from './restructure'; +export { orderStatements, restructureSql } from './restructure'; export type { ExtensionDefinition, ExtensionRoute, diff --git a/packages/transform/src/restructure.ts b/packages/transform/src/restructure.ts new file mode 100644 index 000000000..5b6bb0a56 --- /dev/null +++ b/packages/transform/src/restructure.ts @@ -0,0 +1,389 @@ +/** + * Granularity restructuring: rewrite a DDL script between equivalent shapes + * without changing the schema it produces. + * + * Three levels, two directions: + * + * - `atomic` — every table is a bare `CREATE TABLE ()` followed by one + * `ALTER TABLE ADD COLUMN` per column and one `ADD CONSTRAINT` per + * constraint (the shape machine emitters produce). + * - `object` — each table is fully baked: columns and same-table constraints + * (PK / UNIQUE / CHECK / NOT NULL / DEFAULT) fold into the `CREATE TABLE`; + * cross-object statements (FKs, indexes, triggers, policies) stay separate. + * - `consolidated` — additionally inlines foreign keys into the table + * definition whenever the dependency graph proves it safe (the referenced + * table can be ordered first); FKs on cycles stay as `ALTER TABLE`. + * + * Every fold is validated against the {@link buildStatementGraph} statement + * graph: a candidate merge is rejected if it would create an ordering cycle, + * so the pass degrades gracefully to the atomic form instead of emitting an + * undeployable script. Output statements are re-emitted in the graph's + * stable topological order. + */ +import { Deparser, parseSql } from 'plpgsql-parser'; + +import { classifyStatements } from './facts'; +import { buildStatementGraph, StatementGraph } from './graph'; + +/** The target shape of a restructured script. */ +export type Granularity = 'atomic' | 'object' | 'consolidated'; + +export interface RestructureOptions { + granularity: Granularity; +} + +export interface RestructureResult { + /** The restructured script, statements in stable topological order. */ + sql: string; + /** Number of statements folded into a `CREATE TABLE` (fold direction). */ + folded: number; + /** Number of statements produced by explosion (atomize direction). */ + exploded: number; + /** Folds that were rejected (with the reason) and other non-fatal notes. */ + warnings: string[]; +} + +type AnyNode = Record; + +const relKey = (rel: AnyNode | undefined): string | null => + rel ? `${rel.schemaname ?? ''}.${rel.relname}` : null; + +const sameRel = (a: AnyNode | undefined, b: AnyNode | undefined): boolean => + !!a && !!b && relKey(a) === relKey(b); + +/** + * Restructure a DDL script to the requested granularity. The input and + * output scripts deploy to identical schemas; only statement shape and + * order change. + */ +export function restructureSql(sql: string, options: RestructureOptions): RestructureResult { + const warnings: string[] = []; + const parsed = parseSql(sql); + const stmts: AnyNode[] = (parsed?.stmts ?? []) + .map((s: AnyNode) => s?.stmt) + .filter(Boolean); + + let outStmts: AnyNode[]; + let folded = 0; + let exploded = 0; + + if (options.granularity === 'atomic') { + const result = explodeStatements(stmts); + outStmts = result.stmts; + exploded = result.exploded; + } else { + const facts = classifyStatements(sql); + const graph = buildStatementGraph(facts); + const result = foldStatements(stmts, graph, options.granularity === 'consolidated', warnings); + outStmts = result.stmts; + folded = result.folded; + } + + const ordered = orderStatements(outStmts); + const text = ordered.map(s => `${Deparser.deparse(s)};`).join('\n\n'); + return { sql: text, folded, exploded, warnings }; +} + +/** + * Re-emit statements in the stable topological order of their dependency + * graph. Statements are deparsed and re-classified so the ordering reflects + * exactly what will be emitted. + */ +export function orderStatements(stmts: AnyNode[]): AnyNode[] { + if (stmts.length <= 1) return stmts; + const script = stmts.map(s => `${Deparser.deparse(s)};`).join('\n'); + const graph = buildStatementGraph(classifyStatements(script)); + if (graph.order.length !== stmts.length) return stmts; + return graph.order.map(i => stmts[i]); +} + +interface FoldResult { + stmts: AnyNode[]; + folded: number; +} + +/** + * Fold `ALTER TABLE` commands into their table's `CREATE TABLE` statement. + * `inlineFks` additionally folds `ADD FOREIGN KEY` when the graph proves the + * referenced table can be created first. + */ +function foldStatements( + stmts: AnyNode[], + graph: StatementGraph, + inlineFks: boolean, + warnings: string[] +): FoldResult { + // Locate the CREATE TABLE for each relation. + const createIndex = new Map(); + stmts.forEach((s, i) => { + const create = s?.CreateStmt; + if (create?.relation) { + const key = relKey(create.relation); + if (key && !createIndex.has(key)) createIndex.set(key, i); + } + }); + + // Fold bookkeeping is a union-find over statements: folding j into the + // CREATE at i merges their graph nodes, and the merged node's dependency + // set is the union of both. Reachability is then computed over merged + // nodes so a second fold cannot silently complete a cycle the first one + // started (the mutual-FK case). + const rep = new Map(); + const find = (v: number): number => { + let r = v; + while (rep.has(r)) r = rep.get(r)!; + return r; + }; + + // Per-statement dependencies: typed graph edges plus the implicit edge + // from every ALTER to its own table's CREATE (the facts model records the + // alter as *targeting* the table, so the graph carries no self edge). + const deps = new Map>(); + stmts.forEach((s, j) => { + const set = new Set(); + for (const e of graph.nodes[j]?.out ?? []) { + if (e.kind !== 'late') set.add(e.to); + } + const alterKey = relKey(s?.AlterTableStmt?.relation); + const own = alterKey ? createIndex.get(alterKey) : undefined; + if (own !== undefined && own !== j) set.add(own); + deps.set(j, set); + }); + + const depsOf = (v: number): number[] => + [...(deps.get(find(v)) ?? [])].map(find).filter(t => t !== find(v)); + + const dependsOn = (from: number, target: number): boolean => { + const goal = find(target); + const seen = new Set(); + const stack = [find(from)]; + while (stack.length > 0) { + const v = stack.pop()!; + if (v === goal) return true; + if (seen.has(v)) continue; + seen.add(v); + stack.push(...depsOf(v)); + } + return false; + }; + + /** + * A command at statement `j` may fold into the CREATE at statement `i` + * iff none of j's dependencies (other than i itself) transitively depend + * on i — otherwise the merged node would sit on a cycle. + */ + const safeToFold = (j: number, i: number): boolean => + !depsOf(j).some(t => t !== find(i) && dependsOn(t, i)); + + /** Merge statement `j` into the CREATE at `i`. */ + const absorb = (i: number, j: number): void => { + const target = find(i); + const merged = deps.get(target) ?? new Set(); + for (const t of deps.get(find(j)) ?? []) merged.add(t); + deps.set(target, merged); + if (find(j) !== target) rep.set(find(j), target); + }; + + const remove = new Set(); + let folded = 0; + + stmts.forEach((s, j) => { + const alter = s?.AlterTableStmt; + if (!alter || alter.objtype !== 'OBJECT_TABLE') return; + const key = relKey(alter.relation); + const i = key ? createIndex.get(key) : undefined; + if (i === undefined || i === j) return; + + const create = stmts[i].CreateStmt; + const remaining: AnyNode[] = []; + + for (const wrapped of alter.cmds ?? []) { + const cmd = wrapped?.AlterTableCmd; + if (!cmd) { + remaining.push(wrapped); + continue; + } + const constraint = cmd.def?.Constraint; + const isFk = cmd.subtype === 'AT_AddConstraint' && constraint?.contype === 'CONSTR_FOREIGN'; + const selfFk = isFk && sameRel(constraint?.pktable, alter.relation); + + let foldable = false; + switch (cmd.subtype) { + case 'AT_AddColumn': + foldable = !!cmd.def?.ColumnDef; + break; + case 'AT_ColumnDefault': + foldable = !!cmd.name && cmd.def !== undefined; + break; + case 'AT_SetNotNull': + foldable = !!cmd.name; + break; + case 'AT_AddConstraint': + foldable = isFk ? selfFk || inlineFks : true; + break; + default: + foldable = false; + } + + if (foldable && !selfFk && !safeToFold(j, i)) { + warnings.push( + `kept atomic: statement ${j} on ${key} would create an ordering cycle if folded` + ); + foldable = false; + } + + if (!foldable) { + remaining.push(wrapped); + continue; + } + + if (!applyFold(create, cmd)) { + remaining.push(wrapped); + continue; + } + absorb(i, j); + folded++; + } + + if (remaining.length === 0) { + remove.add(j); + } else { + alter.cmds = remaining; + } + }); + + return { stmts: stmts.filter((_, i) => !remove.has(i)), folded }; +} + +/** Apply one foldable ALTER TABLE command onto a CreateStmt. */ +function applyFold(create: AnyNode, cmd: AnyNode): boolean { + create.tableElts = create.tableElts ?? []; + + const findColumn = (name: string): AnyNode | undefined => + create.tableElts.find((e: AnyNode) => e?.ColumnDef?.colname === name)?.ColumnDef; + + switch (cmd.subtype) { + case 'AT_AddColumn': + create.tableElts.push({ ColumnDef: cmd.def.ColumnDef }); + return true; + case 'AT_ColumnDefault': { + const col = findColumn(cmd.name); + if (!col) return false; + col.constraints = col.constraints ?? []; + col.constraints.push({ Constraint: { contype: 'CONSTR_DEFAULT', raw_expr: cmd.def } }); + return true; + } + case 'AT_SetNotNull': { + const col = findColumn(cmd.name); + if (!col) return false; + col.constraints = col.constraints ?? []; + col.constraints.push({ Constraint: { contype: 'CONSTR_NOTNULL' } }); + return true; + } + case 'AT_AddConstraint': + create.tableElts.push({ Constraint: cmd.def.Constraint }); + return true; + default: + return false; + } +} + +interface ExplodeResult { + stmts: AnyNode[]; + exploded: number; +} + +/** Column-level constraint types that stay inline on `ADD COLUMN`. */ +const INLINE_COLUMN_CONSTRAINTS = new Set(['CONSTR_DEFAULT', 'CONSTR_NOTNULL', 'CONSTR_NULL', 'CONSTR_IDENTITY', 'CONSTR_GENERATED']); + +/** + * Explode consolidated `CREATE TABLE` statements into the atomic shape: + * bare create, one `ADD COLUMN` per column (keeping column-local defaults / + * NOT NULL inline), one `ADD CONSTRAINT` per table-level or key constraint. + */ +function explodeStatements(stmts: AnyNode[]): ExplodeResult { + const out: AnyNode[] = []; + let exploded = 0; + + const alterFor = (relation: AnyNode, cmd: AnyNode): AnyNode => ({ + AlterTableStmt: { + objtype: 'OBJECT_TABLE', + relation, + cmds: [{ AlterTableCmd: cmd }] + } + }); + + for (const s of stmts) { + const create = s?.CreateStmt; + const elts: AnyNode[] = create?.tableElts ?? []; + // Typed tables / partitions / inheritance keep their shape. + if (!create || elts.length === 0 || create.ofTypename || create.partbound || create.inhRelations) { + out.push(s); + continue; + } + + const relation = create.relation; + const columns: AnyNode[] = []; + const constraints: AnyNode[] = []; + + for (const elt of elts) { + if (elt?.ColumnDef) columns.push(elt.ColumnDef); + else if (elt?.Constraint) constraints.push(elt.Constraint); + else { + // Unknown element (e.g. LIKE clause): keep the table intact. + columns.length = 0; + constraints.length = 0; + break; + } + } + if (columns.length === 0 && constraints.length === 0) { + out.push(s); + continue; + } + + out.push({ CreateStmt: { ...create, tableElts: [] } }); + + for (const col of columns) { + const inline: AnyNode[] = []; + for (const wrapped of col.constraints ?? []) { + const c = wrapped?.Constraint; + if (c && INLINE_COLUMN_CONSTRAINTS.has(c.contype)) { + inline.push(wrapped); + } else if (c) { + // Promote a column constraint (PK/UNIQUE/CHECK/FK) to table level. + constraints.push(columnConstraintToTable(c, col.colname)); + } + } + out.push(alterFor(relation, { + subtype: 'AT_AddColumn', + def: { ColumnDef: { ...col, constraints: inline.length > 0 ? inline : undefined } } + })); + exploded++; + } + + for (const constraint of constraints) { + out.push(alterFor(relation, { + subtype: 'AT_AddConstraint', + def: { Constraint: constraint } + })); + exploded++; + } + } + + return { stmts: out, exploded }; +} + +/** Rewrite a column-level constraint as its table-level equivalent. */ +function columnConstraintToTable(constraint: AnyNode, colname: string): AnyNode { + const strNode = (sval: string): AnyNode => ({ String: { sval } }); + switch (constraint.contype) { + case 'CONSTR_PRIMARY': + case 'CONSTR_UNIQUE': + return { ...constraint, keys: [strNode(colname)] }; + case 'CONSTR_FOREIGN': + return { ...constraint, fk_attrs: [strNode(colname)] }; + default: + // CHECK and others are valid table constraints as-is. + return constraint; + } +}