From 3faeac3cc3f8c6ca4da65559219123c0553500ab Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 21 Jul 2026 08:46:48 +0000 Subject: [PATCH] fix(plpgsql-deparser): emit ALIAS FOR declarations from parser alias metadata The parser (libpg_query 17-constructive) now serializes ALIAS declarations as an aliases array on PLpgSQL_function ({name, varno, lineno}); previously the alias name existed only in the compiler namespace and was dropped, so deparsed bodies referenced undeclared names. Alias declarations are interleaved with variable declarations by source lineno (variables first on ties) so aliases of local variables follow their target's declaration. Fixtures 61-63 cover positional-param, named-param/local-var, and OLD/NEW trigger aliases. Requires @libpg-query/parser >= 17.8.1 (aliases field). --- __fixtures__/plpgsql-generated/generated.json | 3 + .../plpgsql/plpgsql_deparser_fixes.sql | 32 ++++++++++ .../__snapshots__/deparser-fixes.test.ts.snap | 29 +++++++++ .../__tests__/deparser-fixes.test.ts | 61 +++++++++++++++++++ .../plpgsql-deparser/src/plpgsql-deparser.ts | 41 ++++++++++--- packages/plpgsql-deparser/src/types.ts | 12 ++++ 6 files changed, 170 insertions(+), 8 deletions(-) diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json index 0130d1f2..c2984e05 100644 --- a/__fixtures__/plpgsql-generated/generated.json +++ b/__fixtures__/plpgsql-generated/generated.json @@ -159,6 +159,9 @@ "plpgsql_deparser_fixes-58.sql": "-- Test 58: RETURN NEXT with a variable (retvarno must be emitted as the variable name)\nCREATE FUNCTION test_return_next_var() RETURNS SETOF integer\nLANGUAGE plpgsql AS $$\nDECLARE\n r integer;\nBEGIN\n FOR r IN SELECT g FROM generate_series(1, 3) g LOOP\n RETURN NEXT r;\n END LOOP;\nEND$$", "plpgsql_deparser_fixes-59.sql": "-- Test 59: Top-level block with EXCEPTION clause (compiler wraps it in a synthetic outer block; must not deparse a nested BEGIN)\nCREATE FUNCTION test_toplevel_exception(a numeric, b numeric) RETURNS numeric\nLANGUAGE plpgsql AS $$\nDECLARE\n v_result numeric;\nBEGIN\n v_result := a / b;\n RETURN v_result;\nEXCEPTION\n WHEN division_by_zero THEN\n RETURN NULL;\nEND$$", "plpgsql_deparser_fixes-60.sql": "-- Test 60: Explicit nested block with EXCEPTION inside a top-level block (nesting must be preserved)\nCREATE FUNCTION test_explicit_nested_exception(p_id integer) RETURNS text\nLANGUAGE plpgsql AS $$\nDECLARE\n v_result text;\nBEGIN\n v_result := 'unknown';\n BEGIN\n SELECT status INTO v_result FROM items WHERE id = p_id;\n EXCEPTION\n WHEN no_data_found THEN\n v_result := 'not_found';\n END;\n RETURN v_result;\nEND$$", + "plpgsql_deparser_fixes-61.sql": "-- Test 61: ALIAS FOR a positional parameter (alias declaration must be preserved)\nCREATE 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-62.sql": "-- Test 62: ALIAS FOR a named parameter and a local variable\nCREATE 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-63.sql": "-- Test 63: ALIAS FOR OLD/NEW in a trigger function\nCREATE 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_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 7846b1c1..be661cd6 100644 --- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql +++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql @@ -754,3 +754,35 @@ BEGIN END; RETURN v_result; END$$; + +-- Test 61: ALIAS FOR a positional parameter (alias declaration must be preserved) +CREATE FUNCTION test_alias_positional_param(integer) RETURNS integer +LANGUAGE plpgsql AS $$ +DECLARE + arg ALIAS FOR $1; +BEGIN + RETURN arg + 1; +END$$; + +-- Test 62: ALIAS FOR a named parameter and a local variable +CREATE FUNCTION test_alias_named(input_value text) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + val ALIAS FOR input_value; + buffer text := 'x'; + buf ALIAS FOR buffer; +BEGIN + buf := buf || val; + RETURN buf; +END$$; + +-- Test 63: ALIAS FOR OLD/NEW in a trigger function +CREATE FUNCTION test_alias_trigger() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + prior ALIAS FOR old; + updated ALIAS FOR new; +BEGIN + updated.updated_at := now(); + RETURN updated; +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 4ad5cc0f..cbd9f11d 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap @@ -1,5 +1,34 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`plpgsql-deparser bug fixes ALIAS declarations should interleave aliases with variable declarations by source order 1`] = ` +"DECLARE + val ALIAS FOR input_value; + buffer text := 'x'; + buf ALIAS FOR buffer; +BEGIN + buf := buf || val; + RETURN buf; +END" +`; + +exports[`plpgsql-deparser bug fixes ALIAS declarations should preserve ALIAS FOR OLD/NEW in trigger functions 1`] = ` +"DECLARE + prior ALIAS FOR old; + updated ALIAS FOR new; +BEGIN + updated.updated_at := now(); + RETURN updated; +END" +`; + +exports[`plpgsql-deparser bug fixes ALIAS declarations should preserve ALIAS FOR a positional parameter 1`] = ` +"DECLARE + arg ALIAS FOR $1; +BEGIN + RETURN arg + 1; +END" +`; + exports[`plpgsql-deparser bug fixes DML RETURNING ... INTO re-insertion should handle multi-column RETURNING ... INTO 1`] = ` "DECLARE v_id uuid; diff --git a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts index 35db7f75..b5d6f4b8 100644 --- a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts +++ b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts @@ -1345,4 +1345,65 @@ END$$`; expect(beginCount).toBe(2); }); }); + + describe('ALIAS declarations', () => { + it('should preserve ALIAS FOR a positional parameter', async () => { + const sql = `CREATE FUNCTION test_alias_positional_param(integer) RETURNS integer +LANGUAGE plpgsql AS $$ +DECLARE + arg ALIAS FOR $1; +BEGIN + RETURN arg + 1; +END$$`; + + await testUtils.expectAstMatch('alias for positional parameter', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('arg ALIAS FOR $1;'); + }); + + it('should interleave aliases with variable declarations by source order', async () => { + const sql = `CREATE FUNCTION test_alias_named(input_value text) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + val ALIAS FOR input_value; + buffer text := 'x'; + buf ALIAS FOR buffer; +BEGIN + buf := buf || val; + RETURN buf; +END$$`; + + await testUtils.expectAstMatch('aliases interleaved with variables', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + // the alias of a local variable must come after that variable's declaration + expect(deparsed.indexOf('buf ALIAS FOR buffer;')).toBeGreaterThan(deparsed.indexOf('buffer text')); + expect(deparsed).toContain('val ALIAS FOR input_value;'); + }); + + it('should preserve ALIAS FOR OLD/NEW in trigger functions', async () => { + const sql = `CREATE FUNCTION test_alias_trigger() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + prior ALIAS FOR old; + updated ALIAS FOR new; +BEGIN + updated.updated_at := now(); + RETURN updated; +END$$`; + + await testUtils.expectAstMatch('alias for OLD/NEW in trigger', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('prior ALIAS FOR old;'); + expect(deparsed).toContain('updated ALIAS FOR new;'); + }); + }); }); diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts index 2f099d36..3dc82370 100644 --- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts +++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts @@ -17,6 +17,7 @@ import { PLpgSQLFunctionNode, PLpgSQL_function, PLpgSQLDatum, + PLpgSQLAliasNode, PLpgSQL_var, PLpgSQL_rec, PLpgSQL_row, @@ -213,7 +214,8 @@ export class PLpgSQLDeparser { context, loopVarLinenos, undefined, // includedIndices - not used for top-level - nestedDatumIndices // excludedIndices - exclude datums that belong to nested blocks + nestedDatumIndices, // excludedIndices - exclude datums that belong to nested blocks + func.aliases ); if (declareSection) { parts.push(declareSection); @@ -631,7 +633,8 @@ export class PLpgSQLDeparser { context: PLpgSQLDeparserContext, loopVarLinenos: Set = new Set(), includedIndices?: Set, - excludedIndices?: Set + excludedIndices?: Set, + aliases?: PLpgSQLAliasNode[] ): string { if (!datums || datums.length === 0) { return ''; @@ -698,20 +701,42 @@ export class PLpgSQLDeparser { return false; }); - if (localVars.length === 0) { - return ''; + // ALIAS declarations bind a new name to an existing datum (e.g. `arg ALIAS FOR $1`). + // They are interleaved with variable declarations by source lineno so an alias + // targeting a local variable is emitted after that variable's declaration. + // On equal linenos, variables sort before aliases so a same-line alias + // targeting a variable still follows its declaration. + const decls: { lineno: number; order: number; text: string }[] = []; + for (const alias of aliases ?? []) { + const { name, varno, lineno } = alias.PLpgSQL_alias; + const targetName = this.getVarName(varno, context); + decls.push({ lineno: lineno ?? 0, order: 1, text: `${name} ${this.keyword('ALIAS FOR')} ${targetName}` }); } - const kw = this.keyword; - const parts: string[] = [kw('DECLARE')]; - for (const datum of localVars) { const varDecl = this.deparseDatum(datum, context); if (varDecl) { - parts.push(this.indent(varDecl + ';', context.indentLevel + 1)); + const lineno = + ('PLpgSQL_var' in datum ? datum.PLpgSQL_var.lineno : undefined) ?? + ('PLpgSQL_rec' in datum ? datum.PLpgSQL_rec.lineno : undefined) ?? + 0; + decls.push({ lineno, order: 0, text: varDecl }); } } + if (decls.length === 0) { + return ''; + } + + decls.sort((a, b) => a.lineno - b.lineno || a.order - b.order); + + const kw = this.keyword; + const parts: string[] = [kw('DECLARE')]; + + for (const decl of decls) { + parts.push(this.indent(decl.text + ';', context.indentLevel + 1)); + } + return parts.join(this.options.newline); } diff --git a/packages/plpgsql-deparser/src/types.ts b/packages/plpgsql-deparser/src/types.ts index 0ebc971a..71bb692b 100644 --- a/packages/plpgsql-deparser/src/types.ts +++ b/packages/plpgsql-deparser/src/types.ts @@ -18,6 +18,18 @@ export interface PLpgSQL_function { datums?: PLpgSQLDatum[]; action?: PLpgSQLStmtNode; out_param_varno?: number; + aliases?: PLpgSQLAliasNode[]; +} + +export type PLpgSQLAliasNode = { + PLpgSQL_alias: PLpgSQL_alias; +}; + +/** An ALIAS declaration (e.g. `arg ALIAS FOR $1`): a name bound to an existing datum */ +export interface PLpgSQL_alias { + name: string; + varno: number; + lineno?: number; } export type PLpgSQLDatum =