From 4f7de7107bb31eea04a22444e15c6a5ae4223da0 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 20 Jul 2026 05:22:04 +0000 Subject: [PATCH] fix(plpgsql-deparser): cursor SCROLL options, FETCH/MOVE directions, SQLSTATE conditions, bare RAISE, subscripted assignment targets --- __fixtures__/plpgsql-generated/generated.json | 7 + .../plpgsql/plpgsql_deparser_fixes.sql | 87 ++++++++++ .../__snapshots__/deparser-fixes.test.ts.snap | 81 +++++++++ .../__snapshots__/hydrate-demo.test.ts.snap | 4 +- .../__tests__/deparser-fixes.test.ts | 164 ++++++++++++++++++ .../__snapshots__/plpgsql-pretty.test.ts.snap | 8 +- .../plpgsql-deparser/src/plpgsql-deparser.ts | 58 +++++-- 7 files changed, 392 insertions(+), 17 deletions(-) diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json index c4eb4bf68..16e8a9dde 100644 --- a/__fixtures__/plpgsql-generated/generated.json +++ b/__fixtures__/plpgsql-generated/generated.json @@ -145,6 +145,13 @@ "plpgsql_deparser_fixes-44.sql": "-- Test 44: Trigger function with no final return (implicit compiler RETURN must not be emitted)\nCREATE FUNCTION test_trigger_no_final_return() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n IF TG_OP = 'INSERT' THEN\n RETURN NEW;\n END IF;\nEND$$", "plpgsql_deparser_fixes-45.sql": "-- Test 45: Void function with explicit trailing RETURN (must be preserved)\nCREATE FUNCTION test_void_explicit_return() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE NOTICE 'hi';\n RETURN;\nEND$$", "plpgsql_deparser_fixes-46.sql": "-- Test 46: Trigger function ending in RETURN NEW (unchanged)\nCREATE FUNCTION test_trigger_return_new() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n NEW.updated_at := now();\n RETURN NEW;\nEND$$", + "plpgsql_deparser_fixes-47.sql": "-- Test 47: Bound cursor declared with SCROLL (option must stay on the declaration, not OPEN)\nCREATE FUNCTION test_scroll_cursor_decl() RETURNS int\nLANGUAGE plpgsql AS $$\nDECLARE\n c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id;\n v int;\nBEGIN\n OPEN c;\n FETCH PRIOR FROM c INTO v;\n CLOSE c;\n RETURN v;\nEND$$", + "plpgsql_deparser_fixes-48.sql": "-- Test 48: Bound cursor declared with NO SCROLL\nCREATE FUNCTION test_no_scroll_cursor_decl() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n c NO SCROLL CURSOR FOR SELECT id FROM s.t;\n v int;\nBEGIN\n OPEN c;\n FETCH c INTO v;\n CLOSE c;\nEND$$", + "plpgsql_deparser_fixes-49.sql": "-- Test 49: Plain bound cursor (OPEN must not gain a SCROLL keyword)\nCREATE FUNCTION test_plain_cursor_open() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n c CURSOR FOR SELECT id FROM s.t;\n v int;\nBEGIN\n OPEN c;\n FETCH c INTO v;\n CLOSE c;\nEND$$", + "plpgsql_deparser_fixes-50.sql": "-- Test 50: MOVE with count/expression directions (counts must be preserved)\nCREATE FUNCTION test_move_directions() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id;\nBEGIN\n OPEN c;\n MOVE FORWARD 3 FROM c;\n MOVE BACKWARD 2 FROM c;\n MOVE FORWARD ALL FROM c;\n MOVE BACKWARD ALL FROM c;\n MOVE LAST IN c;\n CLOSE c;\nEND$$", + "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$$", + "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$$", + "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$$", "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$$", "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$$", "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$$", diff --git a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql index 30f025440..9d2d8d631 100644 --- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql +++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql @@ -589,3 +589,90 @@ BEGIN NEW.updated_at := now(); RETURN NEW; END$$; + +-- Test 47: Bound cursor declared with SCROLL (option must stay on the declaration, not OPEN) +CREATE FUNCTION test_scroll_cursor_decl() RETURNS int +LANGUAGE plpgsql AS $$ +DECLARE + c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id; + v int; +BEGIN + OPEN c; + FETCH PRIOR FROM c INTO v; + CLOSE c; + RETURN v; +END$$; + +-- Test 48: Bound cursor declared with NO SCROLL +CREATE FUNCTION test_no_scroll_cursor_decl() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + c NO SCROLL CURSOR FOR SELECT id FROM s.t; + v int; +BEGIN + OPEN c; + FETCH c INTO v; + CLOSE c; +END$$; + +-- Test 49: Plain bound cursor (OPEN must not gain a SCROLL keyword) +CREATE FUNCTION test_plain_cursor_open() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + c CURSOR FOR SELECT id FROM s.t; + v int; +BEGIN + OPEN c; + FETCH c INTO v; + CLOSE c; +END$$; + +-- Test 50: MOVE with count/expression directions (counts must be preserved) +CREATE FUNCTION test_move_directions() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id; +BEGIN + OPEN c; + MOVE FORWARD 3 FROM c; + MOVE BACKWARD 2 FROM c; + MOVE FORWARD ALL FROM c; + MOVE BACKWARD ALL FROM c; + MOVE LAST IN c; + CLOSE c; +END$$; + +-- Test 51: Exception handler with SQLSTATE condition (must emit SQLSTATE 'xxxxx') +CREATE FUNCTION test_sqlstate_condition() RETURNS int +LANGUAGE plpgsql AS $$ +BEGIN + RETURN 1; +EXCEPTION + WHEN unique_violation OR SQLSTATE '23503' THEN + RETURN -1; + WHEN SQLSTATE 'P0001' THEN + RETURN -2; +END$$; + +-- Test 52: Bare RAISE re-throw inside an exception handler (must stay bare) +CREATE FUNCTION test_bare_raise_rethrow() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM 1; +EXCEPTION + WHEN OTHERS THEN + RAISE; +END$$; + +-- Test 53: Array element and slice assignment (target must not be parenthesized) +CREATE FUNCTION test_array_element_assignment() RETURNS int[] +LANGUAGE plpgsql AS $$ +DECLARE + a int[] := ARRAY[1, 2, 3, 4, 5]; + m int[][] := ARRAY[ARRAY[1, 2], ARRAY[3, 4]]; +BEGIN + a[2] := 20; + a[2:3] := ARRAY[9, 9]; + m[1][2] := 42; + RETURN a; +END$$; diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap index a0deb9d6b..41965ab43 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap @@ -53,6 +53,21 @@ BEGIN END" `; +exports[`plpgsql-deparser bug fixes FETCH/MOVE directions should preserve counts and FIRST/LAST directions 1`] = ` +"DECLARE + c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id; +BEGIN + OPEN c; + MOVE FORWARD 3 FROM c; + MOVE BACKWARD 2 FROM c; + MOVE ALL FROM c; + MOVE BACKWARD ALL FROM c; + MOVE LAST FROM c; + MOVE FIRST FROM c; + CLOSE c; +END" +`; + exports[`plpgsql-deparser bug fixes INTO clause depth-aware scanner should handle INTO STRICT 1`] = ` "DECLARE v_id integer; @@ -209,6 +224,37 @@ exports[`plpgsql-deparser bug fixes Record field qualification (recfield) should END" `; +exports[`plpgsql-deparser bug fixes SQLSTATE exception conditions should emit SQLSTATE codes with the SQLSTATE keyword 1`] = ` +"BEGIN + BEGIN + RETURN 1; + EXCEPTION + WHEN unique_violation OR SQLSTATE '23503' THEN + RETURN -1; + WHEN SQLSTATE 'P0001' THEN + RETURN -2; + END; +END" +`; + +exports[`plpgsql-deparser bug fixes bare RAISE re-throw should keep a bare RAISE bare (not RAISE EXCEPTION;) 1`] = ` +"BEGIN + BEGIN + PERFORM 1; + EXCEPTION + WHEN others THEN + RAISE; + END; +END" +`; + +exports[`plpgsql-deparser bug fixes bare RAISE re-throw should still emit level and message for normal RAISE 1`] = ` +"BEGIN + RAISE EXCEPTION 'boom %', 42 USING HINT = 'h'; + RAISE NOTICE 'hi'; +END" +`; + exports[`plpgsql-deparser bug fixes blocks inside control structures should handle block inside CASE WHEN 1`] = ` "BEGIN CASE p_status @@ -276,6 +322,29 @@ BEGIN END" `; +exports[`plpgsql-deparser bug fixes cursor SCROLL options should keep SCROLL on the cursor declaration, not OPEN 1`] = ` +"DECLARE + c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id; + v int; +BEGIN + OPEN c; + FETCH PRIOR FROM c INTO v; + CLOSE c; + RETURN v; +END" +`; + +exports[`plpgsql-deparser bug fixes cursor SCROLL options should not add SCROLL to OPEN of a plain bound cursor 1`] = ` +"DECLARE + c CURSOR FOR SELECT id FROM s.t; + v int; +BEGIN + OPEN c; + FETCH FROM c INTO v; + CLOSE c; +END" +`; + exports[`plpgsql-deparser bug fixes deep nesting and sequential blocks should handle block inside exception handler 1`] = ` "BEGIN BEGIN @@ -444,6 +513,18 @@ BEGIN END" `; +exports[`plpgsql-deparser bug fixes subscripted assignment targets should not parenthesize array element assignment targets 1`] = ` +"DECLARE + a int[] := ARRAY[1, 2, 3, 4, 5]; + m int[][] := ARRAY[ARRAY[1, 2], ARRAY[3, 4]]; +BEGIN + a[2] := 20; + a[2:3] := ARRAY[9, 9]; + m[1][2] := 42; + RETURN a; +END" +`; + exports[`plpgsql-deparser bug fixes untested statement types should handle ASSERT statement 1`] = ` "BEGIN ASSERT p_x > 0, 'x must be positive'; 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 86583099e..ed1f089eb 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap @@ -177,12 +177,12 @@ BEGIN EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'unique_violation: %', sqlerrm; - RAISE EXCEPTION; + RAISE; WHEN others THEN IF p_debug THEN RAISE NOTICE 'error: % (%:%)', sqlerrm, sqlstate, sqlerrm; END IF; - RAISE EXCEPTION; + RAISE; END; END$$" `; diff --git a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts index bedd24a29..753db18a2 100644 --- a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts +++ b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts @@ -1050,4 +1050,168 @@ END$$`; expect(deparsed).not.toMatch(/RETURN;/); }); }); + + describe('cursor SCROLL options', () => { + it('should keep SCROLL on the cursor declaration, not OPEN', async () => { + const sql = `CREATE FUNCTION test_scroll_cursor() RETURNS integer +LANGUAGE plpgsql AS $$ +DECLARE + c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id; + v int; +BEGIN + OPEN c; + FETCH PRIOR FROM c INTO v; + CLOSE c; + RETURN v; +END$$`; + + await testUtils.expectAstMatch('scroll cursor declaration', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('SCROLL CURSOR FOR'); + expect(deparsed).not.toMatch(/OPEN\s+(NO\s+)?SCROLL/i); + expect(deparsed).not.toMatch(/OPEN\s+c\s+(NO\s+)?SCROLL/i); + }); + + it('should not add SCROLL to OPEN of a plain bound cursor', async () => { + const sql = `CREATE FUNCTION test_plain_cursor() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + c CURSOR FOR SELECT id FROM s.t; + v int; +BEGIN + OPEN c; + FETCH c INTO v; + CLOSE c; +END$$`; + + await testUtils.expectAstMatch('plain cursor open', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('OPEN c'); + expect(deparsed).not.toMatch(/SCROLL/i); + }); + }); + + describe('FETCH/MOVE directions', () => { + it('should preserve counts and FIRST/LAST directions', async () => { + const sql = `CREATE FUNCTION test_move_directions() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + c SCROLL CURSOR FOR SELECT id FROM s.t ORDER BY id; +BEGIN + OPEN c; + MOVE FORWARD 3 FROM c; + MOVE BACKWARD 2 FROM c; + MOVE FORWARD ALL FROM c; + MOVE BACKWARD ALL FROM c; + MOVE LAST IN c; + MOVE FIRST IN c; + CLOSE c; +END$$`; + + await testUtils.expectAstMatch('move directions', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('FORWARD 3'); + expect(deparsed).toContain('BACKWARD 2'); + expect(deparsed).toContain('BACKWARD ALL'); + expect(deparsed).toContain('LAST'); + expect(deparsed).toContain('FIRST'); + }); + }); + + describe('SQLSTATE exception conditions', () => { + it('should emit SQLSTATE codes with the SQLSTATE keyword', async () => { + const sql = `CREATE FUNCTION test_sqlstate_cond() RETURNS integer +LANGUAGE plpgsql AS $$ +BEGIN + RETURN 1; +EXCEPTION + WHEN unique_violation OR SQLSTATE '23503' THEN + RETURN -1; + WHEN SQLSTATE 'P0001' THEN + RETURN -2; +END$$`; + + await testUtils.expectAstMatch('sqlstate condition', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain(`SQLSTATE '23503'`); + expect(deparsed).toContain(`SQLSTATE 'P0001'`); + expect(deparsed).not.toMatch(/WHEN\s+23503/); + }); + }); + + describe('bare RAISE re-throw', () => { + it('should keep a bare RAISE bare (not RAISE EXCEPTION;)', async () => { + const sql = `CREATE FUNCTION test_bare_raise() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM 1; +EXCEPTION + WHEN OTHERS THEN + RAISE; +END$$`; + + await testUtils.expectAstMatch('bare raise', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toMatch(/RAISE;/); + expect(deparsed).not.toMatch(/RAISE EXCEPTION;/); + }); + + it('should still emit level and message for normal RAISE', async () => { + const sql = `CREATE FUNCTION test_raise_msg() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'boom %', 42 USING HINT = 'h'; + RAISE NOTICE 'hi'; +END$$`; + + await testUtils.expectAstMatch('raise with message', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain(`RAISE EXCEPTION 'boom %', 42`); + expect(deparsed).toContain(`RAISE NOTICE 'hi'`); + }); + }); + + describe('subscripted assignment targets', () => { + it('should not parenthesize array element assignment targets', async () => { + const sql = `CREATE FUNCTION test_array_assign() RETURNS int[] +LANGUAGE plpgsql AS $$ +DECLARE + a int[] := ARRAY[1, 2, 3, 4, 5]; + m int[][] := ARRAY[ARRAY[1, 2], ARRAY[3, 4]]; +BEGIN + a[2] := 20; + a[2:3] := ARRAY[9, 9]; + m[1][2] := 42; + RETURN a; +END$$`; + + await testUtils.expectAstMatch('array element assignment', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('a[2] := 20'); + expect(deparsed).toContain('m[1][2] := 42'); + expect(deparsed).not.toMatch(/\(a\)\[/); + expect(deparsed).not.toMatch(/\(m\)\[/); + }); + }); }); diff --git a/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap b/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap index 2549e936f..1a87ba39a 100644 --- a/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap @@ -166,12 +166,12 @@ begin exception when unique_violation then raise notice 'unique_violation: %', SQLERRM; - raise exception; + raise; when others then if p_debug then raise notice 'error: % (%:%)', SQLERRM, SQLSTATE, SQLERRM; end if; - raise exception; + raise; end; end" `; @@ -393,12 +393,12 @@ BEGIN EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'unique_violation: %', SQLERRM; - RAISE EXCEPTION; + RAISE; WHEN others THEN IF p_debug THEN RAISE NOTICE 'error: % (%:%)', SQLERRM, SQLSTATE, SQLERRM; END IF; - RAISE EXCEPTION; + RAISE; END; END" `; diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts index aff10bdce..c23b355ff 100644 --- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts +++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts @@ -692,8 +692,15 @@ export class PLpgSQLDeparser { } // Handle cursor declarations - don't output the type for cursors - // The syntax is: cursor_name CURSOR FOR query + // The syntax is: cursor_name [NO] SCROLL CURSOR FOR query if (v.cursor_explicit_expr) { + if (v.cursor_options !== undefined) { + if (v.cursor_options & 2) { // CURSOR_OPT_SCROLL + parts.push(kw('SCROLL')); + } else if (v.cursor_options & 4) { // CURSOR_OPT_NO_SCROLL + parts.push(kw('NO SCROLL')); + } + } parts.push(kw('CURSOR FOR')); parts.push(this.deparseExpr(v.cursor_explicit_expr)); return parts.join(' '); @@ -980,7 +987,14 @@ export class PLpgSQLDeparser { const excData = exc.PLpgSQL_exception; const conditions = excData.conditions?.map((c: any) => { if ('PLpgSQL_condition' in c) { - return c.PLpgSQL_condition.condname || c.PLpgSQL_condition.sqlerrstate || 'OTHERS'; + const condname = c.PLpgSQL_condition.condname || c.PLpgSQL_condition.sqlerrstate; + if (!condname) return 'OTHERS'; + // SQLSTATE conditions are stored as their raw 5-character code + // (e.g. '23503'); named conditions are lowercase identifiers. + if (/^[0-9A-Z]{5}$/.test(condname)) { + return `${kw('SQLSTATE')} '${condname}'`; + } + return condname; } return 'OTHERS'; }).join(' OR ') || 'OTHERS'; @@ -1016,7 +1030,10 @@ export class PLpgSQLDeparser { // The expression already contains the assignment in the query // e.g., "sum := sum + n" if (expr.includes(':=')) { - return expr; + // The SQL deparser parenthesizes subscripted targets like '(a)[2]', + // but the PL/pgSQL assignment grammar requires a bare identifier + // before subscripts/field selections. + return expr.replace(/^\((\w+(?:\.\w+)*)\)(?=\[|\.)/, '$1'); } return `${varName} := ${expr}`; @@ -1445,6 +1462,13 @@ export class PLpgSQLDeparser { const kw = this.keyword; const parts: string[] = [kw('RAISE')]; + // Bare RAISE (re-throw inside an exception handler) has no condition + // name, message, or options; emitting a level like 'RAISE EXCEPTION;' + // alone is a syntax error. + if (!raise.condname && !raise.message && (!raise.options || raise.options.length === 0)) { + return kw('RAISE'); + } + // Log level const level = this.getElogLevelName(raise.elog_level); if (level) { @@ -1841,12 +1865,14 @@ export class PLpgSQLDeparser { } } - // Handle SCROLL option - if (open.cursor_options) { - if (open.cursor_options & 256) { // CURSOR_OPT_SCROLL - parts.splice(1, 0, kw('SCROLL')); - } else if (open.cursor_options & 512) { // CURSOR_OPT_NO_SCROLL - parts.splice(1, 0, kw('NO SCROLL')); + // Handle SCROLL option: only valid for unbound cursors + // (OPEN cursor_var [NO] SCROLL FOR query); a bound cursor's options + // belong on its declaration. + if (open.cursor_options && (open.query || open.dynquery)) { + if (open.cursor_options & 2) { // CURSOR_OPT_SCROLL + parts.splice(2, 0, kw('SCROLL')); + } else if (open.cursor_options & 4) { // CURSOR_OPT_NO_SCROLL + parts.splice(2, 0, kw('NO SCROLL')); } } @@ -2155,17 +2181,27 @@ export class PLpgSQLDeparser { switch (direction) { case FetchDirection.FETCH_FORWARD: + if (expr) { + return `${this.keyword('FORWARD')} ${this.deparseExpr(expr)}`; + } if (howMany === 1) return ''; - if (howMany === 0) return this.keyword('ALL'); + if (howMany === 2147483647) return this.keyword('ALL'); // FETCH_ALL return `${this.keyword('FORWARD')} ${howMany}`; case FetchDirection.FETCH_BACKWARD: + if (expr) { + return `${this.keyword('BACKWARD')} ${this.deparseExpr(expr)}`; + } if (howMany === 1) return this.keyword('PRIOR'); - if (howMany === 0) return `${this.keyword('BACKWARD')} ${this.keyword('ALL')}`; + if (howMany === 2147483647) return `${this.keyword('BACKWARD')} ${this.keyword('ALL')}`; // FETCH_ALL return `${this.keyword('BACKWARD')} ${howMany}`; case FetchDirection.FETCH_ABSOLUTE: if (expr) { return `${this.keyword('ABSOLUTE')} ${this.deparseExpr(expr)}`; } + // FIRST/LAST are stored as ABSOLUTE 1 / ABSOLUTE -1 with no expr; + // an explicit ABSOLUTE n always carries an expr. + if (howMany === 1) return this.keyword('FIRST'); + if (howMany === -1) return this.keyword('LAST'); return `${this.keyword('ABSOLUTE')} ${howMany}`; case FetchDirection.FETCH_RELATIVE: if (expr) {