Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions __fixtures__/plpgsql-generated/generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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$$",
Expand Down
35 changes: 35 additions & 0 deletions __fixtures__/plpgsql/plpgsql_deparser_fixes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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$$;
70 changes: 65 additions & 5 deletions packages/plpgsql-deparser/src/plpgsql-deparser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '');
}
Comment on lines +886 to 889

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Quote-stripping still mangles other quoted type names

The new guard protects only the exact name "char" (plus array suffixes); every other quoted simple type is still blindly unquoted at packages/plpgsql-deparser/src/plpgsql-deparser.ts:888. A quoted mixed-case or otherwise unquotable type name such as "CHAR" or "my type" is emitted as CHAR / my type, silently re-resolving to the built-in char or producing invalid SQL. This is the same meaning-changing-unquoting defect the change fixes for "char", left unhandled for all other quoted names.

📋 Prompt for AI Agents

In packages/plpgsql-deparser/src/plpgsql-deparser.ts around lines 886-889, replace the blanket trimmed.replace(/"/g, '') with a conditional: keep the quotes unless the inner content is already a valid lowercase unquoted identifier (/^[a-z_][a-z0-9_$]*$/), with the documented exception that "char" is always preserved as-is per the existing regex. This prevents quoted names like "CHAR" or "my type" from being silently unquoted into a different or invalid type.


// Schema-qualified type - apply proper quoting
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading