|
| 1 | +import { loadModule } from 'plpgsql-parser'; |
| 2 | + |
| 3 | +import { PartitionCycleError, partitionUnits } from '../src/partition-driver'; |
| 4 | + |
| 5 | +beforeAll(async () => { |
| 6 | + await loadModule(); |
| 7 | +}); |
| 8 | + |
| 9 | +const SQL = [ |
| 10 | + 'CREATE SCHEMA app;', |
| 11 | + 'CREATE SCHEMA billing;', |
| 12 | + 'CREATE TABLE app.users (id uuid PRIMARY KEY);', |
| 13 | + 'CREATE TABLE billing.invoices (id uuid PRIMARY KEY, user_id uuid REFERENCES app.users (id));', |
| 14 | + 'CREATE FUNCTION app.user_count() RETURNS bigint LANGUAGE sql AS $$ SELECT count(*) FROM app.users $$;', |
| 15 | + 'ALTER TABLE app.users ENABLE ROW LEVEL SECURITY;', |
| 16 | + 'CREATE POLICY users_self ON app.users FOR SELECT USING (id = current_setting(\'jwt.claims.user_id\')::uuid);', |
| 17 | + 'GRANT SELECT ON app.users TO authenticated;' |
| 18 | +].join('\n'); |
| 19 | + |
| 20 | +describe('partitionUnits', () => { |
| 21 | + it('assigns whole schemas to packages and derives cross-package requires', () => { |
| 22 | + const result = partitionUnits(SQL, { |
| 23 | + rules: [ |
| 24 | + { package: 'pkg-billing', select: [{ schema: 'billing' }] } |
| 25 | + ], |
| 26 | + defaultPackage: 'pkg-app' |
| 27 | + }); |
| 28 | + |
| 29 | + const billing = result.packages.find(p => p.name === 'pkg-billing')!; |
| 30 | + expect(billing.changes.map(c => c.name)).toEqual([ |
| 31 | + 'schemas/billing/schema', |
| 32 | + 'schemas/billing/tables/invoices/table' |
| 33 | + ]); |
| 34 | + expect(billing.requires).toEqual(['pkg-app']); |
| 35 | + |
| 36 | + const invoices = billing.changes.find(c => c.name === 'schemas/billing/tables/invoices/table')!; |
| 37 | + expect(invoices.dependencies).toContain('pkg-app:schemas/app/tables/users/table'); |
| 38 | + expect(invoices.dependencies).toContain('schemas/billing/schema'); |
| 39 | + |
| 40 | + const app = result.packages.find(p => p.name === 'pkg-app')!; |
| 41 | + expect(app.requires).toEqual([]); |
| 42 | + expect(app.changes.map(c => c.name)).toContain('schemas/app/tables/users/table'); |
| 43 | + }); |
| 44 | + |
| 45 | + it('cherry-picks single objects by kind, name, and path', () => { |
| 46 | + const result = partitionUnits(SQL, { |
| 47 | + rules: [ |
| 48 | + { package: 'pkg-fns', select: [{ kind: 'function', schema: 'app' }] }, |
| 49 | + { package: 'pkg-security', select: [{ kind: 'policy', table: 'users' }] }, |
| 50 | + { package: 'pkg-billing', select: [{ path: 'schemas/billing/tables/invoices/table' }] } |
| 51 | + ], |
| 52 | + defaultPackage: 'pkg-app' |
| 53 | + }); |
| 54 | + |
| 55 | + expect(result.assignments.get('schemas/app/procedures/user_count/procedure')).toBe('pkg-fns'); |
| 56 | + expect(result.assignments.get('schemas/app/tables/users/policies/users_self/policy')).toBe('pkg-security'); |
| 57 | + expect(result.assignments.get('schemas/billing/tables/invoices/table')).toBe('pkg-billing'); |
| 58 | + expect(result.assignments.get('schemas/billing/schema')).toBe('pkg-app'); |
| 59 | + }); |
| 60 | + |
| 61 | + it('grants ride with the object they attach to by default', () => { |
| 62 | + const result = partitionUnits(SQL, { |
| 63 | + rules: [], |
| 64 | + defaultPackage: 'pkg-app' |
| 65 | + }); |
| 66 | + const users = result.packages[0].changes.find(c => c.name === 'schemas/app/tables/users/table')!; |
| 67 | + expect(users.deploy).toContain('GRANT SELECT'); |
| 68 | + |
| 69 | + const rls = result.packages[0].changes.find( |
| 70 | + c => c.name === 'schemas/app/tables/users/policies/enable_row_level_security' |
| 71 | + )!; |
| 72 | + expect(rls.deploy).toContain('ENABLE ROW LEVEL SECURITY'); |
| 73 | + }); |
| 74 | + |
| 75 | + it('splits grants into selectable units and partitions them separately', () => { |
| 76 | + const result = partitionUnits(SQL, { |
| 77 | + splitRiders: ['grant'], |
| 78 | + rules: [ |
| 79 | + { package: 'pkg-security', select: [{ statementKind: 'grant' }, { kind: 'policy' }] } |
| 80 | + ], |
| 81 | + defaultPackage: 'pkg-app' |
| 82 | + }); |
| 83 | + |
| 84 | + const security = result.packages.find(p => p.name === 'pkg-security')!; |
| 85 | + const grant = security.changes.find(c => c.deploy.includes('GRANT SELECT'))!; |
| 86 | + expect(grant.name).toBe('schemas/app/tables/users/table/grants/authenticated'); |
| 87 | + expect(grant.dependencies).toContain('pkg-app:schemas/app/tables/users/table'); |
| 88 | + expect(security.requires).toEqual(['pkg-app']); |
| 89 | + |
| 90 | + const app = result.packages.find(p => p.name === 'pkg-app')!; |
| 91 | + const users = app.changes.find(c => c.name === 'schemas/app/tables/users/table')!; |
| 92 | + expect(users.deploy).not.toContain('GRANT SELECT'); |
| 93 | + }); |
| 94 | + |
| 95 | + it('pulls the dependency closure of a package when closure is set', () => { |
| 96 | + const result = partitionUnits(SQL, { |
| 97 | + rules: [ |
| 98 | + { package: 'pkg-billing', select: [{ path: 'schemas/billing/tables/invoices/table' }], closure: true } |
| 99 | + ], |
| 100 | + defaultPackage: 'pkg-app' |
| 101 | + }); |
| 102 | + |
| 103 | + expect(result.assignments.get('schemas/billing/schema')).toBe('pkg-billing'); |
| 104 | + expect(result.assignments.get('schemas/app/tables/users/table')).toBe('pkg-billing'); |
| 105 | + expect(result.closureIncluded.get('schemas/billing/schema')).toBe('pkg-billing'); |
| 106 | + }); |
| 107 | + |
| 108 | + it('closure never steals units another rule claimed', () => { |
| 109 | + const result = partitionUnits(SQL, { |
| 110 | + rules: [ |
| 111 | + { package: 'pkg-app-core', select: [{ schema: 'app' }] }, |
| 112 | + { package: 'pkg-billing', select: [{ schema: 'billing' }], closure: true } |
| 113 | + ], |
| 114 | + defaultPackage: 'pkg-app' |
| 115 | + }); |
| 116 | + |
| 117 | + expect(result.assignments.get('schemas/app/tables/users/table')).toBe('pkg-app-core'); |
| 118 | + const billing = result.packages.find(p => p.name === 'pkg-billing')!; |
| 119 | + expect(billing.requires).toContain('pkg-app-core'); |
| 120 | + }); |
| 121 | + |
| 122 | + it('rejects cross-package dependency cycles', () => { |
| 123 | + const cyclic = [ |
| 124 | + 'CREATE SCHEMA a;', |
| 125 | + 'CREATE SCHEMA b;', |
| 126 | + 'CREATE TABLE a.t1 (id uuid PRIMARY KEY);', |
| 127 | + 'CREATE TABLE b.t2 (id uuid PRIMARY KEY, t1_id uuid REFERENCES a.t1 (id));', |
| 128 | + 'CREATE TABLE a.t3 (id uuid PRIMARY KEY, t2_id uuid REFERENCES b.t2 (id));' |
| 129 | + ].join('\n'); |
| 130 | + |
| 131 | + expect(() => |
| 132 | + partitionUnits(cyclic, { |
| 133 | + rules: [{ package: 'pkg-b', select: [{ schema: 'b' }] }], |
| 134 | + defaultPackage: 'pkg-a' |
| 135 | + }) |
| 136 | + ).toThrow(PartitionCycleError); |
| 137 | + }); |
| 138 | + |
| 139 | + it('accepts pgpm changes as input and warns on dynamic SQL', () => { |
| 140 | + const result = partitionUnits( |
| 141 | + [ |
| 142 | + { name: 'x', dependencies: [], deploy: 'CREATE SCHEMA app;' }, |
| 143 | + { |
| 144 | + name: 'y', |
| 145 | + dependencies: [], |
| 146 | + deploy: [ |
| 147 | + 'CREATE FUNCTION app.dyn() RETURNS void LANGUAGE plpgsql AS $$', |
| 148 | + 'BEGIN EXECUTE \'SELECT 1\'; END $$;' |
| 149 | + ].join('\n') |
| 150 | + } |
| 151 | + ], |
| 152 | + { rules: [], defaultPackage: 'pkg-app' } |
| 153 | + ); |
| 154 | + |
| 155 | + expect(result.packages).toHaveLength(1); |
| 156 | + expect(result.warnings.some(w => w.includes('dynamic SQL'))).toBe(true); |
| 157 | + }); |
| 158 | +}); |
0 commit comments