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
149 changes: 149 additions & 0 deletions packages/transform/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
});
});
2 changes: 2 additions & 0 deletions packages/transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export {
} from './qualify';
export type {
ObjectNamespace,
ObjectRoute,
ObjectRouteTarget,
RouteNamespace,
RouteSpec,
SchemaRoute,
Expand Down
108 changes: 98 additions & 10 deletions packages/transform/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
/**
Expand All @@ -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<string, string>;
/** Function/procedure/aggregate name → target schema. */
functions?: Record<string, string>;
/** Type/domain name → target schema. */
types?: Record<string, string>;
/** Relation name (table/view/sequence/matview) → target schema or rebind. */
relations?: Record<string, ObjectRouteTarget>;
/** Function/procedure/aggregate name → target schema or rebind. */
functions?: Record<string, ObjectRouteTarget>;
/** Type/domain name → target schema or rebind. */
types?: Record<string, ObjectRouteTarget>;
}

/** The full routing specification: one {@link SchemaRoute} per source schema. */
Expand Down Expand Up @@ -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()];
Expand All @@ -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;
}

/**
Expand Down
37 changes: 28 additions & 9 deletions packages/transform/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading