Skip to content

Commit 4dda559

Browse files
committed
feat(transform): partition dial — project one deploy surface into pgpm packages
1 parent 55460b8 commit 4dda559

3 files changed

Lines changed: 679 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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+
});

pgpm/transform/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ export {
2323
defaultChangeName,
2424
restructureChanges,
2525
} from './granularity-driver';
26+
export type {
27+
PartitionConfig,
28+
PartitionedChange,
29+
PartitionedPackage,
30+
PartitionInputChange,
31+
PartitionRule,
32+
PartitionUnit,
33+
PartitionUnitsResult,
34+
UnitSelector,
35+
} from './partition-driver';
36+
export {
37+
PartitionCycleError,
38+
partitionUnits,
39+
RESIDUAL_UNIT_PATH,
40+
} from './partition-driver';
2641
export type {
2742
CategoryProfile,
2843
ChangeCategory,

0 commit comments

Comments
 (0)