Skip to content

Commit 37e4c60

Browse files
authored
Merge pull request #256 from constructive-io/devin/1767232213-fix-dehydrate-ast
feat: deparse modified AST and improve INSERT pretty-printing
2 parents ec3b608 + 704144f commit 37e4c60

File tree

4 files changed

+128
-83
lines changed

4 files changed

+128
-83
lines changed

packages/deparser/src/deparser.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,9 @@ export class Deparser implements DeparserVisitor {
563563
output.push('VALUES');
564564
const lists = ListUtils.unwrapList(node.valuesLists).map(list => {
565565
const values = ListUtils.unwrapList(list).map(val => this.visit(val as Node, context));
566-
return context.parens(values.join(', '));
566+
// Put each value on its own line for pretty printing
567+
const indentedValues = values.map(val => context.indent(val));
568+
return '(\n' + indentedValues.join(',\n') + '\n)';
567569
});
568570
const indentedTuples = lists.map(tuple => {
569571
if (this.containsMultilineStringLiteral(tuple)) {
@@ -1116,7 +1118,13 @@ export class Deparser implements DeparserVisitor {
11161118
} else {
11171119
const updateContext = context.spawn('UpdateStmt', { update: true });
11181120
const targets = targetList.map(target => this.visit(target as Node, updateContext));
1119-
output.push(targets.join(', '));
1121+
if (context.isPretty()) {
1122+
// Put each assignment on its own line for pretty printing
1123+
const indentedTargets = targets.map(target => context.indent(target));
1124+
output.push('\n' + indentedTargets.join(',\n'));
1125+
} else {
1126+
output.push(targets.join(', '));
1127+
}
11201128
}
11211129
}
11221130

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

Lines changed: 71 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -71,34 +71,33 @@ END IF;
7171
IF p_debug THEN
7272
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;
7373
END IF;
74-
WITH base AS (
75-
SELECT
76-
o.id,
77-
o.total_amount::numeric AS total_amount,
78-
o.currency,
79-
o.created_at
80-
FROM app_public.app_order o
81-
WHERE o.org_id = p_org_id
82-
AND o.user_id = p_user_id
83-
AND o.created_at >= p_from_ts
84-
AND o.created_at < p_to_ts
85-
AND o.total_amount::numeric >= v_min_total
86-
AND o.currency = p_currency
87-
ORDER BY o.created_at DESC
88-
LIMIT p_max_rows
89-
),
90-
totals AS (
91-
SELECT
92-
count(*)::int AS orders_scanned,
93-
COALESCE(sum(total_amount), 0) AS gross_total,
94-
COALESCE(avg(total_amount), 0) AS avg_total
95-
FROM base
96-
)
97-
SELECT
98-
t.orders_scanned,
99-
t.gross_total,
100-
t.avg_total
101-
FROM totals t;
74+
WITH
75+
base AS (SELECT
76+
o.id,
77+
o.total_amount::numeric AS total_amount,
78+
o.currency,
79+
o.created_at
80+
FROM app_public.app_order AS o
81+
WHERE
82+
o.org_id = p_org_id
83+
AND o.user_id = p_user_id
84+
AND o.created_at >= p_from_ts
85+
AND o.created_at < p_to_ts
86+
AND o.total_amount::numeric >= v_min_total
87+
AND o.currency = p_currency
88+
ORDER BY
89+
o.created_at DESC
90+
LIMIT p_max_rows),
91+
totals AS (SELECT
92+
(count(*))::int AS orders_scanned,
93+
COALESCE(sum(total_amount), 0) AS gross_total,
94+
COALESCE(avg(total_amount), 0) AS avg_total
95+
FROM base)
96+
SELECT
97+
t.orders_scanned,
98+
t.gross_total,
99+
t.avg_total
100+
FROM totals AS t;
102101
IF p_apply_discount THEN
103102
v_rebate := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
104103
ELSE
@@ -107,36 +106,40 @@ END IF;
107106
v_levy := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to);
108107
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
109108
SELECT
110-
oi.sku,
111-
sum(oi.quantity)::bigint AS qty
112-
FROM app_public.order_item oi
113-
JOIN app_public.app_order o ON o.id = oi.order_id
114-
WHERE o.org_id = p_org_id
115-
AND o.user_id = p_user_id
116-
AND o.created_at >= p_from_ts
117-
AND o.created_at < p_to_ts
118-
AND o.currency = p_currency
119-
GROUP BY oi.sku
120-
ORDER BY qty DESC, oi.sku ASC
121-
LIMIT 1;
109+
oi.sku,
110+
CAST(sum(oi.quantity) AS bigint) AS qty
111+
FROM app_public.order_item AS oi
112+
JOIN app_public.app_order AS o ON o.id = oi.order_id
113+
WHERE
114+
o.org_id = p_org_id
115+
AND o.user_id = p_user_id
116+
AND o.created_at >= p_from_ts
117+
AND o.created_at < p_to_ts
118+
AND o.currency = p_currency
119+
GROUP BY
120+
oi.sku
121+
ORDER BY
122+
qty DESC,
123+
oi.sku ASC
124+
LIMIT 1;
122125
INSERT INTO app_public.order_rollup (
123-
org_id,
124-
user_id,
125-
period_from,
126-
period_to,
127-
currency,
128-
orders_scanned,
129-
gross_total,
130-
discount_total,
131-
tax_total,
132-
net_total,
133-
avg_order_total,
134-
top_sku,
135-
top_sku_qty,
136-
note,
137-
updated_at
138-
)
139-
VALUES (
126+
org_id,
127+
user_id,
128+
period_from,
129+
period_to,
130+
currency,
131+
orders_scanned,
132+
gross_total,
133+
discount_total,
134+
tax_total,
135+
net_total,
136+
avg_order_total,
137+
top_sku,
138+
top_sku_qty,
139+
note,
140+
updated_at
141+
) VALUES
142+
(
140143
p_org_id,
141144
p_user_id,
142145
p_from_ts,
@@ -152,19 +155,17 @@ END IF;
152155
v_top_sku_qty,
153156
p_note,
154157
now()
155-
)
156-
ON CONFLICT (org_id, user_id, period_from, period_to, currency)
157-
DO UPDATE SET
158-
orders_scanned = EXCLUDED.orders_scanned,
159-
gross_total = EXCLUDED.gross_total,
160-
discount_total = EXCLUDED.discount_total,
161-
tax_total = EXCLUDED.tax_total,
162-
net_total = EXCLUDED.net_total,
163-
avg_order_total = EXCLUDED.avg_order_total,
164-
top_sku = EXCLUDED.top_sku,
165-
top_sku_qty = EXCLUDED.top_sku_qty,
166-
note = COALESCE(EXCLUDED.note, app_public.order_rollup.note),
167-
updated_at = now();
158+
) ON CONFLICT (org_id, user_id, period_from, period_to, currency) DO UPDATE SET
159+
orders_scanned = excluded.orders_scanned,
160+
gross_total = excluded.gross_total,
161+
discount_total = excluded.discount_total,
162+
tax_total = excluded.tax_total,
163+
net_total = excluded.net_total,
164+
avg_order_total = excluded.avg_order_total,
165+
top_sku = excluded.top_sku,
166+
top_sku_qty = excluded.top_sku_qty,
167+
note = COALESCE(excluded.note, app_public.order_rollup.note),
168+
updated_at = now();
168169
GET DIAGNOSTICS v_rowcount = ;
169170
v_orders_upserted := v_rowcount;
170171
v_sql := format(

packages/plpgsql-deparser/src/hydrate.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { parseSync, scanSync } from '@libpg-query/parser';
22
import { ParseResult, Node } from '@pgsql/types';
3+
import { Deparser, DeparserOptions } from 'pgsql-deparser';
34
import {
45
HydratedExprQuery,
56
HydratedExprRaw,
67
HydratedExprSqlExpr,
8+
HydratedExprSqlStmt,
79
HydratedExprAssign,
810
HydrationOptions,
911
HydrationResult,
@@ -13,6 +15,18 @@ import {
1315
} from './hydrate-types';
1416
import { PLpgSQLParseResult } from './types';
1517

18+
/**
19+
* Options for dehydrating (converting back to strings) a hydrated PL/pgSQL AST
20+
*/
21+
export interface DehydrationOptions {
22+
/**
23+
* Options to pass to the SQL deparser when deparsing sql-stmt expressions.
24+
* This allows callers to control formatting (pretty printing, etc.) of
25+
* embedded SQL statements inside PL/pgSQL function bodies.
26+
*/
27+
sqlDeparseOptions?: DeparserOptions;
28+
}
29+
1630
function extractExprFromSelectWrapper(result: ParseResult): Node | undefined {
1731
const stmt = result.stmts?.[0]?.stmt as any;
1832
if (stmt?.SelectStmt?.targetList?.[0]?.ResTarget?.val) {
@@ -350,17 +364,17 @@ export function getOriginalQuery(query: string | HydratedExprQuery): string {
350364
return query.original;
351365
}
352366

353-
export function dehydratePlpgsqlAst<T>(ast: T): T {
354-
return dehydrateNode(ast) as T;
367+
export function dehydratePlpgsqlAst<T>(ast: T, options?: DehydrationOptions): T {
368+
return dehydrateNode(ast, options) as T;
355369
}
356370

357-
function dehydrateNode(node: any): any {
371+
function dehydrateNode(node: any, options?: DehydrationOptions): any {
358372
if (node === null || node === undefined) {
359373
return node;
360374
}
361375

362376
if (Array.isArray(node)) {
363-
return node.map(item => dehydrateNode(item));
377+
return node.map(item => dehydrateNode(item, options));
364378
}
365379

366380
if (typeof node !== 'object') {
@@ -375,7 +389,7 @@ function dehydrateNode(node: any): any {
375389
if (typeof query === 'string') {
376390
dehydratedQuery = query;
377391
} else if (isHydratedExpr(query)) {
378-
dehydratedQuery = dehydrateQuery(query);
392+
dehydratedQuery = dehydrateQuery(query, options?.sqlDeparseOptions);
379393
} else {
380394
dehydratedQuery = String(query);
381395
}
@@ -390,17 +404,39 @@ function dehydrateNode(node: any): any {
390404

391405
const result: any = {};
392406
for (const [key, value] of Object.entries(node)) {
393-
result[key] = dehydrateNode(value);
407+
result[key] = dehydrateNode(value, options);
394408
}
395409
return result;
396410
}
397411

398-
function dehydrateQuery(query: HydratedExprQuery): string {
412+
function dehydrateQuery(query: HydratedExprQuery, sqlDeparseOptions?: DeparserOptions): string {
399413
switch (query.kind) {
400-
case 'assign':
401-
return `${query.target} := ${query.value}`;
414+
case 'assign': {
415+
// For assignments, use the target and value strings directly
416+
// These may have been modified by the caller
417+
const assignQuery = query as HydratedExprAssign;
418+
return `${assignQuery.target} := ${assignQuery.value}`;
419+
}
420+
case 'sql-stmt': {
421+
// Deparse the modified parseResult back to SQL
422+
// This enables AST-based transformations (e.g., schema renaming)
423+
// Pass through sqlDeparseOptions to control formatting (pretty printing, etc.)
424+
const stmtQuery = query as HydratedExprSqlStmt;
425+
if (stmtQuery.parseResult?.stmts?.[0]?.stmt) {
426+
try {
427+
return Deparser.deparse(stmtQuery.parseResult.stmts[0].stmt, sqlDeparseOptions);
428+
} catch {
429+
// Fall back to original if deparse fails
430+
return query.original;
431+
}
432+
}
433+
return query.original;
434+
}
402435
case 'sql-expr':
403-
case 'sql-stmt':
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
439+
return query.original;
404440
case 'raw':
405441
default:
406442
return query.original;

packages/plpgsql-deparser/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ export const deparseFunction = async (
2121
export { PLpgSQLDeparser, PLpgSQLDeparserOptions };
2222
export * from './types';
2323
export * from './hydrate-types';
24-
export { hydratePlpgsqlAst, dehydratePlpgsqlAst, isHydratedExpr, getOriginalQuery } from './hydrate';
24+
export { hydratePlpgsqlAst, dehydratePlpgsqlAst, isHydratedExpr, getOriginalQuery, DehydrationOptions } from './hydrate';

0 commit comments

Comments
 (0)