diff --git a/packages/transform/__tests__/bundle-driver.test.ts b/packages/transform/__tests__/bundle-driver.test.ts new file mode 100644 index 00000000..55b00936 --- /dev/null +++ b/packages/transform/__tests__/bundle-driver.test.ts @@ -0,0 +1,142 @@ +import { loadModule } from 'plpgsql-parser'; + +import { makeNamespaceValidator, makeSchemaTranspiler } from '../src'; + +beforeAll(async () => { + await loadModule(); +}); + +const CTX = { change: 'schemas/auth/tables/users', kind: 'deploy' as const }; + +describe('makeSchemaTranspiler', () => { + const transpiler = () => + makeSchemaTranspiler({ schemaMap: { auth: 'tenant_auth', billing: 'tenant_billing' } }); + + describe('renameChange', () => { + it('renames the segment following a schemas segment', () => { + const { renameChange } = transpiler(); + expect(renameChange('schemas/auth/tables/users')).toBe('schemas/tenant_auth/tables/users'); + expect(renameChange('schemas/billing/procedures/charge')).toBe( + 'schemas/tenant_billing/procedures/charge' + ); + }); + + it('leaves unmapped schemas and non-schema segments untouched', () => { + const { renameChange } = transpiler(); + expect(renameChange('schemas/public/tables/logs')).toBe('schemas/public/tables/logs'); + expect(renameChange('roles/auth')).toBe('roles/auth'); + expect(renameChange('extensions/auth/setup')).toBe('extensions/auth/setup'); + }); + + it('does not rename a table segment that matches a schema name', () => { + const { renameChange } = transpiler(); + expect(renameChange('schemas/public/tables/auth')).toBe('schemas/public/tables/auth'); + }); + }); + + describe('transformScript', () => { + it('rewrites schema references via the AST, not text', () => { + const t = transpiler(); + const sql = [ + '-- Deploy myapp:schemas/auth/tables/users to pg', + '', + 'CREATE TABLE auth.users (', + ' id uuid PRIMARY KEY,', + ' auth text,', + " note text DEFAULT 'auth'", + ');' + ].join('\n'); + const out = t.transformScript(sql, CTX); + expect(out).toContain('tenant_auth.users'); + // column named auth and string literal 'auth' are untouched + expect(out).toMatch(/\bauth text\b/); + expect(out).toContain("'auth'"); + expect(t.result.schemasTransformed.get('auth')).toBe('tenant_auth'); + }); + + it('rewrites FK targets, grants, policies and function bodies', () => { + const t = transpiler(); + const sql = [ + 'CREATE TABLE billing.invoices (', + ' id uuid PRIMARY KEY,', + ' user_id uuid REFERENCES auth.users (id)', + ');', + 'GRANT SELECT ON ALL TABLES IN SCHEMA billing TO authenticated;', + 'CREATE POLICY p ON billing.invoices USING (user_id = auth.current_user_id());', + 'CREATE FUNCTION billing.total()', + 'RETURNS bigint', + 'LANGUAGE plpgsql', + 'AS $$', + 'BEGIN', + ' RETURN (SELECT count(*) FROM billing.invoices JOIN auth.users u ON u.id = user_id);', + 'END;', + '$$;' + ].join('\n'); + const out = t.transformScript(sql, CTX); + expect(out).toContain('tenant_billing.invoices'); + expect(out).toContain('tenant_auth.users'); + expect(out).toContain('IN SCHEMA tenant_billing'); + expect(out).toContain('tenant_auth.current_user_id()'); + expect(out).not.toMatch(/\bbilling\./); + expect(out).not.toMatch(/\bauth\./); + }); + + it('accumulates a report across scripts', () => { + const t = transpiler(); + t.transformScript('CREATE TABLE auth.a (id int);', CTX); + t.transformScript('CREATE TABLE billing.b (id int);', { + change: 'schemas/billing/tables/b', + kind: 'deploy' + }); + expect([...t.result.schemasFound].sort()).toEqual(['auth', 'billing']); + expect(t.result.errors).toEqual([]); + }); + }); +}); + +describe('makeNamespaceValidator', () => { + it('reports creates/references/FK targets outside the allowed namespace', () => { + const validate = makeNamespaceValidator({ allowedSchemas: ['tenant_auth', 'public'] }); + const violations = validate( + [ + 'CREATE TABLE tenant_auth.users (id uuid PRIMARY KEY);', + 'CREATE TABLE tenant_auth.sessions (', + ' id uuid PRIMARY KEY,', + ' user_id uuid REFERENCES other_app.users (id)', + ');', + 'SELECT * FROM billing.invoices;' + ].join('\n'), + CTX + ); + expect(violations.some(v => v.includes('other_app.users'))).toBe(true); + expect(violations.some(v => v.includes('billing.invoices'))).toBe(true); + expect(violations.some(v => v.includes('tenant_auth'))).toBe(false); + }); + + it('returns no violations when everything is contained', () => { + const validate = makeNamespaceValidator({ allowedSchemas: ['tenant_auth'] }); + expect( + validate('CREATE TABLE tenant_auth.users (id uuid PRIMARY KEY);', CTX) + ).toEqual([]); + }); + + it('optionally flags dynamic SQL', () => { + const sql = [ + 'CREATE FUNCTION tenant_auth.run(q text)', + 'RETURNS void', + 'LANGUAGE plpgsql', + 'AS $$', + 'BEGIN', + ' EXECUTE q;', + 'END;', + '$$;' + ].join('\n'); + const lax = makeNamespaceValidator({ allowedSchemas: ['tenant_auth'] }); + expect(lax(sql, CTX)).toEqual([]); + const strict = makeNamespaceValidator({ + allowedSchemas: ['tenant_auth'], + flagDynamicSql: true + }); + expect(strict(sql, CTX).some(v => v.includes('dynamic SQL'))).toBe(true); + }); +}); diff --git a/packages/transform/src/bundle-driver.ts b/packages/transform/src/bundle-driver.ts new file mode 100644 index 00000000..dbbb5056 --- /dev/null +++ b/packages/transform/src/bundle-driver.ts @@ -0,0 +1,113 @@ +/** + * Bundle transpile/apply drivers. + * + * Adapters that plug this package's AST transforms into the pgpm migration + * bundle seams (`transpileBundle`'s `renameChange`/`transformScript` and + * `applyBundle`'s `validateReferences`). The seams are structurally typed on + * purpose — no dependency on `@pgpmjs/bundle`/`@pgpmjs/core` — so the driver + * stays a pure function factory over `transformSql` and `classifyStatements`. + */ + +import { classifyStatements } from './facts'; +import { SchemaTransformResult, transformSql, TransformSqlOptions } from './transform'; + +/** Identity of the script being transformed/validated (matches the bundle seams). */ +export interface BundleScriptContext { + change: string; + kind: 'deploy' | 'revert' | 'verify'; +} + +export interface SchemaTranspilerOptions { + /** Old schema name → new schema name. Drives both dimensions. */ + schemaMap: Record; + /** Forwarded to {@link transformSql} (round-trip validation, extra passes). */ + transform?: TransformSqlOptions; +} + +export interface SchemaTranspiler { + /** + * Change-name/path rewrite (the pgpm structural dimension): renames the + * segment following any `schemas` path segment, e.g. + * `schemas/auth/tables/users` → `schemas/tenant_auth/tables/users`. + */ + renameChange: (name: string) => string; + /** + * SQL body rewrite (the AST dimension): full AST transform of every mapped + * schema reference via {@link transformSql}, including PL/pgSQL bodies. + * Throws if a mapped schema survives untransformed. + */ + transformScript: (sql: string, ctx: BundleScriptContext) => string; + /** + * Accumulated report across every script this transpiler has transformed: + * schemas found/transformed and any per-script errors. + */ + result: SchemaTransformResult; +} + +/** + * Build the caller-supplied callbacks for `transpileBundle` from a single + * schema map, so the folder/plan rename and the in-SQL rewrite stay in + * lockstep. + */ +export function makeSchemaTranspiler(options: SchemaTranspilerOptions): SchemaTranspiler { + const mapping = new Map(Object.entries(options.schemaMap)); + const result: SchemaTransformResult = { + schemasFound: new Set(), + schemasTransformed: new Map(), + errors: [] + }; + + const renameChange = (name: string): string => { + const parts = name.split('/'); + for (let i = 0; i < parts.length - 1; i++) { + if (parts[i] === 'schemas' && mapping.has(parts[i + 1])) { + parts[i + 1] = mapping.get(parts[i + 1])!; + } + } + return parts.join('/'); + }; + + const transformScript = (sql: string, _ctx: BundleScriptContext): string => { + return transformSql(sql, mapping, options.transform, result).content; + }; + + return { renameChange, transformScript, result }; +} + +export interface NamespaceValidatorOptions { + /** Schemas the bundle is allowed to create objects in or reference. */ + allowedSchemas: string[]; + /** + * Also flag statements whose PL/pgSQL bodies execute dynamic SQL — their + * references are invisible to the AST, so containment cannot be proven. + * Off by default (dynamic SQL is common in legitimate functions). + */ + flagDynamicSql?: boolean; +} + +/** + * Build an `applyBundle`-compatible `validateReferences` callback: returns a + * description of every schema-qualified object a script creates or references + * outside the allowed namespace. Unqualified references resolve via + * search_path and are not reported. + */ +export function makeNamespaceValidator( + options: NamespaceValidatorOptions +): (sql: string, ctx: BundleScriptContext) => string[] { + const allowed = new Set(options.allowedSchemas); + + return (sql: string, _ctx: BundleScriptContext): string[] => { + const violations = new Set(); + for (const facts of classifyStatements(sql)) { + for (const ref of [...facts.creates, ...facts.references, ...facts.fkTargets]) { + if (ref.schema && !allowed.has(ref.schema)) { + violations.add(`${facts.nodeTag}: ${ref.schema}.${ref.name}`); + } + } + if (options.flagDynamicSql && facts.dynamicSql) { + violations.add(`${facts.nodeTag}: dynamic SQL — references not statically verifiable`); + } + } + return [...violations]; + }; +} diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index cd447917..be1fced5 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -1,3 +1,13 @@ +export type { + BundleScriptContext, + NamespaceValidatorOptions, + SchemaTranspiler, + SchemaTranspilerOptions, +} from './bundle-driver'; +export { + makeNamespaceValidator, + makeSchemaTranspiler, +} from './bundle-driver'; export type { CategoryProfile, ChangeCategory,