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
97 changes: 97 additions & 0 deletions packages/transform/__tests__/extension-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {
COMMON_EXTENSIONS,
ExtensionDefinition,
ExtensionRouter
} from '../src/extension-router';

describe('ExtensionRouter.resolveInstall', () => {
it('returns the target schema for a routed extension', () => {
const router = new ExtensionRouter({ pgcrypto: { to: 'extensions' } });
expect(router.resolveInstall('pgcrypto')).toBe('extensions');
});

it('returns null to strip the SCHEMA clause (repollute)', () => {
const router = new ExtensionRouter({ pgcrypto: { to: null } });
expect(router.resolveInstall('pgcrypto')).toBeNull();
});

it('returns undefined (leave unchanged) for an unrouted extension', () => {
const router = new ExtensionRouter({ pgcrypto: { to: 'extensions' } });
expect(router.resolveInstall('pg_trgm')).toBeUndefined();
expect(router.resolveInstall(undefined)).toBeUndefined();
});

it('refuses to move an extension pinned to a fixed schema', () => {
const inventory: ExtensionDefinition[] = [
{ name: 'postgis_tiger_geocoder', fixedSchema: 'tiger', symbols: [] }
];
const router = new ExtensionRouter(
{ postgis_tiger_geocoder: { to: 'extensions' } },
{ inventory }
);
expect(router.resolveInstall('postgis_tiger_geocoder')).toBeUndefined();
});
});

describe('ExtensionRouter.resolveSymbol', () => {
it('routes a bare extension symbol to the target schema', () => {
const router = ExtensionRouter.toSchema('extensions');
expect(router.resolveSymbol(null, 'crypt', 'function')).toEqual({ to: 'extensions' });
expect(router.resolveSymbol(null, 'gen_salt', 'function')).toEqual({ to: 'extensions' });
});

it('requalifies a public-qualified symbol', () => {
const router = ExtensionRouter.toSchema('extensions');
expect(router.resolveSymbol('public', 'digest', 'function')).toEqual({ to: 'extensions' });
});

it('strips qualification when routing to bare (null)', () => {
const router = ExtensionRouter.toSchema(null, { from: ['extensions'] });
expect(router.resolveSymbol('extensions', 'crypt', 'function')).toEqual({ to: null });
});

it('leaves symbols already in the target schema unchanged', () => {
const router = ExtensionRouter.toSchema('extensions');
expect(router.resolveSymbol('extensions', 'crypt', 'function')).toBeUndefined();
});

it('never routes a symbol that graduated into core at the target version', () => {
const pg13 = ExtensionRouter.toSchema('extensions', { serverVersion: 13 });
expect(pg13.resolveSymbol(null, 'gen_random_uuid', 'function')).toBeUndefined();

const pg12 = ExtensionRouter.toSchema('extensions', { serverVersion: 12 });
expect(pg12.resolveSymbol(null, 'gen_random_uuid', 'function')).toEqual({ to: 'extensions' });
});

it('routes extension-provided types (citext)', () => {
const router = ExtensionRouter.toSchema('extensions');
expect(router.resolveSymbol(null, 'citext', 'type')).toEqual({ to: 'extensions' });
// citext is a type, not a function — namespace must match
expect(router.resolveSymbol(null, 'citext', 'function')).toBeUndefined();
});

it('ignores symbols not in the inventory (user-defined lookalikes)', () => {
const router = ExtensionRouter.toSchema('extensions');
expect(router.resolveSymbol(null, 'my_helper', 'function')).toBeUndefined();
expect(router.resolveSymbol('app', 'crypt', 'function')).toBeUndefined();
});

it('honors an explicit `from` allowlist', () => {
const router = new ExtensionRouter({ pgcrypto: { to: 'extensions', from: ['public'] } });
// only public-qualified refs are rewritten; bare refs are left alone
expect(router.resolveSymbol('public', 'crypt', 'function')).toEqual({ to: 'extensions' });
expect(router.resolveSymbol(null, 'crypt', 'function')).toBeUndefined();
});
});

describe('ExtensionRouter inventory', () => {
it('exposes a curated set of common extensions', () => {
const names = COMMON_EXTENSIONS.map(d => d.name);
expect(names).toEqual(expect.arrayContaining(['pgcrypto', 'uuid-ossp', 'citext']));
});

it('reports whether configured extensions have symbol routes', () => {
expect(ExtensionRouter.toSchema('extensions', { extensions: ['pgcrypto'] }).hasSymbolRoutes()).toBe(true);
expect(new ExtensionRouter({ nonexistent_ext: { to: 'x' } }).hasSymbolRoutes()).toBe(false);
});
});
165 changes: 165 additions & 0 deletions packages/transform/__tests__/extension-transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { Deparser, loadModule, parseSql } from 'plpgsql-parser';

import { ExtensionRouter } from '../src/extension-router';
import { transformExtensions } from '../src/extension-transform';

beforeAll(async () => {
await loadModule();
});

/** parse -> deparse -> parse must be stable (structural round-trip). */
function assertRoundTrip(sql: string): void {
const once = parseSql(sql);
const deparsed = once.stmts.map((s: any) => Deparser.deparse(s.stmt)).join(';\n') + ';';
const twice = parseSql(deparsed);
expect(twice.stmts.length).toBe(once.stmts.length);
}

describe('transformExtensions — install schema (node construction)', () => {
it('adds a SCHEMA clause to CREATE EXTENSION that has none', () => {
const { sql, result } = transformExtensions(
'CREATE EXTENSION pgcrypto;',
{ pgcrypto: { to: 'extensions' } }
);
expect(sql).toMatch(/CREATE EXTENSION pgcrypto\s+(WITH\s+)?SCHEMA extensions/i);
expect(result.installsMoved.get('pgcrypto')).toBe('extensions');
assertRoundTrip(sql);
});

it('changes an existing SCHEMA clause', () => {
const { sql } = transformExtensions(
'CREATE EXTENSION pgcrypto WITH SCHEMA public;',
{ pgcrypto: { to: 'extensions' } }
);
expect(sql).toMatch(/SCHEMA extensions/i);
expect(sql).not.toMatch(/SCHEMA public/i);
assertRoundTrip(sql);
});

it('removes the SCHEMA clause when routing to bare (null)', () => {
const { sql } = transformExtensions(
'CREATE EXTENSION pgcrypto WITH SCHEMA extensions;',
{ pgcrypto: { to: null } }
);
expect(sql).not.toMatch(/SCHEMA/i);
expect(sql).toMatch(/CREATE EXTENSION pgcrypto/i);
assertRoundTrip(sql);
});

it('preserves IF NOT EXISTS when adding a schema', () => {
const { sql } = transformExtensions(
'CREATE EXTENSION IF NOT EXISTS pgcrypto;',
{ pgcrypto: { to: 'extensions' } }
);
expect(sql).toMatch(/IF NOT EXISTS/i);
expect(sql).toMatch(/SCHEMA extensions/i);
assertRoundTrip(sql);
});

it('rewrites ALTER EXTENSION ... SET SCHEMA', () => {
const { sql, result } = transformExtensions(
'ALTER EXTENSION pgcrypto SET SCHEMA public;',
{ pgcrypto: { to: 'extensions' } }
);
expect(sql).toMatch(/ALTER EXTENSION pgcrypto SET SCHEMA extensions/i);
expect(result.installsMoved.get('pgcrypto')).toBe('extensions');
assertRoundTrip(sql);
});

it('leaves unrouted extensions untouched', () => {
const { sql, result } = transformExtensions(
'CREATE EXTENSION pg_trgm;',
{ pgcrypto: { to: 'extensions' } }
);
expect(sql).toMatch(/CREATE EXTENSION pg_trgm/i);
expect(sql).not.toMatch(/SCHEMA/i);
expect(result.installsMoved.size).toBe(0);
});
});

describe('transformExtensions — symbol references (node construction)', () => {
it('qualifies a bare extension function call', () => {
const { sql, result } = transformExtensions(
"SELECT crypt('pw', gen_salt('bf'));",
ExtensionRouter.toSchema('extensions')
);
expect(sql).toMatch(/extensions\.crypt/);
expect(sql).toMatch(/extensions\.gen_salt/);
expect(result.symbolsRewritten.get('crypt')).toBe(1);
assertRoundTrip(sql);
});

it('requalifies a public-qualified extension call', () => {
const { sql } = transformExtensions(
"SELECT public.digest('x', 'sha256');",
ExtensionRouter.toSchema('extensions')
);
expect(sql).toMatch(/extensions\.digest/);
expect(sql).not.toMatch(/public\.digest/);
assertRoundTrip(sql);
});

it('strips qualification when routing to bare', () => {
const { sql } = transformExtensions(
"SELECT extensions.crypt('pw', extensions.gen_salt('bf'));",
ExtensionRouter.toSchema(null, { from: ['extensions'] })
);
expect(sql).toMatch(/\bcrypt\(/);
expect(sql).not.toMatch(/extensions\.crypt/);
assertRoundTrip(sql);
});

it('does not touch gen_random_uuid on modern PostgreSQL (core symbol)', () => {
const { sql, result } = transformExtensions(
'SELECT gen_random_uuid();',
ExtensionRouter.toSchema('extensions', { serverVersion: 16 })
);
expect(sql).not.toMatch(/extensions\.gen_random_uuid/);
expect(result.symbolsRewritten.has('gen_random_uuid')).toBe(false);
});

it('routes an extension-provided type (citext) in a column definition', () => {
const { sql } = transformExtensions(
'CREATE TABLE t (email citext NOT NULL);',
ExtensionRouter.toSchema('extensions')
);
expect(sql).toMatch(/extensions\.citext/);
assertRoundTrip(sql);
});

it('rewrites extension calls inside a LANGUAGE sql body', () => {
const { sql } = transformExtensions(
`CREATE FUNCTION hash_pw(pw text) RETURNS text AS $$
SELECT crypt(pw, gen_salt('bf'))
$$ LANGUAGE sql;`,
ExtensionRouter.toSchema('extensions')
);
expect(sql).toMatch(/extensions\.crypt/);
expect(sql).toMatch(/extensions\.gen_salt/);
assertRoundTrip(sql);
});

it('rewrites extension calls inside a PL/pgSQL body', () => {
const { sql } = transformExtensions(
`CREATE FUNCTION hash_pw(pw text) RETURNS text AS $$
BEGIN
RETURN crypt(pw, gen_salt('bf'));
END;
$$ LANGUAGE plpgsql;`,
ExtensionRouter.toSchema('extensions')
);
expect(sql).toMatch(/extensions\.crypt/);
expect(sql).toMatch(/extensions\.gen_salt/);
assertRoundTrip(sql);
});

it('leaves user-defined lookalikes alone', () => {
const { sql, result } = transformExtensions(
'SELECT app.crypt(x), my_helper();',
ExtensionRouter.toSchema('extensions')
);
expect(sql).toMatch(/app\.crypt/);
expect(sql).toMatch(/my_helper\(\)/);
expect(result.symbolsRewritten.size).toBe(0);
});
});
44 changes: 44 additions & 0 deletions packages/transform/__tests__/facts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,48 @@ describe('classifyStatements', () => {
expect(facts[0].securityRelevant).toBe(true);
expect(facts[0].roles).toEqual(['administrator']);
});

it('classifies CREATE EXTENSION with and without a schema clause', () => {
const facts = classifyStatements(`
CREATE EXTENSION pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA partman;
`);
expect(facts.map(f => f.kind)).toEqual(['extension', 'extension']);
expect(facts[0].extension).toEqual({
name: 'pgcrypto',
schema: null,
action: 'create',
ifNotExists: false
});
expect(facts[1].extension).toEqual({
name: 'pg_partman',
schema: 'partman',
action: 'create',
ifNotExists: true
});
});

it('classifies ALTER EXTENSION ... SET SCHEMA and DROP EXTENSION', () => {
const facts = classifyStatements(`
ALTER EXTENSION pg_partman SET SCHEMA public;
DROP EXTENSION IF EXISTS pgcrypto CASCADE;
`);
expect(facts.map(f => f.kind)).toEqual(['extension', 'extension']);
expect(facts[0].extension).toEqual({
name: 'pg_partman',
schema: 'public',
action: 'set_schema'
});
expect(facts[1].extension).toEqual({
name: 'pgcrypto',
schema: null,
action: 'drop'
});
});

it('does not classify non-extension ALTER ... SET SCHEMA as extension', () => {
const facts = classifyStatements(`ALTER TABLE app.t SET SCHEMA app2;`);
expect(facts[0].kind).not.toBe('extension');
expect(facts[0].extension).toBeUndefined();
});
});
Loading
Loading