diff --git a/packages/transform/__tests__/router.test.ts b/packages/transform/__tests__/router.test.ts new file mode 100644 index 000000000..9e229de87 --- /dev/null +++ b/packages/transform/__tests__/router.test.ts @@ -0,0 +1,225 @@ +import { loadModule } from 'plpgsql-parser'; + +import { + SchemaRouter, + SchemaTransformResult, + transformSql, + transformSqlStatement, +} from '../src'; + +beforeAll(async () => { + await loadModule(); +}); + +function freshResult(): SchemaTransformResult { + return { + schemasFound: new Set(), + schemasTransformed: new Map(), + errors: [], + }; +} + +// ============================================================================= +// SchemaRouter unit behaviour +// ============================================================================= + +describe('SchemaRouter', () => { + it('degenerates to whole-schema behaviour via fromSchemaMap', () => { + const router = SchemaRouter.fromSchemaMap(new Map([['users', 'tenant_a']])); + expect(router.resolve('users', 'accounts', 'relation')).toBe('tenant_a'); + expect(router.resolve('users', 'account_count', 'function')).toBe('tenant_a'); + expect(router.resolve('users', undefined, 'schema')).toBe('tenant_a'); + expect(router.resolve('other', 'x', 'relation')).toBeUndefined(); + }); + + it('routes individual objects over the schema-level default', () => { + const router = new SchemaRouter({ + users: { + schema: 'tenant_a', + functions: { account_count: 'reporting' }, + types: { user_status: 'shared' }, + }, + }); + // object routes win + expect(router.resolve('users', 'account_count', 'function')).toBe('reporting'); + expect(router.resolve('users', 'user_status', 'type')).toBe('shared'); + // everything else falls back to the schema-level default + expect(router.resolve('users', 'accounts', 'relation')).toBe('tenant_a'); + expect(router.resolve('users', undefined, 'schema')).toBe('tenant_a'); + }); + + it('supports pure object routing with no schema-level default', () => { + const router = new SchemaRouter({ + users: { + relations: { accounts: 'tenant_a' }, + functions: { account_count: 'reporting' }, + }, + }); + expect(router.resolve('users', 'accounts', 'relation')).toBe('tenant_a'); + expect(router.resolve('users', 'account_count', 'function')).toBe('reporting'); + // unlisted object with no default stays put + expect(router.resolve('users', 'sessions', 'relation')).toBeUndefined(); + expect(router.resolve('users', undefined, 'schema')).toBeUndefined(); + }); + + it('keeps namespaces independent (a table and function of the same name)', () => { + const router = new SchemaRouter({ + app: { + relations: { widget: 'rel_schema' }, + functions: { widget: 'fn_schema' }, + }, + }); + expect(router.resolve('app', 'widget', 'relation')).toBe('rel_schema'); + expect(router.resolve('app', 'widget', 'function')).toBe('fn_schema'); + }); + + it('reports only fully-moved schemas', () => { + const router = new SchemaRouter({ + whole: { schema: 'moved' }, + partial: { functions: { helper: 'shared' } }, + }); + const moved = router.fullyMovedSchemas(); + expect([...moved.entries()]).toEqual([['whole', 'moved']]); + expect(router.sourceSchemas().sort()).toEqual(['partial', 'whole']); + }); +}); + +// ============================================================================= +// Object-level routing through transformSql / transformSqlStatement +// ============================================================================= + +describe('object-level routing (transformSqlStatement)', () => { + it('routes a table and a function from one source schema to different schemas', () => { + const router = new SchemaRouter({ + users: { + relations: { accounts: 'tenant_a' }, + functions: { account_count: 'reporting' }, + }, + }); + + const table = transformSqlStatement( + 'CREATE TABLE users.accounts (id int PRIMARY KEY);', + router, + freshResult() + ).sql; + expect(table).toContain('tenant_a.accounts'); + expect(table).not.toContain('users.accounts'); + + const fn = transformSqlStatement( + 'CREATE FUNCTION users.account_count() RETURNS bigint AS $$ SELECT count(*) FROM users.accounts $$ LANGUAGE sql;', + router, + freshResult() + ).sql; + // the function's own identity is routed to reporting … + expect(fn).toContain('reporting.account_count'); + // … while the table reference inside its body is routed to tenant_a + expect(fn).toContain('tenant_a.accounts'); + expect(fn).not.toContain('users.'); + }); + + it('routes a cross-object reference inside a PL/pgSQL body', () => { + const router = new SchemaRouter({ + users: { + schema: 'tenant_a', + functions: { account_count: 'reporting' }, + }, + }); + const sql = + 'CREATE FUNCTION users.account_count() RETURNS bigint AS $$\n' + + 'DECLARE n bigint;\n' + + 'BEGIN\n' + + ' SELECT count(*) INTO n FROM users.accounts;\n' + + ' RETURN n;\n' + + 'END;\n' + + '$$ LANGUAGE plpgsql;'; + const out = transformSqlStatement(sql, router, freshResult()).sql; + expect(out).toContain('reporting.account_count'); + expect(out).toContain('tenant_a.accounts'); + expect(out).not.toContain('users.'); + }); + + it('routes DROP statements (revert scripts) by object namespace', () => { + const router = new SchemaRouter({ + users: { + relations: { accounts: 'tenant_a' }, + functions: { account_count: 'reporting' }, + }, + }); + const dropFn = transformSqlStatement( + 'DROP FUNCTION users.account_count();', + router, + freshResult() + ).sql; + expect(dropFn).toContain('reporting.account_count'); + + const dropTable = transformSqlStatement( + 'DROP TABLE users.accounts;', + router, + freshResult() + ).sql; + expect(dropTable).toContain('tenant_a.accounts'); + }); + + it('leaves unrouted objects in place when there is no schema-level default', () => { + const router = new SchemaRouter({ + users: { functions: { account_count: 'reporting' } }, + }); + const out = transformSqlStatement( + 'CREATE TABLE users.accounts (id int PRIMARY KEY);', + router, + freshResult() + ).sql; + // no route for the table and no default → untouched + expect(out).toContain('users.accounts'); + }); +}); + +describe('object-level routing (transformSql, full module content)', () => { + const source = + '-- Deploy users:schemas/users/procedures/account_count to pg\n' + + '\n' + + 'CREATE SCHEMA users;\n' + + 'CREATE TABLE users.accounts (id int PRIMARY KEY, email text NOT NULL);\n' + + 'CREATE FUNCTION users.account_count() RETURNS bigint AS $$\n' + + ' SELECT count(*) FROM users.accounts;\n' + + '$$ LANGUAGE sql STABLE;\n'; + + it('sends everything to a tenant except a shared function, cross-refs intact', () => { + const router = new SchemaRouter({ + users: { + schema: 'tenant_a', + functions: { account_count: 'reporting' }, + }, + }); + const { content, result } = transformSql(source, router, { roundTrip: true }); + + // schema + table go to the tenant default + expect(content).toContain('CREATE SCHEMA tenant_a'); + expect(content).toContain('tenant_a.accounts'); + // the function is routed to the shared schema + expect(content).toContain('reporting.account_count'); + // and still reads the tenant table + expect(content).toMatch(/FROM\s+tenant_a\.accounts/); + // nothing from the source schema survives (validateNoUntransformedSchemas + // would have thrown otherwise, since `users` is fully moved) + expect(content).not.toContain('users.'); + expect(result.errors).toHaveLength(0); + }); + + it('honours assumeSchemasExist with a router (idempotent target schema)', () => { + const router = new SchemaRouter({ users: { schema: 'tenant_b' } }); + const { content } = transformSql(source, router, { + assumeSchemasExist: ['tenant_b'], + }); + expect(content).toMatch(/CREATE SCHEMA IF NOT EXISTS tenant_b/); + }); + + it('is identical to a plain Map when only a schema-level default is used', () => { + const viaMap = transformSql(source, new Map([['users', 'tenant_a']])).content; + const viaRouter = transformSql( + source, + SchemaRouter.fromSchemaMap({ users: 'tenant_a' }) + ).content; + expect(viaRouter).toEqual(viaMap); + }); +}); diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 81b58f382..ced506c1c 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -17,6 +17,13 @@ export { mergeInventories, qualifyUnqualified, } from './qualify'; +export type { + ObjectNamespace, + RouteNamespace, + RouteSpec, + SchemaRoute, +} from './router'; +export { SchemaRouter } from './router'; export type { CapturedAsts } from './round-trip'; export { captureAstsFromSql, @@ -33,6 +40,7 @@ export { trimDefElemBody, } from './round-trip-core'; export type { + SchemaMappingInput, SchemaTransformPass, SchemaTransformResult, TransformSqlOptions, diff --git a/packages/transform/src/router.ts b/packages/transform/src/router.ts new file mode 100644 index 000000000..3d1ea392e --- /dev/null +++ b/packages/transform/src/router.ts @@ -0,0 +1,160 @@ +/** + * Schema routing for the core transform. + * + * The schema transform historically rewrote every reference to a source + * schema to a single target schema (`Map`). That is the + * degenerate, whole-schema case of a more general question asked at every + * schema-qualified occurrence: + * + * given a reference to `(schema, name)` in namespace `ns`, what schema + * should it live in now? + * + * A {@link SchemaRouter} answers exactly that. It unifies two dimensions: + * + * - **schema-level** default: move everything in a source schema to one target + * (the classic `Map` behaviour), and + * - **object-level** routes: send a specific object — a table, a function, a + * type — to its own target schema, independent of its siblings. + * + * Object routes are bucketed by PostgreSQL namespace (`relations`, `functions`, + * `types` — matching `pg_class` / `pg_proc` / `pg_type`), mirroring the routing + * model already used by {@link qualifyUnqualified}. Resolution is + * object-route-first, then the schema-level default, then "leave unchanged". + */ + +/** PostgreSQL object namespaces relevant to schema routing. */ +export type ObjectNamespace = 'relation' | 'function' | 'type'; + +/** + * A namespace hint for a schema-qualified occurrence. `schema` marks an + * operation on the schema itself (CREATE/DROP/GRANT ON SCHEMA, search_path); + * `unknown` marks a site whose namespace cannot be determined statically, in + * which case only the schema-level default applies. + */ +export type RouteNamespace = ObjectNamespace | 'schema' | 'unknown'; + +/** Per-source-schema routing: a schema-level default plus per-object routes. */ +export interface SchemaRoute { + /** + * Schema-level default: every object in this source schema that has no more + * specific object route moves here. Omit to route *only* the named objects + * and leave the rest (and the schema itself) untouched. + */ + schema?: string; + /** Relation name (table/view/sequence/matview) → target schema. */ + relations?: Record; + /** Function/procedure/aggregate name → target schema. */ + functions?: Record; + /** Type/domain name → target schema. */ + types?: Record; +} + +/** The full routing specification: one {@link SchemaRoute} per source schema. */ +export type RouteSpec = Record; + +const NS_BUCKET: Record> = { + relation: 'relations', + function: 'functions', + type: 'types' +}; + +/** + * Resolves the target schema for any schema-qualified occurrence, unifying the + * whole-schema `Map` behaviour and per-object routing behind one `resolve`. + */ +export class SchemaRouter { + private readonly routes: Map; + + constructor(routes: RouteSpec | Map = {}) { + this.routes = routes instanceof Map ? new Map(routes) : new Map(Object.entries(routes)); + } + + /** Build a router from the classic whole-schema `Map`. */ + static fromSchemaMap(mapping: Map | Record): SchemaRouter { + const entries = mapping instanceof Map ? [...mapping.entries()] : Object.entries(mapping); + const spec: RouteSpec = {}; + for (const [from, to] of entries) spec[from] = { schema: to }; + return new SchemaRouter(spec); + } + + /** Coerce a `Map`, plain mapping, or existing router into a router. */ + static from(source: SchemaRouter | Map | Record): SchemaRouter { + if (source instanceof SchemaRouter) return source; + return SchemaRouter.fromSchemaMap(source); + } + + /** True when this router might rewrite something in `sourceSchema`. */ + has(sourceSchema: string | undefined | null): boolean { + if (!sourceSchema) return false; + return this.routes.has(sourceSchema); + } + + /** True when the router carries no routes at all. */ + get size(): number { + return this.routes.size; + } + + /** + * True when any route targets individual objects (as opposed to whole + * schemas). Object routes require AST-precise rewriting of opaque function + * bodies; whole-schema routes are handled by the cheaper string passes. + */ + hasObjectRoutes(): boolean { + for (const route of this.routes.values()) { + if (route.relations || route.functions || route.types) return true; + } + return false; + } + + /** Every source schema this router may touch. */ + sourceSchemas(): string[] { + return [...this.routes.keys()]; + } + + /** + * Resolve the target schema for `(sourceSchema, name)` in namespace `ns`, + * or `undefined` to leave it unchanged. Object routes win over the + * schema-level default; the default applies to schema-level operations and + * to any object without a specific route. + */ + resolve( + sourceSchema: string | undefined | null, + name?: string, + ns: RouteNamespace = 'unknown' + ): string | undefined { + if (!sourceSchema) return undefined; + const route = this.routes.get(sourceSchema); + if (!route) return undefined; + + if (name && (ns === 'relation' || ns === 'function' || ns === 'type')) { + const bucket = route[NS_BUCKET[ns]]; + const mapped = bucket?.[name]; + if (mapped !== undefined) return mapped; + } + return route.schema; + } + + /** + * Source schemas whose *every* object is guaranteed to move — i.e. those + * with a schema-level default. After a transform, none of these should + * survive as a qualifier; partially-routed schemas may legitimately remain, + * so they are excluded from the strict leftover check. + */ + fullyMovedSchemas(): Map { + const out = new Map(); + for (const [from, route] of this.routes) { + if (route.schema !== undefined) out.set(from, route.schema); + } + return out; + } + + /** + * A flat schema-level view (`oldSchema → newSchema`) for legacy string-level + * passes and comment/verify/JSON rewrites that operate per source schema. + * Only schema-level defaults are included; object-only routes carry no single + * schema answer and are omitted. + */ + schemaLevelMap(): Map { + return this.fullyMovedSchemas(); + } +} diff --git a/packages/transform/src/transform.ts b/packages/transform/src/transform.ts index e94f5720c..f8215b80a 100644 --- a/packages/transform/src/transform.ts +++ b/packages/transform/src/transform.ts @@ -32,6 +32,21 @@ import type { QualifyUnqualifiedOptions } from './qualify'; import { qualifyUnqualified } from './qualify'; import type { CapturedAsts } from './round-trip'; import { captureTransformAsts, validateRoundTrip } from './round-trip'; +import type { RouteNamespace } from './router'; +import { SchemaRouter } from './router'; + +/** A schema mapping accepted by the transform: the classic whole-schema map or a router. */ +export type SchemaMappingInput = Map | SchemaRouter; + +/** Coerce any accepted mapping form into a {@link SchemaRouter}. */ +function asRouter(mapping: SchemaMappingInput): SchemaRouter { + return SchemaRouter.from(mapping); +} + +/** Flat schema-level view for string-level passes that operate per source schema. */ +function schemaLevelMap(mapping: SchemaMappingInput): Map { + return mapping instanceof SchemaRouter ? mapping.schemaLevelMap() : mapping; +} export interface SchemaTransformResult { schemasFound: Set; @@ -133,25 +148,31 @@ export function shouldTransformSchema( /** * Transform schema names in a String node array (used for funcname, names, etc.) * These arrays contain String nodes like { String: { sval: 'schema_name' } } + * + * `ns` names the namespace of the referenced object so object-level routes can + * apply; the object's own name is the last element of the list. When `ns` is + * `unknown` (the default) only the schema-level default applies — identical to + * the historic whole-schema behaviour. */ export function transformNameList( names: any[] | undefined, - schemaMapping: Map, - result: SchemaTransformResult + schemaMapping: SchemaMappingInput, + result: SchemaTransformResult, + ns: RouteNamespace = 'unknown' ): void { if (!names || names.length < 2) return; - - // For schema-qualified names, the first element is the schema + const router = asRouter(schemaMapping); + + // For schema-qualified names, the first element is the schema. const first = names[0]; if (first?.String?.sval) { const schemaName = first.String.sval; - if (shouldTransformSchema(schemaName, schemaMapping)) { + const objName = names[names.length - 1]?.String?.sval; + const newName = router.resolve(schemaName, objName, ns); + if (newName && newName !== schemaName) { result.schemasFound.add(schemaName); - const newName = schemaMapping.get(schemaName); - if (newName) { - first.String.sval = newName; - result.schemasTransformed.set(schemaName, newName); - } + first.String.sval = newName; + result.schemasTransformed.set(schemaName, newName); } } } @@ -164,18 +185,18 @@ export function transformNameList( export function transformSchemaNameField( container: any, field: string, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult ): void { const schemaName = container?.[field]; if (typeof schemaName !== 'string') return; - if (shouldTransformSchema(schemaName, schemaMapping)) { + // A bare schema name is an operation on the schema itself: only the + // schema-level default applies. + const newName = asRouter(schemaMapping).resolve(schemaName, undefined, 'schema'); + if (newName && newName !== schemaName) { result.schemasFound.add(schemaName); - const newName = schemaMapping.get(schemaName); - if (newName) { - container[field] = newName; - result.schemasTransformed.set(schemaName, newName); - } + container[field] = newName; + result.schemasTransformed.set(schemaName, newName); } } @@ -185,17 +206,18 @@ export function transformSchemaNameField( */ export function transformRelation( relation: any, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult ): void { - if (relation?.schemaname && shouldTransformSchema(relation.schemaname, schemaMapping)) { - const oldName = relation.schemaname; + if (!relation?.schemaname) return; + const oldName = relation.schemaname; + // A RangeVar names a relation (table/view/sequence/matview); route by the + // relation name so object-level routes can send it to its own schema. + const newName = asRouter(schemaMapping).resolve(oldName, relation.relname, 'relation'); + if (newName && newName !== oldName) { result.schemasFound.add(oldName); - const newName = schemaMapping.get(oldName); - if (newName) { - relation.schemaname = newName; - result.schemasTransformed.set(oldName, newName); - } + relation.schemaname = newName; + result.schemasTransformed.set(oldName, newName); } } @@ -207,11 +229,13 @@ export function transformRelation( */ export function transformSchemaRefsInString( str: string, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult ): string { let out = str; - for (const [oldSchema, newSchema] of schemaMapping) { + // References embedded in opaque strings carry no object identity, so only + // whole-schema (schema-level default) routes can be applied here. + for (const [oldSchema, newSchema] of schemaLevelMap(schemaMapping)) { const pattern = new RegExp(`(?, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult, visitorOptions?: { assumeSchemasExist?: Set } ) { + const router = asRouter(schemaMapping); const assumeSchemasExist = visitorOptions?.assumeSchemasExist; + const useAstBodies = router.hasObjectRoutes(); + + // Rewrite a bare schema-name string field in place (schema-level default). + const rewriteSchemaField = (container: any, key: string): void => { + const schemaName = container?.[key]; + if (typeof schemaName !== 'string') return; + const newName = router.resolve(schemaName, undefined, 'schema'); + if (newName && newName !== schemaName) { + result.schemasFound.add(schemaName); + container[key] = newName; + result.schemasTransformed.set(schemaName, newName); + } + }; + + // Namespace of the object a DROP/ALTER targets, from its removeType, so + // object routes apply to DROP TABLE/FUNCTION/TYPE (e.g. revert scripts). + const namespaceOfObjectType = (objType: string | undefined): RouteNamespace => { + switch (objType) { + case 'OBJECT_TABLE': + case 'OBJECT_VIEW': + case 'OBJECT_SEQUENCE': + case 'OBJECT_MATVIEW': + case 'OBJECT_FOREIGN_TABLE': + case 'OBJECT_INDEX': + return 'relation'; + case 'OBJECT_FUNCTION': + case 'OBJECT_PROCEDURE': + case 'OBJECT_AGGREGATE': + case 'OBJECT_ROUTINE': + return 'function'; + case 'OBJECT_TYPE': + case 'OBJECT_DOMAIN': + return 'type'; + default: + return 'unknown'; + } + }; + return { // Transform RangeVar nodes (table references) RangeVar: (path: any) => { - const node = path.node; - if (node.schemaname && shouldTransformSchema(node.schemaname, schemaMapping)) { - const oldName = node.schemaname; - result.schemasFound.add(oldName); - const newName = schemaMapping.get(oldName); - if (newName) { - node.schemaname = newName; - result.schemasTransformed.set(oldName, newName); - } - } + transformRelation(path.node, router, result); }, // Transform CreateSchemaStmt nodes CreateSchemaStmt: (path: any) => { const node = path.node; - if (node.schemaname && shouldTransformSchema(node.schemaname, schemaMapping)) { + if (node.schemaname) { const oldName = node.schemaname; - result.schemasFound.add(oldName); - const newName = schemaMapping.get(oldName); - if (newName) { + // The schema object itself: schema-level default only. + const newName = router.resolve(oldName, undefined, 'schema'); + if (newName && newName !== oldName) { node.schemaname = newName; + result.schemasFound.add(oldName); result.schemasTransformed.set(oldName, newName); } } @@ -272,7 +327,7 @@ export function createSqlVisitor( // Transform FuncCall nodes (function calls with schema-qualified names) FuncCall: (path: any) => { const node = path.node; - transformNameList(node.funcname, schemaMapping, result); + transformNameList(node.funcname, router, result, 'function'); }, // Transform CallStmt (CALL schema.procedure(...)). @@ -281,20 +336,23 @@ export function createSqlVisitor( CallStmt: (path: any) => { const node = path.node; if (node.funccall?.funcname) { - transformNameList(node.funccall.funcname, schemaMapping, result); + transformNameList(node.funccall.funcname, router, result, 'function'); } }, // Transform TypeName nodes (type references with schema-qualified names) TypeName: (path: any) => { const node = path.node; - transformNameList(node.names, schemaMapping, result); + transformNameList(node.names, router, result, 'type'); }, - // Transform ColumnRef nodes (column references with schema-qualified names) + // Transform ColumnRef nodes (column references with schema-qualified names). + // A qualified column is schema.table.column — the routed object is the + // table, which we cannot disambiguate from the schema here, so only the + // schema-level default applies. ColumnRef: (path: any) => { const node = path.node; - transformNameList(node.fields, schemaMapping, result); + transformNameList(node.fields, router, result); }, // Transform GrantStmt objects (GRANT ON SCHEMA schema; @@ -318,25 +376,9 @@ export function createSqlVisitor( for (const arg of node.args) { // search_path args can be String nodes or A_Const with sval if (arg?.String?.sval) { - const schemaName = arg.String.sval; - if (shouldTransformSchema(schemaName, schemaMapping)) { - result.schemasFound.add(schemaName); - const newName = schemaMapping.get(schemaName); - if (newName) { - arg.String.sval = newName; - result.schemasTransformed.set(schemaName, newName); - } - } + rewriteSchemaField(arg.String, 'sval'); } else if (arg?.A_Const?.sval?.sval) { - const schemaName = arg.A_Const.sval.sval; - if (shouldTransformSchema(schemaName, schemaMapping)) { - result.schemasFound.add(schemaName); - const newName = schemaMapping.get(schemaName); - if (newName) { - arg.A_Const.sval.sval = newName; - result.schemasTransformed.set(schemaName, newName); - } - } + rewriteSchemaField(arg.A_Const.sval, 'sval'); } } } @@ -350,15 +392,7 @@ export function createSqlVisitor( if (opt?.DefElem?.defname === 'schemas' && opt.DefElem.arg?.List?.items) { for (const item of opt.DefElem.arg.List.items) { if (item?.String?.sval) { - const schemaName = item.String.sval; - if (shouldTransformSchema(schemaName, schemaMapping)) { - result.schemasFound.add(schemaName); - const newName = schemaMapping.get(schemaName); - if (newName) { - item.String.sval = newName; - result.schemasTransformed.set(schemaName, newName); - } - } + rewriteSchemaField(item.String, 'sval'); } } } @@ -372,13 +406,14 @@ export function createSqlVisitor( DropStmt: (path: any) => { const node = path.node; if (node.removeType !== 'OBJECT_SCHEMA' && Array.isArray(node.objects)) { + const ns = namespaceOfObjectType(node.removeType); for (const obj of node.objects) { if (obj?.List?.items) { - transformNameList(obj.List.items, schemaMapping, result); + transformNameList(obj.List.items, router, result, ns); } else if (obj?.ObjectWithArgs?.objname) { - transformNameList(obj.ObjectWithArgs.objname, schemaMapping, result); + transformNameList(obj.ObjectWithArgs.objname, router, result, ns); } else if (obj?.TypeName?.names) { - transformNameList(obj.TypeName.names, schemaMapping, result); + transformNameList(obj.TypeName.names, router, result, ns); } } return; @@ -389,27 +424,11 @@ export function createSqlVisitor( if (obj?.List?.items) { for (const item of obj.List.items) { if (item?.String?.sval) { - const schemaName = item.String.sval; - if (shouldTransformSchema(schemaName, schemaMapping)) { - result.schemasFound.add(schemaName); - const newName = schemaMapping.get(schemaName); - if (newName) { - item.String.sval = newName; - result.schemasTransformed.set(schemaName, newName); - } - } + rewriteSchemaField(item.String, 'sval'); } } } else if (obj?.String?.sval) { - const schemaName = obj.String.sval; - if (shouldTransformSchema(schemaName, schemaMapping)) { - result.schemasFound.add(schemaName); - const newName = schemaMapping.get(schemaName); - if (newName) { - obj.String.sval = newName; - result.schemasTransformed.set(schemaName, newName); - } - } + rewriteSchemaField(obj.String, 'sval'); } } } @@ -471,7 +490,7 @@ export function createSqlVisitor( ColumnDef: (path: any) => { const node = path.node; if (node.typeName?.names) { - transformNameList(node.typeName.names, schemaMapping, result); + transformNameList(node.typeName.names, schemaMapping, result, 'type'); } }, @@ -556,7 +575,7 @@ export function createSqlVisitor( TypeCast: (path: any) => { const node = path.node; if (node.typeName?.names) { - transformNameList(node.typeName.names, schemaMapping, result); + transformNameList(node.typeName.names, schemaMapping, result, 'type'); } }, @@ -564,17 +583,17 @@ export function createSqlVisitor( // Also handles RETURNS [SETOF] schema.type via returnType.names CreateFunctionStmt: (path: any) => { const node = path.node; - transformNameList(node.funcname, schemaMapping, result); + transformNameList(node.funcname, schemaMapping, result, 'function'); // Transform the return type (e.g., RETURNS SETOF schema.tablename) if (node.returnType?.names) { - transformNameList(node.returnType.names, schemaMapping, result); + transformNameList(node.returnType.names, schemaMapping, result, 'type'); } // Transform parameter types (the walker does NOT auto-recurse into // FunctionParameter.argType TypeName nodes) if (Array.isArray(node.parameters)) { for (const param of node.parameters) { if (param?.FunctionParameter?.argType?.names) { - transformNameList(param.FunctionParameter.argType.names, schemaMapping, result); + transformNameList(param.FunctionParameter.argType.names, schemaMapping, result, 'type'); } } } @@ -587,8 +606,14 @@ export function createSqlVisitor( for (const opt of node.options) { if (opt?.DefElem?.defname === 'as' && opt.DefElem.arg?.List?.items) { for (const item of opt.DefElem.arg.List.items) { - if (typeof item?.String?.sval === 'string' && item.String.sval.includes('.')) { - item.String.sval = transformSchemaRefsInString(item.String.sval, schemaMapping, result); + if (typeof item?.String?.sval !== 'string') continue; + // With object routes, references inside a LANGUAGE sql body must + // be rewritten AST-precisely; whole-schema routes keep the + // cheaper (and quoting-preserving) string pass. + if (useAstBodies) { + item.String.sval = transformSqlBodyString(item.String.sval, router, result); + } else if (item.String.sval.includes('.')) { + item.String.sval = transformSchemaRefsInString(item.String.sval, router, result); } } } @@ -601,7 +626,7 @@ export function createSqlVisitor( // so we must explicitly transform the RangeVar here. CreateTrigStmt: (path: any) => { const node = path.node; - transformNameList(node.funcname, schemaMapping, result); + transformNameList(node.funcname, schemaMapping, result, 'function'); transformRelation(node.relation, schemaMapping, result); }, @@ -651,25 +676,25 @@ export function createSqlVisitor( // Transform CreateDomainStmt (CREATE DOMAIN schema.domname) CreateDomainStmt: (path: any) => { const node = path.node; - transformNameList(node.domainname, schemaMapping, result); + transformNameList(node.domainname, schemaMapping, result, 'type'); }, // Transform CreateEnumStmt (CREATE TYPE schema.enumname AS ENUM) CreateEnumStmt: (path: any) => { const node = path.node; - transformNameList(node.typeName, schemaMapping, result); + transformNameList(node.typeName, schemaMapping, result, 'type'); }, // Transform AlterEnumStmt (ALTER TYPE schema.enumname ADD VALUE) AlterEnumStmt: (path: any) => { const node = path.node; - transformNameList(node.typeName, schemaMapping, result); + transformNameList(node.typeName, schemaMapping, result, 'type'); }, // Transform AlterDomainStmt (ALTER DOMAIN schema.domname) AlterDomainStmt: (path: any) => { const node = path.node; - transformNameList(node.typeName, schemaMapping, result); + transformNameList(node.typeName, schemaMapping, result, 'type'); }, // Transform AlterTypeStmt (ALTER TYPE schema.typename) @@ -677,7 +702,7 @@ export function createSqlVisitor( // but some ALTER TYPE statements use typeName as a name list AlterTypeStmt: (path: any) => { const node = path.node; - transformNameList(node.typeName, schemaMapping, result); + transformNameList(node.typeName, schemaMapping, result, 'type'); }, // Transform ObjectWithArgs (used in ALTER FUNCTION, DROP FUNCTION with args, etc.) @@ -726,7 +751,7 @@ export function createSqlVisitor( AlterFunctionStmt: (path: any) => { const node = path.node; if (node.func?.objname) { - transformNameList(node.func.objname, schemaMapping, result); + transformNameList(node.func.objname, schemaMapping, result, 'function'); } }, @@ -747,27 +772,27 @@ export function createSqlVisitor( CreateCastStmt: (path: any) => { const node = path.node; if (node.sourcetype?.names) { - transformNameList(node.sourcetype.names, schemaMapping, result); + transformNameList(node.sourcetype.names, schemaMapping, result, 'type'); } if (node.targettype?.names) { - transformNameList(node.targettype.names, schemaMapping, result); + transformNameList(node.targettype.names, schemaMapping, result, 'type'); } if (node.func?.objname) { - transformNameList(node.func.objname, schemaMapping, result); + transformNameList(node.func.objname, schemaMapping, result, 'function'); } if (Array.isArray(node.func?.objargs)) { for (const arg of node.func.objargs) { if (arg?.TypeName?.names) { - transformNameList(arg.TypeName.names, schemaMapping, result); + transformNameList(arg.TypeName.names, schemaMapping, result, 'type'); } else if (arg?.names) { - transformNameList(arg.names, schemaMapping, result); + transformNameList(arg.names, schemaMapping, result, 'type'); } } } if (Array.isArray(node.func?.objfuncargs)) { for (const param of node.func.objfuncargs) { if (param?.FunctionParameter?.argType?.names) { - transformNameList(param.FunctionParameter.argType.names, schemaMapping, result); + transformNameList(param.FunctionParameter.argType.names, schemaMapping, result, 'type'); } } } @@ -776,7 +801,7 @@ export function createSqlVisitor( // Transform CreateEventTrigStmt (CREATE EVENT TRIGGER ... EXECUTE FUNCTION schema.fn()) CreateEventTrigStmt: (path: any) => { const node = path.node; - transformNameList(node.funcname, schemaMapping, result); + transformNameList(node.funcname, schemaMapping, result, 'function'); }, // Transform IndexElem opclass (CREATE INDEX ... (col schema.opclass)) @@ -823,13 +848,18 @@ export function createSqlVisitor( */ export function validateNoUntransformedSchemas( content: string, - schemaMapping: Map + schemaMapping: SchemaMappingInput ): void { - if (schemaMapping.size === 0) { + // Only schemas with a schema-level default are guaranteed to move entirely; + // partially (object-only) routed schemas may legitimately keep some + // references, so they are excluded from the strict leftover check. + const moved = + schemaMapping instanceof SchemaRouter ? schemaMapping.fullyMovedSchemas() : schemaMapping; + if (moved.size === 0) { return; } - for (const [oldSchema, newSchema] of schemaMapping) { + for (const [oldSchema, newSchema] of moved) { const escapedSchema = escapeRegexp(oldSchema); // Pattern 1: quoted or unquoted schema name followed by dot (schema-qualified) @@ -880,14 +910,15 @@ export function validateNoUntransformedSchemas( * Create a PL/pgSQL visitor that transforms schema names in PL/pgSQL-specific nodes. */ export function createPlpgsqlVisitor( - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult ) { + const schemaMap = schemaLevelMap(schemaMapping); return { PLpgSQL_type: (path: any) => { const node = path.node; if (node.typname) { - for (const [oldSchema, newSchema] of schemaMapping.entries()) { + for (const [oldSchema, newSchema] of schemaMap.entries()) { if (node.typname.startsWith(oldSchema + '.')) { const typeName = node.typname.substring(oldSchema.length + 1); result.schemasFound.add(oldSchema); @@ -902,7 +933,7 @@ export function createPlpgsqlVisitor( PLpgSQL_var: (path: any) => { const node = path.node; if (node.refname) { - for (const [oldSchema, newSchema] of schemaMapping.entries()) { + for (const [oldSchema, newSchema] of schemaMap.entries()) { if (node.refname.startsWith(oldSchema + '.')) { const rest = node.refname.substring(oldSchema.length + 1); result.schemasFound.add(oldSchema); @@ -921,9 +952,10 @@ export function createPlpgsqlVisitor( */ export function transformPlpgsqlTypeAst( typname: string, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult ): string { + const schemaMap = schemaLevelMap(schemaMapping); let suffix = ''; let baseTypname = typname; @@ -934,7 +966,7 @@ export function transformPlpgsqlTypeAst( } let needsTransform = false; - for (const oldSchema of schemaMapping.keys()) { + for (const oldSchema of schemaMap.keys()) { if (baseTypname.startsWith(oldSchema + '.') || baseTypname.startsWith('"' + oldSchema + '".')) { needsTransform = true; break; @@ -957,7 +989,7 @@ export function transformPlpgsqlTypeAst( TypeName: (path: any) => { const typeNode = path.node; if (typeNode.names && Array.isArray(typeNode.names)) { - transformNameList(typeNode.names, schemaMapping, result); + transformNameList(typeNode.names, schemaMapping, result, 'type'); } } }; @@ -984,10 +1016,10 @@ export function transformPlpgsqlTypeAst( */ export function transformPlpgsqlTypeString( typname: string, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult ): string { - for (const [oldSchema, newSchema] of schemaMapping.entries()) { + for (const [oldSchema, newSchema] of schemaLevelMap(schemaMapping).entries()) { // Unquoted schema: old_schema.rest if (typname.startsWith(oldSchema + '.')) { const rest = typname.substring(oldSchema.length + 1); @@ -1011,7 +1043,7 @@ export function transformPlpgsqlTypeString( */ export function walkPlpgsqlForSchemas( node: any, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult ): void { if (node === null || node === undefined || typeof node !== 'object') { @@ -1066,7 +1098,7 @@ export function walkPlpgsqlForSchemas( // We cannot use walkSql here because the raw typeNameNode is not // wrapped in the expected AST envelope that the traverse walker needs. if (plType.typname.typeNameNode?.names) { - transformNameList(plType.typname.typeNameNode.names, schemaMapping, result); + transformNameList(plType.typname.typeNameNode.names, schemaMapping, result, 'type'); } // The deparser can fall back to the 'original' string to render // DECLARE types, so update it as well. Rewrite only the schema @@ -1094,6 +1126,38 @@ export function walkPlpgsqlForSchemas( } } +/** + * Rewrite a `LANGUAGE sql` function-body string using the full AST visitor so + * object-level routes reach references inside the body (the body is an opaque + * String node the outer walker never parses). Parses each statement, walks it + * with the router-aware SQL visitor, and deparses. Falls back to the + * schema-level string pass for anything that does not parse standalone (e.g. + * PL/pgSQL blocks or C symbol names). + */ +function transformSqlBodyString( + body: string, + router: SchemaRouter, + result: SchemaTransformResult +): string { + try { + const parseResult = parseSql(body); + const stmts: any[] = parseResult?.stmts ?? []; + if (stmts.length === 0) { + return body.includes('.') ? transformSchemaRefsInString(body, router, result) : body; + } + const visitor = createSqlVisitor(router, result); + const pieces: string[] = []; + for (const stmt of stmts) { + if (!stmt?.stmt) continue; + walkSql(stmt.stmt, visitor); + pieces.push(Deparser.deparse(stmt.stmt)); + } + return pieces.join(';\n'); + } catch { + return body.includes('.') ? transformSchemaRefsInString(body, router, result) : body; + } +} + /** * Escape a string for use in a regular expression */ @@ -1279,7 +1343,7 @@ export function transformJsonStringValues( */ export function transformSql( content: string, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, options?: TransformSqlOptions | SchemaTransformResult, result?: SchemaTransformResult ): { content: string; result: SchemaTransformResult } { @@ -1298,12 +1362,14 @@ export function transformSql( return { content, result: r }; } + // String-level passes operate on whole schemas; give them the flat view. + const passMap = schemaLevelMap(schemaMapping); let newContent = content; // Run pre-passes (app-specific string-level transforms) if (opts.prePasses) { for (const pass of opts.prePasses) { - newContent = pass(newContent, schemaMapping, r); + newContent = pass(newContent, passMap, r); } } @@ -1313,7 +1379,7 @@ export function transformSql( // Run post-passes (app-specific string-level transforms) if (opts.postPasses) { for (const pass of opts.postPasses) { - newContent = pass(newContent, schemaMapping, r); + newContent = pass(newContent, passMap, r); } } @@ -1327,7 +1393,7 @@ export function transformSql( */ export function transformSqlStatement( sql: string, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result?: SchemaTransformResult ): { sql: string; result: SchemaTransformResult } { const r = result || createResult(); @@ -1373,7 +1439,7 @@ export function transformSqlStatement( */ function transformSqlContentAst( content: string, - schemaMapping: Map, + schemaMapping: SchemaMappingInput, result: SchemaTransformResult, options?: TransformSqlOptions ): string { @@ -1390,7 +1456,8 @@ function transformSqlContentAst( let transformedHeader = header; if (header.length > 0) { - transformedHeader = transformComments(header, schemaMapping, result); + // Header/path rewrites (-- Deploy: schemas//...) are whole-schema. + transformedHeader = transformComments(header, schemaLevelMap(schemaMapping), result); } let transformedBody = body;