diff --git a/packages/transform/__tests__/router.test.ts b/packages/transform/__tests__/router.test.ts index 9e229de8..f90efe16 100644 --- a/packages/transform/__tests__/router.test.ts +++ b/packages/transform/__tests__/router.test.ts @@ -223,3 +223,152 @@ describe('object-level routing (transformSql, full module content)', () => { expect(viaRouter).toEqual(viaMap); }); }); + +// ============================================================================= +// Name rebinding (substitution: repoint a reference at a different object) +// ============================================================================= + +describe('SchemaRouter name rebinding (unit)', () => { + it('resolves a full rebind target and keeps the schema-only API unchanged', () => { + const router = new SchemaRouter({ + accounts: { + functions: { current_actor: { schema: null, name: 'current_user_id' } }, + }, + }); + expect(router.resolveObject('accounts', 'current_actor', 'function')).toEqual({ + schema: null, + name: 'current_user_id', + }); + // schema-only API cannot express de-qualification → reads as unchanged + expect(router.resolve('accounts', 'current_actor', 'function')).toBeUndefined(); + }); + + it('inherits the schema-level default for a pure name rebind', () => { + const router = new SchemaRouter({ + accounts: { + schema: 'app', + relations: { members: { name: 'users' } }, + }, + }); + expect(router.resolveObject('accounts', 'members', 'relation')).toEqual({ + schema: 'app', + name: 'users', + }); + expect(router.resolve('accounts', 'members', 'relation')).toBe('app'); + }); + + it('treats the string shorthand as { schema } and reports rebinds', () => { + const router = new SchemaRouter({ + accounts: { + relations: { members: 'app' }, + functions: { current_actor: { schema: null, name: 'current_user_id' } }, + }, + }); + expect(router.resolveObject('accounts', 'members', 'relation')).toEqual({ schema: 'app' }); + expect(router.hasNameRebinds()).toBe(true); + expect(router.nameRebinds()).toEqual([ + { + schema: 'accounts', + ns: 'function', + from: 'current_actor', + to: { schema: null, name: 'current_user_id' }, + }, + ]); + + const plain = new SchemaRouter({ accounts: { relations: { members: 'app' } } }); + expect(plain.hasNameRebinds()).toBe(false); + }); +}); + +describe('name rebinding (transformSqlStatement)', () => { + it('rebinds a function call site to a different, unqualified function', () => { + const router = new SchemaRouter({ + accounts: { + functions: { current_actor: { schema: null, name: 'current_user_id' } }, + }, + }); + const out = transformSqlStatement( + 'ALTER TABLE app.posts ADD COLUMN owner uuid DEFAULT accounts.current_actor();', + router, + freshResult() + ).sql; + expect(out).toContain('current_user_id()'); + expect(out).not.toContain('accounts.'); + expect(out).not.toContain('current_actor'); + }); + + it('rebinds a FK target table to a replacement table in another schema', () => { + const router = new SchemaRouter({ + accounts: { + relations: { members: { schema: 'app', name: 'users' } }, + }, + }); + const out = transformSqlStatement( + 'ALTER TABLE storage.objects ADD CONSTRAINT objects_owner_fkey FOREIGN KEY (owner) REFERENCES accounts.members(id);', + router, + freshResult() + ).sql; + expect(out).toContain('app.users'); + expect(out).not.toContain('accounts.members'); + }); + + it('rebinds a call site inside a LANGUAGE sql body', () => { + const router = new SchemaRouter({ + accounts: { + functions: { current_actor: { schema: null, name: 'current_user_id' } }, + }, + }); + const out = transformSqlStatement( + 'CREATE FUNCTION app.is_owner(row_owner uuid) RETURNS boolean AS $$ SELECT row_owner = accounts.current_actor() $$ LANGUAGE sql STABLE;', + router, + freshResult() + ).sql; + expect(out).toContain('current_user_id()'); + expect(out).not.toContain('accounts.current_actor'); + }); + + it('rebinds a policy predicate call site', () => { + const router = new SchemaRouter({ + accounts: { + functions: { current_actor: { schema: null, name: 'current_user_id' } }, + }, + }); + const out = transformSqlStatement( + 'CREATE POLICY owner_select ON app.posts FOR SELECT USING (owner = accounts.current_actor());', + router, + freshResult() + ).sql; + expect(out).toContain('current_user_id()'); + expect(out).not.toContain('accounts.'); + }); + + it('de-qualifies a relation reference when the target schema is null', () => { + const router = new SchemaRouter({ + legacy: { + relations: { settings: { schema: null } }, + }, + }); + const out = transformSqlStatement( + 'SELECT * FROM legacy.settings;', + router, + freshResult() + ).sql; + expect(out).toContain('FROM settings'); + expect(out).not.toContain('legacy.'); + }); + + it('leaves siblings untouched when only one object is rebound', () => { + const router = new SchemaRouter({ + accounts: { + functions: { current_actor: { schema: null, name: 'current_user_id' } }, + }, + }); + const out = transformSqlStatement( + 'SELECT accounts.current_actor(), accounts.display_name(1);', + router, + freshResult() + ).sql; + expect(out).toContain('current_user_id()'); + expect(out).toContain('accounts.display_name(1)'); + }); +}); diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index 27550252..b4024487 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -45,6 +45,8 @@ export { } from './qualify'; export type { ObjectNamespace, + ObjectRoute, + ObjectRouteTarget, RouteNamespace, RouteSpec, SchemaRoute, diff --git a/packages/transform/src/router.ts b/packages/transform/src/router.ts index 3d1ea392..9f229037 100644 --- a/packages/transform/src/router.ts +++ b/packages/transform/src/router.ts @@ -20,6 +20,19 @@ * `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". + * + * An object route may also *rebind* — change the object's **name**, not just + * the schema it lives in. Routing preserves identity (the same function, a new + * address); rebinding repoints a reference at a *different* object, which is + * what lets one implementation of a contract be substituted for another: + * + * ```ts + * { auth: { functions: { uid: { schema: null, name: 'current_user_id' } } } } + * // auth.uid() -> current_user_id() + * ``` + * + * A `null` target schema de-qualifies the reference (relying on `search_path`), + * matching the convention used by the extension router. */ /** PostgreSQL object namespaces relevant to schema routing. */ @@ -33,6 +46,26 @@ export type ObjectNamespace = 'relation' | 'function' | 'type'; */ export type RouteNamespace = ObjectNamespace | 'schema' | 'unknown'; +/** + * Where a specific object should be reached instead. Either field may be + * omitted: omitting `schema` keeps the schema-level default (or the current + * schema when the route has none), and omitting `name` keeps the object's own + * name — so `{ name }` alone is a pure rebind and `{ schema }` alone is + * equivalent to the shorthand string form. + */ +export interface ObjectRoute { + /** Target schema, or `null` to make the reference unqualified. */ + schema?: string | null; + /** Target object name — rebinds the reference to a different object. */ + name?: string; +} + +/** + * An object route target. The shorthand `string` form is the target schema, + * identical to `{ schema: target }`. + */ +export type ObjectRouteTarget = string | ObjectRoute; + /** Per-source-schema routing: a schema-level default plus per-object routes. */ export interface SchemaRoute { /** @@ -41,12 +74,12 @@ export interface SchemaRoute { * 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; + /** Relation name (table/view/sequence/matview) → target schema or rebind. */ + relations?: Record; + /** Function/procedure/aggregate name → target schema or rebind. */ + functions?: Record; + /** Type/domain name → target schema or rebind. */ + types?: Record; } /** The full routing specification: one {@link SchemaRoute} per source schema. */ @@ -106,6 +139,35 @@ export class SchemaRouter { return false; } + /** + * True when any object route changes a name or de-qualifies (rather than + * only moving between schemas). Such rewrites cannot be expressed by the + * string-level passes at all, so callers use this to require the AST path. + */ + hasNameRebinds(): boolean { + return this.nameRebinds().length > 0; + } + + /** + * Every object route that rebinds a name or de-qualifies, keyed by source + * schema and namespace. Callers use this to report or verify substitutions. + */ + nameRebinds(): Array<{ schema: string; ns: ObjectNamespace; from: string; to: ObjectRoute }> { + const out: Array<{ schema: string; ns: ObjectNamespace; from: string; to: ObjectRoute }> = []; + for (const [schema, route] of this.routes) { + for (const ns of ['relation', 'function', 'type'] as ObjectNamespace[]) { + const bucket = route[NS_BUCKET[ns]]; + if (!bucket) continue; + for (const [from, target] of Object.entries(bucket)) { + if (typeof target === 'string') continue; + if (target.name === undefined && target.schema !== null) continue; + out.push({ schema, ns, from, to: target }); + } + } + } + return out; + } + /** Every source schema this router may touch. */ sourceSchemas(): string[] { return [...this.routes.keys()]; @@ -122,16 +184,42 @@ export class SchemaRouter { name?: string, ns: RouteNamespace = 'unknown' ): string | undefined { + // A `null` target de-qualifies the reference; the schema-only API cannot + // express that, so it reads as "unchanged" here. + return this.resolveObject(sourceSchema, name, ns)?.schema ?? undefined; + } + + /** + * Resolve the full target for `(sourceSchema, name)` in namespace `ns` — both + * the schema the reference should live in and, when the route rebinds, the + * name it should be reached by. Returns `undefined` to leave it unchanged. + * + * In the result, `schema` is `null` when the reference should become + * unqualified and `undefined` when only the name changes; `name` is + * `undefined` when only the schema changes. + */ + resolveObject( + sourceSchema: string | undefined | null, + name?: string, + ns: RouteNamespace = 'unknown' + ): ObjectRoute | 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; + const target = route[NS_BUCKET[ns]]?.[name]; + if (target !== undefined) { + if (typeof target === 'string') return { schema: target }; + // An object route naming no schema inherits the schema-level default, + // so a pure rebind leaves placement alone. + return { + schema: target.schema !== undefined ? target.schema : route.schema, + name: target.name + }; + } } - return route.schema; + return route.schema !== undefined ? { schema: route.schema } : undefined; } /** diff --git a/packages/transform/src/transform.ts b/packages/transform/src/transform.ts index f8215b80..c1677c09 100644 --- a/packages/transform/src/transform.ts +++ b/packages/transform/src/transform.ts @@ -167,12 +167,22 @@ export function transformNameList( const first = names[0]; if (first?.String?.sval) { const schemaName = first.String.sval; - const objName = names[names.length - 1]?.String?.sval; - const newName = router.resolve(schemaName, objName, ns); - if (newName && newName !== schemaName) { + const last = names[names.length - 1]; + const objName = last?.String?.sval; + const target = router.resolveObject(schemaName, objName, ns); + if (!target) return; + if (target.name !== undefined && last?.String?.sval) { result.schemasFound.add(schemaName); - first.String.sval = newName; - result.schemasTransformed.set(schemaName, newName); + last.String.sval = target.name; + } + if (target.schema === null) { + // De-qualify: drop the schema element and rely on search_path. + result.schemasFound.add(schemaName); + names.splice(0, 1); + } else if (target.schema && target.schema !== schemaName) { + result.schemasFound.add(schemaName); + first.String.sval = target.schema; + result.schemasTransformed.set(schemaName, target.schema); } } } @@ -213,11 +223,20 @@ export function transformRelation( 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) { + const target = asRouter(schemaMapping).resolveObject(oldName, relation.relname, 'relation'); + if (!target) return; + if (target.name !== undefined && relation.relname) { + result.schemasFound.add(oldName); + relation.relname = target.name; + } + if (target.schema === null) { + // De-qualify: drop the schema qualifier and rely on search_path. + result.schemasFound.add(oldName); + delete relation.schemaname; + } else if (target.schema && target.schema !== oldName) { result.schemasFound.add(oldName); - relation.schemaname = newName; - result.schemasTransformed.set(oldName, newName); + relation.schemaname = target.schema; + result.schemasTransformed.set(oldName, target.schema); } }