Skip to content

Commit ebe7ea0

Browse files
committed
fix: recover RETURN NEXT variable when retvarno is lost by libpg-query
libpg-query's protobuf does not serialize the retvarno field of PLpgSQL_stmt_return_next, causing RETURN NEXT v_entry to deparse as bare RETURN NEXT. Fix: - Thread enclosingForVarName through FOR loop contexts so the deparser knows the loop variable name - Add inferReturnNextVar() that recovers the variable from: 1. Enclosing FOR loop variable (most common pattern) 2. Single user-declared variable (SETOF scalar, no loop) - Only infer when returnInfo is provided (kind='setof'), preventing false inference on OUT-param functions where bare RETURN NEXT is correct - Add extractReturnInfo() to test infrastructure to extract return type from CREATE FUNCTION SQL for round-trip tests Tests: - 4 new snapshot tests for RETURN NEXT retvarno recovery - 2 new SQL fixtures (Tests 47-48) in plpgsql_deparser_fixes.sql - All 236 fixtures round-trip correctly
1 parent 6eb2cba commit ebe7ea0

6 files changed

Lines changed: 256 additions & 10 deletions

File tree

__fixtures__/plpgsql-generated/generated.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@
145145
"plpgsql_deparser_fixes-44.sql": "-- Test 44: Nested COALESCE expressions\nCREATE FUNCTION test_nested_coalesce(a text, b text, c text) RETURNS text\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN COALESCE(a, COALESCE(b, COALESCE(c, 'fallback')));\nEND$$",
146146
"plpgsql_deparser_fixes-45.sql": "-- Test 45: SELECT INTO with COALESCE and function call in FROM\nCREATE FUNCTION test_select_into_from_srf(v_data jsonb) RETURNS jsonb\nLANGUAGE plpgsql AS $$\nDECLARE\n v_first jsonb;\nBEGIN\n SELECT elem INTO v_first\n FROM jsonb_array_elements(v_data) AS elem\n LIMIT 1;\n RETURN v_first;\nEND$$",
147147
"plpgsql_deparser_fixes-46.sql": "-- Test 46: FOR loop with jsonb_array_elements and WHERE filter\nCREATE FUNCTION test_for_srf_with_filter(v_config jsonb, v_key text) RETURNS jsonb\nLANGUAGE plpgsql AS $$\nDECLARE\n v_entry jsonb;\nBEGIN\n FOR v_entry IN\n SELECT elem FROM jsonb_array_elements(v_config) AS elem\n WHERE elem->>'key' = v_key\n LOOP\n RETURN v_entry;\n END LOOP;\n RETURN NULL;\nEND$$",
148+
"plpgsql_deparser_fixes-47.sql": "-- Test 47: RETURN NEXT with FOR loop variable (retvarno recovery)\n-- Exercises the case where libpg-query drops retvarno from PLpgSQL_stmt_return_next\nCREATE FUNCTION test_return_next_for_var(v_data jsonb) RETURNS SETOF jsonb\nLANGUAGE plpgsql AS $$\nDECLARE\n v_entry jsonb;\nBEGIN\n FOR v_entry IN SELECT jsonb_array_elements(v_data)\n LOOP\n RETURN NEXT v_entry;\n END LOOP;\n RETURN;\nEND$$",
149+
"plpgsql_deparser_fixes-48.sql": "-- Test 48: RETURN NEXT with declared variable (no FOR loop, single candidate)\nCREATE FUNCTION test_return_next_declared_var() RETURNS SETOF int\nLANGUAGE plpgsql AS $$\nDECLARE\n v_val int := 42;\nBEGIN\n RETURN NEXT v_val;\nEND$$",
148150
"plpgsql_control-1.sql": "--\n-- Tests for PL/pgSQL control structures\n--\n\n-- integer FOR loop\n\ndo $$\nbegin\n -- basic case\n for i in 1..3 loop\n raise notice '1..3: i = %', i;\n end loop;\n -- with BY, end matches exactly\n for i in 1..10 by 3 loop\n raise notice '1..10 by 3: i = %', i;\n end loop;\n -- with BY, end does not match\n for i in 1..11 by 3 loop\n raise notice '1..11 by 3: i = %', i;\n end loop;\n -- zero iterations\n for i in 1..0 by 3 loop\n raise notice '1..0 by 3: i = %', i;\n end loop;\n -- REVERSE\n for i in reverse 10..0 by 3 loop\n raise notice 'reverse 10..0 by 3: i = %', i;\n end loop;\n -- potential overflow\n for i in 2147483620..2147483647 by 10 loop\n raise notice '2147483620..2147483647 by 10: i = %', i;\n end loop;\n -- potential overflow, reverse direction\n for i in reverse -2147483620..-2147483647 by 10 loop\n raise notice 'reverse -2147483620..-2147483647 by 10: i = %', i;\n end loop;\nend$$",
149151
"plpgsql_control-2.sql": "-- BY can't be zero or negative\ndo $$\nbegin\n for i in 1..3 by 0 loop\n raise notice '1..3 by 0: i = %', i;\n end loop;\nend$$",
150152
"plpgsql_control-3.sql": "do $$\nbegin\n for i in 1..3 by -1 loop\n raise notice '1..3 by -1: i = %', i;\n end loop;\nend$$",

__fixtures__/plpgsql/plpgsql_deparser_fixes.sql

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,26 @@ BEGIN
592592
END LOOP;
593593
RETURN NULL;
594594
END$$;
595+
596+
-- Test 47: RETURN NEXT with FOR loop variable (retvarno recovery)
597+
-- Exercises the case where libpg-query drops retvarno from PLpgSQL_stmt_return_next
598+
CREATE FUNCTION test_return_next_for_var(v_data jsonb) RETURNS SETOF jsonb
599+
LANGUAGE plpgsql AS $$
600+
DECLARE
601+
v_entry jsonb;
602+
BEGIN
603+
FOR v_entry IN SELECT jsonb_array_elements(v_data)
604+
LOOP
605+
RETURN NEXT v_entry;
606+
END LOOP;
607+
RETURN;
608+
END$$;
609+
610+
-- Test 48: RETURN NEXT with declared variable (no FOR loop, single candidate)
611+
CREATE FUNCTION test_return_next_declared_var() RETURNS SETOF int
612+
LANGUAGE plpgsql AS $$
613+
DECLARE
614+
v_val int := 42;
615+
BEGIN
616+
RETURN NEXT v_val;
617+
END$$;

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,48 @@ exports[`plpgsql-deparser bug fixes PERFORM SELECT fix should strip SELECT keywo
183183
END"
184184
`;
185185

186+
exports[`plpgsql-deparser bug fixes RETURN NEXT retvarno recovery should leave RETURN NEXT bare for OUT-param functions 1`] = `
187+
"BEGIN
188+
FOR i IN 1..5 LOOP
189+
x := i;
190+
y := 'item_' || i::text;
191+
RETURN NEXT;
192+
END LOOP;
193+
RETURN;
194+
END"
195+
`;
196+
197+
exports[`plpgsql-deparser bug fixes RETURN NEXT retvarno recovery should leave RETURN NEXT bare when returnInfo is not provided 1`] = `
198+
"DECLARE
199+
v_entry jsonb;
200+
BEGIN
201+
FOR v_entry IN SELECT jsonb_array_elements(v_data) LOOP
202+
RETURN NEXT;
203+
END LOOP;
204+
RETURN;
205+
END"
206+
`;
207+
208+
exports[`plpgsql-deparser bug fixes RETURN NEXT retvarno recovery should recover FOR loop variable in RETURN NEXT for SETOF functions 1`] = `
209+
"DECLARE
210+
v_entry jsonb;
211+
BEGIN
212+
FOR v_entry IN SELECT jsonb_array_elements(v_data) LOOP
213+
RETURN NEXT v_entry;
214+
END LOOP;
215+
RETURN;
216+
END"
217+
`;
218+
219+
exports[`plpgsql-deparser bug fixes RETURN NEXT retvarno recovery should recover single declared variable in RETURN NEXT for SETOF functions 1`] = `
220+
"DECLARE
221+
v_val int := 42;
222+
BEGIN
223+
RETURN NEXT v_val;
224+
RETURN;
225+
END"
226+
`;
227+
186228
exports[`plpgsql-deparser bug fixes Record field qualification (recfield) should handle OLD and NEW record references 1`] = `
187229
"BEGIN
188230
IF OLD.status <> NEW.status THEN

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,4 +1021,82 @@ END$$`;
10211021
expect(deparsed).toContain('now()');
10221022
});
10231023
});
1024+
1025+
describe('RETURN NEXT retvarno recovery', () => {
1026+
it('should recover FOR loop variable in RETURN NEXT for SETOF functions', async () => {
1027+
const sql = `CREATE FUNCTION test_return_next_for_var(v_data jsonb) RETURNS SETOF jsonb
1028+
LANGUAGE plpgsql AS $$
1029+
DECLARE
1030+
v_entry jsonb;
1031+
BEGIN
1032+
FOR v_entry IN SELECT jsonb_array_elements(v_data)
1033+
LOOP
1034+
RETURN NEXT v_entry;
1035+
END LOOP;
1036+
RETURN;
1037+
END$$`;
1038+
1039+
await testUtils.expectAstMatch('RETURN NEXT FOR loop var', sql);
1040+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1041+
const deparsed = deparseSync(parsed, undefined, { kind: 'setof' });
1042+
expect(deparsed).toMatchSnapshot();
1043+
expect(deparsed).toContain('RETURN NEXT v_entry');
1044+
});
1045+
1046+
it('should recover single declared variable in RETURN NEXT for SETOF functions', async () => {
1047+
const sql = `CREATE FUNCTION test_return_next_declared_var() RETURNS SETOF int
1048+
LANGUAGE plpgsql AS $$
1049+
DECLARE
1050+
v_val int := 42;
1051+
BEGIN
1052+
RETURN NEXT v_val;
1053+
END$$`;
1054+
1055+
await testUtils.expectAstMatch('RETURN NEXT declared var', sql);
1056+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1057+
const deparsed = deparseSync(parsed, undefined, { kind: 'setof' });
1058+
expect(deparsed).toMatchSnapshot();
1059+
expect(deparsed).toContain('RETURN NEXT v_val');
1060+
});
1061+
1062+
it('should leave RETURN NEXT bare for OUT-param functions', async () => {
1063+
const sql = `CREATE FUNCTION test_return_next_out(OUT x integer, OUT y text) RETURNS SETOF record
1064+
LANGUAGE plpgsql AS $$
1065+
BEGIN
1066+
FOR i IN 1..5 LOOP
1067+
x := i;
1068+
y := 'item_' || i::text;
1069+
RETURN NEXT;
1070+
END LOOP;
1071+
RETURN;
1072+
END$$`;
1073+
1074+
await testUtils.expectAstMatch('RETURN NEXT OUT params', sql);
1075+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1076+
const deparsed = deparseSync(parsed, undefined, { kind: 'out_params' });
1077+
expect(deparsed).toMatchSnapshot();
1078+
expect(deparsed).toContain('RETURN NEXT;');
1079+
expect(deparsed).not.toMatch(/RETURN NEXT\s+\w/);
1080+
});
1081+
1082+
it('should leave RETURN NEXT bare when returnInfo is not provided', async () => {
1083+
const sql = `CREATE FUNCTION test_return_next_for_var(v_data jsonb) RETURNS SETOF jsonb
1084+
LANGUAGE plpgsql AS $$
1085+
DECLARE
1086+
v_entry jsonb;
1087+
BEGIN
1088+
FOR v_entry IN SELECT jsonb_array_elements(v_data)
1089+
LOOP
1090+
RETURN NEXT v_entry;
1091+
END LOOP;
1092+
RETURN;
1093+
END$$`;
1094+
1095+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1096+
// Without returnInfo, inference should NOT happen (safety)
1097+
const deparsed = deparseSync(parsed);
1098+
expect(deparsed).toMatchSnapshot();
1099+
expect(deparsed).toContain('RETURN NEXT;');
1100+
});
1101+
});
10241102
});

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

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export interface PLpgSQLDeparserContext {
8686
loopVarLinenos?: Set<number>;
8787
/** Map of block lineno to the set of datum indices that belong to that block */
8888
blockDatumMap?: Map<number, Set<number>>;
89+
/** Name of the enclosing FOR loop variable (used to recover RETURN NEXT <var> when retvarno is missing) */
90+
enclosingForVarName?: string;
8991
}
9092

9193
/**
@@ -1179,7 +1181,7 @@ export class PLpgSQLDeparser {
11791181
}
11801182

11811183
if (fori.body) {
1182-
const bodyContext = { ...context, indentLevel: context.indentLevel + 1 };
1184+
const bodyContext = { ...context, indentLevel: context.indentLevel + 1, enclosingForVarName: varName };
11831185
for (const stmt of fori.body) {
11841186
const stmtStr = this.deparseStmt(stmt, bodyContext);
11851187
parts.push(this.indent(stmtStr + ';', bodyContext.indentLevel));
@@ -1210,7 +1212,7 @@ export class PLpgSQLDeparser {
12101212
parts.push(`${kw('FOR')} ${varName} ${kw('IN')} ${query} ${kw('LOOP')}`);
12111213

12121214
if (fors.body) {
1213-
const bodyContext = { ...context, indentLevel: context.indentLevel + 1 };
1215+
const bodyContext = { ...context, indentLevel: context.indentLevel + 1, enclosingForVarName: varName };
12141216
for (const stmt of fors.body) {
12151217
const stmtStr = this.deparseStmt(stmt, bodyContext);
12161218
parts.push(this.indent(stmtStr + ';', bodyContext.indentLevel));
@@ -1246,7 +1248,7 @@ export class PLpgSQLDeparser {
12461248
parts.push(`${forClause} ${kw('LOOP')}`);
12471249

12481250
if (forc.body) {
1249-
const bodyContext = { ...context, indentLevel: context.indentLevel + 1 };
1251+
const bodyContext = { ...context, indentLevel: context.indentLevel + 1, enclosingForVarName: varName };
12501252
for (const stmt of forc.body) {
12511253
const stmtStr = this.deparseStmt(stmt, bodyContext);
12521254
parts.push(this.indent(stmtStr + ';', bodyContext.indentLevel));
@@ -1283,7 +1285,7 @@ export class PLpgSQLDeparser {
12831285
parts.push(`${kw('FOREACH')} ${varName}${sliceClause} ${kw('IN ARRAY')} ${expr} ${kw('LOOP')}`);
12841286

12851287
if (foreach.body) {
1286-
const bodyContext = { ...context, indentLevel: context.indentLevel + 1 };
1288+
const bodyContext = { ...context, indentLevel: context.indentLevel + 1, enclosingForVarName: varName };
12871289
for (const stmt of foreach.body) {
12881290
const stmtStr = this.deparseStmt(stmt, bodyContext);
12891291
parts.push(this.indent(stmtStr + ';', bodyContext.indentLevel));
@@ -1363,22 +1365,83 @@ export class PLpgSQLDeparser {
13631365

13641366
/**
13651367
* Deparse a RETURN NEXT statement
1368+
*
1369+
* libpg-query does not serialize `retvarno` for PLpgSQL_stmt_return_next,
1370+
* so when a function has `RETURN NEXT variable`, the variable reference is
1371+
* lost. We recover it by:
1372+
* 1. Using the enclosing FOR loop variable (most common pattern)
1373+
* 2. Scanning datums for the sole user-declared variable (non-loop case)
1374+
* Bare `RETURN NEXT` remains valid for TABLE / OUT-param functions.
13661375
*/
13671376
private deparseReturnNext(ret: PLpgSQL_stmt_return_next, context: PLpgSQLDeparserContext): string {
13681377
const kw = this.keyword;
1369-
1378+
13701379
if (ret.expr) {
13711380
return `${kw('RETURN NEXT')} ${this.deparseExpr(ret.expr)}`;
13721381
}
1373-
1382+
13741383
if (ret.retvarno !== undefined && ret.retvarno >= 0) {
13751384
const varName = this.getVarName(ret.retvarno, context);
13761385
return `${kw('RETURN NEXT')} ${varName}`;
13771386
}
1378-
1387+
1388+
// retvarno is missing (libpg-query serialization gap). Try to recover.
1389+
const inferred = this.inferReturnNextVar(context);
1390+
if (inferred) {
1391+
return `${kw('RETURN NEXT')} ${inferred}`;
1392+
}
1393+
13791394
return kw('RETURN NEXT');
13801395
}
13811396

1397+
/**
1398+
* Attempt to infer the variable for a bare RETURN NEXT when retvarno is
1399+
* not available. Returns the variable name or undefined.
1400+
*
1401+
* We only infer when `returnInfo` tells us the function returns SETOF
1402+
* scalar. Without returnInfo we cannot distinguish a legitimate bare
1403+
* `RETURN NEXT` (OUT-param / TABLE function) from a dropped retvarno,
1404+
* so we leave the statement bare to avoid incorrect output.
1405+
*/
1406+
private inferReturnNextVar(context: PLpgSQLDeparserContext): string | undefined {
1407+
// Without return-type context we cannot safely infer
1408+
if (!context.returnInfo) {
1409+
return undefined;
1410+
}
1411+
1412+
// TABLE / OUT-param functions legitimately use bare RETURN NEXT
1413+
if (context.returnInfo.kind === 'out_params') {
1414+
return undefined;
1415+
}
1416+
1417+
// Only attempt recovery for SETOF scalar functions
1418+
if (context.returnInfo.kind !== 'setof') {
1419+
return undefined;
1420+
}
1421+
1422+
// 1. Inside a FOR loop → use the loop variable
1423+
if (context.enclosingForVarName) {
1424+
return context.enclosingForVarName;
1425+
}
1426+
1427+
// 2. Outside a FOR loop → look for a single user-declared var
1428+
if (context.datums) {
1429+
const candidates = context.datums.filter((d) => {
1430+
if ('PLpgSQL_var' in d) {
1431+
const v = d.PLpgSQL_var;
1432+
// Skip implicit 'found' and function parameters (no lineno)
1433+
return v.refname !== 'found' && v.lineno !== undefined;
1434+
}
1435+
return false;
1436+
});
1437+
if (candidates.length === 1 && 'PLpgSQL_var' in candidates[0]) {
1438+
return candidates[0].PLpgSQL_var.refname;
1439+
}
1440+
}
1441+
1442+
return undefined;
1443+
}
1444+
13821445
/**
13831446
* Deparse a RETURN QUERY statement
13841447
*/
@@ -1718,7 +1781,7 @@ export class PLpgSQLDeparser {
17181781
parts.push(`${forClause} ${kw('LOOP')}`);
17191782

17201783
if (fors.body) {
1721-
const bodyContext = { ...context, indentLevel: context.indentLevel + 1 };
1784+
const bodyContext = { ...context, indentLevel: context.indentLevel + 1, enclosingForVarName: varName };
17221785
for (const stmt of fors.body) {
17231786
const stmtStr = this.deparseStmt(stmt, bodyContext);
17241787
parts.push(this.indent(stmtStr + ';', bodyContext.indentLevel));

packages/plpgsql-deparser/test-utils/index.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { parsePlPgSQL, parsePlPgSQLSync } from '@libpg-query/parser';
2-
import { deparseSync, PLpgSQLParseResult } from '../src';
2+
import { deparseSync, PLpgSQLParseResult, ReturnInfo } from '../src';
33
import { readFileSync, readdirSync, existsSync } from 'fs';
44
import * as path from 'path';
55
import { diff } from 'jest-diff';
@@ -328,6 +328,43 @@ function reconstructSql(originalSql: string, newBody: string): string {
328328
return parts.prefix + newBody + parts.suffix;
329329
}
330330

331+
/**
332+
* Extract ReturnInfo from a CREATE FUNCTION SQL string by inspecting the
333+
* RETURNS clause and OUT/INOUT parameters. Used by the round-trip test to
334+
* give the deparser enough context to recover dropped retvarno fields.
335+
*
336+
* We intentionally only extract for SETOF and OUT-param functions — these
337+
* are the cases where RETURN NEXT inference needs context. For other
338+
* return types (scalar, void, trigger) we return undefined to avoid
339+
* changing existing RETURN statement behaviour (e.g. bare RETURN in scalar
340+
* functions would become RETURN NULL if we provided returnInfo).
341+
*/
342+
export function extractReturnInfo(sql: string): ReturnInfo | undefined {
343+
// Normalise to a single line for simpler matching
344+
const norm = sql.replace(/\s+/g, ' ').trim();
345+
346+
// Strip the body ($$...$$) so we only inspect the signature
347+
const sig = norm.replace(/\$\$.*\$\$/s, '');
348+
349+
// OUT / INOUT parameters → out_params (prevents false RETURN NEXT inference)
350+
if (/\b(OUT|INOUT)\s+/i.test(sig)) {
351+
return { kind: 'out_params' };
352+
}
353+
354+
// RETURNS TABLE(...) → out_params
355+
if (/RETURNS\s+TABLE\s*\(/i.test(sig)) {
356+
return { kind: 'out_params' };
357+
}
358+
359+
// RETURNS SETOF <type> → needed for RETURN NEXT variable recovery
360+
if (/RETURNS\s+SETOF\b/i.test(sig)) {
361+
return { kind: 'setof' };
362+
}
363+
364+
// All other types: don't provide returnInfo to preserve existing behaviour
365+
return undefined;
366+
}
367+
331368
export class PLpgSQLTestUtils {
332369
protected printErrorMessage(sql: string, position: number) {
333370
const lineNumber = sql.slice(0, position).match(/\n/g)?.length || 0;
@@ -368,7 +405,8 @@ export class PLpgSQLTestUtils {
368405
throw createParseError('PARSE_FAILED', testName, sql);
369406
}
370407

371-
const deparsedBody = deparseSync(originalAst);
408+
const returnInfo = extractReturnInfo(sql);
409+
const deparsedBody = deparseSync(originalAst, undefined, returnInfo);
372410

373411
if (!deparsedBody || deparsedBody.trim().length === 0) {
374412
throw createParseError('DEPARSE_FAILED', testName, sql, deparsedBody);

0 commit comments

Comments
 (0)