From ffd037ee60968019f9b8167ec9e92c548ca14812 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 25 Oct 2026 03:37:17 +0000 Subject: [PATCH] fix(plpgsql-deparser): keep multi-line literals, "char" and empty CASE ELSE intact --- __fixtures__/plpgsql-generated/generated.json | 3 + .../plpgsql/plpgsql_deparser_fixes.sql | 35 ++++++++++ .../plpgsql-deparser/src/plpgsql-deparser.ts | 70 +++++++++++++++++-- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json index 11206b7a..54eac497 100644 --- a/__fixtures__/plpgsql-generated/generated.json +++ b/__fixtures__/plpgsql-generated/generated.json @@ -165,6 +165,9 @@ "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_deparser_fixes-68.sql": "CREATE FUNCTION test_multiline_literals(p_on boolean) RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n IF p_on THEN\n RAISE NOTICE 'line one\n line two';\n EXECUTE format('SELECT 1\n FROM %I', 'pg_class');\n PERFORM E'it''s\\\n escaped', $q$dollar\n quoted$q$;\n END IF;\nEND$$", + "plpgsql_deparser_fixes-69.sql": "CREATE FUNCTION test_quoted_char_type() RETURNS \"char\"\nLANGUAGE plpgsql AS $$\nDECLARE\n c \"char\" := 'a';\nBEGIN\n RETURN c;\nEND$$", + "plpgsql_deparser_fixes-70.sql": "CREATE FUNCTION test_case_empty_else(x int) RETURNS text\nLANGUAGE plpgsql AS $$\nBEGIN\n CASE x\n WHEN 1 THEN RETURN 'one';\n ELSE\n END CASE;\n RETURN 'other';\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 56ac57d0..fb8031c8 100644 --- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql +++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql @@ -837,3 +837,38 @@ BEGIN a[2] := coalesce(nullif(3, 0), 4); r.f := greatest(1, 2); END$$; + +-- Test 68: multi-line string literals keep their exact text inside nested blocks +CREATE FUNCTION test_multiline_literals(p_on boolean) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + IF p_on THEN + RAISE NOTICE 'line one + line two'; + EXECUTE format('SELECT 1 + FROM %I', 'pg_class'); + PERFORM E'it''s\ + escaped', $q$dollar + quoted$q$; + END IF; +END$$; + +-- Test 69: quoted "char" type keeps its quotes +CREATE FUNCTION test_quoted_char_type() RETURNS "char" +LANGUAGE plpgsql AS $$ +DECLARE + c "char" := 'a'; +BEGIN + RETURN c; +END$$; + +-- Test 70: CASE with an empty ELSE keeps the ELSE +CREATE FUNCTION test_case_empty_else(x int) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + CASE x + WHEN 1 THEN RETURN 'one'; + ELSE + END CASE; + RETURN 'other'; +END$$; diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts index ab40695d..39336032 100644 --- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts +++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts @@ -883,8 +883,9 @@ export class PLpgSQLDeparser { const isSchemaQualified = this.isSchemaQualifiedType(typname); if (!isSchemaQualified) { - // Simple type - just strip quotes and return as-is - return typname.replace(/"/g, '').trim(); + // Simple type - strip quotes unless they change the meaning ("char" is not char) + const trimmed = typname.trim(); + return /^"char"(\[\d*\])*$/.test(trimmed) ? trimmed : trimmed.replace(/"/g, ''); } // Schema-qualified type - apply proper quoting @@ -1266,10 +1267,10 @@ export class PLpgSQLDeparser { } // ELSE clause - if (caseStmt.have_else && caseStmt.else_stmts) { + if (caseStmt.have_else) { parts.push(this.indent(kw('ELSE'), context.indentLevel + 1)); const bodyContext = { ...context, indentLevel: context.indentLevel + 2 }; - for (const stmt of caseStmt.else_stmts) { + for (const stmt of caseStmt.else_stmts ?? []) { const stmtStr = this.deparseStmt(stmt, bodyContext); parts.push(this.indent(stmtStr + ';', bodyContext.indentLevel)); } @@ -2406,7 +2407,66 @@ export class PLpgSQLDeparser { */ private indent(text: string, level: number): string { const indent = this.options.indent!.repeat(level); - return indent + text.replace(/\n/g, '\n' + indent); + if (!indent) return text; + let out = indent; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '\n') { + out += '\n' + indent; + i++; + } else if (ch === "'" || ch === '"' || ch === '$' || (ch === '-' && text[i + 1] === '-') || (ch === '/' && text[i + 1] === '*')) { + const end = this.skipQuoted(text, i); + const chunk = text.slice(i, end); + out += ch === '/' ? chunk.replace(/\n/g, '\n' + indent) : chunk; + i = end; + } else { + out += ch; + i++; + } + } + return out; + } + + /** + * Return the index just past a string literal, quoted identifier or + * dollar-quoted string starting at `start`, so its newlines are left intact. + * Comments are skipped so that quotes inside them are not mistaken for + * literal delimiters. + */ + private skipQuoted(text: string, start: number): number { + const ch = text[start]; + if (ch === '-') { + const nl = text.indexOf('\n', start); + return nl === -1 ? text.length : nl; + } + if (ch === '/') { + const close = text.indexOf('*/', start + 2); + return close === -1 ? text.length : close + 2; + } + if (ch === '$') { + const prev = start > 0 ? text[start - 1] : ''; + const tag = /^\$([A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/.exec(text.slice(start)); + if (!tag || /[A-Za-z0-9_$]/.test(prev)) return start + 1; + const close = text.indexOf(tag[0], start + tag[0].length); + return close === -1 ? text.length : close + tag[0].length; + } + const escapes = ch === "'" && start > 0 && /[eE]/.test(text[start - 1]) && !/[A-Za-z0-9_]/.test(text[start - 2] ?? ''); + let i = start + 1; + while (i < text.length) { + if (escapes && text[i] === '\\') { + i += 2; + } else if (text[i] === ch) { + if (text[i + 1] === ch) { + i += 2; + } else { + return i + 1; + } + } else { + i++; + } + } + return text.length; } /**