Skip to content

Commit 76f1d79

Browse files
authored
Merge pull request #322 from constructive-io/feat/name-rebinding
feat(transform): name rebinding in SchemaRouter (repoint references at a different object)
2 parents 6d3c283 + 34d9216 commit 76f1d79

4 files changed

Lines changed: 277 additions & 19 deletions

File tree

packages/transform/__tests__/router.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,152 @@ describe('object-level routing (transformSql, full module content)', () => {
223223
expect(viaRouter).toEqual(viaMap);
224224
});
225225
});
226+
227+
// =============================================================================
228+
// Name rebinding (substitution: repoint a reference at a different object)
229+
// =============================================================================
230+
231+
describe('SchemaRouter name rebinding (unit)', () => {
232+
it('resolves a full rebind target and keeps the schema-only API unchanged', () => {
233+
const router = new SchemaRouter({
234+
accounts: {
235+
functions: { current_actor: { schema: null, name: 'current_user_id' } },
236+
},
237+
});
238+
expect(router.resolveObject('accounts', 'current_actor', 'function')).toEqual({
239+
schema: null,
240+
name: 'current_user_id',
241+
});
242+
// schema-only API cannot express de-qualification → reads as unchanged
243+
expect(router.resolve('accounts', 'current_actor', 'function')).toBeUndefined();
244+
});
245+
246+
it('inherits the schema-level default for a pure name rebind', () => {
247+
const router = new SchemaRouter({
248+
accounts: {
249+
schema: 'app',
250+
relations: { members: { name: 'users' } },
251+
},
252+
});
253+
expect(router.resolveObject('accounts', 'members', 'relation')).toEqual({
254+
schema: 'app',
255+
name: 'users',
256+
});
257+
expect(router.resolve('accounts', 'members', 'relation')).toBe('app');
258+
});
259+
260+
it('treats the string shorthand as { schema } and reports rebinds', () => {
261+
const router = new SchemaRouter({
262+
accounts: {
263+
relations: { members: 'app' },
264+
functions: { current_actor: { schema: null, name: 'current_user_id' } },
265+
},
266+
});
267+
expect(router.resolveObject('accounts', 'members', 'relation')).toEqual({ schema: 'app' });
268+
expect(router.hasNameRebinds()).toBe(true);
269+
expect(router.nameRebinds()).toEqual([
270+
{
271+
schema: 'accounts',
272+
ns: 'function',
273+
from: 'current_actor',
274+
to: { schema: null, name: 'current_user_id' },
275+
},
276+
]);
277+
278+
const plain = new SchemaRouter({ accounts: { relations: { members: 'app' } } });
279+
expect(plain.hasNameRebinds()).toBe(false);
280+
});
281+
});
282+
283+
describe('name rebinding (transformSqlStatement)', () => {
284+
it('rebinds a function call site to a different, unqualified function', () => {
285+
const router = new SchemaRouter({
286+
accounts: {
287+
functions: { current_actor: { schema: null, name: 'current_user_id' } },
288+
},
289+
});
290+
const out = transformSqlStatement(
291+
'ALTER TABLE app.posts ADD COLUMN owner uuid DEFAULT accounts.current_actor();',
292+
router,
293+
freshResult()
294+
).sql;
295+
expect(out).toContain('current_user_id()');
296+
expect(out).not.toContain('accounts.');
297+
expect(out).not.toContain('current_actor');
298+
});
299+
300+
it('rebinds a FK target table to a replacement table in another schema', () => {
301+
const router = new SchemaRouter({
302+
accounts: {
303+
relations: { members: { schema: 'app', name: 'users' } },
304+
},
305+
});
306+
const out = transformSqlStatement(
307+
'ALTER TABLE storage.objects ADD CONSTRAINT objects_owner_fkey FOREIGN KEY (owner) REFERENCES accounts.members(id);',
308+
router,
309+
freshResult()
310+
).sql;
311+
expect(out).toContain('app.users');
312+
expect(out).not.toContain('accounts.members');
313+
});
314+
315+
it('rebinds a call site inside a LANGUAGE sql body', () => {
316+
const router = new SchemaRouter({
317+
accounts: {
318+
functions: { current_actor: { schema: null, name: 'current_user_id' } },
319+
},
320+
});
321+
const out = transformSqlStatement(
322+
'CREATE FUNCTION app.is_owner(row_owner uuid) RETURNS boolean AS $$ SELECT row_owner = accounts.current_actor() $$ LANGUAGE sql STABLE;',
323+
router,
324+
freshResult()
325+
).sql;
326+
expect(out).toContain('current_user_id()');
327+
expect(out).not.toContain('accounts.current_actor');
328+
});
329+
330+
it('rebinds a policy predicate call site', () => {
331+
const router = new SchemaRouter({
332+
accounts: {
333+
functions: { current_actor: { schema: null, name: 'current_user_id' } },
334+
},
335+
});
336+
const out = transformSqlStatement(
337+
'CREATE POLICY owner_select ON app.posts FOR SELECT USING (owner = accounts.current_actor());',
338+
router,
339+
freshResult()
340+
).sql;
341+
expect(out).toContain('current_user_id()');
342+
expect(out).not.toContain('accounts.');
343+
});
344+
345+
it('de-qualifies a relation reference when the target schema is null', () => {
346+
const router = new SchemaRouter({
347+
legacy: {
348+
relations: { settings: { schema: null } },
349+
},
350+
});
351+
const out = transformSqlStatement(
352+
'SELECT * FROM legacy.settings;',
353+
router,
354+
freshResult()
355+
).sql;
356+
expect(out).toContain('FROM settings');
357+
expect(out).not.toContain('legacy.');
358+
});
359+
360+
it('leaves siblings untouched when only one object is rebound', () => {
361+
const router = new SchemaRouter({
362+
accounts: {
363+
functions: { current_actor: { schema: null, name: 'current_user_id' } },
364+
},
365+
});
366+
const out = transformSqlStatement(
367+
'SELECT accounts.current_actor(), accounts.display_name(1);',
368+
router,
369+
freshResult()
370+
).sql;
371+
expect(out).toContain('current_user_id()');
372+
expect(out).toContain('accounts.display_name(1)');
373+
});
374+
});

packages/transform/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export {
4545
} from './qualify';
4646
export type {
4747
ObjectNamespace,
48+
ObjectRoute,
49+
ObjectRouteTarget,
4850
RouteNamespace,
4951
RouteSpec,
5052
SchemaRoute,

packages/transform/src/router.ts

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@
2020
* `types` — matching `pg_class` / `pg_proc` / `pg_type`), mirroring the routing
2121
* model already used by {@link qualifyUnqualified}. Resolution is
2222
* object-route-first, then the schema-level default, then "leave unchanged".
23+
*
24+
* An object route may also *rebind* — change the object's **name**, not just
25+
* the schema it lives in. Routing preserves identity (the same function, a new
26+
* address); rebinding repoints a reference at a *different* object, which is
27+
* what lets one implementation of a contract be substituted for another:
28+
*
29+
* ```ts
30+
* { auth: { functions: { uid: { schema: null, name: 'current_user_id' } } } }
31+
* // auth.uid() -> current_user_id()
32+
* ```
33+
*
34+
* A `null` target schema de-qualifies the reference (relying on `search_path`),
35+
* matching the convention used by the extension router.
2336
*/
2437

2538
/** PostgreSQL object namespaces relevant to schema routing. */
@@ -33,6 +46,26 @@ export type ObjectNamespace = 'relation' | 'function' | 'type';
3346
*/
3447
export type RouteNamespace = ObjectNamespace | 'schema' | 'unknown';
3548

49+
/**
50+
* Where a specific object should be reached instead. Either field may be
51+
* omitted: omitting `schema` keeps the schema-level default (or the current
52+
* schema when the route has none), and omitting `name` keeps the object's own
53+
* name — so `{ name }` alone is a pure rebind and `{ schema }` alone is
54+
* equivalent to the shorthand string form.
55+
*/
56+
export interface ObjectRoute {
57+
/** Target schema, or `null` to make the reference unqualified. */
58+
schema?: string | null;
59+
/** Target object name — rebinds the reference to a different object. */
60+
name?: string;
61+
}
62+
63+
/**
64+
* An object route target. The shorthand `string` form is the target schema,
65+
* identical to `{ schema: target }`.
66+
*/
67+
export type ObjectRouteTarget = string | ObjectRoute;
68+
3669
/** Per-source-schema routing: a schema-level default plus per-object routes. */
3770
export interface SchemaRoute {
3871
/**
@@ -41,12 +74,12 @@ export interface SchemaRoute {
4174
* and leave the rest (and the schema itself) untouched.
4275
*/
4376
schema?: string;
44-
/** Relation name (table/view/sequence/matview) → target schema. */
45-
relations?: Record<string, string>;
46-
/** Function/procedure/aggregate name → target schema. */
47-
functions?: Record<string, string>;
48-
/** Type/domain name → target schema. */
49-
types?: Record<string, string>;
77+
/** Relation name (table/view/sequence/matview) → target schema or rebind. */
78+
relations?: Record<string, ObjectRouteTarget>;
79+
/** Function/procedure/aggregate name → target schema or rebind. */
80+
functions?: Record<string, ObjectRouteTarget>;
81+
/** Type/domain name → target schema or rebind. */
82+
types?: Record<string, ObjectRouteTarget>;
5083
}
5184

5285
/** The full routing specification: one {@link SchemaRoute} per source schema. */
@@ -106,6 +139,35 @@ export class SchemaRouter {
106139
return false;
107140
}
108141

142+
/**
143+
* True when any object route changes a name or de-qualifies (rather than
144+
* only moving between schemas). Such rewrites cannot be expressed by the
145+
* string-level passes at all, so callers use this to require the AST path.
146+
*/
147+
hasNameRebinds(): boolean {
148+
return this.nameRebinds().length > 0;
149+
}
150+
151+
/**
152+
* Every object route that rebinds a name or de-qualifies, keyed by source
153+
* schema and namespace. Callers use this to report or verify substitutions.
154+
*/
155+
nameRebinds(): Array<{ schema: string; ns: ObjectNamespace; from: string; to: ObjectRoute }> {
156+
const out: Array<{ schema: string; ns: ObjectNamespace; from: string; to: ObjectRoute }> = [];
157+
for (const [schema, route] of this.routes) {
158+
for (const ns of ['relation', 'function', 'type'] as ObjectNamespace[]) {
159+
const bucket = route[NS_BUCKET[ns]];
160+
if (!bucket) continue;
161+
for (const [from, target] of Object.entries(bucket)) {
162+
if (typeof target === 'string') continue;
163+
if (target.name === undefined && target.schema !== null) continue;
164+
out.push({ schema, ns, from, to: target });
165+
}
166+
}
167+
}
168+
return out;
169+
}
170+
109171
/** Every source schema this router may touch. */
110172
sourceSchemas(): string[] {
111173
return [...this.routes.keys()];
@@ -122,16 +184,42 @@ export class SchemaRouter {
122184
name?: string,
123185
ns: RouteNamespace = 'unknown'
124186
): string | undefined {
187+
// A `null` target de-qualifies the reference; the schema-only API cannot
188+
// express that, so it reads as "unchanged" here.
189+
return this.resolveObject(sourceSchema, name, ns)?.schema ?? undefined;
190+
}
191+
192+
/**
193+
* Resolve the full target for `(sourceSchema, name)` in namespace `ns` — both
194+
* the schema the reference should live in and, when the route rebinds, the
195+
* name it should be reached by. Returns `undefined` to leave it unchanged.
196+
*
197+
* In the result, `schema` is `null` when the reference should become
198+
* unqualified and `undefined` when only the name changes; `name` is
199+
* `undefined` when only the schema changes.
200+
*/
201+
resolveObject(
202+
sourceSchema: string | undefined | null,
203+
name?: string,
204+
ns: RouteNamespace = 'unknown'
205+
): ObjectRoute | undefined {
125206
if (!sourceSchema) return undefined;
126207
const route = this.routes.get(sourceSchema);
127208
if (!route) return undefined;
128209

129210
if (name && (ns === 'relation' || ns === 'function' || ns === 'type')) {
130-
const bucket = route[NS_BUCKET[ns]];
131-
const mapped = bucket?.[name];
132-
if (mapped !== undefined) return mapped;
211+
const target = route[NS_BUCKET[ns]]?.[name];
212+
if (target !== undefined) {
213+
if (typeof target === 'string') return { schema: target };
214+
// An object route naming no schema inherits the schema-level default,
215+
// so a pure rebind leaves placement alone.
216+
return {
217+
schema: target.schema !== undefined ? target.schema : route.schema,
218+
name: target.name
219+
};
220+
}
133221
}
134-
return route.schema;
222+
return route.schema !== undefined ? { schema: route.schema } : undefined;
135223
}
136224

137225
/**

packages/transform/src/transform.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,22 @@ export function transformNameList(
167167
const first = names[0];
168168
if (first?.String?.sval) {
169169
const schemaName = first.String.sval;
170-
const objName = names[names.length - 1]?.String?.sval;
171-
const newName = router.resolve(schemaName, objName, ns);
172-
if (newName && newName !== schemaName) {
170+
const last = names[names.length - 1];
171+
const objName = last?.String?.sval;
172+
const target = router.resolveObject(schemaName, objName, ns);
173+
if (!target) return;
174+
if (target.name !== undefined && last?.String?.sval) {
173175
result.schemasFound.add(schemaName);
174-
first.String.sval = newName;
175-
result.schemasTransformed.set(schemaName, newName);
176+
last.String.sval = target.name;
177+
}
178+
if (target.schema === null) {
179+
// De-qualify: drop the schema element and rely on search_path.
180+
result.schemasFound.add(schemaName);
181+
names.splice(0, 1);
182+
} else if (target.schema && target.schema !== schemaName) {
183+
result.schemasFound.add(schemaName);
184+
first.String.sval = target.schema;
185+
result.schemasTransformed.set(schemaName, target.schema);
176186
}
177187
}
178188
}
@@ -213,11 +223,20 @@ export function transformRelation(
213223
const oldName = relation.schemaname;
214224
// A RangeVar names a relation (table/view/sequence/matview); route by the
215225
// relation name so object-level routes can send it to its own schema.
216-
const newName = asRouter(schemaMapping).resolve(oldName, relation.relname, 'relation');
217-
if (newName && newName !== oldName) {
226+
const target = asRouter(schemaMapping).resolveObject(oldName, relation.relname, 'relation');
227+
if (!target) return;
228+
if (target.name !== undefined && relation.relname) {
229+
result.schemasFound.add(oldName);
230+
relation.relname = target.name;
231+
}
232+
if (target.schema === null) {
233+
// De-qualify: drop the schema qualifier and rely on search_path.
234+
result.schemasFound.add(oldName);
235+
delete relation.schemaname;
236+
} else if (target.schema && target.schema !== oldName) {
218237
result.schemasFound.add(oldName);
219-
relation.schemaname = newName;
220-
result.schemasTransformed.set(oldName, newName);
238+
relation.schemaname = target.schema;
239+
result.schemasTransformed.set(oldName, target.schema);
221240
}
222241
}
223242

0 commit comments

Comments
 (0)