Skip to content

Commit d8360b6

Browse files
committed
feat(plpgsql-deparser): add context-based RETURN statement handling
Add ReturnInfo type and context to PLpgSQLDeparserContext for correct RETURN statement syntax based on function return type: - void/setof/trigger/out_params: bare RETURN is valid - scalar: RETURN NULL is required for empty returns Add getReturnInfo helper to plpgsql-parser to extract return type info from CreateFunctionStmt AST node. When returnInfo is provided in context, the deparser uses it to determine the correct RETURN syntax. When not provided, it falls back to bare RETURN for backward compatibility. Includes tests for all return type scenarios (void, setof, trigger, scalar, OUT params, RETURNS TABLE).
1 parent 4cd0fff commit d8360b6

File tree

8 files changed

+456
-21
lines changed

8 files changed

+456
-21
lines changed

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ BEGIN
6969
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
7070
END IF;
7171
IF p_lock THEN
72-
PERFORM SELECT pg_advisory_xact_lock(v_lock_key);
72+
PERFORM pg_advisory_xact_lock(v_lock_key);
7373
END IF;
7474
IF p_debug THEN
7575
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;
@@ -99,7 +99,7 @@ BEGIN
9999
SELECT
100100
t.orders_scanned,
101101
t.gross_total,
102-
t.avg_total
102+
t.avg_total INTO v_orders_scanned, v_gross, v_avg
103103
FROM totals AS t;
104104
IF p_apply_discount THEN
105105
v_rebate := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
@@ -110,7 +110,7 @@ BEGIN
110110
v_net := round(((v_gross - v_discount) + v_tax) * power(10::numeric, 0), p_round_to);
111111
SELECT
112112
oi.sku,
113-
CAST(sum(oi.quantity) AS bigint) AS qty
113+
CAST(sum(oi.quantity) AS bigint) AS qty INTO v_top_sku, v_top_sku_qty
114114
FROM app_public.order_item AS oi
115115
JOIN app_public.app_order AS o ON o.id = oi.order_id
116116
WHERE

packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ begin
3636
raise exception 'p_round_to out of range: %', p_round_to;
3737
end if;
3838
if p_lock then
39-
perform SELECT pg_advisory_xact_lock(v_lock_key);
39+
perform pg_advisory_xact_lock(v_lock_key);
4040
end if;
4141
if p_debug then
4242
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;
@@ -67,7 +67,7 @@ begin
6767
SELECT
6868
t.orders_scanned,
6969
t.gross_total,
70-
t.avg_total
70+
t.avg_total into v_orders_scanned, v_gross, v_avg
7171
FROM totals t;
7272
if p_apply_discount then
7373
v_discount := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
@@ -78,7 +78,7 @@ begin
7878
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
7979
SELECT
8080
oi.sku,
81-
sum(oi.quantity)::bigint AS qty
81+
sum(oi.quantity)::bigint AS qty into v_top_sku, v_top_sku_qty
8282
FROM app_public.order_item oi
8383
JOIN app_public.app_order o ON o.id = oi.order_id
8484
WHERE o.org_id = p_org_id
@@ -237,7 +237,7 @@ BEGIN
237237
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
238238
END IF;
239239
IF p_lock THEN
240-
PERFORM SELECT pg_advisory_xact_lock(v_lock_key);
240+
PERFORM pg_advisory_xact_lock(v_lock_key);
241241
END IF;
242242
IF p_debug THEN
243243
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;
@@ -268,7 +268,7 @@ BEGIN
268268
SELECT
269269
t.orders_scanned,
270270
t.gross_total,
271-
t.avg_total
271+
t.avg_total INTO v_orders_scanned, v_gross, v_avg
272272
FROM totals t;
273273
IF p_apply_discount THEN
274274
v_discount := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
@@ -279,7 +279,7 @@ BEGIN
279279
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
280280
SELECT
281281
oi.sku,
282-
sum(oi.quantity)::bigint AS qty
282+
sum(oi.quantity)::bigint AS qty INTO v_top_sku, v_top_sku_qty
283283
FROM app_public.order_item oi
284284
JOIN app_public.app_order o ON o.id = oi.order_id
285285
WHERE o.org_id = p_org_id
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { loadModule, parsePlPgSQLSync, parseSync } from '@libpg-query/parser';
2+
import { deparseSync, ReturnInfo } from '../src';
3+
import { PLpgSQLParseResult } from '../src/types';
4+
5+
describe('RETURN statement context handling', () => {
6+
beforeAll(async () => {
7+
await loadModule();
8+
});
9+
10+
describe('deparseSync with returnInfo context', () => {
11+
const parseBody = (sql: string): PLpgSQLParseResult => {
12+
return parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
13+
};
14+
15+
it('should output bare RETURN for void functions', () => {
16+
const sql = `CREATE FUNCTION test_void() RETURNS void
17+
LANGUAGE plpgsql
18+
AS $$
19+
BEGIN
20+
RETURN;
21+
END;
22+
$$`;
23+
const parsed = parseBody(sql);
24+
const returnInfo: ReturnInfo = { kind: 'void' };
25+
const result = deparseSync(parsed, undefined, returnInfo);
26+
27+
expect(result).toContain('RETURN;');
28+
expect(result).not.toContain('RETURN NULL');
29+
});
30+
31+
it('should output bare RETURN for setof functions', () => {
32+
const sql = `CREATE FUNCTION test_setof() RETURNS SETOF integer
33+
LANGUAGE plpgsql
34+
AS $$
35+
BEGIN
36+
RETURN NEXT 1;
37+
RETURN NEXT 2;
38+
RETURN;
39+
END;
40+
$$`;
41+
const parsed = parseBody(sql);
42+
const returnInfo: ReturnInfo = { kind: 'setof' };
43+
const result = deparseSync(parsed, undefined, returnInfo);
44+
45+
expect(result).toContain('RETURN;');
46+
expect(result).not.toContain('RETURN NULL');
47+
});
48+
49+
it('should output bare RETURN for trigger functions', () => {
50+
const sql = `CREATE FUNCTION test_trigger() RETURNS trigger
51+
LANGUAGE plpgsql
52+
AS $$
53+
BEGIN
54+
RETURN NEW;
55+
END;
56+
$$`;
57+
const parsed = parseBody(sql);
58+
const returnInfo: ReturnInfo = { kind: 'trigger' };
59+
const result = deparseSync(parsed, undefined, returnInfo);
60+
61+
expect(result).toContain('RETURN NEW');
62+
});
63+
64+
it('should output bare RETURN for out_params functions', () => {
65+
const sql = `CREATE FUNCTION test_out(OUT result integer) RETURNS integer
66+
LANGUAGE plpgsql
67+
AS $$
68+
BEGIN
69+
result := 42;
70+
RETURN;
71+
END;
72+
$$`;
73+
const parsed = parseBody(sql);
74+
const returnInfo: ReturnInfo = { kind: 'out_params' };
75+
const result = deparseSync(parsed, undefined, returnInfo);
76+
77+
expect(result).toContain('RETURN;');
78+
expect(result).not.toContain('RETURN NULL');
79+
});
80+
81+
it('should output RETURN NULL for scalar functions with empty return', () => {
82+
const sql = `CREATE FUNCTION test_scalar(val integer) RETURNS text
83+
LANGUAGE plpgsql
84+
AS $$
85+
BEGIN
86+
IF val > 0 THEN
87+
RETURN 'positive';
88+
END IF;
89+
RETURN;
90+
END;
91+
$$`;
92+
const parsed = parseBody(sql);
93+
const returnInfo: ReturnInfo = { kind: 'scalar' };
94+
const result = deparseSync(parsed, undefined, returnInfo);
95+
96+
expect(result).toContain('RETURN NULL');
97+
});
98+
99+
it('should preserve RETURN with expression regardless of context', () => {
100+
const sql = `CREATE FUNCTION test_expr() RETURNS integer
101+
LANGUAGE plpgsql
102+
AS $$
103+
BEGIN
104+
RETURN 42;
105+
END;
106+
$$`;
107+
const parsed = parseBody(sql);
108+
109+
// Test with scalar context
110+
const scalarResult = deparseSync(parsed, undefined, { kind: 'scalar' });
111+
expect(scalarResult).toContain('RETURN 42');
112+
113+
// Test with void context (shouldn't change expression returns)
114+
const voidResult = deparseSync(parsed, undefined, { kind: 'void' });
115+
expect(voidResult).toContain('RETURN 42');
116+
});
117+
118+
it('should default to bare RETURN when no context provided (backward compatibility)', () => {
119+
const sql = `CREATE FUNCTION test_no_context() RETURNS void
120+
LANGUAGE plpgsql
121+
AS $$
122+
BEGIN
123+
RETURN;
124+
END;
125+
$$`;
126+
const parsed = parseBody(sql);
127+
128+
// No returnInfo provided - should default to bare RETURN
129+
const result = deparseSync(parsed);
130+
expect(result).toContain('RETURN;');
131+
expect(result).not.toContain('RETURN NULL');
132+
});
133+
});
134+
});

packages/plpgsql-deparser/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PLpgSQLDeparser, PLpgSQLDeparserOptions } from './plpgsql-deparser';
1+
import { PLpgSQLDeparser, PLpgSQLDeparserOptions, ReturnInfo, ReturnInfoKind } from './plpgsql-deparser';
22

33
const deparseMethod = PLpgSQLDeparser.deparse;
44
const deparseFunctionMethod = PLpgSQLDeparser.deparseFunction;
@@ -18,7 +18,7 @@ export const deparseFunction = async (
1818
return deparseFunctionMethod(...args);
1919
};
2020

21-
export { PLpgSQLDeparser, PLpgSQLDeparserOptions };
21+
export { PLpgSQLDeparser, PLpgSQLDeparserOptions, ReturnInfo, ReturnInfoKind };
2222
export * from './types';
2323
export * from './hydrate-types';
2424
export { hydratePlpgsqlAst, dehydratePlpgsqlAst, isHydratedExpr, getOriginalQuery, DehydrationOptions } from './hydrate';

packages/plpgsql-deparser/src/plpgsql-deparser.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,23 @@ export interface PLpgSQLDeparserOptions {
6565
uppercase?: boolean;
6666
}
6767

68+
/**
69+
* Return type information for a PL/pgSQL function.
70+
* Used to determine the correct RETURN statement syntax:
71+
* - void/setof/trigger/out_params: bare RETURN is valid
72+
* - scalar: RETURN NULL is required for empty returns
73+
*/
74+
export type ReturnInfoKind = 'void' | 'setof' | 'trigger' | 'scalar' | 'out_params';
75+
76+
export interface ReturnInfo {
77+
kind: ReturnInfoKind;
78+
}
79+
6880
export interface PLpgSQLDeparserContext {
6981
indentLevel: number;
7082
options: PLpgSQLDeparserOptions;
7183
datums?: PLpgSQLDatum[];
84+
returnInfo?: ReturnInfo;
7285
}
7386

7487
/**
@@ -90,49 +103,62 @@ export class PLpgSQLDeparser {
90103

91104
/**
92105
* Static method to deparse a PL/pgSQL parse result
106+
* @param parseResult - The PL/pgSQL parse result
107+
* @param options - Deparser options
108+
* @param returnInfo - Optional return type info for correct RETURN statement handling
93109
*/
94-
static deparse(parseResult: PLpgSQLParseResult, options?: PLpgSQLDeparserOptions): string {
95-
return new PLpgSQLDeparser(options).deparseResult(parseResult);
110+
static deparse(parseResult: PLpgSQLParseResult, options?: PLpgSQLDeparserOptions, returnInfo?: ReturnInfo): string {
111+
return new PLpgSQLDeparser(options).deparseResult(parseResult, returnInfo);
96112
}
97113

98114
/**
99115
* Static method to deparse a single PL/pgSQL function body
116+
* @param func - The PL/pgSQL function AST
117+
* @param options - Deparser options
118+
* @param returnInfo - Optional return type info for correct RETURN statement handling
100119
*/
101-
static deparseFunction(func: PLpgSQL_function, options?: PLpgSQLDeparserOptions): string {
102-
return new PLpgSQLDeparser(options).deparseFunction(func);
120+
static deparseFunction(func: PLpgSQL_function, options?: PLpgSQLDeparserOptions, returnInfo?: ReturnInfo): string {
121+
return new PLpgSQLDeparser(options).deparseFunction(func, returnInfo);
103122
}
104123

105124
/**
106125
* Deparse a complete PL/pgSQL parse result
126+
* @param parseResult - The PL/pgSQL parse result
127+
* @param returnInfo - Optional return type info for correct RETURN statement handling
107128
*/
108-
deparseResult(parseResult: PLpgSQLParseResult): string {
129+
deparseResult(parseResult: PLpgSQLParseResult, returnInfo?: ReturnInfo): string {
109130
if (!parseResult.plpgsql_funcs || parseResult.plpgsql_funcs.length === 0) {
110131
return '';
111132
}
112133

113134
return parseResult.plpgsql_funcs
114-
.map(func => this.deparseFunctionNode(func))
135+
.map(func => this.deparseFunctionNode(func, returnInfo))
115136
.join(this.options.newline + this.options.newline);
116137
}
117138

118139
/**
119140
* Deparse a PLpgSQL_function node wrapper
141+
* @param node - The PLpgSQL_function node wrapper
142+
* @param returnInfo - Optional return type info for correct RETURN statement handling
120143
*/
121-
deparseFunctionNode(node: PLpgSQLFunctionNode): string {
144+
deparseFunctionNode(node: PLpgSQLFunctionNode, returnInfo?: ReturnInfo): string {
122145
if ('PLpgSQL_function' in node) {
123-
return this.deparseFunction(node.PLpgSQL_function);
146+
return this.deparseFunction(node.PLpgSQL_function, returnInfo);
124147
}
125148
throw new Error('Unknown function node type');
126149
}
127150

128151
/**
129152
* Deparse a PL/pgSQL function body
153+
* @param func - The PL/pgSQL function AST
154+
* @param returnInfo - Optional return type info for correct RETURN statement handling
130155
*/
131-
deparseFunction(func: PLpgSQL_function): string {
156+
deparseFunction(func: PLpgSQL_function, returnInfo?: ReturnInfo): string {
132157
const context: PLpgSQLDeparserContext = {
133158
indentLevel: 0,
134159
options: this.options,
135160
datums: func.datums,
161+
returnInfo,
136162
};
137163

138164
const parts: string[] = [];
@@ -930,6 +956,13 @@ export class PLpgSQLDeparser {
930956

931957
/**
932958
* Deparse a RETURN statement
959+
*
960+
* PostgreSQL requires different RETURN syntax based on function type:
961+
* - void/setof/trigger/out_params: bare RETURN is valid
962+
* - scalar: RETURN NULL is required for empty returns
963+
*
964+
* When returnInfo is provided in context, we use it to determine the correct syntax.
965+
* When not provided, we fall back to heuristics that scan the function body.
933966
*/
934967
private deparseReturn(ret: PLpgSQL_stmt_return, context: PLpgSQLDeparserContext): string {
935968
const kw = this.keyword;
@@ -943,6 +976,19 @@ export class PLpgSQLDeparser {
943976
return `${kw('RETURN')} ${varName}`;
944977
}
945978

979+
// Empty RETURN - need to determine if we should output bare RETURN or RETURN NULL
980+
// Use context.returnInfo if available, otherwise use heuristics
981+
if (context.returnInfo) {
982+
// Context-based: use the provided return type info
983+
if (context.returnInfo.kind === 'scalar') {
984+
return `${kw('RETURN')} ${kw('NULL')}`;
985+
}
986+
// void, setof, trigger, out_params all use bare RETURN
987+
return kw('RETURN');
988+
}
989+
990+
// Heuristic fallback: bare RETURN is the safest default
991+
// This maintains backward compatibility for callers that don't provide returnInfo
946992
return kw('RETURN');
947993
}
948994

0 commit comments

Comments
 (0)