Skip to content

Commit e6383c9

Browse files
committed
feat(plpgsql-deparser): enable heterogeneous deparse for AST-based transformations
- Add deparseExprNode() helper to deparse expression AST nodes by wrapping in SELECT - Update dehydrateQuery() for sql-expr kind to always prefer deparsed AST - Update dehydrateQuery() for assign kind to prefer deparsed AST nodes - Update hydrate-demo.test.ts to use AST-based modifications instead of string fields - Add tests for schema renaming in hydrated expressions (sql-expr, assign, sql-stmt) This enables AST-based transformations (e.g., schema renaming) to be properly reflected in the deparsed PL/pgSQL function bodies. Previously, modifications to AST nodes were ignored because dehydrateQuery() returned the original string fields instead of deparsing the modified AST.
1 parent cb7aac9 commit e6383c9

File tree

4 files changed

+262
-36
lines changed

4 files changed

+262
-36
lines changed

packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,24 @@ exports[`hydrate demonstration with big-function.sql should parse, hydrate, modi
4848
v_min_total numeric := COALESCE(p_min_total, 0);
4949
v_sql text;
5050
v_rowcount int := 0;
51-
v_lock_key bigint := ('x' || substr(md5(p_org_id::text), 1, 16))::bit(64)::bigint;
51+
v_lock_key bigint := CAST(CAST('x' || substr(md5(p_org_id::text), 1, 16) AS pg_catalog.bit(64)) AS bigint);
5252
sqlstate CONSTANT text;
5353
sqlerrm CONSTANT text;
5454
BEGIN
5555
BEGIN
56-
IF p_org_id IS NULL OR p_user_id IS NULL THEN
56+
IF p_org_id IS NULL
57+
OR p_user_id IS NULL THEN
5758
RAISE EXCEPTION 'p_org_id and p_user_id are required';
5859
END IF;
5960
IF p_from_ts > p_to_ts THEN
6061
RAISE EXCEPTION 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts;
6162
END IF;
62-
IF p_max_rows < 1 OR p_max_rows > 10000 THEN
63+
IF p_max_rows < 1
64+
OR p_max_rows > 10000 THEN
6365
RAISE EXCEPTION 'p_max_rows out of range: %', p_max_rows;
6466
END IF;
65-
IF p_round_to < 0 OR p_round_to > 6 THEN
67+
IF p_round_to < 0
68+
OR p_round_to > 6 THEN
6669
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
6770
END IF;
6871
IF p_lock THEN
@@ -104,7 +107,7 @@ BEGIN
104107
v_discount := 0;
105108
END IF;
106109
v_levy := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to);
107-
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
110+
v_net := round(((v_gross - v_discount) + v_tax) * power(10::numeric, 0), p_round_to);
108111
SELECT
109112
oi.sku,
110113
CAST(sum(oi.quantity) AS bigint) AS qty
@@ -168,11 +171,7 @@ BEGIN
168171
updated_at = now();
169172
GET DIAGNOSTICS v_rowcount = ;
170173
v_orders_upserted := v_rowcount;
171-
v_sql := format(
172-
'SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3',
173-
'app_public',
174-
'app_order'
175-
);
174+
v_sql := format('SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3', 'app_public', 'app_order');
176175
EXECUTE v_sql INTO (unnamed row) USING p_org_id, p_from_ts, p_to_ts;
177176
IF p_debug THEN
178177
RAISE NOTICE 'dynamic count(app_order)=%', v_rowcount;
@@ -190,10 +189,7 @@ BEGIN
190189
avg_order_total := round(v_avg, p_round_to);
191190
top_sku := v_top_sku;
192191
top_sku_qty := v_top_sku_qty;
193-
message := format(
194-
'rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)',
195-
v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate
196-
);
192+
message := format('rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)', v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate);
197193
RETURN NEXT;
198194
RETURN;
199195
END;

packages/plpgsql-deparser/__tests__/hydrate-demo.test.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ function collectHydratedExprs(obj: any, limit: number): any[] {
9090
return results;
9191
}
9292

93+
/**
94+
* Modify AST nodes directly (not string fields).
95+
* This demonstrates the proper way to transform hydrated PL/pgSQL ASTs.
96+
*
97+
* For assign kind: modify targetExpr/valueExpr AST nodes
98+
* For sql-expr kind: modify expr AST node
99+
* For sql-stmt kind: modify parseResult AST
100+
*/
93101
function modifyAst(ast: any): any {
94102
let modCount = 0;
95103
let assignModCount = 0;
@@ -102,26 +110,46 @@ function modifyAst(ast: any): any {
102110
const query = node.PLpgSQL_expr.query;
103111

104112
if (typeof query === 'object' && query.kind === 'assign') {
105-
if (query.target === 'v_discount' && assignModCount === 0) {
106-
query.target = 'v_rebate';
107-
assignModCount++;
108-
modCount++;
113+
// Modify targetExpr AST node (not the string field)
114+
// targetExpr is a ColumnRef with fields array containing String nodes
115+
if (query.target === 'v_discount' && query.targetExpr && assignModCount === 0) {
116+
// ColumnRef structure: { ColumnRef: { fields: [{ String: { sval: 'v_discount' } }] } }
117+
if (query.targetExpr.ColumnRef?.fields?.[0]?.String) {
118+
query.targetExpr.ColumnRef.fields[0].String.sval = 'v_rebate';
119+
assignModCount++;
120+
modCount++;
121+
}
109122
}
110-
if (query.target === 'v_tax' && assignModCount === 1) {
111-
query.target = 'v_levy';
112-
assignModCount++;
113-
modCount++;
123+
if (query.target === 'v_tax' && query.targetExpr && assignModCount === 1) {
124+
if (query.targetExpr.ColumnRef?.fields?.[0]?.String) {
125+
query.targetExpr.ColumnRef.fields[0].String.sval = 'v_levy';
126+
assignModCount++;
127+
modCount++;
128+
}
114129
}
115-
if (query.value === '0' && modCount < 5) {
116-
query.value = '42';
117-
modCount++;
130+
// Modify valueExpr AST node for integer constants
131+
// A_Const structure: { A_Const: { ival: { ival: 0 } } } or { A_Const: { sval: { sval: '0' } } }
132+
if (query.value === '0' && query.valueExpr && modCount < 5) {
133+
if (query.valueExpr.A_Const?.ival !== undefined) {
134+
query.valueExpr.A_Const.ival.ival = 42;
135+
modCount++;
136+
} else if (query.valueExpr.A_Const?.sval !== undefined) {
137+
query.valueExpr.A_Const.sval.sval = '42';
138+
modCount++;
139+
}
118140
}
119141
}
120142

121143
if (typeof query === 'object' && query.kind === 'sql-expr') {
122-
if (query.original === '0' && modCount < 8) {
123-
query.original = '42';
124-
modCount++;
144+
// Modify expr AST node for integer constants
145+
if (query.original === '0' && query.expr && modCount < 8) {
146+
if (query.expr.A_Const?.ival !== undefined) {
147+
query.expr.A_Const.ival.ival = 42;
148+
modCount++;
149+
} else if (query.expr.A_Const?.sval !== undefined) {
150+
query.expr.A_Const.sval.sval = '42';
151+
modCount++;
152+
}
125153
}
126154
}
127155
}

packages/plpgsql-deparser/__tests__/hydrate.test.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { loadModule, parsePlPgSQLSync } from '@libpg-query/parser';
2-
import { hydratePlpgsqlAst, isHydratedExpr, getOriginalQuery, PLpgSQLParseResult } from '../src';
2+
import { hydratePlpgsqlAst, dehydratePlpgsqlAst, deparseSync, isHydratedExpr, getOriginalQuery, PLpgSQLParseResult } from '../src';
33

44
describe('hydratePlpgsqlAst', () => {
55
beforeAll(async () => {
@@ -152,8 +152,145 @@ $$`;
152152
.toBe(result.stats.totalExpressions);
153153
});
154154
});
155+
156+
describe('heterogeneous deparse (AST-based transformations)', () => {
157+
it('should deparse modified sql-expr AST nodes (schema renaming)', () => {
158+
// Note: This test only checks RangeVar nodes in SQL expressions.
159+
// Type references in DECLARE (PLpgSQL_type.typname) are strings, not AST nodes,
160+
// and require separate string-based transformation.
161+
const sql = `CREATE FUNCTION test_func() RETURNS void
162+
LANGUAGE plpgsql
163+
AS $$
164+
BEGIN
165+
IF EXISTS (SELECT 1 FROM "old-schema".users WHERE id = 1) THEN
166+
RAISE NOTICE 'found';
167+
END IF;
168+
END;
169+
$$`;
170+
171+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
172+
const { ast: hydratedAst } = hydratePlpgsqlAst(parsed);
173+
174+
// Modify schema names in the hydrated AST
175+
transformSchemaNames(hydratedAst, 'old-schema', 'new_schema');
176+
177+
// Dehydrate and deparse
178+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
179+
const deparsedBody = deparseSync(dehydratedAst);
180+
181+
// The deparsed body should contain the new schema name
182+
expect(deparsedBody).toContain('new_schema');
183+
// And should NOT contain the old schema name
184+
expect(deparsedBody).not.toContain('old-schema');
185+
});
186+
187+
it('should deparse modified assign AST nodes (schema renaming in assignments)', () => {
188+
const sql = `CREATE FUNCTION test_func() RETURNS void
189+
LANGUAGE plpgsql
190+
AS $$
191+
DECLARE
192+
v_count integer;
193+
BEGIN
194+
v_count := (SELECT count(*) FROM "old-schema".users);
195+
END;
196+
$$`;
197+
198+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
199+
const { ast: hydratedAst } = hydratePlpgsqlAst(parsed);
200+
201+
// Modify schema names in the hydrated AST
202+
transformSchemaNames(hydratedAst, 'old-schema', 'new_schema');
203+
204+
// Dehydrate and deparse
205+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
206+
const deparsedBody = deparseSync(dehydratedAst);
207+
208+
// The deparsed body should contain the new schema name
209+
expect(deparsedBody).toContain('new_schema');
210+
// And should NOT contain the old schema name
211+
expect(deparsedBody).not.toContain('old-schema');
212+
});
213+
214+
it('should deparse modified sql-stmt AST nodes (schema renaming in SQL statements)', () => {
215+
const sql = `CREATE FUNCTION test_func() RETURNS void
216+
LANGUAGE plpgsql
217+
AS $$
218+
BEGIN
219+
INSERT INTO "old-schema".logs (message) VALUES ('test');
220+
END;
221+
$$`;
222+
223+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
224+
const { ast: hydratedAst } = hydratePlpgsqlAst(parsed);
225+
226+
// Modify schema names in the hydrated AST
227+
transformSchemaNames(hydratedAst, 'old-schema', 'new_schema');
228+
229+
// Dehydrate and deparse
230+
const dehydratedAst = dehydratePlpgsqlAst(hydratedAst);
231+
const deparsedBody = deparseSync(dehydratedAst);
232+
233+
// The deparsed body should contain the new schema name
234+
expect(deparsedBody).toContain('new_schema');
235+
// And should NOT contain the old schema name
236+
expect(deparsedBody).not.toContain('old-schema');
237+
});
238+
});
155239
});
156240

241+
/**
242+
* Helper function to transform schema names in a hydrated PL/pgSQL AST.
243+
* This walks the AST and modifies schemaname properties wherever they appear.
244+
*/
245+
function transformSchemaNames(obj: any, oldSchema: string, newSchema: string): void {
246+
if (obj === null || obj === undefined) return;
247+
248+
if (typeof obj === 'object') {
249+
// Check for RangeVar nodes (table references) - wrapped in RangeVar key
250+
if ('RangeVar' in obj && obj.RangeVar?.schemaname === oldSchema) {
251+
obj.RangeVar.schemaname = newSchema;
252+
}
253+
254+
// Check for direct schemaname property (e.g., InsertStmt.relation, UpdateStmt.relation)
255+
// These are RangeVar-like objects without the RangeVar wrapper
256+
if ('schemaname' in obj && obj.schemaname === oldSchema && 'relname' in obj) {
257+
obj.schemaname = newSchema;
258+
}
259+
260+
// Check for hydrated expressions with sql-expr kind
261+
if ('PLpgSQL_expr' in obj) {
262+
const query = obj.PLpgSQL_expr.query;
263+
if (query && typeof query === 'object') {
264+
// Transform the embedded SQL AST
265+
if (query.kind === 'sql-expr' && query.expr) {
266+
transformSchemaNames(query.expr, oldSchema, newSchema);
267+
}
268+
if (query.kind === 'sql-stmt' && query.parseResult) {
269+
transformSchemaNames(query.parseResult, oldSchema, newSchema);
270+
}
271+
if (query.kind === 'assign') {
272+
if (query.valueExpr) {
273+
transformSchemaNames(query.valueExpr, oldSchema, newSchema);
274+
}
275+
if (query.targetExpr) {
276+
transformSchemaNames(query.targetExpr, oldSchema, newSchema);
277+
}
278+
}
279+
}
280+
}
281+
282+
for (const value of Object.values(obj)) {
283+
transformSchemaNames(value, oldSchema, newSchema);
284+
}
285+
}
286+
287+
if (Array.isArray(obj)) {
288+
for (const item of obj) {
289+
transformSchemaNames(item, oldSchema, newSchema);
290+
}
291+
}
292+
}
293+
157294
function findExprByKind(obj: any, kind: string): any {
158295
if (obj === null || obj === undefined) return null;
159296

packages/plpgsql-deparser/src/hydrate.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -409,13 +409,69 @@ function dehydrateNode(node: any, options?: DehydrationOptions): any {
409409
return result;
410410
}
411411

412+
/**
413+
* Deparse a single expression AST node by wrapping it in a SELECT statement,
414+
* deparsing, and stripping the SELECT prefix.
415+
*/
416+
function deparseExprNode(expr: Node, sqlDeparseOptions?: DeparserOptions): string | null {
417+
try {
418+
// Wrap the expression in a minimal SELECT statement
419+
const wrappedStmt = {
420+
SelectStmt: {
421+
targetList: [
422+
{
423+
ResTarget: {
424+
val: expr
425+
}
426+
}
427+
]
428+
}
429+
};
430+
const deparsed = Deparser.deparse(wrappedStmt, sqlDeparseOptions);
431+
// Strip the "SELECT " prefix (case-insensitive, handles whitespace/newlines)
432+
const stripped = deparsed.replace(/^SELECT\s+/i, '').replace(/;?\s*$/, '');
433+
return stripped;
434+
} catch {
435+
return null;
436+
}
437+
}
438+
439+
/**
440+
* Normalize whitespace for comparison purposes.
441+
* This helps detect if a string field was modified vs just having different formatting.
442+
*/
443+
function normalizeForComparison(str: string): string {
444+
return str.replace(/\s+/g, ' ').trim().toLowerCase();
445+
}
446+
412447
function dehydrateQuery(query: HydratedExprQuery, sqlDeparseOptions?: DeparserOptions): string {
413448
switch (query.kind) {
414449
case 'assign': {
415-
// For assignments, use the target and value strings directly
416-
// These may have been modified by the caller
450+
// For assignments, always prefer deparsing the AST nodes if they exist.
451+
// This enables AST-based transformations (e.g., schema renaming).
452+
// Fall back to string fields if AST nodes are missing or deparse fails.
417453
const assignQuery = query as HydratedExprAssign;
418-
return `${assignQuery.target} := ${assignQuery.value}`;
454+
455+
let target = assignQuery.target;
456+
let value = assignQuery.value;
457+
458+
// For target: prefer deparsed AST if available
459+
if (assignQuery.targetExpr) {
460+
const deparsedTarget = deparseExprNode(assignQuery.targetExpr, sqlDeparseOptions);
461+
if (deparsedTarget !== null) {
462+
target = deparsedTarget;
463+
}
464+
}
465+
466+
// For value: prefer deparsed AST if available
467+
if (assignQuery.valueExpr) {
468+
const deparsedValue = deparseExprNode(assignQuery.valueExpr, sqlDeparseOptions);
469+
if (deparsedValue !== null) {
470+
value = deparsedValue;
471+
}
472+
}
473+
474+
return `${target} := ${value}`;
419475
}
420476
case 'sql-stmt': {
421477
// Deparse the modified parseResult back to SQL
@@ -432,11 +488,20 @@ function dehydrateQuery(query: HydratedExprQuery, sqlDeparseOptions?: DeparserOp
432488
}
433489
return query.original;
434490
}
435-
case 'sql-expr':
436-
// For sql-expr, return the original string
437-
// Callers can modify query.original directly for simple transformations
438-
// For AST-based transformations, use sql-stmt instead
491+
case 'sql-expr': {
492+
// For sql-expr, always prefer deparsing the AST.
493+
// This enables AST-based transformations (e.g., schema renaming).
494+
// Fall back to original only if deparse fails.
495+
const exprQuery = query as HydratedExprSqlExpr;
496+
if (exprQuery.expr) {
497+
const deparsed = deparseExprNode(exprQuery.expr, sqlDeparseOptions);
498+
if (deparsed !== null) {
499+
return deparsed;
500+
}
501+
}
502+
// Fall back to original if deparse fails
439503
return query.original;
504+
}
440505
case 'raw':
441506
default:
442507
return query.original;

0 commit comments

Comments
 (0)