Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/transform/__tests__/naming.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
5 changes: 5 additions & 0 deletions packages/transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions packages/transform/src/naming.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
Loading