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 =