Skip to content

Commit f36d819

Browse files
authored
Merge pull request #1403 from constructive-io/feat/pgpm-rename-changes
feat(pgpm-core): renameChanges — atomic change rename/move across files, headers, and plan
2 parents 6a1d3e6 + 6bebb4a commit f36d819

4 files changed

Lines changed: 445 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { dirname, join } from 'path';
4+
5+
import {
6+
deriveRenamesFromSchemaMapping,
7+
renameChanges,
8+
renameInPlanContent,
9+
validateRenames
10+
} from '../../src/refactor/rename';
11+
12+
let moduleDir: string;
13+
14+
const PLAN = `%syntax-version=1.0.0
15+
%project=my-module
16+
%uri=my-module
17+
18+
schemas/my-app/schema 2024-01-01T00:00:00Z Dev <dev@example.com> # add schema
19+
schemas/my-app/tables/users [schemas/my-app/schema] 2024-01-01T00:00:01Z Dev <dev@example.com> # add users
20+
schemas/my-app/tables/posts [schemas/my-app/schema schemas/my-app/tables/users] 2024-01-01T00:00:02Z Dev <dev@example.com> # add posts
21+
schemas/other/functions/fn [schemas/my-app/tables/users auth:schemas/auth/tables/users] 2024-01-01T00:00:03Z Dev <dev@example.com> # fn
22+
@v1.0.0 2024-01-01T00:00:04Z Dev <dev@example.com> # release
23+
`;
24+
25+
function writeScript(rel: string, content: string): void {
26+
const file = join(moduleDir, rel);
27+
mkdirSync(dirname(file), { recursive: true });
28+
writeFileSync(file, content);
29+
}
30+
31+
beforeEach(() => {
32+
moduleDir = mkdtempSync(join(tmpdir(), 'pgpm-rename-'));
33+
writeFileSync(join(moduleDir, 'pgpm.plan'), PLAN);
34+
35+
writeScript('deploy/schemas/my-app/schema.sql', `-- Deploy schemas/my-app/schema
36+
37+
CREATE SCHEMA "my-app";
38+
`);
39+
writeScript('deploy/schemas/my-app/tables/users.sql', `-- Deploy my-module:schemas/my-app/tables/users to pg
40+
-- requires: schemas/my-app/schema
41+
42+
CREATE TABLE "my-app".users (id int);
43+
`);
44+
writeScript('deploy/schemas/my-app/tables/posts.sql', `-- Deploy schemas/my-app/tables/posts
45+
-- requires: schemas/my-app/schema
46+
-- requires: schemas/my-app/tables/users
47+
48+
CREATE TABLE "my-app".posts (id int);
49+
`);
50+
writeScript('deploy/schemas/other/functions/fn.sql', `-- Deploy schemas/other/functions/fn
51+
-- requires: schemas/my-app/tables/users
52+
-- requires: auth:schemas/auth/tables/users
53+
54+
CREATE FUNCTION other.fn() RETURNS void LANGUAGE sql AS $$ SELECT 1 $$;
55+
`);
56+
writeScript('revert/schemas/my-app/tables/users.sql', `-- Revert schemas/my-app/tables/users
57+
58+
DROP TABLE "my-app".users;
59+
`);
60+
writeScript('verify/schemas/my-app/tables/users.sql', `-- Verify schemas/my-app/tables/users
61+
62+
SELECT 1;
63+
`);
64+
});
65+
66+
afterEach(() => {
67+
rmSync(moduleDir, { recursive: true, force: true });
68+
});
69+
70+
describe('validateRenames', () => {
71+
it('rejects unknown source changes', () => {
72+
expect(() => validateRenames(moduleDir, new Map([['nope', 'x']]))).toThrow(/unknown change/);
73+
});
74+
75+
it('rejects targets colliding with existing changes', () => {
76+
expect(() =>
77+
validateRenames(moduleDir, new Map([['schemas/my-app/schema', 'schemas/my-app/tables/users']]))
78+
).toThrow(/already exists/);
79+
});
80+
81+
it('rejects duplicate targets and self renames', () => {
82+
expect(() =>
83+
validateRenames(
84+
moduleDir,
85+
new Map([
86+
['schemas/my-app/schema', 'x'],
87+
['schemas/my-app/tables/users', 'x']
88+
])
89+
)
90+
).toThrow(/same target/);
91+
expect(() =>
92+
validateRenames(moduleDir, new Map([['schemas/my-app/schema', 'schemas/my-app/schema']]))
93+
).toThrow(/itself/);
94+
});
95+
96+
it('allows swapping through the rename map itself', () => {
97+
expect(() =>
98+
validateRenames(moduleDir, new Map([['schemas/my-app/schema', 'schemas/my_app/schema']]))
99+
).not.toThrow();
100+
});
101+
});
102+
103+
describe('renameInPlanContent', () => {
104+
it('rewrites change names and plain dependency refs, preserving format', () => {
105+
const renames = new Map([['schemas/my-app/tables/users', 'schemas/my_app/tables/users']]);
106+
const out = renameInPlanContent(PLAN, renames);
107+
expect(out).toContain('schemas/my_app/tables/users [schemas/my-app/schema] 2024-01-01T00:00:01Z');
108+
expect(out).toContain('[schemas/my-app/schema schemas/my_app/tables/users]');
109+
expect(out).toContain('[schemas/my_app/tables/users auth:schemas/auth/tables/users]');
110+
// cross-package ref untouched
111+
expect(out).toContain('auth:schemas/auth/tables/users');
112+
// metadata untouched
113+
expect(out).toContain('%project=my-module');
114+
});
115+
116+
it('does not rewrite partial-name matches', () => {
117+
const renames = new Map([['schemas/my-app/schema', 'schemas/my_app/schema']]);
118+
const out = renameInPlanContent(PLAN, renames);
119+
// longer change names sharing the prefix are untouched
120+
expect(out).toContain('schemas/my-app/tables/users [schemas/my_app/schema]');
121+
expect(out).toContain('schemas/my-app/tables/posts [schemas/my_app/schema schemas/my-app/tables/users]');
122+
});
123+
});
124+
125+
describe('renameChanges', () => {
126+
it('moves files, rewrites headers of moved scripts and dependents, and rewrites the plan', () => {
127+
const renames = new Map([
128+
['schemas/my-app/schema', 'schemas/my_app/schema'],
129+
['schemas/my-app/tables/users', 'schemas/my_app/tables/users'],
130+
['schemas/my-app/tables/posts', 'schemas/my_app/tables/posts']
131+
]);
132+
const result = renameChanges(moduleDir, renames);
133+
134+
// files moved in all three script dirs
135+
expect(existsSync(join(moduleDir, 'deploy/schemas/my_app/tables/users.sql'))).toBe(true);
136+
expect(existsSync(join(moduleDir, 'deploy/schemas/my-app/tables/users.sql'))).toBe(false);
137+
expect(existsSync(join(moduleDir, 'revert/schemas/my_app/tables/users.sql'))).toBe(true);
138+
expect(existsSync(join(moduleDir, 'verify/schemas/my_app/tables/users.sql'))).toBe(true);
139+
// old empty dirs cleaned up
140+
expect(existsSync(join(moduleDir, 'deploy/schemas/my-app'))).toBe(false);
141+
expect(result.filesMoved.length).toBe(5);
142+
143+
// moved script headers rewritten (identity + requires), format preserved
144+
const users = readFileSync(join(moduleDir, 'deploy/schemas/my_app/tables/users.sql'), 'utf-8');
145+
expect(users).toContain('-- Deploy my-module:schemas/my_app/tables/users to pg');
146+
expect(users).toContain('-- requires: schemas/my_app/schema');
147+
// body untouched
148+
expect(users).toContain('CREATE TABLE "my-app".users (id int);');
149+
150+
// dependent outside the rename set has requires rewritten, cross-package ref untouched
151+
const fn = readFileSync(join(moduleDir, 'deploy/schemas/other/functions/fn.sql'), 'utf-8');
152+
expect(fn).toContain('-- requires: schemas/my_app/tables/users');
153+
expect(fn).toContain('-- requires: auth:schemas/auth/tables/users');
154+
155+
// plan rewritten
156+
const plan = readFileSync(join(moduleDir, 'pgpm.plan'), 'utf-8');
157+
expect(plan).toContain('schemas/my_app/tables/posts [schemas/my_app/schema schemas/my_app/tables/users]');
158+
expect(plan).toContain('[schemas/my_app/tables/users auth:schemas/auth/tables/users]');
159+
expect(result.planRewritten).toBe(true);
160+
});
161+
162+
it('is a no-op for an empty rename map', () => {
163+
const before = readFileSync(join(moduleDir, 'pgpm.plan'), 'utf-8');
164+
const result = renameChanges(moduleDir, new Map());
165+
expect(result.filesMoved).toEqual([]);
166+
expect(result.planRewritten).toBe(false);
167+
expect(readFileSync(join(moduleDir, 'pgpm.plan'), 'utf-8')).toBe(before);
168+
});
169+
170+
it('dry run reports without touching the filesystem', () => {
171+
const renames = new Map([['schemas/my-app/tables/users', 'schemas/my_app/tables/users']]);
172+
const before = readFileSync(join(moduleDir, 'deploy/schemas/my-app/tables/users.sql'), 'utf-8');
173+
const result = renameChanges(moduleDir, renames, { dryRun: true });
174+
175+
expect(existsSync(join(moduleDir, 'deploy/schemas/my-app/tables/users.sql'))).toBe(true);
176+
expect(existsSync(join(moduleDir, 'deploy/schemas/my_app/tables/users.sql'))).toBe(false);
177+
expect(readFileSync(join(moduleDir, 'deploy/schemas/my-app/tables/users.sql'), 'utf-8')).toBe(before);
178+
expect(readFileSync(join(moduleDir, 'pgpm.plan'), 'utf-8')).toBe(PLAN);
179+
expect(result.dryRunReport!.length).toBeGreaterThan(0);
180+
expect(result.planRewritten).toBe(true);
181+
});
182+
183+
it('throws when a target file already exists on disk', () => {
184+
writeScript('deploy/schemas/my_app/tables/users.sql', '-- Deploy schemas/my_app/tables/users\n');
185+
expect(() =>
186+
renameChanges(moduleDir, new Map([['schemas/my-app/tables/users', 'schemas/my_app/tables/users']]))
187+
).toThrow(/already exists/);
188+
});
189+
});
190+
191+
describe('deriveRenamesFromSchemaMapping', () => {
192+
it('derives renames for changes under renamed schemas only', () => {
193+
const renames = deriveRenamesFromSchemaMapping(moduleDir, new Map([['my-app', 'my_app']]));
194+
expect(renames.get('schemas/my-app/schema')).toBe('schemas/my_app/schema');
195+
expect(renames.get('schemas/my-app/tables/users')).toBe('schemas/my_app/tables/users');
196+
expect(renames.get('schemas/my-app/tables/posts')).toBe('schemas/my_app/tables/posts');
197+
expect(renames.has('schemas/other/functions/fn')).toBe(false);
198+
expect(renames.size).toBe(3);
199+
});
200+
201+
it('composes with renameChanges end to end', () => {
202+
const renames = deriveRenamesFromSchemaMapping(moduleDir, new Map([['my-app', 'my_app']]));
203+
const result = renameChanges(moduleDir, renames);
204+
expect(result.planRewritten).toBe(true);
205+
const plan = readFileSync(join(moduleDir, 'pgpm.plan'), 'utf-8');
206+
expect(plan).not.toContain('schemas/my-app/');
207+
});
208+
});

pgpm/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from './core/boilerplate-scanner';
1414

1515
// Export package-files functionality (now integrated into core)
1616
export * from './files';
17+
export * from './refactor';
1718
export { cleanSql } from './migrate/clean';
1819
export { PgpmMigrate } from './migrate/client';
1920
export { PgpmInit } from './init/client';

pgpm/core/src/refactor/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './rename';

0 commit comments

Comments
 (0)