Skip to content

Commit f31cfcd

Browse files
authored
Merge pull request #1564 from constructive-io/feat/semantic-bundle-diff
feat(transform): identity-keyed semantic diff — AST-level schema delta through the granularity pipeline (Phase 5)
2 parents e1d0b61 + 75af1ab commit f31cfcd

3 files changed

Lines changed: 630 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { loadModule } from 'plpgsql-parser';
2+
3+
import { DiffInputChange, diffChangeSets, diffSchemas } from '../src/semantic-diff-driver';
4+
5+
beforeAll(async () => {
6+
await loadModule();
7+
});
8+
9+
const BASE = [
10+
'CREATE SCHEMA app;',
11+
'CREATE TABLE app.users (id uuid PRIMARY KEY, name text);',
12+
'CREATE FUNCTION app.user_count() RETURNS bigint LANGUAGE sql AS $$ SELECT count(*) FROM app.users $$;',
13+
'CREATE VIEW app.active_users AS SELECT * FROM app.users;'
14+
].join('\n');
15+
16+
describe('diffSchemas', () => {
17+
it('is identical for the same schema regardless of formatting', () => {
18+
const reformatted = BASE.replace(/ \(/g, ' (').replace(/, /g, ' , ');
19+
const result = diffSchemas(BASE, reformatted);
20+
expect(result.identical).toBe(true);
21+
expect(result.changes).toEqual([]);
22+
});
23+
24+
it('is dial-invariant: atomic vs consolidated table authorship diffs equal', () => {
25+
const atomic = [
26+
'CREATE SCHEMA app;',
27+
'CREATE TABLE app.users ();',
28+
'ALTER TABLE app.users ADD COLUMN id uuid;',
29+
'ALTER TABLE app.users ADD COLUMN name text;'
30+
].join('\n');
31+
const consolidated = [
32+
'CREATE SCHEMA app;',
33+
'CREATE TABLE app.users (id uuid, name text);'
34+
].join('\n');
35+
const result = diffSchemas(atomic, consolidated);
36+
expect(result.identical).toBe(true);
37+
});
38+
39+
it('emits added objects as changes through the granularity pipeline', () => {
40+
const to = `${BASE}\nCREATE TABLE app.posts (id uuid PRIMARY KEY, author uuid REFERENCES app.users (id));`;
41+
const result = diffSchemas(BASE, to);
42+
43+
expect(result.objects).toEqual([
44+
expect.objectContaining({ delta: 'added', path: 'schemas/app/tables/posts/table' })
45+
]);
46+
const posts = result.changes.find(c => c.name === 'schemas/app/tables/posts/table')!;
47+
expect(posts.deploy).toContain('CREATE TABLE app.posts');
48+
});
49+
50+
it('derives revert and verify for every emitted change via @pgsql/scripts', () => {
51+
const to = `${BASE}\nCREATE TABLE app.posts (id uuid PRIMARY KEY);`;
52+
const added = diffSchemas(BASE, to).changes.find(c => c.name === 'schemas/app/tables/posts/table')!;
53+
expect(added.revert).toContain('DROP TABLE app.posts');
54+
expect(added.verify).toContain('to_regclass');
55+
56+
const modified = diffSchemas(BASE, BASE.replace('count(*)', 'count(1)'))
57+
.changes.find(c => c.name === 'schemas/app/procedures/user_count/procedure')!;
58+
expect(modified.revert).toContain('DROP FUNCTION app.user_count');
59+
expect(modified.revert).toContain('count(*)');
60+
expect(modified.verify).toContain('to_regprocedure');
61+
62+
const alterUsers = diffSchemas(BASE, BASE.replace('name text', 'name text, email text'))
63+
.changes.find(c => c.name === 'schemas/app/tables/users/table/alter')!;
64+
expect(alterUsers.deploy).toContain('ADD COLUMN email text');
65+
expect(alterUsers.revert).toContain('DROP COLUMN email');
66+
67+
const dropped = diffSchemas(BASE, BASE.replace('CREATE VIEW app.active_users AS SELECT * FROM app.users;', ''))
68+
.changes.find(c => c.name === 'schemas/app/views/active_users/view/drop')!;
69+
expect(dropped.deploy).toContain('DROP VIEW app.active_users');
70+
expect(dropped.revert).toContain('CREATE VIEW app.active_users');
71+
});
72+
73+
it('diffs tables column-by-column', () => {
74+
const to = BASE
75+
.replace('name text', 'name text, email text')
76+
.replace('id uuid PRIMARY KEY, name', 'id uuid PRIMARY KEY, name');
77+
const result = diffSchemas(BASE, to);
78+
79+
const users = result.objects.find(o => o.path === 'schemas/app/tables/users/table')!;
80+
expect(users.delta).toBe('modified');
81+
expect(users.columnsAdded).toEqual(['email']);
82+
expect(users.columnsRemoved).toEqual([]);
83+
84+
const change = result.changes.find(c => c.name.startsWith('schemas/app/tables/users/table'))!;
85+
expect(change.deploy).toContain('ADD COLUMN email text');
86+
expect(change.deploy).not.toContain('CREATE TABLE');
87+
});
88+
89+
it('emits column drops and type changes', () => {
90+
const to = BASE.replace('name text', 'name varchar');
91+
const result = diffSchemas(BASE, to);
92+
const change = result.changes.find(c => c.name.startsWith('schemas/app/tables/users/table'))!;
93+
expect(change.deploy).toContain('ALTER COLUMN name TYPE varchar');
94+
95+
const dropped = diffSchemas(BASE, BASE.replace(', name text', ''));
96+
const users = dropped.objects.find(o => o.path === 'schemas/app/tables/users/table')!;
97+
expect(users.columnsRemoved).toEqual(['name']);
98+
});
99+
100+
it('recreates modified non-table objects via DROP + CREATE', () => {
101+
const to = BASE.replace('count(*)', 'count(1)');
102+
const result = diffSchemas(BASE, to);
103+
104+
const fn = result.objects.find(o => o.path === 'schemas/app/procedures/user_count/procedure')!;
105+
expect(fn.delta).toBe('modified');
106+
const change = result.changes.find(c => c.name === 'schemas/app/procedures/user_count/procedure')!;
107+
expect(change.deploy).toContain('DROP FUNCTION app.user_count');
108+
expect(change.deploy).toContain('CREATE FUNCTION app.user_count');
109+
});
110+
111+
it('drops removed objects in reverse topological order', () => {
112+
const result = diffSchemas(BASE, 'CREATE SCHEMA app;\nCREATE TABLE app.users (id uuid PRIMARY KEY, name text);');
113+
114+
const dropNames = result.changes.filter(c => c.name.endsWith('/drop')).map(c => c.name);
115+
expect(dropNames).toEqual([
116+
'schemas/app/views/active_users/view/drop',
117+
'schemas/app/procedures/user_count/procedure/drop'
118+
]);
119+
const view = result.changes.find(c => c.name === 'schemas/app/views/active_users/view/drop')!;
120+
expect(view.deploy).toContain('DROP VIEW app.active_users');
121+
});
122+
});
123+
124+
describe('diffChangeSets', () => {
125+
it('ignores change names entirely — only identity matters', () => {
126+
const from: DiffInputChange[] = [
127+
{ name: 'one-big-change', dependencies: [], deploy: BASE }
128+
];
129+
const to: DiffInputChange[] = [
130+
{ name: 'schemas/app/schema', dependencies: [], deploy: 'CREATE SCHEMA app;' },
131+
{ name: 'users', dependencies: ['schemas/app/schema'], deploy: 'CREATE TABLE app.users (id uuid PRIMARY KEY, name text);' },
132+
{ name: 'fn', dependencies: ['users'], deploy: 'CREATE FUNCTION app.user_count() RETURNS bigint LANGUAGE sql AS $$ SELECT count(*) FROM app.users $$;' },
133+
{ name: 'view', dependencies: ['users'], deploy: 'CREATE VIEW app.active_users AS SELECT * FROM app.users;' }
134+
];
135+
const result = diffChangeSets(from, to);
136+
expect(result.identical).toBe(true);
137+
expect(result.changes).toEqual([]);
138+
});
139+
});

pgpm/transform/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export {
2424
defaultChangeName,
2525
restructureChanges,
2626
} from './granularity-driver';
27+
export type {
28+
DiffInputChange,
29+
ObjectDelta,
30+
SemanticDeltaChange,
31+
SemanticDiffOptions,
32+
SemanticDiffResult,
33+
SemanticObjectDiff,
34+
} from './semantic-diff-driver';
35+
export {
36+
diffChangeSets,
37+
diffSchemas,
38+
} from './semantic-diff-driver';
2739
export type {
2840
PartitionConfig,
2941
PartitionedChange,

0 commit comments

Comments
 (0)