Skip to content

Commit cc6da09

Browse files
committed
feat(transform): PGPM naming spec v1 — identityOf + pathFor (canonical derived change paths)
1 parent ffe0783 commit cc6da09

3 files changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { loadModule } from 'plpgsql-parser';
2+
3+
import { classifyStatements } from '../src/facts';
4+
import { changePathFor, identityOf, pathFor } from '../src/naming';
5+
6+
beforeAll(async () => {
7+
await loadModule();
8+
});
9+
10+
const pathOf = (sql: string): string | null => changePathFor(classifyStatements(sql)[0]);
11+
12+
describe('PGPM naming spec v1', () => {
13+
it('derives canonical paths per object kind', () => {
14+
expect(pathOf('CREATE SCHEMA app;')).toBe('schemas/app/schema');
15+
expect(pathOf('CREATE TABLE app.users (id int);')).toBe('schemas/app/tables/users/table');
16+
expect(pathOf('CREATE VIEW app.v_users AS SELECT 1;')).toBe('schemas/app/views/v_users');
17+
expect(pathOf('CREATE FUNCTION app.fn() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;'))
18+
.toBe('schemas/app/procedures/fn');
19+
expect(pathOf('CREATE TYPE app.status AS ENUM (\'a\');')).toBe('schemas/app/types/status');
20+
expect(pathOf('CREATE SEQUENCE app.seq;')).toBe('schemas/app/sequences/seq');
21+
expect(pathOf('CREATE EXTENSION pgcrypto;')).toBe('extensions/pgcrypto');
22+
});
23+
24+
it('scopes triggers, policies, and indexes to their table', () => {
25+
expect(pathOf(
26+
'CREATE TRIGGER trg BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.fn();'
27+
)).toBe('schemas/app/tables/users/triggers/trg');
28+
expect(pathOf('CREATE POLICY p ON app.users USING (true);'))
29+
.toBe('schemas/app/tables/users/policies/p');
30+
expect(pathOf('CREATE INDEX users_email_idx ON app.users (email);'))
31+
.toBe('schemas/app/tables/users/indexes/users_email_idx');
32+
});
33+
34+
it('routes ALTER TABLE constraint statements to the table constraints dir', () => {
35+
const path = pathOf('ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);');
36+
expect(path).toBe('schemas/app/tables/users/constraints/users');
37+
});
38+
39+
it('returns null for statements with no identity of their own', () => {
40+
expect(pathOf('GRANT SELECT ON app.users TO reader;')).toBeNull();
41+
expect(pathOf("COMMENT ON TABLE app.users IS 'x';")).toBeNull();
42+
});
43+
44+
it('defaults missing schema to public', () => {
45+
expect(pathOf('CREATE TABLE users (id int);')).toBe('schemas/public/tables/users/table');
46+
});
47+
48+
it('pathFor is total and deterministic over identities', () => {
49+
expect(pathFor({ kind: 'role', schema: null, name: 'admin' })).toBe('roles/admin');
50+
expect(pathFor({ kind: 'other', schema: 'app', name: 'thing' })).toBe('schemas/app/objects/thing');
51+
});
52+
53+
it('identity is the key, path is the rendering', () => {
54+
const facts = classifyStatements('CREATE TABLE app.users (id int);')[0];
55+
const identity = identityOf(facts)!;
56+
expect(identity).toEqual({ kind: 'table', schema: 'app', name: 'users' });
57+
expect(pathFor(identity)).toBe('schemas/app/tables/users/table');
58+
});
59+
});

packages/transform/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ export type {
1313
StatementNode,
1414
} from './graph';
1515
export { buildStatementGraph } from './graph';
16+
export type {
17+
ObjectIdentity,
18+
ObjectIdentityKind,
19+
} from './naming';
20+
export {
21+
changePathFor,
22+
identityOf,
23+
pathFor,
24+
PGPM_NAMING_SPEC_VERSION,
25+
} from './naming';
1626
export type {
1727
Granularity,
1828
RestructureOptions,

packages/transform/src/naming.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* PGPM naming spec v1 — canonical, derived change paths.
3+
*
4+
* A change path is never authored and never identity: it is a pure projection
5+
* of an object's identity through this spec. Objects (content-addressed ASTs
6+
* + dependency edges) are the source of truth; paths are re-derivable at any
7+
* time, so regrouping, renaming schemes, or repartitioning packages can never
8+
* break identity-keyed consumers (diff, dependency resolution).
9+
*
10+
* Identity tuple: `(kind, schema, name, table?)` — `table` scopes objects
11+
* that are only unique per table (triggers, policies, indexes, constraints,
12+
* seed data). Function overloads share a path in v1 (disambiguation via a
13+
* signature suffix is reserved for a future spec version).
14+
*
15+
* Canonical templates (matching the conventions used across constructive-db
16+
* deploy trees):
17+
*
18+
* schema schemas/{schema}/schema
19+
* table schemas/{schema}/tables/{table}/table
20+
* trigger schemas/{schema}/tables/{table}/triggers/{name}
21+
* policy schemas/{schema}/tables/{table}/policies/{name}
22+
* index schemas/{schema}/tables/{table}/indexes/{name}
23+
* constraint schemas/{schema}/tables/{table}/constraints/{name}
24+
* seed_dml schemas/{schema}/tables/{table}/fixtures/{name}
25+
* function schemas/{schema}/procedures/{name}
26+
* view schemas/{schema}/views/{name}
27+
* type schemas/{schema}/types/{name}
28+
* sequence schemas/{schema}/sequences/{name}
29+
* extension extensions/{name}
30+
* role roles/{name}
31+
*/
32+
import { StatementFacts } from './facts';
33+
34+
/** Spec version, so bundles/modules can declare which scheme derived their paths. */
35+
export const PGPM_NAMING_SPEC_VERSION = 1;
36+
37+
/** The kinds of objects the naming spec assigns paths to. */
38+
export type ObjectIdentityKind =
39+
| 'schema'
40+
| 'extension'
41+
| 'role'
42+
| 'table'
43+
| 'view'
44+
| 'sequence'
45+
| 'type'
46+
| 'function'
47+
| 'index'
48+
| 'trigger'
49+
| 'policy'
50+
| 'constraint'
51+
| 'seed_dml'
52+
| 'other';
53+
54+
/**
55+
* The identity of a database object — what a change path is derived from.
56+
* Identity is the diff/dependency key; the path is only its rendering.
57+
*/
58+
export interface ObjectIdentity {
59+
kind: ObjectIdentityKind;
60+
/** Owning schema (`null` for non-schema objects: roles, extensions). */
61+
schema: string | null;
62+
/** Object name, unqualified (for table-scoped kinds: without the table). */
63+
name: string;
64+
/** Owning table, for objects only unique per table (trigger/policy/index/constraint/seed). */
65+
table?: string;
66+
}
67+
68+
/** Kinds whose objects are scoped to (and only unique within) a table. */
69+
const TABLE_SCOPED = new Set<ObjectIdentityKind>([
70+
'trigger',
71+
'policy',
72+
'index',
73+
'constraint',
74+
'seed_dml'
75+
]);
76+
77+
/** Directory names for schema-scoped object kinds. */
78+
const SCHEMA_DIRS: Partial<Record<ObjectIdentityKind, string>> = {
79+
view: 'views',
80+
sequence: 'sequences',
81+
type: 'types',
82+
function: 'procedures'
83+
};
84+
85+
/** Directory names for table-scoped object kinds. */
86+
const TABLE_DIRS: Partial<Record<ObjectIdentityKind, string>> = {
87+
trigger: 'triggers',
88+
policy: 'policies',
89+
index: 'indexes',
90+
constraint: 'constraints',
91+
seed_dml: 'fixtures'
92+
};
93+
94+
/**
95+
* Derive the identity of the object a statement primarily creates or
96+
* targets, or `null` when the statement creates nothing (grants, comments —
97+
* such statements ride with the change of the object they attach to).
98+
*
99+
* Table-scoped kinds are recovered from the classifier's table-qualified
100+
* names (`table.trigger`) and, for indexes and constraints, from the
101+
* targeted relation.
102+
*/
103+
export function identityOf(facts: StatementFacts): ObjectIdentity | null {
104+
if (facts.kind === 'extension' && facts.extension) {
105+
return { kind: 'extension', schema: null, name: facts.extension.name };
106+
}
107+
108+
const created = facts.creates[0];
109+
if (!created) return null;
110+
111+
switch (facts.kind) {
112+
case 'schema':
113+
return { kind: 'schema', schema: null, name: created.name };
114+
case 'trigger':
115+
case 'policy': {
116+
const dot = created.name.indexOf('.');
117+
if (dot > 0) {
118+
return {
119+
kind: facts.kind,
120+
schema: created.schema,
121+
name: created.name.slice(dot + 1),
122+
table: created.name.slice(0, dot)
123+
};
124+
}
125+
return { kind: facts.kind, schema: created.schema, name: created.name };
126+
}
127+
case 'index': {
128+
// IndexStmt records the index name in creates and the indexed relation
129+
// in references (same-schema RangeVar).
130+
const rel = facts.references.find(r => r.schema === created.schema) ?? facts.references[0];
131+
return {
132+
kind: 'index',
133+
schema: created.schema,
134+
name: created.name,
135+
table: rel?.name
136+
};
137+
}
138+
case 'fk_constraint':
139+
case 'constraint':
140+
case 'rls_enable':
141+
// ALTER TABLE statements target their table.
142+
return { kind: 'constraint', schema: created.schema, name: created.name, table: created.name };
143+
case 'seed_dml':
144+
return { kind: 'seed_dml', schema: created.schema, name: created.name, table: created.name };
145+
case 'table':
146+
// AlterTableStmt facts also classify as `table`-targeting; the created
147+
// name is the table either way.
148+
return { kind: 'table', schema: created.schema, name: created.name };
149+
case 'view':
150+
case 'function':
151+
case 'type':
152+
return { kind: facts.kind, schema: created.schema, name: created.name };
153+
default:
154+
if (facts.nodeTag === 'CreateSeqStmt') {
155+
return { kind: 'sequence', schema: created.schema, name: created.name };
156+
}
157+
return { kind: 'other', schema: created.schema, name: created.name };
158+
}
159+
}
160+
161+
/**
162+
* Render an identity to its canonical pgpm change path (naming spec v1).
163+
* Total: every identity gets a deterministic path.
164+
*/
165+
export function pathFor(identity: ObjectIdentity): string {
166+
const { kind, name } = identity;
167+
const schema = identity.schema ?? 'public';
168+
169+
if (kind === 'schema') return `schemas/${name}/schema`;
170+
if (kind === 'extension') return `extensions/${name}`;
171+
if (kind === 'role') return `roles/${name}`;
172+
if (kind === 'table') return `schemas/${schema}/tables/${name}/table`;
173+
174+
if (TABLE_SCOPED.has(kind)) {
175+
const dir = TABLE_DIRS[kind]!;
176+
if (identity.table && identity.table !== name) {
177+
return `schemas/${schema}/tables/${identity.table}/${dir}/${name}`;
178+
}
179+
// Table-scoped object whose table equals the target (ALTER TABLE
180+
// constraints, seed data keyed by table).
181+
return `schemas/${schema}/tables/${identity.table ?? name}/${dir}/${name}`;
182+
}
183+
184+
const dir = SCHEMA_DIRS[kind];
185+
if (dir) return `schemas/${schema}/${dir}/${name}`;
186+
return `schemas/${schema}/objects/${name}`;
187+
}
188+
189+
/**
190+
* Convenience: canonical change path for a statement, or `null` when the
191+
* statement has no identity of its own.
192+
*/
193+
export function changePathFor(facts: StatementFacts): string | null {
194+
const identity = identityOf(facts);
195+
return identity ? pathFor(identity) : null;
196+
}

0 commit comments

Comments
 (0)