diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json index 38757ef7..363efcff 100644 --- a/__fixtures__/plpgsql-generated/generated.json +++ b/__fixtures__/plpgsql-generated/generated.json @@ -160,6 +160,9 @@ "plpgsql_deparser_fixes-60.sql": "CREATE 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": "CREATE FUNCTION test_out_param_bare_return(\n IN a text,\n IN b uuid,\n OUT result uuid\n) RETURNS uuid\nLANGUAGE plpgsql AS $$\nDECLARE\n v_id uuid;\nBEGIN\n v_id := b;\n SELECT v_id INTO result;\n RETURN;\nEND$$", "plpgsql_deparser_fixes-62.sql": "CREATE FUNCTION test_multi_out_bare_return(\n IN a integer,\n OUT x integer,\n OUT y text\n)\nLANGUAGE plpgsql AS $$\nBEGIN\n x := a;\n y := 'ok';\n RETURN;\nEND$$", + "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_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$$", diff --git a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql index f46f7be3..c7846f4f 100644 --- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql +++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql @@ -783,3 +783,35 @@ BEGIN y := 'ok'; RETURN; END$$; + +-- Test 63: 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 64: 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 65: 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 a69f95dd..59b1b510 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 buffer; +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 NEW; +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 f5dbd914..17385209 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 b10dcd55..6117130f 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, @@ -215,7 +216,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); @@ -633,7 +635,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 ''; @@ -700,20 +703,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 =