Skip to content

Commit 1a804fa

Browse files
authored
Merge pull request #343 from constructive-io/feat/identity-cast-routing
feat(transform): route object identities by cast type
2 parents 7685f9e + 1bbd895 commit 1a804fa

3 files changed

Lines changed: 238 additions & 0 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { loadModule } from 'plpgsql-parser';
2+
3+
import { SchemaRouter, transformSql, transformSqlStatement } from '../src';
4+
5+
beforeAll(async () => {
6+
await loadModule();
7+
});
8+
9+
const MAPPING = new Map([
10+
['my-schema', 'my_schema'],
11+
['other-schema', 'other_schema']
12+
]);
13+
14+
function run(sql: string, mapping: Map<string, string> | SchemaRouter = MAPPING): string {
15+
return transformSql(sql, mapping).content;
16+
}
17+
18+
describe('object-identity casts', () => {
19+
it('routes a bare schema name through ::regnamespace', () => {
20+
expect(run(`SELECT assert_schema('my-schema'::regnamespace);`))
21+
.toContain(`CAST('my_schema' AS regnamespace)`);
22+
});
23+
24+
it('routes the qualifier of ::regclass', () => {
25+
expect(run(`SELECT assert_table('my-schema.users'::regclass);`))
26+
.toContain(`CAST('my_schema.users' AS regclass)`);
27+
});
28+
29+
it('routes ::regprocedure and keeps the argument list', () => {
30+
expect(run(`SELECT assert_function('my-schema.fn(uuid, text)'::regprocedure);`))
31+
.toContain(`CAST('my_schema.fn(uuid, text)' AS regprocedure)`);
32+
});
33+
34+
it('routes a schema-qualified argument type', () => {
35+
expect(
36+
run(`SELECT assert_function('my-schema.fn("other-schema".row_t)'::regprocedure);`)
37+
).toContain(`CAST('my_schema.fn("other_schema".row_t)' AS regprocedure)`);
38+
});
39+
40+
it('routes an argument type under an unqualified function name', () => {
41+
expect(
42+
run(`SELECT assert_function('fn("other-schema".row_t)'::regprocedure);`)
43+
).toContain(`CAST('fn("other_schema".row_t)' AS regprocedure)`);
44+
});
45+
46+
it('routes ::regproc', () => {
47+
expect(run(`SELECT assert_trigger('my-schema.users'::regclass, 'stamps', 'other-schema.tg'::regproc);`))
48+
.toContain(`CAST('other_schema.tg' AS regproc)`);
49+
});
50+
51+
it('routes ::regtype', () => {
52+
expect(run(`SELECT CAST('my-schema.status'::regtype AS text);`))
53+
.toContain(`CAST('my_schema.status' AS regtype)`);
54+
});
55+
56+
it('leaves an unqualified identity alone (search_path resolves it)', () => {
57+
expect(run(`SELECT assert_table('users'::regclass);`)).toContain(`CAST('users' AS regclass)`);
58+
});
59+
60+
it('leaves an unmapped schema alone', () => {
61+
expect(run(`SELECT assert_schema('stamps'::regnamespace);`))
62+
.toContain(`CAST('stamps' AS regnamespace)`);
63+
});
64+
65+
it('quotes a target name that is not a bare identifier', () => {
66+
const mapping = new Map([['my_schema', 'my-schema']]);
67+
expect(run(`SELECT assert_schema('my_schema'::regnamespace);`, mapping))
68+
.toContain(`CAST('"my-schema"' AS regnamespace)`);
69+
expect(run(`SELECT assert_table('my_schema.users'::regclass);`, mapping))
70+
.toContain(`CAST('"my-schema".users' AS regclass)`);
71+
});
72+
73+
it('reads an already-quoted operand', () => {
74+
expect(run(`SELECT assert_table('"my-schema".users'::regclass);`))
75+
.toContain(`CAST('my_schema.users' AS regclass)`);
76+
});
77+
78+
it('routes a pg_catalog-qualified cast', () => {
79+
expect(run(`SELECT assert_schema('my-schema'::pg_catalog.regnamespace);`))
80+
.toContain(`'my_schema'`);
81+
});
82+
83+
it('does not treat a non-identity cast as a reference', () => {
84+
expect(run(`SELECT 'my-schema'::text;`)).toContain(`'my-schema'::text`);
85+
});
86+
87+
it('routes each identity exactly once under a cyclic mapping', () => {
88+
const swap = new Map([['a', 'b'], ['b', 'a']]);
89+
const out = transformSqlStatement(
90+
`SELECT assert_schema('a'::regnamespace), assert_table('a.users'::regclass);`,
91+
swap
92+
).sql;
93+
expect(out).toContain(`CAST('b' AS regnamespace)`);
94+
expect(out).toContain(`CAST('b.users' AS regclass)`);
95+
});
96+
97+
it('applies object-level routes to a qualified identity', () => {
98+
const router = new SchemaRouter({
99+
'my-schema': { schema: 'my_schema', relations: { users: 'people_schema' } }
100+
});
101+
const out = run(
102+
`SELECT assert_table('my-schema.users'::regclass), assert_table('my-schema.orders'::regclass);`,
103+
router
104+
);
105+
expect(out).toContain(`CAST('people_schema.users' AS regclass)`);
106+
expect(out).toContain(`CAST('my_schema.orders' AS regclass)`);
107+
});
108+
109+
it('rebinds an object route that renames', () => {
110+
const router = new SchemaRouter({
111+
'my-schema': { functions: { uid: { schema: null, name: 'current_user_id' } } }
112+
});
113+
expect(run(`SELECT assert_function('my-schema.uid(uuid)'::regprocedure);`, router))
114+
.toContain(`CAST('current_user_id(uuid)' AS regprocedure)`);
115+
});
116+
117+
it('routes identities inside a DO body', () => {
118+
const sql = `DO $$ BEGIN PERFORM 'my-schema.users'::regclass; END $$;`;
119+
expect(run(sql)).toContain('my_schema.users');
120+
});
121+
});

‎packages/transform/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ export {
9696
createSqlVisitor,
9797
escapeRegexp,
9898
extractPgpmHeader,
99+
identityCastNamespace,
99100
shouldTransformSchema,
100101
transformComments,
102+
transformIdentityCastLiteral,
101103
transformJsonStringValues,
102104
transformNameList,
103105
transformPlpgsqlTypeAst,

‎packages/transform/src/transform.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,105 @@ export function transformSchemaRefsInString(
304304
return out;
305305
}
306306

307+
/**
308+
* Cast types whose string operand is an object reference rather than opaque
309+
* text. `'sch.tbl'::regclass` is a relation the parser resolves, and the cast
310+
* names the namespace it resolves in — so the reference can be routed from the
311+
* cast, semantically, instead of pattern-matched. This reaches what the string
312+
* passes cannot: a bare `'sch'::regnamespace` has no dot to match on.
313+
*/
314+
const IDENTITY_CAST_NAMESPACES: Record<string, RouteNamespace> = {
315+
regclass: 'relation',
316+
regcollation: 'unknown',
317+
regconfig: 'unknown',
318+
regdictionary: 'unknown',
319+
regnamespace: 'schema',
320+
regoper: 'unknown',
321+
regoperator: 'unknown',
322+
regproc: 'function',
323+
regprocedure: 'function',
324+
regtype: 'type'
325+
};
326+
327+
/**
328+
* The namespace a cast resolves its operand in, or `undefined` when the target
329+
* type is not an object-identity type.
330+
*/
331+
export function identityCastNamespace(typeName: any): RouteNamespace | undefined {
332+
const names = typeName?.names;
333+
if (!Array.isArray(names) || names.length === 0) return undefined;
334+
// Only `pg_catalog`-qualified (or unqualified) reg* types are the built-ins.
335+
if (names.length > 1 && names[0]?.String?.sval !== 'pg_catalog') return undefined;
336+
const typeIdent = names[names.length - 1]?.String?.sval;
337+
if (typeof typeIdent !== 'string') return undefined;
338+
return IDENTITY_CAST_NAMESPACES[typeIdent.toLowerCase()];
339+
}
340+
341+
const IDENTITY_TOKEN = String.raw`(?:"(?:[^"]|"")*"|[^\s".,()[\]]+)`;
342+
const BARE_IDENTITY_RE = new RegExp(`^\\s*(${IDENTITY_TOKEN})\\s*$`);
343+
const QUALIFIED_IDENTITY_RE = new RegExp(`^\\s*(${IDENTITY_TOKEN})\\.([\\s\\S]+)$`);
344+
345+
function unquoteIdentifier(token: string): string {
346+
if (token.length > 1 && token.startsWith('"') && token.endsWith('"')) {
347+
return token.slice(1, -1).replace(/""/g, '"');
348+
}
349+
return token;
350+
}
351+
352+
/**
353+
* Route the object reference carried in the string operand of an identity
354+
* cast. `ns` comes from the cast's target type.
355+
*
356+
* Unqualified operands (`'tbl'::regclass`) name no schema — they resolve
357+
* through `search_path` — so they are returned unchanged.
358+
*/
359+
export function transformIdentityCastLiteral(
360+
sval: string,
361+
ns: RouteNamespace,
362+
schemaMapping: SchemaMappingInput,
363+
result: SchemaTransformResult
364+
): string {
365+
const router = asRouter(schemaMapping);
366+
367+
if (ns === 'schema') {
368+
const bare = BARE_IDENTITY_RE.exec(sval);
369+
if (!bare) return sval;
370+
const schemaName = unquoteIdentifier(bare[1]);
371+
const newName = router.resolve(schemaName, undefined, 'schema');
372+
if (!newName || newName === schemaName) return sval;
373+
result.schemasFound.add(schemaName);
374+
result.schemasTransformed.set(schemaName, newName);
375+
return QuoteUtils.quoteIdentifier(newName);
376+
}
377+
378+
// `regprocedure` and `regoperator` carry an argument list after the name, and
379+
// an argument type can itself be schema-qualified
380+
// (`fn("my-schema".row_type)`), so the list is routed as ordinary schema refs.
381+
const argsAt = sval.indexOf('(');
382+
const head = argsAt === -1 ? sval : sval.slice(0, argsAt);
383+
const args =
384+
argsAt === -1 ? '' : transformSchemaRefsInString(sval.slice(argsAt), router, result);
385+
386+
const qualified = QUALIFIED_IDENTITY_RE.exec(head);
387+
if (!qualified) return `${head}${args}`;
388+
const schemaName = unquoteIdentifier(qualified[1]);
389+
const objName = unquoteIdentifier(qualified[2].trim());
390+
391+
const target = router.resolveObject(schemaName, objName, ns);
392+
if (!target) return `${head}${args}`;
393+
const rebound = target.name !== undefined && target.name !== objName;
394+
const requalified = target.schema === null || (!!target.schema && target.schema !== schemaName);
395+
if (!rebound && !requalified) return `${head}${args}`;
396+
397+
result.schemasFound.add(schemaName);
398+
const nameToken = QuoteUtils.quoteIdentifier(rebound ? target.name! : objName);
399+
if (target.schema === null) return `${nameToken}${args}`;
400+
if (target.schema && target.schema !== schemaName) {
401+
result.schemasTransformed.set(schemaName, target.schema);
402+
}
403+
return `${QuoteUtils.quoteIdentifier(target.schema ?? schemaName)}.${nameToken}${args}`;
404+
}
405+
307406
/**
308407
* Create a SQL AST visitor that transforms schema names.
309408
*
@@ -569,10 +668,26 @@ export function createSqlVisitor(
569668
A_Const: (path: any) => {
570669
const node = path.node;
571670
if (typeof node.sval?.sval === 'string' && node.sval.sval.includes('.')) {
671+
if (!claimSite(result, node.sval, 'sval')) return;
572672
node.sval.sval = transformSchemaRefsInString(node.sval.sval, schemaMapping, result);
573673
}
574674
},
575675

676+
// Object identities carried as the operand of an identity cast
677+
// ('sch.tbl'::regclass, 'sch.fn(uuid)'::regprocedure, 'sch'::regnamespace).
678+
// The cast names the namespace, so these route through the same object
679+
// routes as a RangeVar rather than through a string pattern — and the bare
680+
// schema form, invisible to every string pass, routes here.
681+
TypeCast: (path: any) => {
682+
const node = path.node;
683+
const sval = node?.arg?.A_Const?.sval;
684+
if (typeof sval?.sval !== 'string') return;
685+
const ns = identityCastNamespace(node.typeName);
686+
if (!ns) return;
687+
if (!claimSite(result, sval, 'sval')) return;
688+
sval.sval = transformIdentityCastLiteral(sval.sval, ns, router, result);
689+
},
690+
576691
// Transform DefineStmt (CREATE TYPE schema.name, CREATE AGGREGATE schema.agg)
577692
DefineStmt: (path: any) => {
578693
const node = path.node;

0 commit comments

Comments
 (0)