From 070f9a0013cbc3f260f99c375070ad01fcf161b8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 1 Jan 2026 01:55:25 +0000 Subject: [PATCH 1/4] feat(plpgsql-deparser): deparse modified AST for sql-stmt kind This change enables AST-based transformations for SQL statements inside PL/pgSQL function bodies. When the AST is modified (e.g., schema names renamed), the dehydration process now produces the modified SQL instead of returning the original string. Changes: - Import Deparser from pgsql-deparser - For sql-stmt kind: deparse the modified parseResult AST back to SQL - For sql-expr kind: keep returning original string (backward compatible) - For assign kind: use target and value strings directly This enables use cases like schema renaming in introspection exports where SQL statements inside function bodies need to have their schema references transformed. --- .../__snapshots__/hydrate-demo.test.ts.snap | 145 ++++++++---------- packages/plpgsql-deparser/src/hydrate.ts | 29 +++- 2 files changed, 86 insertions(+), 88 deletions(-) diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap index edebf409..8be87a3a 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap @@ -71,34 +71,33 @@ END IF; IF p_debug THEN RAISE NOTICE 'big_kitchen_sink start=% org=% user=% from=% to=% min_total=%', v_now, p_org_id, p_user_id, p_from_ts, p_to_ts, v_min_total; END IF; - WITH base AS ( - SELECT - o.id, - o.total_amount::numeric AS total_amount, - o.currency, - o.created_at - FROM app_public.app_order o - WHERE o.org_id = p_org_id - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.total_amount::numeric >= v_min_total - AND o.currency = p_currency - ORDER BY o.created_at DESC - LIMIT p_max_rows - ), - totals AS ( - SELECT - count(*)::int AS orders_scanned, - COALESCE(sum(total_amount), 0) AS gross_total, - COALESCE(avg(total_amount), 0) AS avg_total - FROM base - ) - SELECT - t.orders_scanned, - t.gross_total, - t.avg_total - FROM totals t; + WITH + base AS (SELECT + o.id, + o.total_amount::numeric AS total_amount, + o.currency, + o.created_at + FROM app_public.app_order AS o + WHERE + o.org_id = p_org_id + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.total_amount::numeric >= v_min_total + AND o.currency = p_currency + ORDER BY + o.created_at DESC + LIMIT p_max_rows), + totals AS (SELECT + (count(*))::int AS orders_scanned, + COALESCE(sum(total_amount), 0) AS gross_total, + COALESCE(avg(total_amount), 0) AS avg_total + FROM base) +SELECT + t.orders_scanned, + t.gross_total, + t.avg_total +FROM totals AS t; IF p_apply_discount THEN v_rebate := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to); ELSE @@ -107,64 +106,40 @@ END IF; v_levy := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to); v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to); SELECT - oi.sku, - sum(oi.quantity)::bigint AS qty - FROM app_public.order_item oi - JOIN app_public.app_order o ON o.id = oi.order_id - WHERE o.org_id = p_org_id - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.currency = p_currency - GROUP BY oi.sku - ORDER BY qty DESC, oi.sku ASC - LIMIT 1; + oi.sku, + CAST(sum(oi.quantity) AS bigint) AS qty +FROM app_public.order_item AS oi +JOIN app_public.app_order AS o ON o.id = oi.order_id +WHERE + o.org_id = p_org_id + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.currency = p_currency +GROUP BY + oi.sku +ORDER BY + qty DESC, + oi.sku ASC +LIMIT 1; INSERT INTO app_public.order_rollup ( - org_id, - user_id, - period_from, - period_to, - currency, - orders_scanned, - gross_total, - discount_total, - tax_total, - net_total, - avg_order_total, - top_sku, - top_sku_qty, - note, - updated_at - ) - VALUES ( - p_org_id, - p_user_id, - p_from_ts, - p_to_ts, - p_currency, - v_orders_scanned, - v_gross, - v_discount, - v_tax, - v_net, - v_avg, - v_top_sku, - v_top_sku_qty, - p_note, - now() - ) - ON CONFLICT (org_id, user_id, period_from, period_to, currency) - DO UPDATE SET - orders_scanned = EXCLUDED.orders_scanned, - gross_total = EXCLUDED.gross_total, - discount_total = EXCLUDED.discount_total, - tax_total = EXCLUDED.tax_total, - net_total = EXCLUDED.net_total, - avg_order_total = EXCLUDED.avg_order_total, - top_sku = EXCLUDED.top_sku, - top_sku_qty = EXCLUDED.top_sku_qty, - note = COALESCE(EXCLUDED.note, app_public.order_rollup.note), - updated_at = now(); + org_id, + user_id, + period_from, + period_to, + currency, + orders_scanned, + gross_total, + discount_total, + tax_total, + net_total, + avg_order_total, + top_sku, + top_sku_qty, + note, + updated_at +) VALUES + (p_org_id, p_user_id, p_from_ts, p_to_ts, p_currency, v_orders_scanned, v_gross, v_discount, v_tax, v_net, v_avg, v_top_sku, v_top_sku_qty, p_note, now()) ON CONFLICT (org_id, user_id, period_from, period_to, currency) DO UPDATE SET orders_scanned = excluded.orders_scanned, gross_total = excluded.gross_total, discount_total = excluded.discount_total, tax_total = excluded.tax_total, net_total = excluded.net_total, avg_order_total = excluded.avg_order_total, top_sku = excluded.top_sku, top_sku_qty = excluded.top_sku_qty, note = COALESCE(excluded.note, app_public.order_rollup.note), updated_at = now(); GET DIAGNOSTICS v_rowcount = ; v_orders_upserted := v_rowcount; v_sql := format( diff --git a/packages/plpgsql-deparser/src/hydrate.ts b/packages/plpgsql-deparser/src/hydrate.ts index f4c2749c..827d8515 100644 --- a/packages/plpgsql-deparser/src/hydrate.ts +++ b/packages/plpgsql-deparser/src/hydrate.ts @@ -1,9 +1,11 @@ import { parseSync, scanSync } from '@libpg-query/parser'; import { ParseResult, Node } from '@pgsql/types'; +import { Deparser } from 'pgsql-deparser'; import { HydratedExprQuery, HydratedExprRaw, HydratedExprSqlExpr, + HydratedExprSqlStmt, HydratedExprAssign, HydrationOptions, HydrationResult, @@ -397,10 +399,31 @@ function dehydrateNode(node: any): any { function dehydrateQuery(query: HydratedExprQuery): string { switch (query.kind) { - case 'assign': - return `${query.target} := ${query.value}`; + case 'assign': { + // For assignments, use the target and value strings directly + // These may have been modified by the caller + const assignQuery = query as HydratedExprAssign; + return `${assignQuery.target} := ${assignQuery.value}`; + } + case 'sql-stmt': { + // Deparse the modified parseResult back to SQL + // This enables AST-based transformations (e.g., schema renaming) + const stmtQuery = query as HydratedExprSqlStmt; + if (stmtQuery.parseResult?.stmts?.[0]?.stmt) { + try { + return Deparser.deparse(stmtQuery.parseResult.stmts[0].stmt); + } catch { + // Fall back to original if deparse fails + return query.original; + } + } + return query.original; + } case 'sql-expr': - case 'sql-stmt': + // For sql-expr, return the original string + // Callers can modify query.original directly for simple transformations + // For AST-based transformations, use sql-stmt instead + return query.original; case 'raw': default: return query.original; From 1278b391534f4627997b09db909b143984d4c24b Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 1 Jan 2026 02:44:33 +0000 Subject: [PATCH 2/4] feat(plpgsql-deparser): add DehydrationOptions to thread SQL deparse options - Add DehydrationOptions interface with sqlDeparseOptions field - Thread options through dehydratePlpgsqlAst -> dehydrateNode -> dehydrateQuery - Pass sqlDeparseOptions to Deparser.deparse() for sql-stmt kinds - Export DehydrationOptions from package index This allows callers to control formatting (pretty printing, etc.) of embedded SQL statements inside PL/pgSQL function bodies. --- packages/plpgsql-deparser/src/hydrate.ts | 31 +++++++++++++++++------- packages/plpgsql-deparser/src/index.ts | 2 +- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/plpgsql-deparser/src/hydrate.ts b/packages/plpgsql-deparser/src/hydrate.ts index 827d8515..d9e7a3f7 100644 --- a/packages/plpgsql-deparser/src/hydrate.ts +++ b/packages/plpgsql-deparser/src/hydrate.ts @@ -1,6 +1,6 @@ import { parseSync, scanSync } from '@libpg-query/parser'; import { ParseResult, Node } from '@pgsql/types'; -import { Deparser } from 'pgsql-deparser'; +import { Deparser, DeparserOptions } from 'pgsql-deparser'; import { HydratedExprQuery, HydratedExprRaw, @@ -15,6 +15,18 @@ import { } from './hydrate-types'; import { PLpgSQLParseResult } from './types'; +/** + * Options for dehydrating (converting back to strings) a hydrated PL/pgSQL AST + */ +export interface DehydrationOptions { + /** + * Options to pass to the SQL deparser when deparsing sql-stmt expressions. + * This allows callers to control formatting (pretty printing, etc.) of + * embedded SQL statements inside PL/pgSQL function bodies. + */ + sqlDeparseOptions?: DeparserOptions; +} + function extractExprFromSelectWrapper(result: ParseResult): Node | undefined { const stmt = result.stmts?.[0]?.stmt as any; if (stmt?.SelectStmt?.targetList?.[0]?.ResTarget?.val) { @@ -352,17 +364,17 @@ export function getOriginalQuery(query: string | HydratedExprQuery): string { return query.original; } -export function dehydratePlpgsqlAst(ast: T): T { - return dehydrateNode(ast) as T; +export function dehydratePlpgsqlAst(ast: T, options?: DehydrationOptions): T { + return dehydrateNode(ast, options) as T; } -function dehydrateNode(node: any): any { +function dehydrateNode(node: any, options?: DehydrationOptions): any { if (node === null || node === undefined) { return node; } if (Array.isArray(node)) { - return node.map(item => dehydrateNode(item)); + return node.map(item => dehydrateNode(item, options)); } if (typeof node !== 'object') { @@ -377,7 +389,7 @@ function dehydrateNode(node: any): any { if (typeof query === 'string') { dehydratedQuery = query; } else if (isHydratedExpr(query)) { - dehydratedQuery = dehydrateQuery(query); + dehydratedQuery = dehydrateQuery(query, options?.sqlDeparseOptions); } else { dehydratedQuery = String(query); } @@ -392,12 +404,12 @@ function dehydrateNode(node: any): any { const result: any = {}; for (const [key, value] of Object.entries(node)) { - result[key] = dehydrateNode(value); + result[key] = dehydrateNode(value, options); } return result; } -function dehydrateQuery(query: HydratedExprQuery): string { +function dehydrateQuery(query: HydratedExprQuery, sqlDeparseOptions?: DeparserOptions): string { switch (query.kind) { case 'assign': { // For assignments, use the target and value strings directly @@ -408,10 +420,11 @@ function dehydrateQuery(query: HydratedExprQuery): string { case 'sql-stmt': { // Deparse the modified parseResult back to SQL // This enables AST-based transformations (e.g., schema renaming) + // Pass through sqlDeparseOptions to control formatting (pretty printing, etc.) const stmtQuery = query as HydratedExprSqlStmt; if (stmtQuery.parseResult?.stmts?.[0]?.stmt) { try { - return Deparser.deparse(stmtQuery.parseResult.stmts[0].stmt); + return Deparser.deparse(stmtQuery.parseResult.stmts[0].stmt, sqlDeparseOptions); } catch { // Fall back to original if deparse fails return query.original; diff --git a/packages/plpgsql-deparser/src/index.ts b/packages/plpgsql-deparser/src/index.ts index 50cb6df6..54ef674f 100644 --- a/packages/plpgsql-deparser/src/index.ts +++ b/packages/plpgsql-deparser/src/index.ts @@ -21,4 +21,4 @@ export const deparseFunction = async ( export { PLpgSQLDeparser, PLpgSQLDeparserOptions }; export * from './types'; export * from './hydrate-types'; -export { hydratePlpgsqlAst, dehydratePlpgsqlAst, isHydratedExpr, getOriginalQuery } from './hydrate'; +export { hydratePlpgsqlAst, dehydratePlpgsqlAst, isHydratedExpr, getOriginalQuery, DehydrationOptions } from './hydrate'; From 6428b87b98bdfcc7fd1b37ff8fd254f59a6d3303 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 1 Jan 2026 03:06:36 +0000 Subject: [PATCH 3/4] feat(pgsql-deparser): add pretty-printing for INSERT VALUES and ON CONFLICT SET - VALUES tuple items: put each value on its own line when isPretty() - ON CONFLICT DO UPDATE SET: put each assignment on its own line when isPretty() This enables proper multi-line formatting for INSERT statements with VALUES lists and ON CONFLICT clauses when pretty mode is enabled. --- packages/deparser/src/deparser.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index ff065da4..e3d75e50 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -563,7 +563,9 @@ export class Deparser implements DeparserVisitor { output.push('VALUES'); const lists = ListUtils.unwrapList(node.valuesLists).map(list => { const values = ListUtils.unwrapList(list).map(val => this.visit(val as Node, context)); - return context.parens(values.join(', ')); + // Put each value on its own line for pretty printing + const indentedValues = values.map(val => context.indent(val)); + return '(\n' + indentedValues.join(',\n') + '\n)'; }); const indentedTuples = lists.map(tuple => { if (this.containsMultilineStringLiteral(tuple)) { @@ -1116,7 +1118,13 @@ export class Deparser implements DeparserVisitor { } else { const updateContext = context.spawn('UpdateStmt', { update: true }); const targets = targetList.map(target => this.visit(target as Node, updateContext)); - output.push(targets.join(', ')); + if (context.isPretty()) { + // Put each assignment on its own line for pretty printing + const indentedTargets = targets.map(target => context.indent(target)); + output.push('\n' + indentedTargets.join(',\n')); + } else { + output.push(targets.join(', ')); + } } } From 704144f36755c9cb28c9a194adc27279812c3229 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 1 Jan 2026 03:28:44 +0000 Subject: [PATCH 4/4] test(plpgsql-deparser): update snapshot with pretty-printed INSERT VALUES and ON CONFLICT The snapshot now shows proper multi-line formatting for: - VALUES tuple items: each value on its own line - ON CONFLICT DO UPDATE SET: each assignment on its own line --- .../__snapshots__/hydrate-demo.test.ts.snap | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap index 8be87a3a..8fc18103 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap @@ -139,7 +139,33 @@ LIMIT 1; note, updated_at ) VALUES - (p_org_id, p_user_id, p_from_ts, p_to_ts, p_currency, v_orders_scanned, v_gross, v_discount, v_tax, v_net, v_avg, v_top_sku, v_top_sku_qty, p_note, now()) ON CONFLICT (org_id, user_id, period_from, period_to, currency) DO UPDATE SET orders_scanned = excluded.orders_scanned, gross_total = excluded.gross_total, discount_total = excluded.discount_total, tax_total = excluded.tax_total, net_total = excluded.net_total, avg_order_total = excluded.avg_order_total, top_sku = excluded.top_sku, top_sku_qty = excluded.top_sku_qty, note = COALESCE(excluded.note, app_public.order_rollup.note), updated_at = now(); + ( + p_org_id, + p_user_id, + p_from_ts, + p_to_ts, + p_currency, + v_orders_scanned, + v_gross, + v_discount, + v_tax, + v_net, + v_avg, + v_top_sku, + v_top_sku_qty, + p_note, + now() + ) ON CONFLICT (org_id, user_id, period_from, period_to, currency) DO UPDATE SET + orders_scanned = excluded.orders_scanned, + gross_total = excluded.gross_total, + discount_total = excluded.discount_total, + tax_total = excluded.tax_total, + net_total = excluded.net_total, + avg_order_total = excluded.avg_order_total, + top_sku = excluded.top_sku, + top_sku_qty = excluded.top_sku_qty, + note = COALESCE(excluded.note, app_public.order_rollup.note), + updated_at = now(); GET DIAGNOSTICS v_rowcount = ; v_orders_upserted := v_rowcount; v_sql := format(