|
| 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 | +}); |
0 commit comments