From 2f7a111b21a8359a4a7ef2d47b639eec8056acd8 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 7 Aug 2026 16:53:48 +0000 Subject: [PATCH] fix(plpgsql-deparser): decide assignment targets by parse mode, not a ':=' substring test --- __fixtures__/plpgsql-generated/generated.json | 39 ++++--- .../plpgsql/plpgsql_deparser_fixes.sql | 22 ++++ .../__snapshots__/deparser-fixes.test.ts.snap | 9 ++ .../__tests__/deparser-fixes.test.ts | 108 +++++++++++++++++- .../plpgsql-deparser/src/plpgsql-deparser.ts | 39 ++++++- 5 files changed, 193 insertions(+), 24 deletions(-) diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json index 363efcffd..11206b7a2 100644 --- a/__fixtures__/plpgsql-generated/generated.json +++ b/__fixtures__/plpgsql-generated/generated.json @@ -163,6 +163,8 @@ "plpgsql_deparser_fixes-63.sql": "CREATE FUNCTION test_alias_positional_param(integer) RETURNS integer\nLANGUAGE plpgsql AS $$\nDECLARE\n arg ALIAS FOR $1;\nBEGIN\n RETURN arg + 1;\nEND$$", "plpgsql_deparser_fixes-64.sql": "CREATE FUNCTION test_alias_named(input_value text) RETURNS text\nLANGUAGE plpgsql AS $$\nDECLARE\n val ALIAS FOR input_value;\n buffer text := 'x';\n buf ALIAS FOR buffer;\nBEGIN\n buf := buf || val;\n RETURN buf;\nEND$$", "plpgsql_deparser_fixes-65.sql": "CREATE FUNCTION test_alias_trigger() RETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n prior ALIAS FOR old;\n updated ALIAS FOR new;\nBEGIN\n updated.updated_at := now();\n RETURN updated;\nEND$$", + "plpgsql_deparser_fixes-66.sql": "CREATE FUNCTION test_assign_named_args() RETURNS text\nLANGUAGE plpgsql AS $$\nDECLARE\n v_body text;\nBEGIN\n v_body := ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f');\n RETURN v_body;\nEND$$", + "plpgsql_deparser_fixes-67.sql": "CREATE FUNCTION test_assign_targets() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n a int[] := ARRAY[1, 2];\n r record;\nBEGIN\n SELECT 1 AS f INTO r;\n a[2] := coalesce(nullif(3, 0), 4);\n r.f := greatest(1, 2);\nEND$$", "plpgsql_control-1.sql": "do $$\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": "do $$\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$$", @@ -207,24 +209,25 @@ "plpgsql_call-23.sql": "DO $$\nDECLARE _a int; _b int;\nBEGIN\n _a := 10; _b := 30;\n CALL test_proc9(_a, _b);\n RAISE NOTICE '_a: %, _b: %', _a, _b;\nEND\n$$", "plpgsql_call-24.sql": "CREATE PROCEDURE test_proc10(IN a int, OUT b int, IN c int DEFAULT 11)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RAISE NOTICE 'a: %, b: %, c: %', a, b, c;\n b := a - c;\nEND;\n$$", "plpgsql_call-25.sql": "DO $$\nDECLARE _a int; _b int; _c int;\nBEGIN\n _a := 10; _b := 30; _c := 7;\n CALL test_proc10(_a, _b, _c);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\n\n _a := 10; _b := 30; _c := 7;\n CALL test_proc10(_a, _b, c => _c);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\n\n _a := 10; _b := 30; _c := 7;\n CALL test_proc10(a => _a, b => _b, c => _c);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\n\n _a := 10; _b := 30; _c := 7;\n CALL test_proc10(_a, c => _c, b => _b);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\n\n _a := 10; _b := 30; _c := 7;\n CALL test_proc10(_a, _b);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\n\n _a := 10; _b := 30; _c := 7;\n CALL test_proc10(_a, b => _b);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\n\n _a := 10; _b := 30; _c := 7;\n CALL test_proc10(b => _b, a => _a);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\nEND\n$$", - "plpgsql_call-26.sql": "DO $$\nDECLARE _a int; _b int; _c int;\nBEGIN\n _a := 10; _b := 30; _c := 7;\n CALL test_proc11(_a, _b, _c);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\nEND\n$$", - "plpgsql_call-27.sql": "CREATE PROCEDURE test_proc12(a anyelement, OUT b anyelement, OUT c anyarray)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RAISE NOTICE 'a: %', a;\n b := a;\n c := array[a];\nEND;\n$$", - "plpgsql_call-28.sql": "DO $$\nDECLARE _a int; _b int; _c int[];\nBEGIN\n _a := 10;\n CALL test_proc12(_a, _b, _c);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\nEND\n$$", - "plpgsql_call-29.sql": "DO $$\nDECLARE _a int; _b int; _c text[];\nBEGIN\n _a := 10;\n CALL test_proc12(_a, _b, _c); -- error\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\nEND\n$$", - "plpgsql_call-30.sql": "CREATE FUNCTION triggerfunc1() RETURNS trigger\nLANGUAGE plpgsql\nAS $$\nDECLARE\n z int := 0;\nBEGIN\n CALL test_proc6(2, NEW.a, NEW.a);\n RETURN NEW;\nEND;\n$$", - "plpgsql_call-31.sql": "CREATE PROCEDURE p1(v_cnt int, v_Text inout text = NULL)\nAS $$\nBEGIN\n v_Text := 'v_cnt = ' || v_cnt;\nEND\n$$ LANGUAGE plpgsql", - "plpgsql_call-32.sql": "DO $$\nDECLARE\n v_Text text;\n v_cnt integer := 42;\nBEGIN\n CALL p1(v_cnt := v_cnt); -- error, must supply something for v_Text\n RAISE NOTICE '%', v_Text;\nEND;\n$$", - "plpgsql_call-33.sql": "DO $$\nDECLARE\n v_Text text;\n v_cnt integer := 42;\nBEGIN\n CALL p1(v_cnt := v_cnt, v_Text := v_Text);\n RAISE NOTICE '%', v_Text;\nEND;\n$$", - "plpgsql_call-34.sql": "DO $$\nDECLARE\n v_Text text;\nBEGIN\n CALL p1(10, v_Text := v_Text);\n RAISE NOTICE '%', v_Text;\nEND;\n$$", - "plpgsql_call-35.sql": "DO $$\nDECLARE\n v_Text text;\n v_cnt integer;\nBEGIN\n CALL p1(v_Text := v_Text, v_cnt := v_cnt);\n RAISE NOTICE '%', v_Text;\nEND;\n$$", - "plpgsql_call-36.sql": "CREATE PROCEDURE inner_p (f1 int)\nAS $$\nBEGIN\n RAISE NOTICE 'inner_p(%)', f1;\nEND\n$$ LANGUAGE plpgsql", - "plpgsql_call-37.sql": "CREATE PROCEDURE outer_p (f1 int)\nAS $$\nBEGIN\n RAISE NOTICE 'outer_p(%)', f1;\n CALL inner_p(f(f1));\nEND\n$$ LANGUAGE plpgsql", - "plpgsql_call-38.sql": "CREATE FUNCTION outer_f (f1 int) RETURNS void\nAS $$\nBEGIN\n RAISE NOTICE 'outer_f(%)', f1;\n CALL inner_p(f(f1));\nEND\n$$ LANGUAGE plpgsql", - "plpgsql_call-39.sql": "CREATE FUNCTION f_get_x () RETURNS int\nAS $$\nDECLARE l_result int;\nBEGIN\n SELECT x INTO l_result FROM t_test;\n RETURN l_result;\nEND\n$$ LANGUAGE plpgsql STABLE", - "plpgsql_call-40.sql": "CREATE PROCEDURE f_print_x (x int)\nAS $$\nBEGIN\n RAISE NOTICE 'f_print_x(%)', x;\nEND\n$$ LANGUAGE plpgsql", - "plpgsql_call-41.sql": "DO $$\nBEGIN\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n ROLLBACK;\nEND\n$$", - "plpgsql_call-42.sql": "DO $$\nBEGIN\n BEGIN\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n EXCEPTION WHEN division_by_zero THEN\n RAISE NOTICE '%', SQLERRM;\n END;\n ROLLBACK;\nEND\n$$", - "plpgsql_call-43.sql": "DO $$\nBEGIN\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\nEND\n$$", + "plpgsql_call-26.sql": "CREATE PROCEDURE test_proc11(a OUT int, VARIADIC b int[])\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RAISE NOTICE 'a: %, b: %', a, b;\n a := b[1] + b[2];\nEND;\n$$", + "plpgsql_call-27.sql": "DO $$\nDECLARE _a int; _b int; _c int;\nBEGIN\n _a := 10; _b := 30; _c := 7;\n CALL test_proc11(_a, _b, _c);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\nEND\n$$", + "plpgsql_call-28.sql": "CREATE PROCEDURE test_proc12(a anyelement, OUT b anyelement, OUT c anyarray)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RAISE NOTICE 'a: %', a;\n b := a;\n c := array[a];\nEND;\n$$", + "plpgsql_call-29.sql": "DO $$\nDECLARE _a int; _b int; _c int[];\nBEGIN\n _a := 10;\n CALL test_proc12(_a, _b, _c);\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\nEND\n$$", + "plpgsql_call-30.sql": "DO $$\nDECLARE _a int; _b int; _c text[];\nBEGIN\n _a := 10;\n CALL test_proc12(_a, _b, _c); -- error\n RAISE NOTICE '_a: %, _b: %, _c: %', _a, _b, _c;\nEND\n$$", + "plpgsql_call-31.sql": "CREATE FUNCTION triggerfunc1() RETURNS trigger\nLANGUAGE plpgsql\nAS $$\nDECLARE\n z int := 0;\nBEGIN\n CALL test_proc6(2, NEW.a, NEW.a);\n RETURN NEW;\nEND;\n$$", + "plpgsql_call-32.sql": "CREATE PROCEDURE p1(v_cnt int, v_Text inout text = NULL)\nAS $$\nBEGIN\n v_Text := 'v_cnt = ' || v_cnt;\nEND\n$$ LANGUAGE plpgsql", + "plpgsql_call-33.sql": "DO $$\nDECLARE\n v_Text text;\n v_cnt integer := 42;\nBEGIN\n CALL p1(v_cnt := v_cnt); -- error, must supply something for v_Text\n RAISE NOTICE '%', v_Text;\nEND;\n$$", + "plpgsql_call-34.sql": "DO $$\nDECLARE\n v_Text text;\n v_cnt integer := 42;\nBEGIN\n CALL p1(v_cnt := v_cnt, v_Text := v_Text);\n RAISE NOTICE '%', v_Text;\nEND;\n$$", + "plpgsql_call-35.sql": "DO $$\nDECLARE\n v_Text text;\nBEGIN\n CALL p1(10, v_Text := v_Text);\n RAISE NOTICE '%', v_Text;\nEND;\n$$", + "plpgsql_call-36.sql": "DO $$\nDECLARE\n v_Text text;\n v_cnt integer;\nBEGIN\n CALL p1(v_Text := v_Text, v_cnt := v_cnt);\n RAISE NOTICE '%', v_Text;\nEND;\n$$", + "plpgsql_call-37.sql": "CREATE PROCEDURE inner_p (f1 int)\nAS $$\nBEGIN\n RAISE NOTICE 'inner_p(%)', f1;\nEND\n$$ LANGUAGE plpgsql", + "plpgsql_call-38.sql": "CREATE PROCEDURE outer_p (f1 int)\nAS $$\nBEGIN\n RAISE NOTICE 'outer_p(%)', f1;\n CALL inner_p(f(f1));\nEND\n$$ LANGUAGE plpgsql", + "plpgsql_call-39.sql": "CREATE FUNCTION outer_f (f1 int) RETURNS void\nAS $$\nBEGIN\n RAISE NOTICE 'outer_f(%)', f1;\n CALL inner_p(f(f1));\nEND\n$$ LANGUAGE plpgsql", + "plpgsql_call-40.sql": "CREATE FUNCTION f_get_x () RETURNS int\nAS $$\nDECLARE l_result int;\nBEGIN\n SELECT x INTO l_result FROM t_test;\n RETURN l_result;\nEND\n$$ LANGUAGE plpgsql STABLE", + "plpgsql_call-41.sql": "CREATE PROCEDURE f_print_x (x int)\nAS $$\nBEGIN\n RAISE NOTICE 'f_print_x(%)', x;\nEND\n$$ LANGUAGE plpgsql", + "plpgsql_call-42.sql": "DO $$\nBEGIN\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n ROLLBACK;\nEND\n$$", + "plpgsql_call-43.sql": "DO $$\nBEGIN\n BEGIN\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n EXCEPTION WHEN division_by_zero THEN\n RAISE NOTICE '%', SQLERRM;\n END;\n ROLLBACK;\nEND\n$$", + "plpgsql_call-44.sql": "DO $$\nBEGIN\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\n UPDATE t_test SET x = x + 1;\n RAISE NOTICE 'f_get_x(%)', f_get_x();\n CALL f_print_x(f_get_x());\nEND\n$$", "plpgsql_cache-1.sql": "create function c_sillyaddone(int) returns int language plpgsql as\n$$ declare r c_mutable; begin r.f1 := $1; return r.f1 + 1; end $$", "plpgsql_cache-2.sql": "create function show_result_type(text) returns text language plpgsql as\n$$\n declare\n r record;\n t text;\n begin\n execute $1 into r;\n select pg_typeof(r.a) into t;\n return format('type %s value %s', t, r.a::text);\n end;\n$$", "plpgsql_array-1.sql": "do $$ declare a int[];\nbegin a := array[1,2]; a[3] := 4; raise notice 'a = %', a; end$$", diff --git a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql index c7846f4f6..56ac57d08 100644 --- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql +++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql @@ -815,3 +815,25 @@ BEGIN updated.updated_at := now(); RETURN updated; END$$; + +-- Test 66: assignment whose value is a call using named arguments +CREATE FUNCTION test_assign_named_args() RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + v_body text; +BEGIN + v_body := ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f'); + RETURN v_body; +END$$; + +-- Test 67: field and subscript assignment targets with named-argument values +CREATE FUNCTION test_assign_targets() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + a int[] := ARRAY[1, 2]; + r record; +BEGIN + SELECT 1 AS f INTO r; + a[2] := coalesce(nullif(3, 0), 4); + r.f := greatest(1, 2); +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 59b1b510b..f4fd5211b 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap @@ -276,6 +276,15 @@ EXCEPTION END" `; +exports[`plpgsql-deparser bug fixes assignment targets vs named arguments should keep the target when the value uses named arguments 1`] = ` +"DECLARE + v_body text; +BEGIN + v_body := ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f'); + RETURN v_body; +END" +`; + exports[`plpgsql-deparser bug fixes bare RAISE re-throw should keep a bare RAISE bare (not RAISE EXCEPTION;) 1`] = ` "BEGIN PERFORM 1; diff --git a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts index 7722b63a7..56f8aaa88 100644 --- a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts +++ b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts @@ -1,6 +1,6 @@ import { loadModule, parsePlPgSQLSync } from 'libpg-query'; -import { deparseSync, PLpgSQLParseResult } from '../src'; +import { deparseSync, ParseMode, PLpgSQLParseResult } from '../src'; import { PLpgSQLTestUtils } from '../test-utils'; describe('plpgsql-deparser bug fixes', () => { @@ -1216,6 +1216,112 @@ END$$`; }); }); + describe('assignment targets vs named arguments', () => { + it('should keep the target when the value uses named arguments', async () => { + const sql = `CREATE FUNCTION test_assign_named_args() RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + v_body text; +BEGIN + v_body := ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f'); + RETURN v_body; +END$$`; + + await testUtils.expectAstMatch('assignment with named arguments', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain( + `v_body := ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f');` + ); + }); + + it('should emit a self-contained assignment exactly once', async () => { + const sql = `CREATE FUNCTION test_assign_self_contained(n int) RETURNS int +LANGUAGE plpgsql AS $$ +DECLARE + sum int := 0; +BEGIN + sum := sum + n; + RETURN sum; +END$$`; + + await testUtils.expectAstMatch('self-contained assignment', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toContain('sum := sum + n;'); + expect(deparsed).not.toContain('sum := sum := '); + }); + + it('should keep field and subscript targets', async () => { + const sql = `CREATE FUNCTION test_assign_targets() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + a int[] := ARRAY[1, 2]; + r record; +BEGIN + SELECT 1 AS f INTO r; + a[2] := coalesce(nullif(3, 0), 4); + r.f := greatest(1, 2); +END$$`; + + await testUtils.expectAstMatch('field and subscript assignment targets', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toContain('a[2] := coalesce(nullif(3, 0), 4);'); + expect(deparsed).toContain('r.f := greatest(1, 2);'); + }); + + const buildAssign = (query: string, parseMode?: number): PLpgSQLParseResult => ({ + plpgsql_funcs: [ + { + PLpgSQL_function: { + datums: [{ PLpgSQL_var: { refname: 'v_x' } }], + action: { + PLpgSQL_stmt_block: { + body: [ + { + PLpgSQL_stmt_assign: { + varno: 0, + expr: { PLpgSQL_expr: { query, parseMode } }, + }, + }, + ], + }, + }, + }, + }, + ], + }); + + it('should keep the target of a hand-built assignment whose value uses named arguments', () => { + const deparsed = deparseSync( + buildAssign(`ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f')`) + ); + + expect(deparsed).toContain( + `v_x := ast_helpers.create_function(v_schema_name := 'app_public', v_function_name := 'f');` + ); + }); + + // A missing parseMode is treated as "not an assignment parse mode": the query + // text is the value only, and the target comes from varno. + it('should treat an absent parseMode as a value-only expression', () => { + expect(deparseSync(buildAssign('1 + 1'))).toContain('v_x := 1 + 1;'); + expect(deparseSync(buildAssign('v_x := 1'))).toContain('v_x := v_x := 1;'); + expect(deparseSync(buildAssign('v_x := 1', ParseMode.RAW_PARSE_PLPGSQL_ASSIGN1))).toContain('v_x := 1;'); + }); + + it('should un-parenthesize a subscripted target carried in the query text', () => { + const deparsed = deparseSync(buildAssign('(v_x)[2] := 5', ParseMode.RAW_PARSE_PLPGSQL_ASSIGN3)); + + expect(deparsed).toContain('v_x[2] := 5;'); + }); + }); + describe('uppercase %ROWTYPE/%TYPE references', () => { it('should not quote uppercase %ROWTYPE and %TYPE references', async () => { const sql = `CREATE FUNCTION test_rowtype_case() RETURNS void diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts index e32a9b0c8..ab40695d7 100644 --- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts +++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts @@ -13,6 +13,7 @@ import { QuoteUtils } from 'pgsql-deparser'; +import { ParseMode } from './hydrate-types'; import { DiagItemKind, ElogLevel, @@ -59,6 +60,18 @@ import { RaiseOptionType, } from './types'; +/** + * PostgreSQL parses the right-hand side of an assignment in a dedicated raw + * parse mode per target shape — RAW_PARSE_PLPGSQL_ASSIGN1 (3), ASSIGN2 (4) and + * ASSIGN3 (5) for `var`, `var.field` and `var.field[...]` targets — and that + * mode is preserved on `PLpgSQL_expr.parseMode`. In those modes the stored + * query text is the entire assignment, target included (`cnt := cnt + 1`); + * every lower mode (RAW_PARSE_DEFAULT, RAW_PARSE_TYPE_NAME, + * RAW_PARSE_PLPGSQL_EXPR) is a plain expression/statement parse whose text is + * only the value. + */ +const LOWEST_ASSIGN_PARSE_MODE = ParseMode.RAW_PARSE_PLPGSQL_ASSIGN1; + export interface PLpgSQLDeparserOptions { indent?: string; newline?: string; @@ -1129,19 +1142,35 @@ export class PLpgSQLDeparser { private deparseAssign(assign: PLpgSQL_stmt_assign, context: PLpgSQLDeparserContext): string { const varName = this.getVarName(assign.varno, context); const expr = assign.expr ? this.deparseExpr(assign.expr) : ''; - - // The expression already contains the assignment in the query - // e.g., "sum := sum + n" - if (expr.includes(':=')) { + const parseMode = assign.expr?.PLpgSQL_expr?.parseMode; + + if (this.exprCarriesAssignmentTarget(parseMode)) { // 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}`; } + /** + * Whether the stored query text of an assignment already spells out the + * assignment target, so that prefixing the target again would emit it twice. + * + * The parse mode answers this exactly. A substring test for ':=' cannot: ':=' + * is also PostgreSQL's named-argument operator, so a value like `f(a := 1)` + * reads as self-contained and the target gets silently dropped. + * + * An absent parseMode — hand-built nodes, or output from a PostgreSQL older + * than the assignment parse modes — means the same as mode 0: the text is the + * value only and the target comes from `varno`. A hand-built node that does + * embed its target in the text must say so with `parseMode`. + */ + private exprCarriesAssignmentTarget(parseMode?: number): boolean { + return (parseMode ?? ParseMode.RAW_PARSE_DEFAULT) >= LOWEST_ASSIGN_PARSE_MODE; + } + /** * Deparse an IF statement */