Skip to content

Commit 6beda3d

Browse files
authored
Merge pull request #326 from constructive-io/feat/pgpm-naming-spec
feat(transform): identityOf — canonical Postgres-native object identity
2 parents ffe0783 + cfa7fb9 commit 6beda3d

3 files changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { loadModule } from 'plpgsql-parser';
2+
3+
import { classifyStatements } from '../src/facts';
4+
import { identityOf } from '../src/naming';
5+
6+
beforeAll(async () => {
7+
await loadModule();
8+
});
9+
10+
const idOf = (sql: string) => identityOf(classifyStatements(sql)[0]);
11+
12+
describe('identityOf', () => {
13+
it('derives identities per object kind', () => {
14+
expect(idOf('CREATE SCHEMA app;')).toEqual({ kind: 'schema', schema: null, name: 'app' });
15+
expect(idOf('CREATE TABLE app.users (id int);')).toEqual({ kind: 'table', schema: 'app', name: 'users' });
16+
expect(idOf('CREATE VIEW app.v_users AS SELECT 1;')).toEqual({ kind: 'view', schema: 'app', name: 'v_users' });
17+
expect(idOf('CREATE FUNCTION app.fn() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;'))
18+
.toEqual({ kind: 'function', schema: 'app', name: 'fn' });
19+
expect(idOf("CREATE TYPE app.status AS ENUM ('a');")).toEqual({ kind: 'type', schema: 'app', name: 'status' });
20+
expect(idOf('CREATE SEQUENCE app.seq;')).toEqual({ kind: 'sequence', schema: 'app', name: 'seq' });
21+
expect(idOf('CREATE EXTENSION pgcrypto;')).toEqual({ kind: 'extension', schema: null, name: 'pgcrypto' });
22+
});
23+
24+
it('scopes triggers, policies, and indexes to their table', () => {
25+
expect(idOf(
26+
'CREATE TRIGGER trg BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.fn();'
27+
)).toEqual({ kind: 'trigger', schema: 'app', name: 'trg', table: 'users' });
28+
expect(idOf('CREATE POLICY p ON app.users USING (true);'))
29+
.toEqual({ kind: 'policy', schema: 'app', name: 'p', table: 'users' });
30+
expect(idOf('CREATE INDEX users_email_idx ON app.users (email);'))
31+
.toEqual({ kind: 'index', schema: 'app', name: 'users_email_idx', table: 'users' });
32+
});
33+
34+
it('targets ALTER TABLE constraint statements at their table', () => {
35+
expect(idOf('ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);'))
36+
.toEqual({ kind: 'constraint', schema: 'app', name: 'users', table: 'users' });
37+
});
38+
39+
it('returns null for statements with no identity of their own', () => {
40+
expect(idOf('GRANT SELECT ON app.users TO reader;')).toBeNull();
41+
expect(idOf("COMMENT ON TABLE app.users IS 'x';")).toBeNull();
42+
});
43+
44+
it('leaves schema null when unqualified (resolution is a consumer concern)', () => {
45+
expect(idOf('CREATE TABLE users (id int);')).toEqual({ kind: 'table', schema: null, name: 'users' });
46+
});
47+
});

packages/transform/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export type {
1313
StatementNode,
1414
} from './graph';
1515
export { buildStatementGraph } from './graph';
16+
export type {
17+
ObjectIdentity,
18+
ObjectIdentityKind,
19+
} from './naming';
20+
export { identityOf } from './naming';
1621
export type {
1722
Granularity,
1823
RestructureOptions,

packages/transform/src/naming.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Object identity — the canonical, Postgres-native answer to "what object is
3+
* this statement about?".
4+
*
5+
* Identity is the key used by dependency graphs, semantic diffing, and any
6+
* downstream naming scheme. It is a pure function of classifier facts —
7+
* grounded in the parser's node taxonomy (`CreateStmt`, `CreateTrigStmt`,
8+
* `IndexStmt`, ...), never in surface syntax like RangeVars. Rendering an
9+
* identity to a change path (e.g. a pgpm module layout) is deliberately NOT
10+
* defined here: paths are derived projections that belong to whichever
11+
* packaging layer consumes the identity, so nothing is ever attached to them.
12+
*
13+
* Identity tuple: `(kind, schema, name, table?)` — `table` scopes objects
14+
* that are only unique per table (triggers, policies, indexes, constraints,
15+
* seed data). Function overloads share an identity for now (signature
16+
* disambiguation is a planned refinement).
17+
*/
18+
import { StatementFacts } from './facts';
19+
20+
/** The kinds of objects an identity can describe. */
21+
export type ObjectIdentityKind =
22+
| 'schema'
23+
| 'extension'
24+
| 'role'
25+
| 'table'
26+
| 'view'
27+
| 'sequence'
28+
| 'type'
29+
| 'function'
30+
| 'index'
31+
| 'trigger'
32+
| 'policy'
33+
| 'constraint'
34+
| 'seed_dml'
35+
| 'other';
36+
37+
/**
38+
* The identity of a database object. Identity is the diff/dependency key;
39+
* any path or name is only a downstream rendering of it.
40+
*/
41+
export interface ObjectIdentity {
42+
kind: ObjectIdentityKind;
43+
/** Owning schema (`null` for non-schema objects: roles, extensions). */
44+
schema: string | null;
45+
/** Object name, unqualified (for table-scoped kinds: without the table). */
46+
name: string;
47+
/** Owning table, for objects only unique per table (trigger/policy/index/constraint/seed). */
48+
table?: string;
49+
}
50+
51+
/**
52+
* Derive the identity of the object a statement primarily creates or
53+
* targets, or `null` when the statement creates nothing (grants, comments —
54+
* such statements ride with the change of the object they attach to).
55+
*
56+
* Table-scoped kinds are recovered from the classifier's table-qualified
57+
* names (`table.trigger`) and, for indexes and constraints, from the
58+
* targeted relation.
59+
*/
60+
export function identityOf(facts: StatementFacts): ObjectIdentity | null {
61+
if (facts.kind === 'extension' && facts.extension) {
62+
return { kind: 'extension', schema: null, name: facts.extension.name };
63+
}
64+
65+
const created = facts.creates[0];
66+
if (!created) return null;
67+
68+
switch (facts.kind) {
69+
case 'schema':
70+
return { kind: 'schema', schema: null, name: created.name };
71+
case 'trigger':
72+
case 'policy': {
73+
const dot = created.name.indexOf('.');
74+
if (dot > 0) {
75+
return {
76+
kind: facts.kind,
77+
schema: created.schema,
78+
name: created.name.slice(dot + 1),
79+
table: created.name.slice(0, dot)
80+
};
81+
}
82+
return { kind: facts.kind, schema: created.schema, name: created.name };
83+
}
84+
case 'index': {
85+
// IndexStmt records the index name in creates and the indexed relation
86+
// in references (same-schema RangeVar).
87+
const rel = facts.references.find(r => r.schema === created.schema) ?? facts.references[0];
88+
return {
89+
kind: 'index',
90+
schema: created.schema,
91+
name: created.name,
92+
table: rel?.name
93+
};
94+
}
95+
case 'fk_constraint':
96+
case 'constraint':
97+
case 'rls_enable':
98+
// ALTER TABLE statements target their table.
99+
return { kind: 'constraint', schema: created.schema, name: created.name, table: created.name };
100+
case 'seed_dml':
101+
return { kind: 'seed_dml', schema: created.schema, name: created.name, table: created.name };
102+
case 'table':
103+
// AlterTableStmt facts also classify as `table`-targeting; the created
104+
// name is the table either way.
105+
return { kind: 'table', schema: created.schema, name: created.name };
106+
case 'view':
107+
case 'function':
108+
case 'type':
109+
return { kind: facts.kind, schema: created.schema, name: created.name };
110+
default:
111+
if (facts.nodeTag === 'CreateSeqStmt') {
112+
return { kind: 'sequence', schema: created.schema, name: created.name };
113+
}
114+
return { kind: 'other', schema: created.schema, name: created.name };
115+
}
116+
}

0 commit comments

Comments
 (0)