Skip to content

Commit fb40c11

Browse files
authored
Merge pull request #306 from constructive-io/fix/cursor-args-raise-sqlstate
fix(plpgsql-deparser): bound cursor argument lists and RAISE SQLSTATE conditions
2 parents 96ac7df + 51d4b77 commit fb40c11

5 files changed

Lines changed: 154 additions & 3 deletions

File tree

__fixtures__/plpgsql-generated/generated.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@
152152
"plpgsql_deparser_fixes-51.sql": "-- Test 51: Exception handler with SQLSTATE condition (must emit SQLSTATE 'xxxxx')\nCREATE FUNCTION test_sqlstate_condition() RETURNS int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN 1;\nEXCEPTION\n WHEN unique_violation OR SQLSTATE '23503' THEN\n RETURN -1;\n WHEN SQLSTATE 'P0001' THEN\n RETURN -2;\nEND$$",
153153
"plpgsql_deparser_fixes-52.sql": "-- Test 52: Bare RAISE re-throw inside an exception handler (must stay bare)\nCREATE FUNCTION test_bare_raise_rethrow() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n PERFORM 1;\nEXCEPTION\n WHEN OTHERS THEN\n RAISE;\nEND$$",
154154
"plpgsql_deparser_fixes-53.sql": "-- Test 53: Array element and slice assignment (target must not be parenthesized)\nCREATE FUNCTION test_array_element_assignment() RETURNS int[]\nLANGUAGE plpgsql AS $$\nDECLARE\n a int[] := ARRAY[1, 2, 3, 4, 5];\n m int[][] := ARRAY[ARRAY[1, 2], ARRAY[3, 4]];\nBEGIN\n a[2] := 20;\n a[2:3] := ARRAY[9, 9];\n m[1][2] := 42;\n RETURN a;\nEND$$",
155+
"plpgsql_deparser_fixes-54.sql": "-- Test 54: Bound cursor with explicit arguments (must emit the parameter list)\nCREATE FUNCTION test_bound_cursor_args() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n c CURSOR (key int, label text) FOR SELECT * FROM users WHERE id = key AND name = label;\n r record;\nBEGIN\n OPEN c(42, 'x');\n FETCH c INTO r;\n CLOSE c;\nEND$$",
156+
"plpgsql_deparser_fixes-55.sql": "-- Test 55: RAISE with a SQLSTATE condition code (must emit SQLSTATE 'xxxxx', not a bare number)\nCREATE FUNCTION test_raise_sqlstate() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE SQLSTATE '22012';\nEND$$",
157+
"plpgsql_deparser_fixes-56.sql": "-- Test 56: RAISE EXCEPTION with a named condition (must stay a bare identifier)\nCREATE FUNCTION test_raise_named_condition() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE EXCEPTION division_by_zero;\nEND$$",
155158
"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$$",
156159
"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$$",
157160
"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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,3 +676,29 @@ BEGIN
676676
m[1][2] := 42;
677677
RETURN a;
678678
END$$;
679+
680+
-- Test 54: Bound cursor with explicit arguments (must emit the parameter list)
681+
CREATE FUNCTION test_bound_cursor_args() RETURNS void
682+
LANGUAGE plpgsql AS $$
683+
DECLARE
684+
c CURSOR (key int, label text) FOR SELECT * FROM users WHERE id = key AND name = label;
685+
r record;
686+
BEGIN
687+
OPEN c(42, 'x');
688+
FETCH c INTO r;
689+
CLOSE c;
690+
END$$;
691+
692+
-- Test 55: RAISE with a SQLSTATE condition code (must emit SQLSTATE 'xxxxx', not a bare number)
693+
CREATE FUNCTION test_raise_sqlstate() RETURNS void
694+
LANGUAGE plpgsql AS $$
695+
BEGIN
696+
RAISE SQLSTATE '22012';
697+
END$$;
698+
699+
-- Test 56: RAISE EXCEPTION with a named condition (must stay a bare identifier)
700+
CREATE FUNCTION test_raise_named_condition() RETURNS void
701+
LANGUAGE plpgsql AS $$
702+
BEGIN
703+
RAISE EXCEPTION division_by_zero;
704+
END$$;

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,18 @@ exports[`plpgsql-deparser bug fixes PERFORM SELECT fix should strip SELECT keywo
180180
END"
181181
`;
182182

183+
exports[`plpgsql-deparser bug fixes RAISE with SQLSTATE condition should emit SQLSTATE codes as SQLSTATE literals, not bare numbers 1`] = `
184+
"BEGIN
185+
RAISE EXCEPTION SQLSTATE '22012';
186+
END"
187+
`;
188+
189+
exports[`plpgsql-deparser bug fixes RAISE with SQLSTATE condition should keep named conditions as bare identifiers 1`] = `
190+
"BEGIN
191+
RAISE EXCEPTION division_by_zero;
192+
END"
193+
`;
194+
183195
exports[`plpgsql-deparser bug fixes Record field qualification (recfield) should handle OLD and NEW record references 1`] = `
184196
"BEGIN
185197
IF OLD.status <> NEW.status THEN
@@ -303,6 +315,17 @@ exports[`plpgsql-deparser bug fixes blocks inside control structures should hand
303315
END"
304316
`;
305317

318+
exports[`plpgsql-deparser bug fixes bound cursor arguments should emit the parameter list of a bound cursor and not redeclare its args 1`] = `
319+
"DECLARE
320+
c CURSOR (key int, label text) FOR SELECT * FROM users WHERE id = key AND name = label;
321+
r RECORD;
322+
BEGIN
323+
OPEN c (42, 'x');
324+
FETCH FROM c INTO r;
325+
CLOSE c;
326+
END"
327+
`;
328+
306329
exports[`plpgsql-deparser bug fixes combined scenarios should handle PERFORM with record fields 1`] = `
307330
"BEGIN
308331
PERFORM notify_change(NEW.id, NEW.status);

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,4 +1238,63 @@ $$`;
12381238
expect(deparsed).not.toContain('"name%TYPE"');
12391239
});
12401240
});
1241+
1242+
describe('bound cursor arguments', () => {
1243+
it('should emit the parameter list of a bound cursor and not redeclare its args', async () => {
1244+
const sql = `CREATE FUNCTION test_bound_cursor_args() RETURNS void
1245+
LANGUAGE plpgsql AS $$
1246+
DECLARE
1247+
c CURSOR (key int, label text) FOR SELECT * FROM users WHERE id = key AND name = label;
1248+
r record;
1249+
BEGIN
1250+
OPEN c(42, 'x');
1251+
FETCH c INTO r;
1252+
CLOSE c;
1253+
END$$`;
1254+
1255+
await testUtils.expectAstMatch('bound cursor args', sql);
1256+
1257+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1258+
const deparsed = deparseSync(parsed);
1259+
expect(deparsed).toMatchSnapshot();
1260+
expect(deparsed).toMatch(/c CURSOR \(key int, label text\) FOR/);
1261+
// The cursor args must not leak out as standalone DECLARE variables
1262+
expect(deparsed).not.toMatch(/^\s*key int;/m);
1263+
expect(deparsed).not.toMatch(/^\s*label text;/m);
1264+
});
1265+
});
1266+
1267+
describe('RAISE with SQLSTATE condition', () => {
1268+
it('should emit SQLSTATE codes as SQLSTATE literals, not bare numbers', async () => {
1269+
const sql = `CREATE FUNCTION test_raise_sqlstate() RETURNS void
1270+
LANGUAGE plpgsql AS $$
1271+
BEGIN
1272+
RAISE SQLSTATE '22012';
1273+
END$$`;
1274+
1275+
await testUtils.expectAstMatch('raise sqlstate', sql);
1276+
1277+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1278+
const deparsed = deparseSync(parsed);
1279+
expect(deparsed).toMatchSnapshot();
1280+
expect(deparsed).toMatch(/RAISE EXCEPTION SQLSTATE '22012'/i);
1281+
expect(deparsed).not.toMatch(/RAISE EXCEPTION 22012/);
1282+
});
1283+
1284+
it('should keep named conditions as bare identifiers', async () => {
1285+
const sql = `CREATE FUNCTION test_raise_named_condition() RETURNS void
1286+
LANGUAGE plpgsql AS $$
1287+
BEGIN
1288+
RAISE EXCEPTION division_by_zero;
1289+
END$$`;
1290+
1291+
await testUtils.expectAstMatch('raise named condition', sql);
1292+
1293+
const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult;
1294+
const deparsed = deparseSync(parsed);
1295+
expect(deparsed).toMatchSnapshot();
1296+
expect(deparsed).toMatch(/RAISE EXCEPTION division_by_zero/i);
1297+
expect(deparsed).not.toMatch(/SQLSTATE/i);
1298+
});
1299+
});
12411300
});

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -603,8 +603,25 @@ export class PLpgSQLDeparser {
603603
return '';
604604
}
605605

606+
// Bound-cursor argument variables are emitted inside the cursor's own
607+
// declaration (`c CURSOR (key int) FOR ...`), not as standalone DECLAREs.
608+
const cursorArgVarnos = new Set<number>();
609+
for (const datum of datums) {
610+
if ('PLpgSQL_var' in datum && datum.PLpgSQL_var.cursor_explicit_argrow !== undefined) {
611+
const argrow = datums[datum.PLpgSQL_var.cursor_explicit_argrow];
612+
if (argrow && 'PLpgSQL_row' in argrow) {
613+
for (const field of argrow.PLpgSQL_row.fields ?? []) {
614+
cursorArgVarnos.add(field.varno);
615+
}
616+
}
617+
}
618+
}
619+
606620
// Filter out internal variables (like 'found', parameters, etc.) and loop variables
607621
const localVars = datums.filter((datum, index) => {
622+
if (cursorArgVarnos.has(index)) {
623+
return false;
624+
}
608625
// If includedIndices is provided, only include datums at those indices
609626
if (includedIndices !== undefined && !includedIndices.has(index)) {
610627
return false;
@@ -701,7 +718,24 @@ export class PLpgSQLDeparser {
701718
parts.push(kw('NO SCROLL'));
702719
}
703720
}
704-
parts.push(kw('CURSOR FOR'));
721+
parts.push(kw('CURSOR'));
722+
if (v.cursor_explicit_argrow !== undefined && context.datums) {
723+
const argrow = context.datums[v.cursor_explicit_argrow];
724+
if (argrow && 'PLpgSQL_row' in argrow) {
725+
const args = (argrow.PLpgSQL_row.fields ?? []).map((field) => {
726+
const argDatum = context.datums![field.varno];
727+
const argType =
728+
argDatum && 'PLpgSQL_var' in argDatum && argDatum.PLpgSQL_var.datatype
729+
? ` ${this.deparseType(argDatum.PLpgSQL_var.datatype)}`
730+
: '';
731+
return `${field.name}${argType}`;
732+
});
733+
if (args.length > 0) {
734+
parts.push(`(${args.join(', ')})`);
735+
}
736+
}
737+
}
738+
parts.push(kw('FOR'));
705739
parts.push(this.deparseExpr(v.cursor_explicit_expr));
706740
return parts.join(' ');
707741
}
@@ -1476,9 +1510,15 @@ export class PLpgSQLDeparser {
14761510
parts.push(level);
14771511
}
14781512

1479-
// Condition name (for RAISE without message)
1513+
// Condition name (for RAISE without message). SQLSTATE conditions are
1514+
// stored as their raw 5-character code (e.g. '22012') and must be emitted
1515+
// as `SQLSTATE '22012'`; named conditions are lowercase identifiers.
14801516
if (raise.condname) {
1481-
parts.push(raise.condname);
1517+
if (/^[0-9A-Z]{5}$/.test(raise.condname)) {
1518+
parts.push(`${kw('SQLSTATE')} '${raise.condname}'`);
1519+
} else {
1520+
parts.push(raise.condname);
1521+
}
14821522
}
14831523

14841524
// Message

0 commit comments

Comments
 (0)