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
Original file line number Diff line number Diff line change
@@ -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);"
`;
79 changes: 79 additions & 0 deletions packages/transform/__tests__/graph.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
122 changes: 122 additions & 0 deletions packages/transform/__tests__/restructure.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading