Skip to content
Closed
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
142 changes: 142 additions & 0 deletions packages/transform/__tests__/bundle-driver.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
113 changes: 113 additions & 0 deletions packages/transform/src/bundle-driver.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** 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<string>();
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];
};
}
10 changes: 10 additions & 0 deletions packages/transform/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
export type {
BundleScriptContext,
NamespaceValidatorOptions,
SchemaTranspiler,
SchemaTranspilerOptions,
} from './bundle-driver';
export {
makeNamespaceValidator,
makeSchemaTranspiler,
} from './bundle-driver';
export type {
CategoryProfile,
ChangeCategory,
Expand Down
Loading