Skip to content

Commit 688c95e

Browse files
committed
test(plpgsql-deparser): add schema transform demo test
Demonstrates the heterogeneous AST transformation pipeline: - Parse SQL containing PL/pgSQL functions - Hydrate embedded SQL expressions into AST nodes - Traverse and transform schema names in both outer SQL AST and embedded SQL - Dehydrate back to strings - Deparse to final SQL output Includes 4 test cases: - Simple function with schema-qualified table reference - Trigger function with INSERT into schema-qualified table - RETURN QUERY function with schema-qualified table - Function calls inside PL/pgSQL expressions
1 parent e41670d commit 688c95e

1 file changed

Lines changed: 376 additions & 0 deletions

File tree

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
/**
2+
* Schema Transform Demo
3+
*
4+
* This test demonstrates the heterogeneous AST transformation pipeline:
5+
* 1. Parse SQL containing PL/pgSQL functions
6+
* 2. Hydrate embedded SQL expressions into AST nodes
7+
* 3. Traverse and transform schema names in both:
8+
* - Outer SQL AST (CreateFunctionStmt, return types, etc.)
9+
* - Embedded SQL inside PL/pgSQL function bodies
10+
* 4. Dehydrate back to strings
11+
* 5. Deparse to final SQL output
12+
*
13+
* This pattern is useful for:
14+
* - Schema renaming (e.g., old_schema -> new_schema)
15+
* - Identifier rewriting
16+
* - Cross-cutting AST transformations
17+
*/
18+
19+
import { loadModule, parsePlPgSQLSync, parseSync } from '@libpg-query/parser';
20+
import { Deparser } from 'pgsql-deparser';
21+
import { hydratePlpgsqlAst, dehydratePlpgsqlAst, PLpgSQLParseResult, deparseSync } from '../src';
22+
23+
describe('schema transform demo', () => {
24+
beforeAll(async () => {
25+
await loadModule();
26+
});
27+
28+
/**
29+
* Transform schema names in SQL AST nodes.
30+
* Handles RangeVar, TypeName, FuncCall, and other schema-qualified references.
31+
*/
32+
function transformSchemaInSqlAst(
33+
node: any,
34+
oldSchema: string,
35+
newSchema: string
36+
): void {
37+
if (node === null || node === undefined || typeof node !== 'object') {
38+
return;
39+
}
40+
41+
if (Array.isArray(node)) {
42+
for (const item of node) {
43+
transformSchemaInSqlAst(item, oldSchema, newSchema);
44+
}
45+
return;
46+
}
47+
48+
// Handle RangeVar nodes (table references like old_schema.users)
49+
if ('RangeVar' in node) {
50+
const rangeVar = node.RangeVar;
51+
if (rangeVar.schemaname === oldSchema) {
52+
rangeVar.schemaname = newSchema;
53+
}
54+
}
55+
56+
// Handle direct relation references (INSERT/UPDATE/DELETE statements)
57+
// These have schemaname directly on the relation object, not wrapped in RangeVar
58+
if ('relation' in node && node.relation && typeof node.relation === 'object') {
59+
const relation = node.relation;
60+
if (relation.schemaname === oldSchema) {
61+
relation.schemaname = newSchema;
62+
}
63+
}
64+
65+
// Handle TypeName nodes (type references like old_schema.my_type)
66+
if ('TypeName' in node) {
67+
const typeName = node.TypeName;
68+
if (Array.isArray(typeName.names) && typeName.names.length >= 2) {
69+
const firstNameNode = typeName.names[0];
70+
if (firstNameNode?.String?.sval === oldSchema) {
71+
firstNameNode.String.sval = newSchema;
72+
}
73+
}
74+
}
75+
76+
// Handle FuncCall nodes (function calls like old_schema.my_func())
77+
if ('FuncCall' in node) {
78+
const funcCall = node.FuncCall;
79+
if (Array.isArray(funcCall.funcname) && funcCall.funcname.length >= 2) {
80+
const firstNameNode = funcCall.funcname[0];
81+
if (firstNameNode?.String?.sval === oldSchema) {
82+
firstNameNode.String.sval = newSchema;
83+
}
84+
}
85+
}
86+
87+
// Handle CreateFunctionStmt funcname (CREATE FUNCTION old_schema.my_func)
88+
if ('CreateFunctionStmt' in node) {
89+
const createFunc = node.CreateFunctionStmt;
90+
if (Array.isArray(createFunc.funcname) && createFunc.funcname.length >= 2) {
91+
const firstNameNode = createFunc.funcname[0];
92+
if (firstNameNode?.String?.sval === oldSchema) {
93+
firstNameNode.String.sval = newSchema;
94+
}
95+
}
96+
}
97+
98+
// Handle direct type references (returnType in CreateFunctionStmt)
99+
if ('names' in node && 'typemod' in node && Array.isArray(node.names) && node.names.length >= 2) {
100+
const firstNameNode = node.names[0];
101+
if (firstNameNode?.String?.sval === oldSchema) {
102+
firstNameNode.String.sval = newSchema;
103+
}
104+
}
105+
106+
// Recurse into all object properties
107+
for (const value of Object.values(node)) {
108+
transformSchemaInSqlAst(value, oldSchema, newSchema);
109+
}
110+
}
111+
112+
/**
113+
* Transform schema names in hydrated PL/pgSQL AST.
114+
* Walks through PLpgSQL_expr nodes and transforms embedded SQL ASTs.
115+
*/
116+
function transformSchemaInPlpgsqlAst(
117+
node: any,
118+
oldSchema: string,
119+
newSchema: string
120+
): void {
121+
if (node === null || node === undefined || typeof node !== 'object') {
122+
return;
123+
}
124+
125+
if (Array.isArray(node)) {
126+
for (const item of node) {
127+
transformSchemaInPlpgsqlAst(item, oldSchema, newSchema);
128+
}
129+
return;
130+
}
131+
132+
// Handle PLpgSQL_expr nodes with hydrated queries
133+
if ('PLpgSQL_expr' in node) {
134+
const expr = node.PLpgSQL_expr;
135+
const query = expr.query;
136+
137+
if (query && typeof query === 'object' && 'kind' in query) {
138+
// Handle sql-stmt kind (full SQL statements like SELECT, INSERT)
139+
if (query.kind === 'sql-stmt' && query.parseResult) {
140+
transformSchemaInSqlAst(query.parseResult, oldSchema, newSchema);
141+
}
142+
143+
// Handle sql-expr kind (SQL expressions like function calls)
144+
if (query.kind === 'sql-expr' && query.expr) {
145+
transformSchemaInSqlAst(query.expr, oldSchema, newSchema);
146+
}
147+
148+
// Handle assign kind (assignments like var := expr)
149+
if (query.kind === 'assign') {
150+
if (query.targetExpr) {
151+
transformSchemaInSqlAst(query.targetExpr, oldSchema, newSchema);
152+
}
153+
if (query.valueExpr) {
154+
transformSchemaInSqlAst(query.valueExpr, oldSchema, newSchema);
155+
}
156+
}
157+
}
158+
}
159+
160+
// Handle PLpgSQL_type nodes (variable type declarations)
161+
if ('PLpgSQL_type' in node) {
162+
const plType = node.PLpgSQL_type;
163+
if (plType.typname && plType.typname.startsWith(oldSchema + '.')) {
164+
plType.typname = plType.typname.replace(oldSchema + '.', newSchema + '.');
165+
}
166+
}
167+
168+
// Recurse into all object properties
169+
for (const value of Object.values(node)) {
170+
transformSchemaInPlpgsqlAst(value, oldSchema, newSchema);
171+
}
172+
}
173+
174+
it('should transform schema names in a simple PL/pgSQL function', async () => {
175+
// Simple function with schema-qualified table reference in the body
176+
const sql = `
177+
CREATE FUNCTION old_schema.get_user_count()
178+
RETURNS int
179+
LANGUAGE plpgsql
180+
AS $$
181+
DECLARE
182+
user_count int;
183+
BEGIN
184+
SELECT count(*) INTO user_count FROM old_schema.users;
185+
RETURN user_count;
186+
END$$;
187+
`;
188+
189+
// Step 1: Parse the SQL (includes PL/pgSQL parsing)
190+
const sqlParsed = parseSync(sql) as any;
191+
const plpgsqlParsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
192+
193+
// Step 2: Hydrate the PL/pgSQL AST (parses embedded SQL into AST nodes)
194+
const { ast: hydratedAst, stats } = hydratePlpgsqlAst(plpgsqlParsed);
195+
196+
// Verify hydration worked
197+
expect(stats.parsedExpressions).toBeGreaterThan(0);
198+
expect(stats.failedExpressions).toBe(0);
199+
200+
// Step 3: Transform schema names in both ASTs
201+
const oldSchema = 'old_schema';
202+
const newSchema = 'new_schema';
203+
204+
// Transform outer SQL AST (CreateFunctionStmt)
205+
transformSchemaInSqlAst(sqlParsed, oldSchema, newSchema);
206+
207+
// Transform PL/pgSQL AST (embedded SQL in function body)
208+
transformSchemaInPlpgsqlAst(hydratedAst, oldSchema, newSchema);
209+
210+
// Step 4: Dehydrate the PL/pgSQL AST (converts AST back to strings)
211+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
212+
213+
// Step 5: Deparse the PL/pgSQL body
214+
const newBody = deparseSync(dehydratedAst);
215+
216+
// Step 6: Stitch the new body back into the SQL AST
217+
const createFunctionStmt = sqlParsed.stmts[0].stmt.CreateFunctionStmt;
218+
const asOption = createFunctionStmt.options.find(
219+
(opt: any) => opt.DefElem?.defname === 'as'
220+
);
221+
if (asOption?.DefElem?.arg?.List?.items?.[0]?.String) {
222+
asOption.DefElem.arg.List.items[0].String.sval = newBody;
223+
}
224+
225+
// Step 7: Deparse the full SQL statement
226+
const output = Deparser.deparse(sqlParsed.stmts[0].stmt);
227+
228+
// Verify transformations
229+
// Function name should be transformed
230+
expect(output).toContain('new_schema.get_user_count');
231+
expect(output).not.toContain('old_schema.get_user_count');
232+
233+
// Table reference in SELECT should be transformed
234+
expect(output).toContain('new_schema.users');
235+
expect(output).not.toContain('old_schema.users');
236+
237+
// Verify the output is valid SQL by re-parsing
238+
const reparsed = parseSync(output);
239+
expect(reparsed.stmts).toHaveLength(1);
240+
expect(reparsed.stmts[0].stmt).toHaveProperty('CreateFunctionStmt');
241+
});
242+
243+
it('should transform schema names in trigger functions', async () => {
244+
const sql = `
245+
CREATE FUNCTION old_schema.audit_trigger()
246+
RETURNS trigger
247+
LANGUAGE plpgsql
248+
AS $$
249+
BEGIN
250+
INSERT INTO old_schema.audit_log (table_name, action)
251+
VALUES (TG_TABLE_NAME, TG_OP);
252+
RETURN NEW;
253+
END$$;
254+
`;
255+
256+
const sqlParsed = parseSync(sql) as any;
257+
const plpgsqlParsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
258+
const { ast: hydratedAst } = hydratePlpgsqlAst(plpgsqlParsed);
259+
260+
const oldSchema = 'old_schema';
261+
const newSchema = 'audit_schema';
262+
263+
transformSchemaInSqlAst(sqlParsed, oldSchema, newSchema);
264+
transformSchemaInPlpgsqlAst(hydratedAst, oldSchema, newSchema);
265+
266+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
267+
const newBody = deparseSync(dehydratedAst);
268+
269+
const createFunctionStmt = sqlParsed.stmts[0].stmt.CreateFunctionStmt;
270+
const asOption = createFunctionStmt.options.find(
271+
(opt: any) => opt.DefElem?.defname === 'as'
272+
);
273+
if (asOption?.DefElem?.arg?.List?.items?.[0]?.String) {
274+
asOption.DefElem.arg.List.items[0].String.sval = newBody;
275+
}
276+
277+
const output = Deparser.deparse(sqlParsed.stmts[0].stmt);
278+
279+
expect(output).toContain('audit_schema.audit_trigger');
280+
expect(output).toContain('audit_schema.audit_log');
281+
expect(output).not.toContain('old_schema');
282+
283+
// Verify valid SQL
284+
const reparsed = parseSync(output);
285+
expect(reparsed.stmts).toHaveLength(1);
286+
});
287+
288+
it('should transform schema names in RETURN QUERY functions', async () => {
289+
const sql = `
290+
CREATE FUNCTION app_public.get_active_users()
291+
RETURNS SETOF int
292+
LANGUAGE plpgsql
293+
AS $$
294+
BEGIN
295+
RETURN QUERY SELECT id FROM app_public.users WHERE is_active = true;
296+
RETURN;
297+
END$$;
298+
`;
299+
300+
const sqlParsed = parseSync(sql) as any;
301+
const plpgsqlParsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
302+
const { ast: hydratedAst } = hydratePlpgsqlAst(plpgsqlParsed);
303+
304+
transformSchemaInSqlAst(sqlParsed, 'app_public', 'myapp_public');
305+
transformSchemaInPlpgsqlAst(hydratedAst, 'app_public', 'myapp_public');
306+
307+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
308+
const newBody = deparseSync(dehydratedAst);
309+
310+
const createFunctionStmt = sqlParsed.stmts[0].stmt.CreateFunctionStmt;
311+
const asOption = createFunctionStmt.options.find(
312+
(opt: any) => opt.DefElem?.defname === 'as'
313+
);
314+
if (asOption?.DefElem?.arg?.List?.items?.[0]?.String) {
315+
asOption.DefElem.arg.List.items[0].String.sval = newBody;
316+
}
317+
318+
const output = Deparser.deparse(sqlParsed.stmts[0].stmt);
319+
320+
// All app_public references should be transformed
321+
expect(output).toContain('myapp_public.get_active_users');
322+
expect(output).toContain('myapp_public.users');
323+
// Use regex with word boundary to avoid matching 'app_public' inside 'myapp_public'
324+
expect(output).not.toMatch(/\bapp_public\./)
325+
326+
// Verify valid SQL
327+
const reparsed = parseSync(output);
328+
expect(reparsed.stmts).toHaveLength(1);
329+
});
330+
331+
it('should transform function calls inside PL/pgSQL expressions', async () => {
332+
const sql = `
333+
CREATE FUNCTION old_schema.calculate_total(p_amount numeric)
334+
RETURNS numeric
335+
LANGUAGE plpgsql
336+
AS $$
337+
DECLARE
338+
tax_rate numeric;
339+
BEGIN
340+
tax_rate := old_schema.get_tax_rate();
341+
RETURN p_amount * (1 + tax_rate);
342+
END$$;
343+
`;
344+
345+
const sqlParsed = parseSync(sql) as any;
346+
const plpgsqlParsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
347+
const { ast: hydratedAst } = hydratePlpgsqlAst(plpgsqlParsed);
348+
349+
transformSchemaInSqlAst(sqlParsed, 'old_schema', 'billing_schema');
350+
transformSchemaInPlpgsqlAst(hydratedAst, 'old_schema', 'billing_schema');
351+
352+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
353+
const newBody = deparseSync(dehydratedAst);
354+
355+
const createFunctionStmt = sqlParsed.stmts[0].stmt.CreateFunctionStmt;
356+
const asOption = createFunctionStmt.options.find(
357+
(opt: any) => opt.DefElem?.defname === 'as'
358+
);
359+
if (asOption?.DefElem?.arg?.List?.items?.[0]?.String) {
360+
asOption.DefElem.arg.List.items[0].String.sval = newBody;
361+
}
362+
363+
const output = Deparser.deparse(sqlParsed.stmts[0].stmt);
364+
365+
// Function name should be transformed
366+
expect(output).toContain('billing_schema.calculate_total');
367+
368+
// Function call in assignment should be transformed
369+
expect(output).toContain('billing_schema.get_tax_rate');
370+
expect(output).not.toContain('old_schema');
371+
372+
// Verify valid SQL
373+
const reparsed = parseSync(output);
374+
expect(reparsed.stmts).toHaveLength(1);
375+
});
376+
});

0 commit comments

Comments
 (0)