Skip to content

Commit 30717c3

Browse files
authored
Merge pull request #1401 from constructive-io/feat/pgpm-header-model
feat(pgpm-core): shared SQL header model + control-only cross-package dep mode
2 parents dbec64b + c7fd535 commit 30717c3

7 files changed

Lines changed: 642 additions & 80 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
5+
import {
6+
parsePgpmHeader,
7+
renameInHeader,
8+
scanDeployScript,
9+
verifyPlanMatchesHeaders,
10+
writePgpmScript
11+
} from '../../src/files/sql/header';
12+
13+
const LEGACY = `-- Deploy my-module:tables/users to pg
14+
-- requires: schema
15+
-- requires: other-pkg:tables/accounts
16+
17+
BEGIN;
18+
CREATE TABLE app.users (id serial primary key);
19+
COMMIT;
20+
`;
21+
22+
const MODERN = `-- Deploy tables/users
23+
-- requires: schema
24+
25+
CREATE TABLE app.users (id serial primary key);
26+
`;
27+
28+
const WRITER_COLON = `-- Deploy: tables/users
29+
-- made with <3 @ constructive.io
30+
31+
-- requires: schema
32+
33+
CREATE TABLE app.users (id serial primary key);
34+
`;
35+
36+
describe('parsePgpmHeader', () => {
37+
it('parses the legacy sqitch-compatible format', () => {
38+
const { header, body } = parsePgpmHeader(LEGACY);
39+
expect(header.verb).toBe('Deploy');
40+
expect(header.project).toBe('my-module');
41+
expect(header.change).toBe('tables/users');
42+
expect(header.toPg).toBe(true);
43+
expect(header.requires.map(r => r.raw)).toEqual(['schema', 'other-pkg:tables/accounts']);
44+
expect(header.requires[1].ref?.package).toBe('other-pkg');
45+
expect(body).toContain('CREATE TABLE app.users');
46+
});
47+
48+
it('parses the modern format without project or to pg', () => {
49+
const { header } = parsePgpmHeader(MODERN);
50+
expect(header.verb).toBe('Deploy');
51+
expect(header.project).toBeNull();
52+
expect(header.change).toBe('tables/users');
53+
expect(header.toPg).toBe(false);
54+
expect(header.requires.map(r => r.raw)).toEqual(['schema']);
55+
});
56+
57+
it('parses the writer colon format with interleaved comments', () => {
58+
const { header } = parsePgpmHeader(WRITER_COLON);
59+
expect(header.change).toBe('tables/users');
60+
expect(header.requires.map(r => r.raw)).toEqual(['schema']);
61+
});
62+
63+
it('parses Revert and Verify headers', () => {
64+
expect(parsePgpmHeader('-- Revert my-module:schema to pg\n\nDROP SCHEMA app;\n').header.verb).toBe('Revert');
65+
expect(parsePgpmHeader('-- Verify my-module:schema to pg\n\nSELECT 1;\n').header.verb).toBe('Verify');
66+
});
67+
68+
it('parses tag references in requires', () => {
69+
const { header } = parsePgpmHeader('-- Deploy x\n-- requires: pkg:@v1\n\nSELECT 1;\n');
70+
expect(header.requires[0].raw).toBe('pkg:@v1');
71+
expect(header.requires[0].ref?.package).toBe('pkg');
72+
expect(header.requires[0].ref?.tag).toBe('v1');
73+
});
74+
75+
it('round-trips content byte-exactly', () => {
76+
for (const content of [LEGACY, MODERN, WRITER_COLON, 'SELECT 1;\n', '']) {
77+
expect(writePgpmScript(parsePgpmHeader(content))).toBe(content);
78+
}
79+
});
80+
});
81+
82+
describe('renameInHeader', () => {
83+
it('rewrites the header identity and matching requires', () => {
84+
const script = parsePgpmHeader(LEGACY);
85+
const count = renameInHeader(script, new Map([
86+
['tables/users', 'tables/app_users'],
87+
['schema', 'app_schema']
88+
]));
89+
expect(count).toBe(2);
90+
expect(script.header.change).toBe('tables/app_users');
91+
const out = writePgpmScript(script);
92+
expect(out).toContain('-- Deploy my-module:tables/app_users to pg');
93+
expect(out).toContain('-- requires: app_schema');
94+
expect(out).toContain('-- requires: other-pkg:tables/accounts');
95+
expect(out).toContain('CREATE TABLE app.users');
96+
});
97+
98+
it('rewrites package-qualified requires but leaves tag refs untouched', () => {
99+
const script = parsePgpmHeader('-- Deploy x\n-- requires: pkg:tables/users\n-- requires: pkg:@v1\n\nSELECT 1;\n');
100+
const count = renameInHeader(script, new Map([['tables/users', 'tables/app_users']]));
101+
expect(count).toBe(1);
102+
const out = writePgpmScript(script);
103+
expect(out).toContain('-- requires: pkg:tables/app_users');
104+
expect(out).toContain('-- requires: pkg:@v1');
105+
});
106+
107+
it('is a no-op when nothing matches', () => {
108+
const script = parsePgpmHeader(MODERN);
109+
expect(renameInHeader(script, new Map([['nope', 'nada']]))).toBe(0);
110+
expect(writePgpmScript(script)).toBe(MODERN);
111+
});
112+
});
113+
114+
describe('scanDeployScript', () => {
115+
const makeKey = (change: string) => `/deploy/${change}.sql`;
116+
117+
it('collects requires lines verbatim', () => {
118+
const { requires } = scanDeployScript(LEGACY, {
119+
key: '/deploy/tables/users.sql',
120+
extname: 'my-module',
121+
makeKey
122+
});
123+
expect(requires).toEqual(['schema', 'other-pkg:tables/accounts']);
124+
});
125+
126+
it('throws on project mismatch', () => {
127+
expect(() =>
128+
scanDeployScript(LEGACY, { key: '/deploy/tables/users.sql', extname: 'other', makeKey })
129+
).toThrow(/Mismatched project name/);
130+
});
131+
132+
it('throws on path identity mismatch', () => {
133+
expect(() =>
134+
scanDeployScript(LEGACY, { key: '/deploy/tables/accounts.sql', extname: 'my-module', makeKey })
135+
).toThrow(/path or internal name mismatch/);
136+
expect(() =>
137+
scanDeployScript(MODERN, { key: '/deploy/tables/accounts.sql', extname: 'my-module', makeKey })
138+
).toThrow(/wrong place or is named wrong/);
139+
});
140+
});
141+
142+
describe('verifyPlanMatchesHeaders', () => {
143+
let dir: string;
144+
145+
beforeEach(() => {
146+
dir = mkdtempSync(join(tmpdir(), 'pgpm-header-'));
147+
mkdirSync(join(dir, 'deploy', 'tables'), { recursive: true });
148+
writeFileSync(join(dir, 'pgpm.plan'), `%syntax-version=1.0.0
149+
%project=my-module
150+
%uri=my-module
151+
152+
schema 2024-01-01T00:00:00Z Test <t@e.com> # schema
153+
tables/users [schema] 2024-01-02T00:00:00Z Test <t@e.com> # users
154+
`);
155+
writeFileSync(join(dir, 'deploy', 'schema.sql'), '-- Deploy my-module:schema to pg\n\nCREATE SCHEMA app;\n');
156+
writeFileSync(join(dir, 'deploy', 'tables', 'users.sql'), LEGACY.replace('-- requires: other-pkg:tables/accounts\n', ''));
157+
});
158+
159+
afterEach(() => {
160+
rmSync(dir, { recursive: true, force: true });
161+
});
162+
163+
it('reports no issues for a consistent module', () => {
164+
expect(verifyPlanMatchesHeaders(dir)).toEqual([]);
165+
});
166+
167+
it('reports missing deploy files', () => {
168+
rmSync(join(dir, 'deploy', 'tables', 'users.sql'));
169+
const issues = verifyPlanMatchesHeaders(dir);
170+
expect(issues).toHaveLength(1);
171+
expect(issues[0].kind).toBe('missing-file');
172+
});
173+
174+
it('reports identity mismatches', () => {
175+
writeFileSync(join(dir, 'deploy', 'tables', 'users.sql'), '-- Deploy my-module:tables/accounts to pg\n-- requires: schema\n\nSELECT 1;\n');
176+
const issues = verifyPlanMatchesHeaders(dir);
177+
expect(issues.map(i => i.kind)).toContain('identity-mismatch');
178+
});
179+
180+
it('reports requires drift between plan and headers', () => {
181+
writeFileSync(join(dir, 'deploy', 'tables', 'users.sql'), '-- Deploy my-module:tables/users to pg\n\nSELECT 1;\n');
182+
const issues = verifyPlanMatchesHeaders(dir);
183+
expect(issues).toHaveLength(1);
184+
expect(issues[0].kind).toBe('requires-drift');
185+
expect(issues[0].message).toContain('schema');
186+
});
187+
});

pgpm/core/__tests__/slice/slice.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,43 @@ schemas/public/functions/get_user [schemas/auth/tables/users] 2024-01-02T00:00:0
342342
expect(publicPkg!.planContent).toContain('auth:schemas/auth/tables/users');
343343
});
344344

345+
it('should drop per-change cross-package refs in control-only mode', () => {
346+
const planContent = `%syntax-version=1.0.0
347+
%project=test-project
348+
349+
schemas/auth/tables/users 2024-01-01T00:00:00Z Developer <dev@example.com>
350+
schemas/public/functions/get_user [schemas/auth/tables/users] 2024-01-02T00:00:00Z Developer <dev@example.com>
351+
schemas/public/functions/get_user_v2 [schemas/public/functions/get_user] 2024-01-03T00:00:00Z Developer <dev@example.com>
352+
`;
353+
const planPath = join(testDir, 'control-only.plan');
354+
writeFileSync(planPath, planContent);
355+
356+
const result = slicePlan({
357+
sourcePlan: planPath,
358+
outputDir: join(testDir, 'output-control-only'),
359+
strategy: { type: 'folder' },
360+
crossPackageDepMode: 'control-only'
361+
});
362+
363+
const publicPkg = result.packages.find(p => p.name === 'public');
364+
expect(publicPkg).toBeDefined();
365+
366+
// Package-level dependency is preserved in the control file
367+
expect(publicPkg!.packageDependencies).toContain('auth');
368+
expect(publicPkg!.controlContent).toContain(`requires = 'auth'`);
369+
370+
// But no per-change cross-package ref remains in the plan
371+
expect(publicPkg!.planContent).not.toContain('auth:');
372+
373+
// Internal dependencies are untouched
374+
const getUserV2 = publicPkg!.changes.find(c => c.name === 'schemas/public/functions/get_user_v2');
375+
expect(getUserV2!.dependencies).toEqual(['schemas/public/functions/get_user']);
376+
377+
// The dependent change loses only the cross-package ref
378+
const getUser = publicPkg!.changes.find(c => c.name === 'schemas/public/functions/get_user');
379+
expect(getUser!.dependencies).toEqual([]);
380+
});
381+
345382
it('should slice using pattern strategy', () => {
346383
const planContent = `%syntax-version=1.0.0
347384
%project=test-project

0 commit comments

Comments
 (0)