Skip to content

Commit a591cdb

Browse files
authored
Merge pull request #325 from constructive-io/feat/granularity-restructure
feat(transform): statement dependency graph + granularity restructuring (atomic ↔ object ↔ consolidated)
2 parents 9796081 + d8dafd2 commit a591cdb

6 files changed

Lines changed: 966 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`restructureSql — atomize explodes CREATE TABLE into bare create + per-column/per-constraint alters 1`] = `
4+
"CREATE TABLE app.users (
5+
6+
);
7+
8+
ALTER TABLE app.users
9+
ADD COLUMN id uuid
10+
DEFAULT gen_random_uuid();
11+
12+
ALTER TABLE app.users
13+
ADD COLUMN email text
14+
NOT NULL;
15+
16+
ALTER TABLE app.users
17+
ADD CONSTRAINT users_email_uniq
18+
UNIQUE (email);
19+
20+
ALTER TABLE app.users
21+
ADD PRIMARY KEY (id);
22+
23+
CREATE TABLE app.orders (
24+
25+
);
26+
27+
ALTER TABLE app.orders
28+
ADD COLUMN id uuid;
29+
30+
ALTER TABLE app.orders
31+
ADD COLUMN user_id uuid;
32+
33+
ALTER TABLE app.orders
34+
ADD PRIMARY KEY (id);
35+
36+
ALTER TABLE app.orders
37+
ADD
38+
FOREIGN KEY(user_id)
39+
REFERENCES app.users (id);"
40+
`;
41+
42+
exports[`restructureSql — fold (consolidated granularity) additionally inlines safe FKs into the table definition 1`] = `
43+
"CREATE TABLE app.users (
44+
id uuid DEFAULT gen_random_uuid(),
45+
email text NOT NULL,
46+
CONSTRAINT users_pkey PRIMARY KEY (id)
47+
);
48+
49+
CREATE TABLE app.orders (
50+
id uuid,
51+
user_id uuid,
52+
CONSTRAINT orders_pkey PRIMARY KEY (id),
53+
CONSTRAINT orders_user_fk
54+
FOREIGN KEY(user_id)
55+
REFERENCES app.users (id)
56+
);"
57+
`;
58+
59+
exports[`restructureSql — fold (consolidated granularity) keeps mutually-referencing FKs atomic instead of breaking the cycle 1`] = `
60+
"CREATE TABLE app.b (
61+
id uuid,
62+
a_id uuid,
63+
CONSTRAINT b_pkey PRIMARY KEY (id)
64+
);
65+
66+
CREATE TABLE app.a (
67+
id uuid,
68+
b_id uuid,
69+
CONSTRAINT a_pkey PRIMARY KEY (id),
70+
CONSTRAINT a_b_fk
71+
FOREIGN KEY(b_id)
72+
REFERENCES app.b (id)
73+
);
74+
75+
ALTER TABLE app.b
76+
ADD CONSTRAINT b_a_fk
77+
FOREIGN KEY(a_id)
78+
REFERENCES app.a (id);"
79+
`;
80+
81+
exports[`restructureSql — fold (object granularity) folds columns and same-table constraints into CREATE TABLE, keeps FKs separate 1`] = `
82+
"CREATE TABLE app.users (
83+
id uuid DEFAULT gen_random_uuid(),
84+
email text NOT NULL,
85+
CONSTRAINT users_pkey PRIMARY KEY (id)
86+
);
87+
88+
CREATE TABLE app.orders (
89+
id uuid,
90+
user_id uuid,
91+
CONSTRAINT orders_pkey PRIMARY KEY (id)
92+
);
93+
94+
ALTER TABLE app.orders
95+
ADD CONSTRAINT orders_user_fk
96+
FOREIGN KEY(user_id)
97+
REFERENCES app.users (id);"
98+
`;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { loadModule } from 'plpgsql-parser';
2+
3+
import { classifyStatements } from '../src/facts';
4+
import { buildStatementGraph } from '../src/graph';
5+
6+
beforeAll(async () => {
7+
await loadModule();
8+
});
9+
10+
const graphOf = (sql: string) => buildStatementGraph(classifyStatements(sql));
11+
12+
describe('buildStatementGraph', () => {
13+
it('links references to their producers with hard edges', () => {
14+
const g = graphOf(`
15+
CREATE TABLE app.users (id int);
16+
CREATE VIEW app.v_users AS SELECT * FROM app.users;
17+
`);
18+
expect(g.edges).toHaveLength(1);
19+
expect(g.edges[0]).toMatchObject({ from: 1, to: 0, kind: 'hard' });
20+
expect(g.order).toEqual([0, 1]);
21+
});
22+
23+
it('classifies FK targets as fk edges', () => {
24+
const g = graphOf(`
25+
CREATE TABLE app.orders (id int);
26+
CREATE TABLE app.users (id int);
27+
ALTER TABLE app.orders ADD CONSTRAINT fk FOREIGN KEY (id) REFERENCES app.users (id);
28+
`);
29+
const fk = g.edges.find(e => e.kind === 'fk');
30+
expect(fk).toMatchObject({ from: 2, to: 1 });
31+
// ALTER also hard-depends on its own table via creates/references dedupe:
32+
// the alter "creates" (targets) app.orders so no self edge exists.
33+
expect(g.order.indexOf(1)).toBeLessThan(g.order.indexOf(2));
34+
});
35+
36+
it('treats PL/pgSQL body references as late edges that allow cycles', () => {
37+
const g = graphOf(`
38+
CREATE FUNCTION app.a() RETURNS int LANGUAGE plpgsql AS $$ BEGIN RETURN app.b(); END $$;
39+
CREATE FUNCTION app.b() RETURNS int LANGUAGE plpgsql AS $$ BEGIN RETURN app.a(); END $$;
40+
`);
41+
expect(g.edges.every(e => e.kind === 'late')).toBe(true);
42+
// Late edges never force multi-member components.
43+
expect(g.components.every(c => c.length === 1)).toBe(true);
44+
expect(g.order).toEqual([0, 1]);
45+
});
46+
47+
it('orders mutually-referencing FKs without a cycle at statement granularity', () => {
48+
const g = graphOf(`
49+
CREATE TABLE app.a (id int);
50+
CREATE TABLE app.b (id int);
51+
ALTER TABLE app.a ADD CONSTRAINT fk_ab FOREIGN KEY (id) REFERENCES app.b (id);
52+
ALTER TABLE app.b ADD CONSTRAINT fk_ba FOREIGN KEY (id) REFERENCES app.a (id);
53+
`);
54+
// Atomic statements are exactly what makes mutual FKs deployable: the
55+
// separate ALTERs order after both CREATEs, so no component is bigger
56+
// than one statement. (The cycle only appears when folding — which is
57+
// why restructure keeps such FKs atomic.)
58+
expect(g.components.every(c => c.length === 1)).toBe(true);
59+
expect(g.order.indexOf(2)).toBeGreaterThan(g.order.indexOf(1));
60+
expect(g.order.indexOf(3)).toBeGreaterThan(g.order.indexOf(0));
61+
});
62+
63+
it('produces a stable topological order (source order for ties)', () => {
64+
const g = graphOf(`
65+
CREATE TABLE app.z (id int);
66+
CREATE TABLE app.a (id int);
67+
CREATE TABLE app.m (id int);
68+
`);
69+
expect(g.order).toEqual([0, 1, 2]);
70+
});
71+
72+
it('reorders forward references', () => {
73+
const g = graphOf(`
74+
CREATE VIEW app.v AS SELECT * FROM app.t;
75+
CREATE TABLE app.t (id int);
76+
`);
77+
expect(g.order).toEqual([1, 0]);
78+
});
79+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { loadModule } from 'plpgsql-parser';
2+
3+
import { restructureSql } from '../src/restructure';
4+
5+
beforeAll(async () => {
6+
await loadModule();
7+
});
8+
9+
const ATOMIC = `
10+
CREATE TABLE app.users ();
11+
ALTER TABLE app.users ADD COLUMN id uuid;
12+
ALTER TABLE app.users ADD COLUMN email text;
13+
ALTER TABLE app.users ALTER COLUMN email SET NOT NULL;
14+
ALTER TABLE app.users ALTER COLUMN id SET DEFAULT gen_random_uuid();
15+
ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);
16+
CREATE TABLE app.orders ();
17+
ALTER TABLE app.orders ADD COLUMN id uuid;
18+
ALTER TABLE app.orders ADD COLUMN user_id uuid;
19+
ALTER TABLE app.orders ADD CONSTRAINT orders_pkey PRIMARY KEY (id);
20+
ALTER TABLE app.orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES app.users (id);
21+
`;
22+
23+
describe('restructureSql — fold (object granularity)', () => {
24+
it('folds columns and same-table constraints into CREATE TABLE, keeps FKs separate', () => {
25+
const result = restructureSql(ATOMIC, { granularity: 'object' });
26+
expect(result.warnings).toEqual([]);
27+
expect(result.sql).toMatchSnapshot();
28+
// Columns, defaults, not-null, PKs folded; FK stays as ALTER TABLE.
29+
expect(result.sql).toContain('CREATE TABLE app.users');
30+
expect(result.sql).toContain('DEFAULT gen_random_uuid()');
31+
expect(result.sql).toContain('NOT NULL');
32+
expect(result.sql.match(/ALTER TABLE/g) ?? []).toHaveLength(1);
33+
expect(result.sql).toContain('FOREIGN KEY');
34+
});
35+
});
36+
37+
describe('restructureSql — fold (consolidated granularity)', () => {
38+
it('additionally inlines safe FKs into the table definition', () => {
39+
const result = restructureSql(ATOMIC, { granularity: 'consolidated' });
40+
expect(result.sql).toMatchSnapshot();
41+
expect(result.sql).not.toContain('ALTER TABLE');
42+
// users must be emitted before orders (FK dependency).
43+
expect(result.sql.indexOf('CREATE TABLE app.users'))
44+
.toBeLessThan(result.sql.indexOf('CREATE TABLE app.orders'));
45+
});
46+
47+
it('keeps mutually-referencing FKs atomic instead of breaking the cycle', () => {
48+
const cyclic = `
49+
CREATE TABLE app.a ();
50+
ALTER TABLE app.a ADD COLUMN id uuid;
51+
ALTER TABLE app.a ADD COLUMN b_id uuid;
52+
ALTER TABLE app.a ADD CONSTRAINT a_pkey PRIMARY KEY (id);
53+
CREATE TABLE app.b ();
54+
ALTER TABLE app.b ADD COLUMN id uuid;
55+
ALTER TABLE app.b ADD COLUMN a_id uuid;
56+
ALTER TABLE app.b ADD CONSTRAINT b_pkey PRIMARY KEY (id);
57+
ALTER TABLE app.a ADD CONSTRAINT a_b_fk FOREIGN KEY (b_id) REFERENCES app.b (id);
58+
ALTER TABLE app.b ADD CONSTRAINT b_a_fk FOREIGN KEY (a_id) REFERENCES app.a (id);
59+
`;
60+
const result = restructureSql(cyclic, { granularity: 'consolidated' });
61+
// At least one FK must remain an ALTER TABLE to break the cycle.
62+
expect(result.sql).toContain('ALTER TABLE');
63+
expect(result.warnings.length).toBeGreaterThan(0);
64+
expect(result.sql).toMatchSnapshot();
65+
});
66+
67+
it('inlines a self-referencing FK', () => {
68+
const selfRef = `
69+
CREATE TABLE app.tree ();
70+
ALTER TABLE app.tree ADD COLUMN id uuid;
71+
ALTER TABLE app.tree ADD CONSTRAINT tree_pkey PRIMARY KEY (id);
72+
ALTER TABLE app.tree ADD COLUMN parent_id uuid;
73+
ALTER TABLE app.tree ADD CONSTRAINT tree_parent_fk FOREIGN KEY (parent_id) REFERENCES app.tree (id);
74+
`;
75+
const result = restructureSql(selfRef, { granularity: 'consolidated' });
76+
expect(result.sql).not.toContain('ALTER TABLE');
77+
expect(result.sql).toContain('FOREIGN KEY');
78+
});
79+
});
80+
81+
describe('restructureSql — atomize', () => {
82+
const CONSOLIDATED = `
83+
CREATE TABLE app.users (
84+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
85+
email text NOT NULL,
86+
CONSTRAINT users_email_uniq UNIQUE (email)
87+
);
88+
CREATE TABLE app.orders (
89+
id uuid PRIMARY KEY,
90+
user_id uuid REFERENCES app.users (id)
91+
);
92+
`;
93+
94+
it('explodes CREATE TABLE into bare create + per-column/per-constraint alters', () => {
95+
const result = restructureSql(CONSOLIDATED, { granularity: 'atomic' });
96+
expect(result.sql).toMatchSnapshot();
97+
expect(result.exploded).toBeGreaterThan(0);
98+
expect(result.sql).toMatch(/CREATE TABLE app\.users \(\s*\)/);
99+
expect(result.sql).toContain('ADD COLUMN');
100+
// Column-level PK/UNIQUE/FK promoted to table-level ADD CONSTRAINT.
101+
expect(result.sql).toContain('PRIMARY KEY (id)');
102+
expect(result.sql).toMatch(/FOREIGN KEY\s*\(user_id\)/);
103+
// Defaults and NOT NULL stay inline on the column.
104+
expect(result.sql).toMatch(/ADD COLUMN email text\s+NOT NULL/);
105+
expect(result.sql).toMatch(/ADD COLUMN id uuid\s+DEFAULT gen_random_uuid\(\)/);
106+
});
107+
108+
it('round-trips: atomize then consolidate returns the baked shape', () => {
109+
const atomic = restructureSql(CONSOLIDATED, { granularity: 'atomic' });
110+
const back = restructureSql(atomic.sql, { granularity: 'consolidated' });
111+
expect(back.sql).not.toContain('ALTER TABLE');
112+
expect(back.sql).toContain('CREATE TABLE app.users');
113+
expect(back.sql).toContain('CREATE TABLE app.orders');
114+
expect(back.sql).toContain('FOREIGN KEY');
115+
});
116+
117+
it('leaves partitioned/typed tables intact', () => {
118+
const sql = 'CREATE TABLE app.log_2026 PARTITION OF app.log FOR VALUES FROM (1) TO (2);';
119+
const result = restructureSql(sql, { granularity: 'atomic' });
120+
expect(result.exploded).toBe(0);
121+
});
122+
});

0 commit comments

Comments
 (0)