diff --git a/packages/transform/__tests__/naming.test.ts b/packages/transform/__tests__/naming.test.ts new file mode 100644 index 00000000..612187c6 --- /dev/null +++ b/packages/transform/__tests__/naming.test.ts @@ -0,0 +1,47 @@ +import { loadModule } from 'plpgsql-parser'; + +import { classifyStatements } from '../src/facts'; +import { identityOf } from '../src/naming'; + +beforeAll(async () => { + await loadModule(); +}); + +const idOf = (sql: string) => identityOf(classifyStatements(sql)[0]); + +describe('identityOf', () => { + it('derives identities per object kind', () => { + expect(idOf('CREATE SCHEMA app;')).toEqual({ kind: 'schema', schema: null, name: 'app' }); + expect(idOf('CREATE TABLE app.users (id int);')).toEqual({ kind: 'table', schema: 'app', name: 'users' }); + expect(idOf('CREATE VIEW app.v_users AS SELECT 1;')).toEqual({ kind: 'view', schema: 'app', name: 'v_users' }); + expect(idOf('CREATE FUNCTION app.fn() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$;')) + .toEqual({ kind: 'function', schema: 'app', name: 'fn' }); + expect(idOf("CREATE TYPE app.status AS ENUM ('a');")).toEqual({ kind: 'type', schema: 'app', name: 'status' }); + expect(idOf('CREATE SEQUENCE app.seq;')).toEqual({ kind: 'sequence', schema: 'app', name: 'seq' }); + expect(idOf('CREATE EXTENSION pgcrypto;')).toEqual({ kind: 'extension', schema: null, name: 'pgcrypto' }); + }); + + it('scopes triggers, policies, and indexes to their table', () => { + expect(idOf( + 'CREATE TRIGGER trg BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.fn();' + )).toEqual({ kind: 'trigger', schema: 'app', name: 'trg', table: 'users' }); + expect(idOf('CREATE POLICY p ON app.users USING (true);')) + .toEqual({ kind: 'policy', schema: 'app', name: 'p', table: 'users' }); + expect(idOf('CREATE INDEX users_email_idx ON app.users (email);')) + .toEqual({ kind: 'index', schema: 'app', name: 'users_email_idx', table: 'users' }); + }); + + it('targets ALTER TABLE constraint statements at their table', () => { + expect(idOf('ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);')) + .toEqual({ kind: 'constraint', schema: 'app', name: 'users', table: 'users' }); + }); + + it('returns null for statements with no identity of their own', () => { + expect(idOf('GRANT SELECT ON app.users TO reader;')).toBeNull(); + expect(idOf("COMMENT ON TABLE app.users IS 'x';")).toBeNull(); + }); + + it('leaves schema null when unqualified (resolution is a consumer concern)', () => { + expect(idOf('CREATE TABLE users (id int);')).toEqual({ kind: 'table', schema: null, name: 'users' }); + }); +}); diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 4609a6ac..1078f782 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -13,6 +13,11 @@ export type { StatementNode, } from './graph'; export { buildStatementGraph } from './graph'; +export type { + ObjectIdentity, + ObjectIdentityKind, +} from './naming'; +export { identityOf } from './naming'; export type { Granularity, RestructureOptions, diff --git a/packages/transform/src/naming.ts b/packages/transform/src/naming.ts new file mode 100644 index 00000000..35d1c5c6 --- /dev/null +++ b/packages/transform/src/naming.ts @@ -0,0 +1,116 @@ +/** + * Object identity — the canonical, Postgres-native answer to "what object is + * this statement about?". + * + * Identity is the key used by dependency graphs, semantic diffing, and any + * downstream naming scheme. It is a pure function of classifier facts — + * grounded in the parser's node taxonomy (`CreateStmt`, `CreateTrigStmt`, + * `IndexStmt`, ...), never in surface syntax like RangeVars. Rendering an + * identity to a change path (e.g. a pgpm module layout) is deliberately NOT + * defined here: paths are derived projections that belong to whichever + * packaging layer consumes the identity, so nothing is ever attached to them. + * + * Identity tuple: `(kind, schema, name, table?)` — `table` scopes objects + * that are only unique per table (triggers, policies, indexes, constraints, + * seed data). Function overloads share an identity for now (signature + * disambiguation is a planned refinement). + */ +import { StatementFacts } from './facts'; + +/** The kinds of objects an identity can describe. */ +export type ObjectIdentityKind = + | 'schema' + | 'extension' + | 'role' + | 'table' + | 'view' + | 'sequence' + | 'type' + | 'function' + | 'index' + | 'trigger' + | 'policy' + | 'constraint' + | 'seed_dml' + | 'other'; + +/** + * The identity of a database object. Identity is the diff/dependency key; + * any path or name is only a downstream rendering of it. + */ +export interface ObjectIdentity { + kind: ObjectIdentityKind; + /** Owning schema (`null` for non-schema objects: roles, extensions). */ + schema: string | null; + /** Object name, unqualified (for table-scoped kinds: without the table). */ + name: string; + /** Owning table, for objects only unique per table (trigger/policy/index/constraint/seed). */ + table?: string; +} + +/** + * Derive the identity of the object a statement primarily creates or + * targets, or `null` when the statement creates nothing (grants, comments — + * such statements ride with the change of the object they attach to). + * + * Table-scoped kinds are recovered from the classifier's table-qualified + * names (`table.trigger`) and, for indexes and constraints, from the + * targeted relation. + */ +export function identityOf(facts: StatementFacts): ObjectIdentity | null { + if (facts.kind === 'extension' && facts.extension) { + return { kind: 'extension', schema: null, name: facts.extension.name }; + } + + const created = facts.creates[0]; + if (!created) return null; + + switch (facts.kind) { + case 'schema': + return { kind: 'schema', schema: null, name: created.name }; + case 'trigger': + case 'policy': { + const dot = created.name.indexOf('.'); + if (dot > 0) { + return { + kind: facts.kind, + schema: created.schema, + name: created.name.slice(dot + 1), + table: created.name.slice(0, dot) + }; + } + return { kind: facts.kind, schema: created.schema, name: created.name }; + } + case 'index': { + // IndexStmt records the index name in creates and the indexed relation + // in references (same-schema RangeVar). + const rel = facts.references.find(r => r.schema === created.schema) ?? facts.references[0]; + return { + kind: 'index', + schema: created.schema, + name: created.name, + table: rel?.name + }; + } + case 'fk_constraint': + case 'constraint': + case 'rls_enable': + // ALTER TABLE statements target their table. + return { kind: 'constraint', schema: created.schema, name: created.name, table: created.name }; + case 'seed_dml': + return { kind: 'seed_dml', schema: created.schema, name: created.name, table: created.name }; + case 'table': + // AlterTableStmt facts also classify as `table`-targeting; the created + // name is the table either way. + return { kind: 'table', schema: created.schema, name: created.name }; + case 'view': + case 'function': + case 'type': + return { kind: facts.kind, schema: created.schema, name: created.name }; + default: + if (facts.nodeTag === 'CreateSeqStmt') { + return { kind: 'sequence', schema: created.schema, name: created.name }; + } + return { kind: 'other', schema: created.schema, name: created.name }; + } +}