From 00985f79ea7f1c8c5a2eb0e7328532a082d6aba3 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 18:46:19 +0000 Subject: [PATCH 1/8] Fix backend crashes and output blowups found by a security audit Every plx language is trusted, so any role with USAGE can send arbitrary input to the transpiler through CREATE FUNCTION or DO. - plx_span_text: skip type-annotation tokens (which carry no source span) at the edges of a span. A span beginning on one computed its length from a NULL pointer and crashed the backend (plxjs/ts/php/ruby/python3). - plxtsql, plxgo, plxcobol: guard the expression emitters with check_stack_depth() and the PLX_MAX_DEPTH counter. Deeply nested calls, conversions or subscripts overflowed the C stack (SIGSEGV). - finally/ensure: cap finally-inside-finally nesting at 8 levels; each level doubled the generated body, so a few hundred bytes of source could demand gigabytes. The three copies of the try lowering are now one helper, plx_emit_try, which also runs the normal-path finally body after the protected block, so a finally that raises no longer runs twice. - plxgo fmt.Sprintf: require a letter verb before lowering to a || chain; a '%' followed by a quote consumed half of an escaped '' pair and let Go string text escape into the generated SQL. Regression tests cover each case. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 37 +++++++++++++++++++ src/plx_dialect_cobol.c | 19 +++++++++- src/plx_dialect_go.c | 58 ++++++++++++++++++++++++++++- src/plx_dialect_python.c | 41 ++------------------- src/plx_dialect_ruby.c | 41 ++------------------- src/plx_dialect_tsql.c | 17 ++++++++- src/plx_engine.h | 6 +++ src/plx_parse_brace.c | 41 ++------------------- src/plx_transpile.c | 71 ++++++++++++++++++++++++++++++++++++ test/expected/plxcobol.out | 13 +++++++ test/expected/plxgo.out | 16 ++++++++ test/expected/plxjs.out | 28 ++++++++++++++ test/expected/plxpython3.out | 6 +++ test/expected/plxtsql.out | 6 +++ test/sql/plxcobol.sql | 13 +++++++ test/sql/plxgo.sql | 12 ++++++ test/sql/plxjs.sql | 21 +++++++++++ test/sql/plxpython3.sql | 7 ++++ test/sql/plxtsql.sql | 6 +++ 19 files changed, 342 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930387..389780f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ All notable changes to plx are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/), and plx uses the extension version in `plx.control` (currently `1.0`). +## [Unreleased] + +Security fixes from a code audit. Code-only: no SQL objects change, so a new +build and a server restart (or new sessions) pick them up without +`ALTER EXTENSION`. Every fix concerns input to `CREATE FUNCTION` or `DO`, which +any role with USAGE on a plx language can send (the languages are trusted, so +PUBLIC has USAGE by default). + +### Security + +- **Backend crash on a type annotation at the start of an expression.** A + `/*:: T */` or `#:: T` annotation as the first token of an `if`/`while` + condition, a call argument, or a statement made the transpiler copy a span + from a NULL pointer, killing the backend with SIGSEGV (and resetting every + session on the server). Affected plxjs, plxts, plxphp, plxruby, plxpython3, + including DO blocks. Annotations are now skipped at span edges. +- **Stack-overflow crash on deeply nested expressions in plxtsql, plxgo and + plxcobol.** Their expression emitters recursed once per nested call, + subscript or literal without `check_stack_depth()`, so a body like + `ISNULL(ISNULL(...))` or `int(int(...))` some 50,000 levels deep crashed the + backend. They now raise `expression nested too deeply`, like the shared + rewriter already did. +- **Exponential output from nested `finally`/`ensure`.** Each finally body is + emitted once per exit path, so a finally nested inside a finally doubled the + output per level: a 668-byte function stored a 94 MB body, and a slightly + deeper one drove a backend to gigabytes of memory. Nesting a finally inside a + finally is now limited to 8 levels. +- **plxgo `fmt.Sprintf` directive without a letter verb.** A `%` followed by a + quote consumed half of the escaped `''` pair, so text inside a Go string + literal could end up as SQL in the generated body. Such directives now fall + back to the `format()` lowering, which keeps the text literal. + +### Fixed + +- A `finally`/`ensure` body that raised an error on the normal path ran a + second time from the error path. It now runs once. + ## [2.0.1] - 2026-08-25 Packaging only. **The extension is unchanged at 2.0.0**, so there is no diff --git a/src/plx_dialect_cobol.c b/src/plx_dialect_cobol.c index 33ffa49..0cf6554 100644 --- a/src/plx_dialect_cobol.c +++ b/src/plx_dialect_cobol.c @@ -442,8 +442,10 @@ cob_is_array(Cb *cb, const char *name) * noise word, figurative constants, ** -> ^, data-name mapping, and array * subscripts (WS-ARR(I) -> ws_arr[i]). */ +static void cob_emit_range(Cb *cb, int a, int b, StringInfo out); + static void -cob_emit_range(Cb *cb, int a, int b, StringInfo out) +cob_emit_range_inner(Cb *cb, int a, int b, StringInfo out) { CbTok *t = cb->t; int i = a; @@ -621,6 +623,21 @@ cob_emit_range(Cb *cb, int a, int b, StringInfo out) } } +/* + * Recursion guard for the expression emitter. Nested calls, subscripts and + * literals recurse through here once per level; without check_stack_depth() + * hostile nesting overflows the C stack and crashes the backend (SIGSEGV). + */ +static void +cob_emit_range(Cb *cb, int a, int b, StringInfo out) +{ + check_stack_depth(); + if (++cb->cx->depth > PLX_MAX_DEPTH) + cob_err(cb, "expression nested too deeply"); + cob_emit_range_inner(cb, a, b, out); + cb->cx->depth--; +} + /* true if the current token stops a value scan */ static bool cob_value_stop(Cb *cb, const char *const *stops, int nstops) diff --git a/src/plx_dialect_go.c b/src/plx_dialect_go.c index be53a05..08383fc 100644 --- a/src/plx_dialect_go.c +++ b/src/plx_dialect_go.c @@ -489,6 +489,36 @@ go_emit_format_str(GoTok *tk, StringInfo out) pfree(raw.data); } +/* + * Index of the verb of the fmt directive whose '%' is at raw->data[pct], after + * skipping flags, width and precision; -1 when that character is not an ASCII + * letter (or the directive runs into the closing quote). + */ +static int +go_sprintf_verb(StringInfo raw, int pct) +{ + int j = pct + 1; + + while (j + 1 < raw->len && (raw->data[j] == '-' || raw->data[j] == '+' || + raw->data[j] == ' ' || raw->data[j] == '#' || + raw->data[j] == '0')) + j++; + while (j + 1 < raw->len && raw->data[j] >= '0' && raw->data[j] <= '9') + j++; + if (j + 1 < raw->len && raw->data[j] == '.') + { + j++; + while (j + 1 < raw->len && raw->data[j] >= '0' && raw->data[j] <= '9') + j++; + } + if (j + 1 >= raw->len) + return -1; + if (!((raw->data[j] >= 'a' && raw->data[j] <= 'z') || + (raw->data[j] >= 'A' && raw->data[j] <= 'Z'))) + return -1; + return j; +} + /* fmt.Sprintf with a literal format string, lowered to a || chain. * format() renders a NULL operand as the empty string and cannot be made to do @@ -565,6 +595,17 @@ go_emit_sprintf_concat(Go *g, int lp, int close, StringInfo out) i++; continue; } + /* + * The verb must be a letter. raw is already a SQL literal (' + * doubled), so a quote here would otherwise be consumed as the verb + * and leave half of a '' pair unescaped in the output; fall back to + * the format() path, which validates verbs, instead. + */ + if (go_sprintf_verb(&raw, i) < 0) + { + pfree(raw.data); + return false; + } ndir++; } if (ndir != nargs) @@ -961,7 +1002,7 @@ go_emit_call(Go *g, int i, int b, StringInfo out, int *ni) /* emit tokens [a,b) as a plpgsql expression */ static void -go_emit_range(Go *g, int a, int b, StringInfo out) +go_emit_range_inner(Go *g, int a, int b, StringInfo out) { int i = a; @@ -1117,6 +1158,21 @@ go_emit_range(Go *g, int a, int b, StringInfo out) } } +/* + * Recursion guard for the expression emitter. Nested calls, subscripts and + * literals recurse through here once per level; without check_stack_depth() + * hostile nesting overflows the C stack and crashes the backend (SIGSEGV). + */ +static void +go_emit_range(Go *g, int a, int b, StringInfo out) +{ + check_stack_depth(); + if (++g->cx->depth > PLX_MAX_DEPTH) + go_err(g, "expression nested too deeply"); + go_emit_range_inner(g, a, b, out); + g->cx->depth--; +} + /* end of a simple statement: the next top-level ; (brackets/braces nest) */ static int go_stmt_end(Go *g, int from) diff --git a/src/plx_dialect_python.c b/src/plx_dialect_python.c index 0daba37..077236b 100644 --- a/src/plx_dialect_python.c +++ b/src/plx_dialect_python.c @@ -354,52 +354,17 @@ parse_py_try(Ctx *cx, int ind) colon = py_header_colon(cx, cx->pos + 1); if (colon < 0) plx_err(cx, cx->t[cx->pos].line, "finally requires ':'"); + plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos = colon + 1; saved = cx->out; initStringInfo(&cx->out); parse_py_block(cx, ind + 1); + cx->ensuredepth--; ensure_body = cx->out.data; cx->out = saved; } - if (!ensure_body) - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } - else - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - plx_indent(o, ind + 1); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind + 1); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind + 1); - appendStringInfoString(o, "END;\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind + 1); - appendStringInfoString(o, "RAISE;\n"); - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } + plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); } /* raise / raise Class("msg") */ diff --git a/src/plx_dialect_ruby.c b/src/plx_dialect_ruby.c index 918a950..58d7e5e 100644 --- a/src/plx_dialect_ruby.c +++ b/src/plx_dialect_ruby.c @@ -484,8 +484,10 @@ parse_begin(Ctx *cx, int ind) if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_ENSURE) { + plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos++; ensure_body = capture_block(cx, ind + 1); + cx->ensuredepth--; } if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_END) @@ -493,44 +495,7 @@ parse_begin(Ctx *cx, int ind) else plx_err(cx, bl, "unterminated begin block"); - if (!ensure_body) - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } - else - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - plx_indent(o, ind + 1); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind + 1); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind + 1); - appendStringInfoString(o, "END;\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind + 1); - appendStringInfoString(o, "RAISE;\n"); - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } + plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); } /* rewrite a comma-separated value list (for CASE WHEN v1, v2) */ diff --git a/src/plx_dialect_tsql.c b/src/plx_dialect_tsql.c index fd03afd..c3b02e6 100644 --- a/src/plx_dialect_tsql.c +++ b/src/plx_dialect_tsql.c @@ -679,7 +679,7 @@ tq_emit_call(Tq *tq, int i, int b, StringInfo out, int *ni) /* emit tokens [a,b) as a plpgsql expression */ static void -tq_emit_range(Tq *tq, int a, int b, StringInfo out) +tq_emit_range_inner(Tq *tq, int a, int b, StringInfo out) { int i = a; @@ -782,6 +782,21 @@ tq_emit_range(Tq *tq, int a, int b, StringInfo out) } } +/* + * Recursion guard for the expression emitter. Nested calls, subscripts and + * literals recurse through here once per level; without check_stack_depth() + * hostile nesting overflows the C stack and crashes the backend (SIGSEGV). + */ +static void +tq_emit_range(Tq *tq, int a, int b, StringInfo out) +{ + check_stack_depth(); + if (++tq->cx->depth > PLX_MAX_DEPTH) + tq_err(tq, "expression nested too deeply"); + tq_emit_range_inner(tq, a, b, out); + tq->cx->depth--; +} + /* end of an expression/raw statement: first depth-0 ; EOF END or ELSE */ static int tq_value_end(Tq *tq, int from) diff --git a/src/plx_engine.h b/src/plx_engine.h index 434be02..73c9ac7 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -27,6 +27,8 @@ #endif #define PLX_MAX_DEPTH 500 /* recursion cap (parsers + expr rewriter) */ +#define PLX_MAX_ENSURE_DEPTH 8 /* finally-in-finally cap; each level doubles + * the inner body (see plx_emit_try) */ /* stacked-diagnostics field bits (e.detail, e.hint, ...); used by the engine's * diagnostics emitter and by the dialect rescue/try parsers. */ @@ -92,6 +94,7 @@ typedef struct PlxCtx int loopdepth; int depth; /* recursion depth guard */ int handlerdepth; /* inside a rescue handler body */ + int ensuredepth; /* finally/ensure nested inside finally/ensure */ int subq; /* __plx_fo_N / __plx_p_N counter */ const char *exc_var; /* current rescue exception var name */ int exc_varlen; @@ -133,6 +136,9 @@ bool plx_tok_is(Tok *tk, const char *op); bool plx_name_eq(Tok *tk, const char *w); int plx_stmt_end(Ctx *cx, int from); void plx_indent(StringInfo o, int n); +void plx_ensure_enter(Ctx *cx, int line); +void plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, + const char *arms, const char *ensure_body); PlxLocal2 *plx_local_add(Ctx *cx, const char *name, int len); PlxLocal2 *plx_local_find(Ctx *cx, const char *name, int len); bool plx_is_param(Ctx *cx, const char *name, int len); diff --git a/src/plx_parse_brace.c b/src/plx_parse_brace.c index 49b8ee3..a65dd76 100644 --- a/src/plx_parse_brace.c +++ b/src/plx_parse_brace.c @@ -427,51 +427,16 @@ parse_try_brace(Ctx *cx, int ind) { StringInfoData saved = cx->out; + plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos++; initStringInfo(&cx->out); parse_brace_block(cx, ind + 1); + cx->ensuredepth--; ensure_body = cx->out.data; cx->out = saved; } - if (!ensure_body) - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } - else - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - plx_indent(o, ind + 1); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind + 1); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind + 1); - appendStringInfoString(o, "END;\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind + 1); - appendStringInfoString(o, "RAISE;\n"); - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } + plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); (void) line; } diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 0a23018..80e2d47 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -1471,6 +1471,15 @@ plx_span_text(Ctx *cx, int a, int b) { const char *st, *en; + /* + * Type-annotation tokens carry no source span (s == NULL), so trim them + * off both ends; a span that began on one used to compute its length + * from a NULL pointer and crash the backend in pnstrdup. + */ + while (a < b && cx->t[a].s == NULL) + a++; + while (b > a && cx->t[b - 1].s == NULL) + b--; if (b <= a) return pstrdup(""); st = cx->t[a].s; @@ -1512,6 +1521,68 @@ plx_indent(StringInfo o, int n) appendStringInfoString(o, " "); } +/* + * Enter a finally/ensure body. The lowering below emits that body twice (once + * per exit path), so a finally nested inside a finally doubles per level: a + * few hundred bytes of source could otherwise demand gigabytes of output. The + * caller decrements cx->ensuredepth after parsing the body. + */ +void +plx_ensure_enter(Ctx *cx, int line) +{ + if (++cx->ensuredepth > PLX_MAX_ENSURE_DEPTH) + plx_err(cx, line, "finally/ensure blocks nested too deeply inside one another"); +} + +/* + * Emit a try/rescue[/finally] statement (shared by every front end): + * + * BEGIN -- only when there is a finally body + * BEGIN body EXCEPTION arms END; + * EXCEPTION WHEN OTHERS THEN + * finally; RAISE; + * END; + * finally; -- normal path, outside the handler + * + * The normal-path copy runs after the protected block rather than inside it, + * so an error raised by the finally body itself is not caught and does not + * run the finally body a second time. + */ +void +plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, + const char *arms, const char *ensure_body) +{ + int in = ensure_body ? ind + 1 : ind; + + if (ensure_body) + { + plx_indent(o, ind); + appendStringInfoString(o, "BEGIN\n"); + } + plx_indent(o, in); + appendStringInfoString(o, "BEGIN\n"); + appendStringInfoString(o, body); + if (nrescue) + { + plx_indent(o, in); + appendStringInfoString(o, "EXCEPTION\n"); + appendStringInfoString(o, arms); + } + plx_indent(o, in); + appendStringInfoString(o, "END;\n"); + if (ensure_body) + { + plx_indent(o, ind); + appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); + appendStringInfoString(o, ensure_body); + plx_indent(o, ind + 1); + appendStringInfoString(o, "RAISE;\n"); + plx_indent(o, ind); + appendStringInfoString(o, "END;\n"); + appendStringInfoString(o, ensure_body); + } +} + /* ---------------------------------------------------------------- leaves */ /* find end of current logical statement: index of NEWLINE/SEMI/EOF */ diff --git a/test/expected/plxcobol.out b/test/expected/plxcobol.out index cf488c0..5b7a6cb 100644 --- a/test/expected/plxcobol.out +++ b/test/expected/plxcobol.out @@ -490,3 +490,16 @@ SELECT msg FROM cob_log; logged (1 row) +-- hostile subscript nesting raises a clean error instead of overflowing the stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION cob_deep() RETURNS int LANGUAGE plxcobol AS $b$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-ARR PIC 9(9) OCCURS 5 TIMES. +01 WS-X PIC 9(9). +PROCEDURE DIVISION. + COMPUTE WS-X = ' || repeat('WS-ARR(', 2000) || '1' || repeat(')', 2000) || ' + GOBACK. +$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxcobol: expression nested too deeply diff --git a/test/expected/plxgo.out b/test/expected/plxgo.out index 549dea0..b15be36 100644 --- a/test/expected/plxgo.out +++ b/test/expected/plxgo.out @@ -352,3 +352,19 @@ CREATE FUNCTION g_panic_msg(who text) RETURNS int LANGUAGE plxgo AS $$ $$; DO $d$ BEGIN PERFORM g_panic_msg(NULL); EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'caught: [%]', SQLERRM; END $d$; +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION g_deep() RETURNS int LANGUAGE plxgo AS $b$return ' + || repeat('int(', 2000) || '1' || repeat(')', 2000) || E'\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxgo: expression nested too deeply +-- a Sprintf directive without a letter verb stays inside the literal +CREATE FUNCTION g_badverb(x text) RETURNS text LANGUAGE plxgo AS $$ + return fmt.Sprintf("%' || version() || '", x) +$$; +SELECT g_badverb('v'); + g_badverb +---------------------- + %' || version() || ' +(1 row) + diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index baf4fdb..42bb65a 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -241,3 +241,31 @@ SELECT js_flt() AS flt, js_exp() AS exp; 3.14 | 1500 (1 row) +-- a type annotation at the start of a condition is ignored (it used to give a +-- span with no source text and crash the backend) +CREATE FUNCTION js_ann_cond(x int) RETURNS int LANGUAGE plxjs AS $$ + if (/*:: int */ x > 0) { return 1; } + return 0; +$$; +SELECT js_ann_cond(3), js_ann_cond(-3); + js_ann_cond | js_ann_cond +-------------+------------- + 1 | 0 +(1 row) + +-- finally inside finally is capped (each level doubles the generated body) +DO $d$ DECLARE s text := 'x = 1;'; BEGIN + FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + || s || ' return x; $b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxjs: finally/ensure blocks nested too deeply inside one another +-- a finally body that throws runs once, not again from the error path +DO LANGUAGE plxjs $p$ + try { raise("warning", "body"); } + finally { raise("warning", "finally ran"); throw new Exception("boom"); } +$p$; +WARNING: body +WARNING: finally ran +ERROR: boom +CONTEXT: PL/pgSQL function inline_code_block line 13 at RAISE diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index 1e0359f..957a353 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -201,3 +201,9 @@ SELECT py_isnull(NULL), py_isnull(5); null | notnull (1 row) +-- a type annotation inside a call argument list is ignored (it used to give a +-- span with no source text and crash the backend) +DO LANGUAGE plxpython3 $p$ +assert(#:: int + 1 > 0) +$p$; diff --git a/test/expected/plxtsql.out b/test/expected/plxtsql.out index daa328c..628c11d 100644 --- a/test/expected/plxtsql.out +++ b/test/expected/plxtsql.out @@ -205,3 +205,9 @@ SELECT id, total, tag FROM tq_trg; (1 row) DROP TABLE tq_trg CASCADE; +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxtsql: expression nested too deeply diff --git a/test/sql/plxcobol.sql b/test/sql/plxcobol.sql index 0bc2f3e..44474e4 100644 --- a/test/sql/plxcobol.sql +++ b/test/sql/plxcobol.sql @@ -376,3 +376,16 @@ PROCEDURE DIVISION. $$; SELECT cob_donote(); SELECT msg FROM cob_log; + +-- hostile subscript nesting raises a clean error instead of overflowing the stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION cob_deep() RETURNS int LANGUAGE plxcobol AS $b$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-ARR PIC 9(9) OCCURS 5 TIMES. +01 WS-X PIC 9(9). +PROCEDURE DIVISION. + COMPUTE WS-X = ' || repeat('WS-ARR(', 2000) || '1' || repeat(')', 2000) || ' + GOBACK. +$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; diff --git a/test/sql/plxgo.sql b/test/sql/plxgo.sql index c3565c3..c11e9c2 100644 --- a/test/sql/plxgo.sql +++ b/test/sql/plxgo.sql @@ -246,3 +246,15 @@ CREATE FUNCTION g_panic_msg(who text) RETURNS int LANGUAGE plxgo AS $$ $$; DO $d$ BEGIN PERFORM g_panic_msg(NULL); EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'caught: [%]', SQLERRM; END $d$; + +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION g_deep() RETURNS int LANGUAGE plxgo AS $b$return ' + || repeat('int(', 2000) || '1' || repeat(')', 2000) || E'\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- a Sprintf directive without a letter verb stays inside the literal +CREATE FUNCTION g_badverb(x text) RETURNS text LANGUAGE plxgo AS $$ + return fmt.Sprintf("%' || version() || '", x) +$$; +SELECT g_badverb('v'); diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index 17218fa..a565368 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -165,3 +165,24 @@ SELECT js_hex() AS hex, js_bin() AS bin, js_oct() AS oct, js_hex_us() AS hex_us; CREATE FUNCTION js_flt() RETURNS numeric LANGUAGE plxjs AS $$ return 3.14; $$; CREATE FUNCTION js_exp() RETURNS numeric LANGUAGE plxjs AS $$ return 1.5e3; $$; SELECT js_flt() AS flt, js_exp() AS exp; + +-- a type annotation at the start of a condition is ignored (it used to give a +-- span with no source text and crash the backend) +CREATE FUNCTION js_ann_cond(x int) RETURNS int LANGUAGE plxjs AS $$ + if (/*:: int */ x > 0) { return 1; } + return 0; +$$; +SELECT js_ann_cond(3), js_ann_cond(-3); + +-- finally inside finally is capped (each level doubles the generated body) +DO $d$ DECLARE s text := 'x = 1;'; BEGIN + FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + || s || ' return x; $b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- a finally body that throws runs once, not again from the error path +DO LANGUAGE plxjs $p$ + try { raise("warning", "body"); } + finally { raise("warning", "finally ran"); throw new Exception("boom"); } +$p$; diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index d87b56a..caaafe1 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -138,3 +138,10 @@ if x is not None: return "?" $$; SELECT py_isnull(NULL), py_isnull(5); + +-- a type annotation inside a call argument list is ignored (it used to give a +-- span with no source text and crash the backend) +DO LANGUAGE plxpython3 $p$ +assert(#:: int + 1 > 0) +$p$; diff --git a/test/sql/plxtsql.sql b/test/sql/plxtsql.sql index 1d9f354..69dac25 100644 --- a/test/sql/plxtsql.sql +++ b/test/sql/plxtsql.sql @@ -147,3 +147,9 @@ CREATE TRIGGER tq_trg_ins BEFORE INSERT ON tq_trg INSERT INTO tq_trg(id, qty, price) VALUES (7, 3, 10); SELECT id, total, tag FROM tq_trg; DROP TABLE tq_trg CASCADE; + +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; From 8e469d67cbba2cb2de012d4c4c8576e49cedfa4e Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 18:52:34 +0000 Subject: [PATCH 2/8] Fix string-literal breakout, quadratic transpiles and ternary memory growth Second round of the security audit (shared transpiler core): - emit_string_value: escape every literal backslash and force E''. A string ending in a lone backslash inside an interpolation was emitted bare in an E'' literal, escaping its closing quote so the next literal's text ran as code. The synthetic-string scanners no longer step past the end of their input on a trailing backslash. - Locals: index by lower-cased name in a hash table beside the ordered list; the linear lookup made many distinct variables quadratic. - plxpython3 lexer: resume after the blank/comment lines the indentation scan already covered, instead of rescanning the run from every newline (O(K^2), and line numbers were counted twice). - CHECK_FOR_INTERRUPTS in the lexer loop, the expression rewriter and the local table, so statement_timeout and cancel reach a long transpile. - Ternary rewrite: free the condition and both arms after composing the CASE, so a chain of N ternaries no longer holds N copies of its tail. - plxpython3 call(f"..."): reject an f-string procedure name, which was unquoted with its prefix into a broken identifier. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 +++++ src/plx_dialect_tsql.c | 2 +- src/plx_engine.h | 3 + src/plx_transpile.c | 134 +++++++++++++++++++++++++++++++++-- test/expected/plxjs.out | 14 ++++ test/expected/plxpython3.out | 13 ++++ test/sql/plxjs.sql | 10 +++ test/sql/plxpython3.sql | 13 ++++ 8 files changed, 202 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 389780f..e90cd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,22 @@ PUBLIC has USAGE by default). output per level: a 668-byte function stored a 94 MB body, and a slightly deeper one drove a backend to gigabytes of memory. Nesting a finally inside a finally is now limited to 8 levels. +- **String literal escaping its quotes.** A dialect string that ended in a lone + backslash, inside an interpolation (plxjs/plxts `${'...\'}`, plxpython3 + f-strings), was emitted with that backslash bare inside an `E'...'` literal, + where it escaped the closing quote. The next string literal in the body then + ran as code, so a function whose source showed only inert string assignments + could execute arbitrary SQL. Literal backslashes are now always escaped. +- **Quadratic, uninterruptible transpile time.** Local-variable lookup was a + linear scan, so a body with 40,000 distinct variables took 47 seconds; the + plxpython3 lexer rescanned every run of blank lines from each newline, so + 400,000 blank lines took 43 seconds. Neither honoured `statement_timeout`. + Locals are now hashed, blank lines are scanned once, and the lexer, rewriter + and local table check for interrupts. (The same fix stops plxpython3 from + counting blank lines twice in error line numbers.) +- **Memory amplification in chained ternaries.** Each `?:` level kept its own + copy of the rest of the chain, so peak memory was depth times source length + (568 MB for a 1 MB body). Intermediate results are now freed. - **plxgo `fmt.Sprintf` directive without a letter verb.** A `%` followed by a quote consumed half of the escaped `''` pair, so text inside a Go string literal could end up as SQL in the generated body. Such directives now fall @@ -40,6 +56,8 @@ PUBLIC has USAGE by default). - A `finally`/`ensure` body that raised an error on the normal path ran a second time from the error path. It now runs once. +- plxpython3 `call(f"...")` emitted an unterminated quoted identifier. An + f-string procedure name is now rejected. ## [2.0.1] - 2026-08-25 diff --git a/src/plx_dialect_tsql.c b/src/plx_dialect_tsql.c index c3b02e6..1ca2534 100644 --- a/src/plx_dialect_tsql.c +++ b/src/plx_dialect_tsql.c @@ -1534,7 +1534,7 @@ plx_tsql_parse_body(Ctx *cx) tq.pos = save; cx->out.len = 0; cx->out.data[0] = '\0'; - cx->locals = cx->ltail = NULL; + plx_locals_reset(cx); } while (tq_cur(&tq)->kind != TQ_EOF) { diff --git a/src/plx_engine.h b/src/plx_engine.h index 73c9ac7..49c9700 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -91,6 +91,8 @@ typedef struct PlxCtx const PlxSurface *surf; StringInfoData out; /* emitted BEGIN..END body */ PlxLocal2 *locals, *ltail; + struct HTAB *lhash; /* lower-cased name -> local (see plx_local_find) */ + bool lhash_off; /* a name too long to index was declared */ int loopdepth; int depth; /* recursion depth guard */ int handlerdepth; /* inside a rescue handler body */ @@ -141,6 +143,7 @@ void plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, const char *arms, const char *ensure_body); PlxLocal2 *plx_local_add(Ctx *cx, const char *name, int len); PlxLocal2 *plx_local_find(Ctx *cx, const char *name, int len); +void plx_locals_reset(Ctx *cx); bool plx_is_param(Ctx *cx, const char *name, int len); /* ---------------------------------------------- dialect front ends (vtable) */ diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 80e2d47..5021b13 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -26,6 +26,7 @@ #include "mb/pg_wchar.h" #include "miscadmin.h" #include "utils/builtins.h" +#include "utils/hsearch.h" #include "plx.h" #include "plx_int.h" @@ -69,6 +70,7 @@ plx_rewrite_expr(Ctx *cx, const char *s, int len, bool boolctx) char *r; check_stack_depth(); + CHECK_FOR_INTERRUPTS(); if (++cx->depth > PLX_MAX_DEPTH) plx_err(cx, 0, "expression nested too deeply"); r = rewrite_expr_inner(cx, s, len, boolctx); @@ -188,6 +190,7 @@ plx_lex(Ctx *cx) { const char *tokstart = p; + CHECK_FOR_INTERRUPTS(); if (*p == ' ' || *p == '\t' || *p == '\r') { p++; @@ -204,8 +207,18 @@ plx_lex(Ctx *cx) PUSH0(T_NEWLINE); if (indent_mode) { - /* find the next real line's indentation, skipping blanks/comments */ + /* + * Find the next real line's indentation, skipping blanks and + * comments. Resume lexing after the skipped lines (up to the + * first type-annotation line, which still has to be lexed) + * rather than at the next newline: rescanning the run from + * every newline made K blank lines cost O(K^2) and counted + * their line numbers twice. + */ const char *q = p; + const char *resume = p; + int resume_line = line; + bool held = false; int col = 0; for (;;) @@ -219,17 +232,26 @@ plx_lex(Ctx *cx) if (q < end && *q == '\n') { q++; - line++; + if (!held) + { + resume = q; + resume_line++; + } continue; } if (q < end && *q == '#') { + if (surf->type_ann && + strncmp(q, surf->type_ann, strlen(surf->type_ann)) == 0) + held = true; while (q < end && *q != '\n') q++; continue; } break; /* real line, or EOF */ } + p = resume; + line = resume_line; if (q >= end) continue; /* trailing dedents handled at EOF */ if (col > indent_stack[indent_sp]) @@ -564,11 +586,57 @@ plx_is_param(Ctx *cx, const char *name, int len) return false; } +/* + * Locals are kept in declaration order (cx->locals, which DECLARE emission + * walks) and indexed by lower-cased name in cx->lhash. A linear scan made a + * body with many distinct variables quadratic: 40,000 assignments took most + * of a minute, uninterruptibly. Names too long for a key (PostgreSQL would + * truncate them anyway) fall back to the scan. + */ +#define PLX_LKEY 128 + +typedef struct +{ + char key[PLX_LKEY]; + PlxLocal2 *l; +} PlxLocalEnt; + +static bool +plx_local_key(const char *name, int len, char *key) +{ + int i; + + if (len >= PLX_LKEY) + return false; + for (i = 0; i < len; i++) + key[i] = pg_ascii_tolower((unsigned char) name[i]); + memset(key + len, 0, PLX_LKEY - len); + return true; +} + +void +plx_locals_reset(Ctx *cx) +{ + cx->locals = cx->ltail = NULL; + if (cx->lhash) + hash_destroy(cx->lhash); + cx->lhash = NULL; + cx->lhash_off = false; +} + PlxLocal2 * plx_local_find(Ctx *cx, const char *name, int len) { PlxLocal2 *l; + char key[PLX_LKEY]; + CHECK_FOR_INTERRUPTS(); + if (cx->lhash && plx_local_key(name, len, key)) + { + PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_FIND, NULL); + + return e ? e->l : NULL; + } for (l = cx->locals; l; l = l->next) if ((int) strlen(l->name) == len && pg_strncasecmp(l->name, name, len) == 0) return l; @@ -580,12 +648,48 @@ plx_local_add(Ctx *cx, const char *name, int len) { PlxLocal2 *l = palloc0(sizeof(PlxLocal2)); + char key[PLX_LKEY]; + l->name = pnstrdup(name, len); if (cx->ltail) cx->ltail->next = l; else cx->locals = l; cx->ltail = l; + + if (cx->lhash_off) + return l; + if (!cx->lhash) + { + HASHCTL ctl; + + ctl.keysize = PLX_LKEY; + ctl.entrysize = sizeof(PlxLocalEnt); + ctl.hcxt = CurrentMemoryContext; +#if PG_VERSION_NUM >= 140000 + cx->lhash = hash_create("plx locals", 64, &ctl, + HASH_ELEM | HASH_STRINGS | HASH_CONTEXT); +#else + cx->lhash = hash_create("plx locals", 64, &ctl, + HASH_ELEM | HASH_CONTEXT); /* string keys */ +#endif + } + if (plx_local_key(name, len, key)) + { + bool found; + PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_ENTER, &found); + + /* keep the first declaration, as the linear scan did */ + if (!found) + e->l = l; + } + else + { + /* a long name defeats the index: drop it so finds scan the list */ + hash_destroy(cx->lhash); + cx->lhash = NULL; + cx->lhash_off = true; + } return l; } @@ -843,6 +947,19 @@ emit_string_value(Ctx *cx, Tok *tk, StringInfo out) s++; continue; } + /* + * A backslash that reaches here is literal content (a raw string, or + * a lone trailing backslash). Escape it and force the E'' form: left + * bare inside an E'' literal it would escape the closing quote and + * let the next literal's text run on as code. + */ + if (s[0] == '\\') + { + appendStringInfoString(&lit, "\\\\"); + need_e = true; + s++; + continue; + } appendStringInfoChar(&lit, *s); s++; } @@ -992,6 +1109,12 @@ rewrite_expr_inner(Ctx *cx, const char *s, int len, bool boolctx) a = plx_rewrite_expr(cx, s + q + 1, c - q - 1, false); b = plx_rewrite_expr(cx, s + c + 1, len - c - 1, false); r = psprintf("CASE WHEN %s THEN %s ELSE %s END", cond, a, b); + /* free the parts: a chain of ternaries nests one level per + * '?', and keeping every level's copy of the rest of the chain + * made peak memory depth * length */ + pfree(cond); + pfree(a); + pfree(b); return r; } } @@ -1064,7 +1187,7 @@ rewrite_expr_inner(Ctx *cx, const char *s, int len, bool boolctx) while (j < len && s[j] != c) { - if (s[j] == '\\') + if (s[j] == '\\' && j + 1 < len) j++; j++; } @@ -1090,7 +1213,7 @@ rewrite_expr_inner(Ctx *cx, const char *s, int len, bool boolctx) while (j < len && s[j] != q) { - if (s[j] == '\\') + if (s[j] == '\\' && j + 1 < len) j++; j++; } @@ -2214,6 +2337,9 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) { Tok *pn = &cx->t[as[0]]; + /* an f-string's prefix would be unquoted as part of the name */ + if (pn->fstr) + plx_err(cx, t0->line, "call needs a plain string procedure name, not an f-string"); appendBinaryStringInfo(o, pn->s + 1, pn->len - 2); /* unquote */ } else diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index 42bb65a..d43c1cd 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -269,3 +269,17 @@ WARNING: body WARNING: finally ran ERROR: boom CONTEXT: PL/pgSQL function inline_code_block line 13 at RAISE +-- a string ending in a lone backslash cannot escape its quotes: the second +-- assignment stays an inert string instead of running set_config +CREATE FUNCTION js_bs_escape() RETURNS text LANGUAGE plxjs AS $q$ +let a = `${'\n\'}`; +let b = " || set_config($x$plx.test_pwned$x$, $x$yes$x$, false)); --"; +return b; +$q$; +SELECT js_bs_escape() LIKE '%set_config%' AS literal_kept, + current_setting('plx.test_pwned', true) IS NULL AS not_run; + literal_kept | not_run +--------------+--------- + t | t +(1 row) + diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index 957a353..f2c6331 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -207,3 +207,16 @@ DO LANGUAGE plxpython3 $p$ assert(#:: int 1 > 0) $p$; +-- call() takes a plain string name; an f-string used to emit a broken identifier +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\ncall(f"noop")\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxpython3: call needs a plain string procedure name, not an f-string +-- blank lines and comments are counted once in error line numbers +DO $d$ DECLARE d text; BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n\n\n# c\n\nclass Foo:\n pass\n$b$'; +EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL; + RAISE WARNING 'caught: % (%)', SQLERRM, d; +END $d$; +WARNING: caught: plxpython3: def is not supported (at plxpython3 source line 7) diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index a565368..9e3730c 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -186,3 +186,13 @@ DO LANGUAGE plxjs $p$ try { raise("warning", "body"); } finally { raise("warning", "finally ran"); throw new Exception("boom"); } $p$; + +-- a string ending in a lone backslash cannot escape its quotes: the second +-- assignment stays an inert string instead of running set_config +CREATE FUNCTION js_bs_escape() RETURNS text LANGUAGE plxjs AS $q$ +let a = `${'\n\'}`; +let b = " || set_config($x$plx.test_pwned$x$, $x$yes$x$, false)); --"; +return b; +$q$; +SELECT js_bs_escape() LIKE '%set_config%' AS literal_kept, + current_setting('plx.test_pwned', true) IS NULL AS not_run; diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index caaafe1..5d80de5 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -145,3 +145,16 @@ DO LANGUAGE plxpython3 $p$ assert(#:: int 1 > 0) $p$; + +-- call() takes a plain string name; an f-string used to emit a broken identifier +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\ncall(f"noop")\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- blank lines and comments are counted once in error line numbers +DO $d$ DECLARE d text; BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n\n\n# c\n\nclass Foo:\n pass\n$b$'; +EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL; + RAISE WARNING 'caught: % (%)', SQLERRM, d; +END $d$; From 2717c2ff9699642f12c3d4c968124d01b8624b81 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 20:01:04 +0000 Subject: [PATCH 3/8] Address review: escape backslash literals, close two DoS bypasses, add tests From the adversarial review of this PR: - standard_conforming_strings: literals emitted by plxgo, plxtsql, plxplsql and plxcobol, and Ruby/PHP single-quoted strings, were plain '...' literals. plpgsql reads the body with the caller's setting, so with it off a trailing backslash swallowed the closing quote and the next literal ran as code. A final pass over the generated body (a small SQL lexer that skips comments, quoted identifiers, dollar quotes, E'' and U&'' strings) now rewrites every ordinary literal containing a backslash as E'...' with the backslash doubled. One place covers every dialect and future emitters. - plxpython3 lexer: a #:: annotation line ended the blank-line scan's resume point but not the scan, so runs of annotation lines were still quadratic. The scan now stops at an annotation line. - Locals index: a name of 128+ bytes disabled the hash and brought back the linear scan. Keys are now downcase_truncate_identifier(), as the server reads an unquoted identifier, so every name is indexed and case folding matches pg_strncasecmp/the server for all encodings; the fallback is gone. - Tests: standard_conforming_strings off across six dialects; timeouts around 60,000 locals after an over-long name, 400,000 blank lines and 40,000 annotation lines; and a nesting depth under the 500-level counter with max_stack_depth = 100kB, which exercises check_stack_depth(). - CHANGELOG: the finally cap bounds depth, not output size. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 ++- src/plx_engine.h | 1 - src/plx_transpile.c | 270 +++++++++++++++++++++++++-------- test/expected/plx_features.out | 49 ++++++ test/expected/plxpython3.out | 17 +++ test/expected/plxruby.out | 17 +++ test/expected/plxtsql.out | 8 + test/sql/plx_features.sql | 40 +++++ test/sql/plxpython3.sql | 13 ++ test/sql/plxruby.sql | 13 ++ test/sql/plxtsql.sql | 8 + 11 files changed, 390 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e90cd75..ed38deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,19 +30,34 @@ PUBLIC has USAGE by default). emitted once per exit path, so a finally nested inside a finally doubled the output per level: a 668-byte function stored a 94 MB body, and a slightly deeper one drove a backend to gigabytes of memory. Nesting a finally inside a - finally is now limited to 8 levels. + finally is now limited to 8 levels. This bounds the depth, not the size: at + the limit the output can still be some 2^8 times the source (a 195 KB body + stores about 144 MB), so a large body can still demand a lot of memory until + the finally body is emitted once. - **String literal escaping its quotes.** A dialect string that ended in a lone backslash, inside an interpolation (plxjs/plxts `${'...\'}`, plxpython3 f-strings), was emitted with that backslash bare inside an `E'...'` literal, where it escaped the closing quote. The next string literal in the body then ran as code, so a function whose source showed only inert string assignments - could execute arbitrary SQL. Literal backslashes are now always escaped. + could execute arbitrary SQL. +- **String literals that depend on `standard_conforming_strings`.** plpgsql + reads a function body with the caller's setting, which any role may change. + Literals that plxgo, plxtsql, plxplsql and plxcobol emit, and Ruby and PHP + single-quoted strings, were written as plain `'...'`, so with the setting off + a literal ending in a backslash swallowed its closing quote and the following + text ran as code; the caller could change what a `SECURITY DEFINER` function + did. The finished body is now rewritten so that every ordinary literal + containing a backslash is an `E'...'` literal with the backslash doubled, + which reads the same under either setting and, with it on, has the value the + original had. This covers every dialect, and SQL passed through verbatim. - **Quadratic, uninterruptible transpile time.** Local-variable lookup was a linear scan, so a body with 40,000 distinct variables took 47 seconds; the plxpython3 lexer rescanned every run of blank lines from each newline, so 400,000 blank lines took 43 seconds. Neither honoured `statement_timeout`. - Locals are now hashed, blank lines are scanned once, and the lexer, rewriter - and local table check for interrupts. (The same fix stops plxpython3 from + Locals are now hashed (keyed as PostgreSQL reads an identifier, down-cased + and truncated to 63 bytes, so no name defeats the index), blank and + annotation lines are scanned once, and the lexer, rewriter and local table + check for interrupts. (The same fix stops plxpython3 from counting blank lines twice in error line numbers.) - **Memory amplification in chained ternaries.** Each `?:` level kept its own copy of the rest of the chain, so peak memory was depth times source length diff --git a/src/plx_engine.h b/src/plx_engine.h index 49c9700..666988f 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -92,7 +92,6 @@ typedef struct PlxCtx StringInfoData out; /* emitted BEGIN..END body */ PlxLocal2 *locals, *ltail; struct HTAB *lhash; /* lower-cased name -> local (see plx_local_find) */ - bool lhash_off; /* a name too long to index was declared */ int loopdepth; int depth; /* recursion depth guard */ int handlerdepth; /* inside a rescue handler body */ diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 5021b13..230b2d4 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -25,6 +25,7 @@ #include "lib/stringinfo.h" #include "mb/pg_wchar.h" #include "miscadmin.h" +#include "parser/scansup.h" #include "utils/builtins.h" #include "utils/hsearch.h" @@ -209,20 +210,23 @@ plx_lex(Ctx *cx) { /* * Find the next real line's indentation, skipping blanks and - * comments. Resume lexing after the skipped lines (up to the - * first type-annotation line, which still has to be lexed) - * rather than at the next newline: rescanning the run from - * every newline made K blank lines cost O(K^2) and counted - * their line numbers twice. + * comments, and resume lexing after the skipped lines rather + * than at the next newline: rescanning the run from every + * newline made K blank lines cost O(K^2) and counted their + * line numbers twice. A type-annotation line ends the scan + * (it has to be lexed); its newline scans on from there, so + * a run of them is linear too. */ const char *q = p; const char *resume = p; int resume_line = line; - bool held = false; + bool ann_line = false; int col = 0; for (;;) { + const char *ls = q; + col = 0; while (*q == ' ' || *q == '\t') { @@ -232,18 +236,19 @@ plx_lex(Ctx *cx) if (q < end && *q == '\n') { q++; - if (!held) - { - resume = q; - resume_line++; - } + resume = q; + resume_line++; continue; } if (q < end && *q == '#') { if (surf->type_ann && strncmp(q, surf->type_ann, strlen(surf->type_ann)) == 0) - held = true; + { + resume = ls; + ann_line = true; + break; + } while (q < end && *q != '\n') q++; continue; @@ -252,8 +257,9 @@ plx_lex(Ctx *cx) } p = resume; line = resume_line; - if (q >= end) - continue; /* trailing dedents handled at EOF */ + if (ann_line || q >= end) + continue; /* indentation is taken at the next real line; + * trailing dedents are handled at EOF */ if (col > indent_stack[indent_sp]) { /* @@ -588,30 +594,29 @@ plx_is_param(Ctx *cx, const char *name, int len) /* * Locals are kept in declaration order (cx->locals, which DECLARE emission - * walks) and indexed by lower-cased name in cx->lhash. A linear scan made a - * body with many distinct variables quadratic: 40,000 assignments took most - * of a minute, uninterruptibly. Names too long for a key (PostgreSQL would - * truncate them anyway) fall back to the scan. + * walks) and indexed in cx->lhash. A linear scan made a body with many + * distinct variables quadratic: 40,000 assignments took most of a minute. + * + * The key is the name as PostgreSQL itself reads an unquoted identifier: + * down-cased and truncated to NAMEDATALEN - 1 bytes (downcase_truncate_ + * identifier). Two names that plpgsql would treat as one variable therefore + * share an entry, any name can be indexed (an over-long one no longer forces a + * fallback scan), and case folding matches the server's for every encoding. */ -#define PLX_LKEY 128 - typedef struct { - char key[PLX_LKEY]; + char key[NAMEDATALEN]; PlxLocal2 *l; } PlxLocalEnt; -static bool +static void plx_local_key(const char *name, int len, char *key) { - int i; + char *k = downcase_truncate_identifier(name, len, false); - if (len >= PLX_LKEY) - return false; - for (i = 0; i < len; i++) - key[i] = pg_ascii_tolower((unsigned char) name[i]); - memset(key + len, 0, PLX_LKEY - len); - return true; + memset(key, 0, NAMEDATALEN); + strlcpy(key, k, NAMEDATALEN); + pfree(k); } void @@ -621,34 +626,29 @@ plx_locals_reset(Ctx *cx) if (cx->lhash) hash_destroy(cx->lhash); cx->lhash = NULL; - cx->lhash_off = false; } PlxLocal2 * plx_local_find(Ctx *cx, const char *name, int len) { - PlxLocal2 *l; - char key[PLX_LKEY]; + char key[NAMEDATALEN]; + PlxLocalEnt *e; CHECK_FOR_INTERRUPTS(); - if (cx->lhash && plx_local_key(name, len, key)) - { - PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_FIND, NULL); - - return e ? e->l : NULL; - } - for (l = cx->locals; l; l = l->next) - if ((int) strlen(l->name) == len && pg_strncasecmp(l->name, name, len) == 0) - return l; - return NULL; + if (!cx->lhash) + return NULL; + plx_local_key(name, len, key); + e = hash_search(cx->lhash, key, HASH_FIND, NULL); + return e ? e->l : NULL; } PlxLocal2 * plx_local_add(Ctx *cx, const char *name, int len) { PlxLocal2 *l = palloc0(sizeof(PlxLocal2)); - - char key[PLX_LKEY]; + char key[NAMEDATALEN]; + bool found; + PlxLocalEnt *e; l->name = pnstrdup(name, len); if (cx->ltail) @@ -657,13 +657,11 @@ plx_local_add(Ctx *cx, const char *name, int len) cx->locals = l; cx->ltail = l; - if (cx->lhash_off) - return l; if (!cx->lhash) { HASHCTL ctl; - ctl.keysize = PLX_LKEY; + ctl.keysize = NAMEDATALEN; ctl.entrysize = sizeof(PlxLocalEnt); ctl.hcxt = CurrentMemoryContext; #if PG_VERSION_NUM >= 140000 @@ -674,22 +672,10 @@ plx_local_add(Ctx *cx, const char *name, int len) HASH_ELEM | HASH_CONTEXT); /* string keys */ #endif } - if (plx_local_key(name, len, key)) - { - bool found; - PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_ENTER, &found); - - /* keep the first declaration, as the linear scan did */ - if (!found) - e->l = l; - } - else - { - /* a long name defeats the index: drop it so finds scan the list */ - hash_destroy(cx->lhash); - cx->lhash = NULL; - cx->lhash_off = true; - } + plx_local_key(name, len, key); + e = hash_search(cx->lhash, key, HASH_ENTER, &found); + if (!found) + e->l = l; /* keep the first declaration */ return l; } @@ -2830,6 +2816,162 @@ djb2(const char *s) return h; } +static bool +sql_ident_char(char c) +{ + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '$' || IS_HIGHBIT_SET(c); +} + +/* + * Rewrite every standard string literal in the generated plpgsql that contains + * a backslash as an escape string: 'C:\' becomes E'C:\\'. + * + * plpgsql reads a function body with the caller's standard_conforming_strings, + * which any role may set. With it off, a backslash in an ordinary '...' + * literal is an escape, so a literal ending in one swallows its closing quote + * and the following text runs as code; a SECURITY DEFINER function's + * behaviour would then depend on its caller. An E'...' literal reads the same + * either way, and with the setting on (the default) the rewritten literal has + * exactly the value of the original. Doing this once over the finished body + * covers every dialect's literal emitters, and SQL the author passed through. + * + * A small SQL lexer: skips comments (nested block comments too), quoted + * identifiers, dollar-quoted strings, E'' strings (already safe) and U&'' + * strings (where a backslash introduces a Unicode escape). + */ +static char * +escape_backslash_literals(const char *s) +{ + StringInfoData o; + const char *p = s; + + if (!strchr(s, '\\')) + return (char *) s; + initStringInfo(&o); + while (*p) + { + const char *q; + + if (p[0] == '-' && p[1] == '-') + { + q = strchr(p, '\n'); + q = q ? q + 1 : p + strlen(p); + appendBinaryStringInfo(&o, p, q - p); + p = q; + continue; + } + if (p[0] == '/' && p[1] == '*') + { + int depth = 1; + + q = p + 2; + while (*q && depth > 0) + { + if (q[0] == '/' && q[1] == '*') + { + depth++; + q += 2; + } + else if (q[0] == '*' && q[1] == '/') + { + depth--; + q += 2; + } + else + q++; + } + appendBinaryStringInfo(&o, p, q - p); + p = q; + continue; + } + if (p[0] == '"') + { + q = p + 1; + while (*q && !(q[0] == '"' && q[1] != '"')) + q += (q[0] == '"') ? 2 : 1; + if (*q) + q++; + appendBinaryStringInfo(&o, p, q - p); + p = q; + continue; + } + if (p[0] == '$' && (p == s || !sql_ident_char(p[-1]))) + { + /* $tag$ ... $tag$; the tag cannot start with a digit ($1 is a param) */ + const char *t = p + 1; + + if (*t != '$' && ((*t >= 'a' && *t <= 'z') || (*t >= 'A' && *t <= 'Z') || + *t == '_' || IS_HIGHBIT_SET(*t))) + while (sql_ident_char(*t) && *t != '$') + t++; + if (*t == '$') + { + int taglen = (int) (t - p) + 1; + char *tag = pnstrdup(p, taglen); + const char *end = strstr(t + 1, tag); + + q = end ? end + taglen : p + strlen(p); + appendBinaryStringInfo(&o, p, q - p); + p = q; + pfree(tag); + continue; + } + } + if (p[0] == '\'') + { + bool estr = p > s && (p[-1] == 'E' || p[-1] == 'e') && + (p - 1 == s || !sql_ident_char(p[-2])); + bool ustr = p - 1 > s && p[-1] == '&' && + (p[-2] == 'U' || p[-2] == 'u') && + (p - 2 == s || !sql_ident_char(p[-3])); + /* B'..', X'..', N'..' and the like: leave other prefixes alone */ + bool prefixed = !estr && !ustr && p > s && sql_ident_char(p[-1]); + bool has_bs = false; + + q = p + 1; + while (*q) + { + if (estr && q[0] == '\\' && q[1]) + q += 2; + else if (q[0] == '\'' && q[1] == '\'') + q += 2; + else if (q[0] == '\'') + break; + else + { + if (q[0] == '\\') + has_bs = true; + q++; + } + } + if (has_bs && !estr && !ustr && !prefixed) + { + const char *c; + + appendStringInfoString(&o, "E'"); + for (c = p + 1; c < q; c++) + { + if (*c == '\\') + appendStringInfoChar(&o, '\\'); + appendStringInfoChar(&o, *c); + } + } + else + appendBinaryStringInfo(&o, p, q - p); + if (*q) + { + appendStringInfoChar(&o, '\''); + q++; + } + p = q; + continue; + } + appendStringInfoChar(&o, *p++); + } + return o.data; +} + char * plx_transpile(const char *body, const PlxFuncMeta *meta, const PlxSurface *surf, MemoryContext scratch) @@ -2889,5 +3031,5 @@ plx_transpile(const char *body, const PlxFuncMeta *meta, const PlxSurface *surf, } appendStringInfo(&asm_, "/*plx-orig:b64$%s$plx-orig*/\n", b64_encode_body(body)); - return asm_.data; + return escape_backslash_literals(asm_.data); } diff --git a/test/expected/plx_features.out b/test/expected/plx_features.out index 859077e..b0f561f 100644 --- a/test/expected/plx_features.out +++ b/test/expected/plx_features.out @@ -233,3 +233,52 @@ SELECT f_strict() AS strict_no_row; -1 (1 row) +-- a literal with a backslash reads the same whatever standard_conforming_strings +-- the caller uses: generated code writes it as E'...', so with the setting off +-- the backslash cannot swallow the closing quote and run the next literal as code +CREATE FUNCTION lit_go() RETURNS text LANGUAGE plxgo AS $P$ +a := "C:\\" +b := "; RAISE EXCEPTION $$go: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_rb() RETURNS text LANGUAGE plxruby AS $P$ +a = 'C:\\' +b = "; RAISE EXCEPTION $$rb: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_php() RETURNS text LANGUAGE plxphp AS $P$ +$a = 'C:\\'; +$b = '; RAISE EXCEPTION $$php: injected$$; --'; +return $a; +$P$; +CREATE FUNCTION lit_tsql() RETURNS varchar LANGUAGE plxtsql AS $P$ +DECLARE @a varchar(10) = 'C:\'; +DECLARE @b varchar(80) = '; RAISE EXCEPTION $$tsql: injected$$; --'; +RETURN @a; +$P$; +CREATE FUNCTION lit_plsql() RETURNS text LANGUAGE plxplsql AS $P$ +DECLARE a text := 'C:\'; b text := '; RAISE EXCEPTION $$plsql: injected$$; --'; +BEGIN RETURN a; END; +$P$; +CREATE FUNCTION lit_cob() RETURNS text LANGUAGE plxcobol AS $P$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 A PIC X(10) VALUE "C:\". +01 B PIC X(60) VALUE "; RAISE EXCEPTION $$cob: injected$$; --". +PROCEDURE DIVISION. + RETURN A. +$P$; +SET standard_conforming_strings = off; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); + lit_go | lit_rb | lit_php | lit_tsql | lit_plsql | lit_cob +--------+--------+---------+----------+-----------+--------- + C:\ | C:\ | C:\ | C:\ | C:\ | C:\ +(1 row) + +RESET standard_conforming_strings; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); + lit_go | lit_rb | lit_php | lit_tsql | lit_plsql | lit_cob +--------+--------+---------+----------+-----------+--------- + C:\ | C:\ | C:\ | C:\ | C:\ | C:\ +(1 row) + diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index f2c6331..9014447 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -220,3 +220,20 @@ EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: % (%)', SQLERRM, d; END $d$; WARNING: caught: plxpython3: def is not supported (at plxpython3 source line 7) +-- runs of blank or annotation lines lex in linear time (a regression takes tens +-- of seconds and trips the timeout, which the handlers below do not catch) +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE E'CREATE FUNCTION py_blank_run() RETURNS int LANGUAGE plxpython3 AS $b$\nx = 1\n' + || repeat(E'\n', 400000) || E'return x\n$b$'; +END $d$; +SELECT py_blank_run(); + py_blank_run +-------------- + 1 +(1 row) + +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n' || repeat(E'#:: int\n', 40000) || E'$b$'; +EXCEPTION WHEN syntax_error OR feature_not_supported THEN NULL; END $d$; +RESET statement_timeout; diff --git a/test/expected/plxruby.out b/test/expected/plxruby.out index c18c4ed..43327d8 100644 --- a/test/expected/plxruby.out +++ b/test/expected/plxruby.out @@ -398,3 +398,20 @@ SELECT rb_hex(); 255 (1 row) +-- many distinct locals transpile in linear time, even after an over-long name +-- (which used to disable the index); a regression takes tens of seconds +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION rb_many_locals() RETURNS int LANGUAGE plxruby AS $b$' + || 'v' || repeat('a', 130) || E' = 0\n' + || (SELECT string_agg(format('v%s = %s', i, i), E'\n') FROM generate_series(1, 60000) AS i) + || E'\nreturn 1\n$b$'; +END $d$; +-- (not called: plpgsql's own compile of 60,000 variables is slow, and is not plx's) +SELECT count(*) FROM pg_proc WHERE proname = 'rb_many_locals'; + count +------- + 1 +(1 row) + +RESET statement_timeout; diff --git a/test/expected/plxtsql.out b/test/expected/plxtsql.out index 628c11d..b2699ef 100644 --- a/test/expected/plxtsql.out +++ b/test/expected/plxtsql.out @@ -211,3 +211,11 @@ DO $d$ BEGIN || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; WARNING: caught: plxtsql: expression nested too deeply +-- below the 500-level counter, a small stack is guarded by check_stack_depth() +SET max_stack_depth = '100kB'; +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 450) || '1' || repeat(', 0)', 450) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: stack depth limit exceeded +RESET max_stack_depth; diff --git a/test/sql/plx_features.sql b/test/sql/plx_features.sql index 6e2699d..a5ea987 100644 --- a/test/sql/plx_features.sql +++ b/test/sql/plx_features.sql @@ -143,3 +143,43 @@ rescue => e end $$; SELECT f_strict() AS strict_no_row; + +-- a literal with a backslash reads the same whatever standard_conforming_strings +-- the caller uses: generated code writes it as E'...', so with the setting off +-- the backslash cannot swallow the closing quote and run the next literal as code +CREATE FUNCTION lit_go() RETURNS text LANGUAGE plxgo AS $P$ +a := "C:\\" +b := "; RAISE EXCEPTION $$go: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_rb() RETURNS text LANGUAGE plxruby AS $P$ +a = 'C:\\' +b = "; RAISE EXCEPTION $$rb: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_php() RETURNS text LANGUAGE plxphp AS $P$ +$a = 'C:\\'; +$b = '; RAISE EXCEPTION $$php: injected$$; --'; +return $a; +$P$; +CREATE FUNCTION lit_tsql() RETURNS varchar LANGUAGE plxtsql AS $P$ +DECLARE @a varchar(10) = 'C:\'; +DECLARE @b varchar(80) = '; RAISE EXCEPTION $$tsql: injected$$; --'; +RETURN @a; +$P$; +CREATE FUNCTION lit_plsql() RETURNS text LANGUAGE plxplsql AS $P$ +DECLARE a text := 'C:\'; b text := '; RAISE EXCEPTION $$plsql: injected$$; --'; +BEGIN RETURN a; END; +$P$; +CREATE FUNCTION lit_cob() RETURNS text LANGUAGE plxcobol AS $P$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 A PIC X(10) VALUE "C:\". +01 B PIC X(60) VALUE "; RAISE EXCEPTION $$cob: injected$$; --". +PROCEDURE DIVISION. + RETURN A. +$P$; +SET standard_conforming_strings = off; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); +RESET standard_conforming_strings; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index 5d80de5..e057e15 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -158,3 +158,16 @@ EXCEPTION WHEN OTHERS THEN GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL; RAISE WARNING 'caught: % (%)', SQLERRM, d; END $d$; + +-- runs of blank or annotation lines lex in linear time (a regression takes tens +-- of seconds and trips the timeout, which the handlers below do not catch) +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE E'CREATE FUNCTION py_blank_run() RETURNS int LANGUAGE plxpython3 AS $b$\nx = 1\n' + || repeat(E'\n', 400000) || E'return x\n$b$'; +END $d$; +SELECT py_blank_run(); +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n' || repeat(E'#:: int\n', 40000) || E'$b$'; +EXCEPTION WHEN syntax_error OR feature_not_supported THEN NULL; END $d$; +RESET statement_timeout; diff --git a/test/sql/plxruby.sql b/test/sql/plxruby.sql index 37f5d2c..65a76db 100644 --- a/test/sql/plxruby.sql +++ b/test/sql/plxruby.sql @@ -282,3 +282,16 @@ CREATE FUNCTION rb_hex() RETURNS int LANGUAGE plxruby AS $$ return 0xff $$; SELECT rb_hex(); + +-- many distinct locals transpile in linear time, even after an over-long name +-- (which used to disable the index); a regression takes tens of seconds +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION rb_many_locals() RETURNS int LANGUAGE plxruby AS $b$' + || 'v' || repeat('a', 130) || E' = 0\n' + || (SELECT string_agg(format('v%s = %s', i, i), E'\n') FROM generate_series(1, 60000) AS i) + || E'\nreturn 1\n$b$'; +END $d$; +-- (not called: plpgsql's own compile of 60,000 variables is slow, and is not plx's) +SELECT count(*) FROM pg_proc WHERE proname = 'rb_many_locals'; +RESET statement_timeout; diff --git a/test/sql/plxtsql.sql b/test/sql/plxtsql.sql index 69dac25..e95cd2c 100644 --- a/test/sql/plxtsql.sql +++ b/test/sql/plxtsql.sql @@ -153,3 +153,11 @@ DO $d$ BEGIN EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- below the 500-level counter, a small stack is guarded by check_stack_depth() +SET max_stack_depth = '100kB'; +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 450) || '1' || repeat(', 0)', 450) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +RESET max_stack_depth; From 94ef2382d99bb6446a16221d9623899f70e3a019 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 19:37:35 +0000 Subject: [PATCH 4/8] Emit finally/ensure once and run it on return, break and continue The try/finally lowering emitted the finally body twice (normal and error path), so nesting doubled the output per level, and a return, break or continue inside the try left without running finally at all. A try now pushes a frame while its body and catch arms are parsed. An exit that would leave the frame is emitted as a placeholder, since the parser does not yet know whether a finally follows; plx_emit_try then resolves it: without a finally, as the plain statement (or a placeholder for the enclosing frame); with one, as "save the return value, note the pending exit, EXIT the labeled block". After the single copy of the finally body, a saved error is re-raised with its SQLSTATE, message, detail and hint, and the pending exit is completed in the enclosing frame's context. Labeled break/continue resolve their target through the labels recorded with the frame they were declared in. The return value lives in a hoisted __plx_ret typed from the function's result (RECORD for triggers and record, ALIAS FOR $0 for polymorphic results). The 8-level finally nesting cap is no longer needed and is removed. Source containing U+0001, the placeholder delimiter, is rejected. Checked against real Python with 1,200 randomly generated programs mixing nested try/except/finally, loops, return/break/continue (also inside finally) and raised errors: no mismatches. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 ++- src/plx_dialect_python.c | 8 +- src/plx_dialect_ruby.c | 8 +- src/plx_engine.h | 55 +++++- src/plx_parse_brace.c | 8 +- src/plx_transpile.c | 358 ++++++++++++++++++++++++++++++----- test/expected/plxjs.out | 132 ++++++++++++- test/expected/plxpython3.out | 21 ++ test/expected/plxruby.out | 15 ++ test/sql/plxjs.sql | 76 +++++++- test/sql/plxpython3.sql | 13 ++ test/sql/plxruby.sql | 9 + 12 files changed, 651 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed38deb..7e9308f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,11 +29,8 @@ PUBLIC has USAGE by default). - **Exponential output from nested `finally`/`ensure`.** Each finally body is emitted once per exit path, so a finally nested inside a finally doubled the output per level: a 668-byte function stored a 94 MB body, and a slightly - deeper one drove a backend to gigabytes of memory. Nesting a finally inside a - finally is now limited to 8 levels. This bounds the depth, not the size: at - the limit the output can still be some 2^8 times the source (a 195 KB body - stores about 144 MB), so a large body can still demand a lot of memory until - the finally body is emitted once. + deeper one drove a backend to gigabytes of memory. The finally body is now + emitted once (see Changed), so output grows linearly with nesting. - **String literal escaping its quotes.** A dialect string that ended in a lone backslash, inside an interpolation (plxjs/plxts `${'...\'}`, plxpython3 f-strings), was emitted with that backslash bare inside an `E'...'` literal, @@ -67,6 +64,21 @@ PUBLIC has USAGE by default). literal could end up as SQL in the generated body. Such directives now fall back to the `format()` lowering, which keeps the text literal. +### Changed + +- **`finally`/`ensure` now runs on `return`, `break` and `continue`** (plxjs, + plxts, plxphp, plxruby, plxpython3), as in the source languages. Before, a + `return` inside `try` left the function without running `finally`. The + finally body is now emitted once: an exit from the try (or from a catch arm) + records which exit is pending, leaves the block, runs the finally body, then + completes the exit; an error is caught, the finally body runs, and the error + is re-raised with its SQLSTATE, message, detail and hint. A `return` or an + error in the finally body replaces the pending exit or error, as in the + source languages. Behaviour difference to be aware of: an error re-raised + after `finally` carries the location of the re-raise in its `CONTEXT`, not + the original statement's. Only functions created or replaced after upgrading + are affected; stored functions keep the plpgsql they were created with. + ### Fixed - A `finally`/`ensure` body that raised an error on the normal path ran a diff --git a/src/plx_dialect_python.c b/src/plx_dialect_python.c index 077236b..6f5efbc 100644 --- a/src/plx_dialect_python.c +++ b/src/plx_dialect_python.c @@ -274,11 +274,13 @@ parse_py_try(Ctx *cx, int ind) arms; int nrescue = 0; char *ensure_body = NULL; + PlxFin *fin; colon = py_header_colon(cx, cx->pos + 1); if (colon < 0) plx_err(cx, hd, "try requires ':'"); cx->pos = colon + 1; + fin = plx_fin_push(cx); saved = cx->out; initStringInfo(&cx->out); parse_py_block(cx, ind + 1); @@ -349,22 +351,21 @@ parse_py_try(Ctx *cx, int ind) appendStringInfoString(&arms, hb); nrescue++; } + plx_fin_pop(cx, fin); if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_ENSURE) /* finally */ { colon = py_header_colon(cx, cx->pos + 1); if (colon < 0) plx_err(cx, cx->t[cx->pos].line, "finally requires ':'"); - plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos = colon + 1; saved = cx->out; initStringInfo(&cx->out); parse_py_block(cx, ind + 1); - cx->ensuredepth--; ensure_body = cx->out.data; cx->out = saved; } - plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); + plx_emit_try(cx, o, ind, body, nrescue, arms.data, ensure_body, fin); } /* raise / raise Class("msg") */ @@ -430,6 +431,7 @@ parse_py_stmt_inner(Ctx *cx, int ind, bool toplevel) { plx_indent(&cx->out, ind); appendStringInfo(&cx->out, "<<%.*s>>\n", tk->len, tk->s); + plx_label_note(cx, tk->s, tk->len); cx->pos += 2; tk = &cx->t[cx->pos]; } diff --git a/src/plx_dialect_ruby.c b/src/plx_dialect_ruby.c index 58d7e5e..139d145 100644 --- a/src/plx_dialect_ruby.c +++ b/src/plx_dialect_ruby.c @@ -419,8 +419,10 @@ parse_begin(Ctx *cx, int ind) StringInfoData arms; int nrescue = 0; char *ensure_body = NULL; + PlxFin *fin; cx->pos++; /* consume 'begin' */ + fin = plx_fin_push(cx); body = capture_block(cx, ind + 1); initStringInfo(&arms); @@ -482,12 +484,11 @@ parse_begin(Ctx *cx, int ind) nrescue++; } + plx_fin_pop(cx, fin); if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_ENSURE) { - plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos++; ensure_body = capture_block(cx, ind + 1); - cx->ensuredepth--; } if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_END) @@ -495,7 +496,7 @@ parse_begin(Ctx *cx, int ind) else plx_err(cx, bl, "unterminated begin block"); - plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); + plx_emit_try(cx, o, ind, body, nrescue, arms.data, ensure_body, fin); } /* rewrite a comma-separated value list (for CASE WHEN v1, v2) */ @@ -634,6 +635,7 @@ parse_stmt_inner(Ctx *cx, int ind, bool toplevel) { plx_indent(&cx->out, ind); appendStringInfo(&cx->out, "<<%.*s>>\n", tk->len, tk->s); + plx_label_note(cx, tk->s, tk->len); cx->pos += 2; tk = &cx->t[cx->pos]; } diff --git a/src/plx_engine.h b/src/plx_engine.h index 666988f..d72d382 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -27,8 +27,6 @@ #endif #define PLX_MAX_DEPTH 500 /* recursion cap (parsers + expr rewriter) */ -#define PLX_MAX_ENSURE_DEPTH 8 /* finally-in-finally cap; each level doubles - * the inner body (see plx_emit_try) */ /* stacked-diagnostics field bits (e.detail, e.hint, ...); used by the engine's * diagnostics emitter and by the dialect rescue/try parsers. */ @@ -75,6 +73,44 @@ typedef struct PlxLocal2 struct PlxLocal2 *next; } PlxLocal2; +/* ------------------------------------------------------- try/finally frames */ + +/* + * A frame is pushed while a try body and its catch arms are parsed. A return, + * break or continue that would leave the frame is emitted as a placeholder + * (PlxFinExit) because the parser does not yet know whether a finally follows; + * plx_emit_try resolves the placeholders once it does. See plx_emit_try. + */ +typedef enum +{ + PLX_EXIT_RETURN, PLX_EXIT_BREAK, PLX_EXIT_CONTINUE +} PlxExitKind; + +typedef struct PlxFinExit +{ + PlxExitKind kind; + char *label; /* loop label, or NULL */ + char *value; /* rewritten return value, or NULL */ + int code; /* dispatch code once routed through finally */ +} PlxFinExit; + +typedef struct PlxFin +{ + int id; + int loopdepth; /* cx->loopdepth when the try began */ + struct PlxFin *outer; + PlxFinExit *exits; + int nexits, + cap; +} PlxFin; + +typedef struct PlxLabel +{ + char *name; + int fin_id; /* frame the labeled loop is in (0 = none) */ + struct PlxLabel *next; +} PlxLabel; + /* ---------------------------------------------------------------- context */ /* @@ -95,7 +131,10 @@ typedef struct PlxCtx int loopdepth; int depth; /* recursion depth guard */ int handlerdepth; /* inside a rescue handler body */ - int ensuredepth; /* finally/ensure nested inside finally/ensure */ + struct PlxFin *fin; /* innermost enclosing try-with-finally frame */ + int fin_seq; /* frame id counter */ + struct PlxLabel *labels; /* loop labels seen so far, most recent first */ + bool ret_declared; /* __plx_ret hoisted (see plx_fin_ret_var) */ int subq; /* __plx_fo_N / __plx_p_N counter */ const char *exc_var; /* current rescue exception var name */ int exc_varlen; @@ -137,9 +176,13 @@ bool plx_tok_is(Tok *tk, const char *op); bool plx_name_eq(Tok *tk, const char *w); int plx_stmt_end(Ctx *cx, int from); void plx_indent(StringInfo o, int n); -void plx_ensure_enter(Ctx *cx, int line); -void plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, - const char *arms, const char *ensure_body); +PlxFin *plx_fin_push(Ctx *cx); +void plx_fin_pop(Ctx *cx, PlxFin *f); +void plx_label_note(Ctx *cx, const char *name, int len); +void plx_emit_exit(Ctx *cx, StringInfo o, int ind, PlxExitKind kind, + const char *label, const char *value, const char *cond); +void plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, + char *arms, const char *ensure_body, PlxFin *f); PlxLocal2 *plx_local_add(Ctx *cx, const char *name, int len); PlxLocal2 *plx_local_find(Ctx *cx, const char *name, int len); void plx_locals_reset(Ctx *cx); diff --git a/src/plx_parse_brace.c b/src/plx_parse_brace.c index a65dd76..b925ca9 100644 --- a/src/plx_parse_brace.c +++ b/src/plx_parse_brace.c @@ -350,8 +350,10 @@ parse_try_brace(Ctx *cx, int ind) StringInfoData arms; int nrescue = 0; char *ensure_body = NULL; + PlxFin *fin; cx->pos++; /* try */ + fin = plx_fin_push(cx); { StringInfoData saved = cx->out; @@ -423,20 +425,19 @@ parse_try_brace(Ctx *cx, int ind) nrescue++; plx_skip_seps(cx); } + plx_fin_pop(cx, fin); if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_ENSURE) /* finally */ { StringInfoData saved = cx->out; - plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos++; initStringInfo(&cx->out); parse_brace_block(cx, ind + 1); - cx->ensuredepth--; ensure_body = cx->out.data; cx->out = saved; } - plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); + plx_emit_try(cx, o, ind, body, nrescue, arms.data, ensure_body, fin); (void) line; } @@ -623,6 +624,7 @@ parse_brace_stmt_inner(Ctx *cx, int ind, bool toplevel) { plx_indent(&cx->out, ind); appendStringInfo(&cx->out, "<<%.*s>>\n", tk->len, tk->s); + plx_label_note(cx, tk->s, tk->len); cx->pos += 2; /* consume name and ':' */ tk = &cx->t[cx->pos]; } diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 230b2d4..e6772fd 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -25,6 +25,7 @@ #include "lib/stringinfo.h" #include "mb/pg_wchar.h" #include "miscadmin.h" +#include "catalog/pg_type.h" #include "parser/scansup.h" #include "utils/builtins.h" #include "utils/hsearch.h" @@ -1630,41 +1631,261 @@ plx_indent(StringInfo o, int n) appendStringInfoString(o, " "); } +/* ------------------------------------------------------- try/finally frames */ + +#define PLX_PH '\x01' /* placeholder delimiter; rejected in source */ + +PlxFin * +plx_fin_push(Ctx *cx) +{ + PlxFin *f = palloc0(sizeof(PlxFin)); + + f->id = ++cx->fin_seq; + f->loopdepth = cx->loopdepth; + f->outer = cx->fin; + cx->fin = f; + return f; +} + +void +plx_fin_pop(Ctx *cx, PlxFin *f) +{ + Assert(cx->fin == f); + cx->fin = f->outer; +} + +/* Record a loop label, so a labeled break/continue knows which frame its + * target loop is in. */ +void +plx_label_note(Ctx *cx, const char *name, int len) +{ + PlxLabel *l = palloc(sizeof(PlxLabel)); + + l->name = pnstrdup(name, len); + l->fin_id = cx->fin ? cx->fin->id : 0; + l->next = cx->labels; + cx->labels = l; +} + /* - * Enter a finally/ensure body. The lowering below emits that body twice (once - * per exit path), so a finally nested inside a finally doubles per level: a - * few hundred bytes of source could otherwise demand gigabytes of output. The - * caller decrements cx->ensuredepth after parsing the body. + * Does this exit leave frame f? A return always does. An unlabeled + * break/continue does when its loop (the innermost loop at `loopdepth`) is + * outside f; a labeled one when its label was recorded outside f. */ +static bool +exit_crosses(Ctx *cx, PlxFin *f, PlxExitKind kind, const char *label, + int loopdepth) +{ + PlxLabel *l; + + if (!f) + return false; + if (kind == PLX_EXIT_RETURN) + return true; + if (!label) + return loopdepth == f->loopdepth; + for (l = cx->labels; l; l = l->next) + if (pg_strcasecmp(l->name, label) == 0) + return l->fin_id != f->id; + return false; /* unknown label: let plpgsql report it */ +} + +/* Register an exit with f and return its placeholder text. */ +static char * +fin_placeholder(PlxFin *f, PlxExitKind kind, const char *label, const char *value) +{ + PlxFinExit *e; + + if (f->nexits >= f->cap) + { + f->cap = f->cap ? f->cap * 2 : 8; + f->exits = f->exits ? repalloc(f->exits, f->cap * sizeof(PlxFinExit)) + : palloc(f->cap * sizeof(PlxFinExit)); + } + e = &f->exits[f->nexits]; + e->kind = kind; + e->label = label ? pstrdup(label) : NULL; + e->value = value ? pstrdup(value) : NULL; + e->code = 0; + return psprintf("%c%d.%d%c", PLX_PH, f->id, f->nexits++, PLX_PH); +} + +/* The exit as a statement in the context of frame g (NULL = none): either + * the plain plpgsql statement, or a placeholder for g to resolve. */ +static char * +render_exit(Ctx *cx, PlxFin *g, PlxExitKind kind, const char *label, + const char *value, int loopdepth) +{ + if (exit_crosses(cx, g, kind, label, loopdepth)) + return fin_placeholder(g, kind, label, value); + if (kind == PLX_EXIT_RETURN) + return value ? psprintf("RETURN %s;", value) : pstrdup("RETURN;"); + return psprintf("%s%s%s;", kind == PLX_EXIT_BREAK ? "EXIT" : "CONTINUE", + label ? " " : "", label ? label : ""); +} + void -plx_ensure_enter(Ctx *cx, int line) +plx_emit_exit(Ctx *cx, StringInfo o, int ind, PlxExitKind kind, + const char *label, const char *value, const char *cond) +{ + plx_indent(o, ind); + if (!exit_crosses(cx, cx->fin, kind, label, cx->loopdepth)) + { + /* unchanged from the pre-frame lowering */ + if (kind == PLX_EXIT_RETURN) + appendStringInfo(o, value ? "RETURN %s;\n" : "RETURN;\n", value); + else + appendStringInfo(o, "%s%s%s%s%s;\n", + kind == PLX_EXIT_BREAK ? "EXIT" : "CONTINUE", + label ? " " : "", label ? label : "", + cond ? " WHEN " : "", cond ? cond : ""); + return; + } + if (cond) + appendStringInfo(o, "IF %s THEN %s END IF;\n", cond, + fin_placeholder(cx->fin, kind, label, value)); + else + appendStringInfo(o, "%s\n", fin_placeholder(cx->fin, kind, label, value)); +} + +/* The hoisted return-value variable, typed from the function's result. */ +static const char * +plx_fin_ret_var(Ctx *cx) +{ + if (!cx->ret_declared) + { + PlxLocal2 *l = plx_local_add(cx, "__plx_ret", 9); + Oid rt = cx->meta ? cx->meta->rettype : InvalidOid; + + if (!OidIsValid(rt) || rt == VOIDOID) + l->typ = "text"; /* plpgsql rejects the RETURN value anyway */ + else if (IsPolymorphicType(rt)) + l->typ = "ALIAS FOR $0"; + else if (rt == RECORDOID || rt == TRIGGEROID) + l->is_record = true; + else + l->typ = format_type_be(rt); + cx->ret_declared = true; + } + return "__plx_ret"; +} + +/* + * Replace frame f's placeholders in s. With a finally, an exit becomes + * "save the value, note which exit, leave the block"; the dispatch after the + * finally body completes it. Without one, the exit is re-rendered for the + * enclosing frame (plain, or a placeholder of its own). + */ +static char * +fin_resolve(Ctx *cx, PlxFin *f, const char *s, bool routed, int *ncodes) { - if (++cx->ensuredepth > PLX_MAX_ENSURE_DEPTH) - plx_err(cx, line, "finally/ensure blocks nested too deeply inside one another"); + StringInfoData r; + + if (!strchr(s, PLX_PH)) + return (char *) s; + initStringInfo(&r); + while (*s) + { + const char *q; + int fid, + idx; + PlxFinExit *e; + + if (*s != PLX_PH) + { + appendStringInfoChar(&r, *s++); + continue; + } + if (sscanf(s + 1, "%d.%d", &fid, &idx) != 2 || fid != f->id || + idx < 0 || idx >= f->nexits || !(q = strchr(s + 1, PLX_PH))) + elog(ERROR, "plx: malformed try/finally placeholder"); + e = &f->exits[idx]; + if (!routed) + appendStringInfoString(&r, render_exit(cx, f->outer, e->kind, e->label, + e->value, f->loopdepth)); + else + { + int i; + + /* one dispatch code per distinct target */ + for (i = 0; i < idx && !e->code; i++) + if (f->exits[i].code && f->exits[i].kind == e->kind && + (f->exits[i].value != NULL) == (e->value != NULL) && + ((!f->exits[i].label && !e->label) || + (f->exits[i].label && e->label && + pg_strcasecmp(f->exits[i].label, e->label) == 0))) + e->code = f->exits[i].code; + if (!e->code) + e->code = ++*ncodes; + if (e->value) + appendStringInfo(&r, "%s := %s; ", plx_fin_ret_var(cx), e->value); + appendStringInfo(&r, "__plx_pend_%d := %d; EXIT __plx_try_%d;", + f->id, e->code, f->id); + } + s = q + 1; + } + return r.data; } /* - * Emit a try/rescue[/finally] statement (shared by every front end): + * Emit a try/rescue[/finally] statement (shared by every front end). Without + * a finally it is BEGIN body EXCEPTION arms END. With one, the finally body + * is emitted once: * - * BEGIN -- only when there is a finally body - * BEGIN body EXCEPTION arms END; + * __plx_pend_N := 0; __plx_es_N := NULL; + * <<__plx_try_N>> + * BEGIN + * BEGIN body EXCEPTION arms END; -- exits: note + EXIT __plx_try_N * EXCEPTION WHEN OTHERS THEN - * finally; RAISE; + * GET STACKED DIAGNOSTICS __plx_es_N = RETURNED_SQLSTATE, ...; * END; - * finally; -- normal path, outside the handler + * finally body + * IF __plx_es_N IS NOT NULL THEN RAISE (the saved error) END IF; + * IF __plx_pend_N = 1 THEN RETURN __plx_ret; ELSIF ... END IF; * - * The normal-path copy runs after the protected block rather than inside it, - * so an error raised by the finally body itself is not caught and does not - * run the finally body a second time. + * So the finally body runs on normal completion, on an error (which is then + * re-raised with its SQLSTATE, message, detail and hint), and on a return, + * break or continue out of the try. An error raised by the finally body, or a + * return from it, replaces the pending one, as in the source languages. */ void -plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, - const char *arms, const char *ensure_body) +plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, + char *arms, const char *ensure_body, PlxFin *f) { - int in = ensure_body ? ind + 1 : ind; + bool fin = (ensure_body != NULL); + int in = fin ? ind + 1 : ind; + int ncodes = 0; + int code; + + body = fin_resolve(cx, f, body, fin, &ncodes); + arms = fin_resolve(cx, f, arms, fin, &ncodes); - if (ensure_body) + if (fin) { + PlxLocal2 *l; + static const char *const errv[] = {"es", "em", "ed", "eh"}; + int i; + + for (i = 0; i < 4; i++) + { + char *nm = psprintf("__plx_%s_%d", errv[i], f->id); + + l = plx_local_add(cx, nm, (int) strlen(nm)); + l->typ = "text"; + } + if (ncodes) + { + char *nm = psprintf("__plx_pend_%d", f->id); + + l = plx_local_add(cx, nm, (int) strlen(nm)); + l->typ = "int"; + plx_indent(o, ind); + appendStringInfo(o, "__plx_pend_%d := 0;\n", f->id); + } + plx_indent(o, ind); + appendStringInfo(o, "__plx_es_%d := NULL;\n", f->id); + plx_indent(o, ind); + appendStringInfo(o, "<<__plx_try_%d>>\n", f->id); plx_indent(o, ind); appendStringInfoString(o, "BEGIN\n"); } @@ -1679,16 +1900,62 @@ plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, } plx_indent(o, in); appendStringInfoString(o, "END;\n"); - if (ensure_body) + if (!fin) + return; + + plx_indent(o, ind); + appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); + plx_indent(o, ind + 1); + appendStringInfo(o, "GET STACKED DIAGNOSTICS __plx_es_%d = RETURNED_SQLSTATE, " + "__plx_em_%d = MESSAGE_TEXT, __plx_ed_%d = PG_EXCEPTION_DETAIL, " + "__plx_eh_%d = PG_EXCEPTION_HINT;\n", f->id, f->id, f->id, f->id); + plx_indent(o, ind); + appendStringInfoString(o, "END;\n"); + appendStringInfoString(o, ensure_body); + + /* re-raise; a RAISE option may not be NULL, and '' would print an empty line */ + plx_indent(o, ind); + appendStringInfo(o, "IF __plx_es_%d IS NOT NULL THEN\n", f->id); + plx_indent(o, ind + 1); + appendStringInfo(o, "IF __plx_ed_%1$d <> '' AND __plx_eh_%1$d <> '' THEN " + "RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, MESSAGE = __plx_em_%1$d, " + "DETAIL = __plx_ed_%1$d, HINT = __plx_eh_%1$d;\n", f->id); + plx_indent(o, ind + 1); + appendStringInfo(o, "ELSIF __plx_ed_%1$d <> '' THEN " + "RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, MESSAGE = __plx_em_%1$d, " + "DETAIL = __plx_ed_%1$d;\n", f->id); + plx_indent(o, ind + 1); + appendStringInfo(o, "ELSIF __plx_eh_%1$d <> '' THEN " + "RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, MESSAGE = __plx_em_%1$d, " + "HINT = __plx_eh_%1$d;\n", f->id); + plx_indent(o, ind + 1); + appendStringInfo(o, "ELSE RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, " + "MESSAGE = __plx_em_%1$d;\n", f->id); + plx_indent(o, ind + 1); + appendStringInfoString(o, "END IF;\n"); + plx_indent(o, ind); + appendStringInfoString(o, "END IF;\n"); + + /* complete the pending exit, in the enclosing frame's context */ + for (code = 1; code <= ncodes; code++) { + int i; + + for (i = 0; i < f->nexits; i++) + if (f->exits[i].code == code) + break; + Assert(i < f->nexits); plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind + 1); - appendStringInfoString(o, "RAISE;\n"); + appendStringInfo(o, "%s __plx_pend_%d = %d THEN %s\n", + code == 1 ? "IF" : "ELSIF", f->id, code, + render_exit(cx, f->outer, f->exits[i].kind, f->exits[i].label, + f->exits[i].value ? plx_fin_ret_var(cx) : NULL, + f->loopdepth)); + } + if (ncodes) + { plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - appendStringInfoString(o, ensure_body); + appendStringInfoString(o, "END IF;\n"); } } @@ -2374,10 +2641,7 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) if (t0->kind == T_KW && t0->kw == KW_RETURN) { if (a + 1 >= b) - { - plx_indent(o, ind); - appendStringInfoString(o, "RETURN;\n"); - } + plx_emit_exit(cx, o, ind, PLX_EXIT_RETURN, NULL, NULL, NULL); else { char *e = plx_rewrite_expr(cx, plx_span_text(cx, a + 1, b), @@ -2385,8 +2649,7 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) if (cx->retset) plx_err(cx, t0->line, "use emit/return_next in a set-returning function, not 'return '"); - plx_indent(o, ind); - appendStringInfo(o, "RETURN %s;\n", e); + plx_emit_exit(cx, o, ind, PLX_EXIT_RETURN, NULL, e, NULL); } return; } @@ -2416,15 +2679,14 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) /* next / break, optionally with a loop label */ if (t0->kind == T_KW && (t0->kw == KW_NEXT || t0->kw == KW_BREAK)) { - const char *kw = (t0->kw == KW_NEXT) ? "CONTINUE" : "EXIT"; + PlxExitKind kind = (t0->kw == KW_NEXT) ? PLX_EXIT_CONTINUE : PLX_EXIT_BREAK; if (cx->loopdepth == 0) plx_err(cx, t0->line, "%s outside a loop", t0->kw == KW_NEXT ? "next" : "break"); - plx_indent(o, ind); - if (a + 1 < b && cx->t[a + 1].kind == T_IDENT) - appendStringInfo(o, "%s %.*s;\n", kw, cx->t[a + 1].len, cx->t[a + 1].s); - else - appendStringInfo(o, "%s;\n", kw); + plx_emit_exit(cx, o, ind, kind, + (a + 1 < b && cx->t[a + 1].kind == T_IDENT) + ? pnstrdup(cx->t[a + 1].s, cx->t[a + 1].len) : NULL, + NULL, NULL); return; } /* raise */ @@ -2716,16 +2978,13 @@ plx_emit_leaf(Ctx *cx, int a, int b, int ind, bool toplevel) /* next/break modifiers -> WHEN (optionally with a loop label) */ if (t0->kind == T_KW && (t0->kw == KW_NEXT || t0->kw == KW_BREAK)) { - char lbl[NAMEDATALEN + 1] = ""; - if (cx->loopdepth == 0) plx_err(cx, t0->line, "%s outside a loop", t0->kw == KW_NEXT ? "next" : "break"); - if (a + 1 < m && cx->t[a + 1].kind == T_IDENT) - snprintf(lbl, sizeof(lbl), " %.*s", cx->t[a + 1].len, cx->t[a + 1].s); - plx_indent(o, ind); - appendStringInfo(o, "%s%s WHEN %s%s%s;\n", - t0->kw == KW_NEXT ? "CONTINUE" : "EXIT", lbl, - neg ? "NOT (" : "", cond, neg ? ")" : ""); + plx_emit_exit(cx, o, ind, + t0->kw == KW_NEXT ? PLX_EXIT_CONTINUE : PLX_EXIT_BREAK, + (a + 1 < m && cx->t[a + 1].kind == T_IDENT) + ? pnstrdup(cx->t[a + 1].s, cx->t[a + 1].len) : NULL, + NULL, neg ? psprintf("NOT (%s)", cond) : cond); return; } if (mk == KW_IF || mk == KW_UNLESS) @@ -2988,6 +3247,13 @@ plx_transpile(const char *body, const PlxFuncMeta *meta, const PlxSurface *surf, cx.mcx = scratch; initStringInfo(&cx.out); + /* the try/finally lowering uses \x01 to delimit placeholders */ + if (strchr(body, PLX_PH)) + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("%s: function body contains the control character U+0001", + surf->lanname))); + /* Run the dialect front end (the vtable method): fills cx.out. */ surf->parse_body(&cx); diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index d43c1cd..f24dce6 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -253,13 +253,19 @@ SELECT js_ann_cond(3), js_ann_cond(-3); 1 | 0 (1 row) --- finally inside finally is capped (each level doubles the generated body) +-- finally inside finally is emitted once per level, so deep nesting stays +-- linear in size (each level used to double the generated body) DO $d$ DECLARE s text := 'x = 1;'; BEGIN - FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; + FOR i IN 1..40 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' || s || ' return x; $b$'; -EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; -WARNING: caught: plxjs: finally/ensure blocks nested too deeply inside one another +END $d$; +SELECT js_fin_deep(), length(prosrc) < 200000 AS linear FROM pg_proc WHERE proname = 'js_fin_deep'; + js_fin_deep | linear +-------------+-------- + 1 | t +(1 row) + -- a finally body that throws runs once, not again from the error path DO LANGUAGE plxjs $p$ try { raise("warning", "body"); } @@ -268,7 +274,7 @@ $p$; WARNING: body WARNING: finally ran ERROR: boom -CONTEXT: PL/pgSQL function inline_code_block line 13 at RAISE +CONTEXT: PL/pgSQL function inline_code_block line 18 at RAISE -- a string ending in a lone backslash cannot escape its quotes: the second -- assignment stays an inert string instead of running set_config CREATE FUNCTION js_bs_escape() RETURNS text LANGUAGE plxjs AS $q$ @@ -283,3 +289,119 @@ SELECT js_bs_escape() LIKE '%set_config%' AS literal_kept, t | t (1 row) +-- finally runs on return, break and continue, and after an error, once each +CREATE FUNCTION js_fin_return() RETURNS int LANGUAGE plxjs AS $$ + let x = 1; + try { return x; } finally { x = 99; raise("warning", "finally after return"); } +$$; +SELECT js_fin_return(); -- 1: the value is taken before finally runs +WARNING: finally after return + js_fin_return +--------------- + 1 +(1 row) + +CREATE FUNCTION js_fin_loop() RETURNS int LANGUAGE plxjs AS $$ + let n = 0; let i = 0; + while (i < 10) { + i = i + 1; + try { if (i == 2) { continue; } if (i == 5) { break; } n = n + i; } + finally { n = n + 100; } + } + return n; +$$; +SELECT js_fin_loop(); -- 508 + js_fin_loop +------------- + 508 +(1 row) + +CREATE FUNCTION js_fin_nested() RETURNS text LANGUAGE plxjs AS $$ + try { try { return "r"; } finally { raise("warning", "inner"); } } + finally { raise("warning", "outer"); } +$$; +SELECT js_fin_nested(); +WARNING: inner +WARNING: outer + js_fin_nested +--------------- + r +(1 row) + +CREATE FUNCTION js_fin_label() RETURNS int LANGUAGE plxjs AS $$ + let n = 0; + outer: for (let i = 0; i < 3; i++) { + try { + for (let j = 0; j < 3; j++) { if (j == 1) { break; } n = n + 1; } + if (i == 1) { break outer; } + } finally { n = n + 10; } + } + return n; +$$; +SELECT js_fin_label(); -- 22 + js_fin_label +-------------- + 22 +(1 row) + +-- an error is re-raised after finally with its SQLSTATE, detail and hint +CREATE FUNCTION js_fin_fail() RETURNS int LANGUAGE plpgsql AS $$ +BEGIN RAISE EXCEPTION USING ERRCODE = '22012', MESSAGE = 'boom', DETAIL = 'd1', HINT = 'h1'; END $$; +CREATE FUNCTION js_fin_err() RETURNS int LANGUAGE plxjs AS $$ + try { return js_fin_fail(); } finally { raise("warning", "cleanup"); } +$$; +DO $d$ DECLARE st text; dt text; ht text; BEGIN PERFORM js_fin_err(); +EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS st = RETURNED_SQLSTATE, dt = PG_EXCEPTION_DETAIL, ht = PG_EXCEPTION_HINT; + RAISE WARNING 'caught: % % % %', st, SQLERRM, dt, ht; +END $d$; +WARNING: cleanup +WARNING: caught: 22012 boom d1 h1 +-- a return in finally replaces the error; a return from catch runs finally +CREATE FUNCTION js_fin_override() RETURNS int LANGUAGE plxjs AS $$ + try { return js_fin_fail(); } finally { return 7; } +$$; +SELECT js_fin_override(); + js_fin_override +----------------- + 7 +(1 row) + +CREATE FUNCTION js_fin_catch() RETURNS int LANGUAGE plxjs AS $$ + try { return js_fin_fail(); } catch (e) { return -1; } finally { raise("warning", "after catch"); } +$$; +SELECT js_fin_catch(); +WARNING: after catch + js_fin_catch +-------------- + -1 +(1 row) + +-- a trigger returning NEW, and a set-returning function's bare return +CREATE TABLE js_fin_t(a int); +CREATE FUNCTION js_fin_trg() RETURNS trigger LANGUAGE plxjs AS $$ + try { NEW.a = NEW.a * 2; return NEW; } finally { raise("warning", "trigger finally"); } +$$; +CREATE TRIGGER js_fin_t1 BEFORE INSERT ON js_fin_t FOR EACH ROW EXECUTE FUNCTION js_fin_trg(); +INSERT INTO js_fin_t VALUES (21); +WARNING: trigger finally +SELECT * FROM js_fin_t; + a +---- + 42 +(1 row) + +DROP TABLE js_fin_t CASCADE; +CREATE FUNCTION js_fin_srf() RETURNS SETOF int LANGUAGE plxjs AS $$ + emit 1; + try { emit 2; return; } finally { emit 3; } + emit 4; +$$; +SELECT * FROM js_fin_srf(); + js_fin_srf +------------ + 1 + 2 + 3 +(3 rows) + diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index 9014447..ac7e914 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -237,3 +237,24 @@ DO $d$ BEGIN EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n' || repeat(E'#:: int\n', 40000) || E'$b$'; EXCEPTION WHEN syntax_error OR feature_not_supported THEN NULL; END $d$; RESET statement_timeout; +-- finally runs on return and continue +CREATE FUNCTION py_fin_return(x int) RETURNS int LANGUAGE plxpython3 AS $$ +for i in range(1, 10): + try: + if i == 3: + return i * x + if i == 1: + continue + finally: + raise("warning", f"py finally {i}") +return 0 +$$; +SELECT py_fin_return(5); +WARNING: py finally 1 +WARNING: py finally 2 +WARNING: py finally 3 + py_fin_return +--------------- + 15 +(1 row) + diff --git a/test/expected/plxruby.out b/test/expected/plxruby.out index 43327d8..4418b04 100644 --- a/test/expected/plxruby.out +++ b/test/expected/plxruby.out @@ -415,3 +415,18 @@ SELECT count(*) FROM pg_proc WHERE proname = 'rb_many_locals'; (1 row) RESET statement_timeout; +-- ensure runs on return +CREATE FUNCTION rb_ensure_return(x int) RETURNS int LANGUAGE plxruby AS $$ + begin + return x * 2 + ensure + raise warning: "ruby ensure" + end +$$; +SELECT rb_ensure_return(4); +WARNING: ruby ensure + rb_ensure_return +------------------ + 8 +(1 row) + diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index 9e3730c..fb835a5 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -174,12 +174,14 @@ CREATE FUNCTION js_ann_cond(x int) RETURNS int LANGUAGE plxjs AS $$ $$; SELECT js_ann_cond(3), js_ann_cond(-3); --- finally inside finally is capped (each level doubles the generated body) +-- finally inside finally is emitted once per level, so deep nesting stays +-- linear in size (each level used to double the generated body) DO $d$ DECLARE s text := 'x = 1;'; BEGIN - FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; + FOR i IN 1..40 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' || s || ' return x; $b$'; -EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +END $d$; +SELECT js_fin_deep(), length(prosrc) < 200000 AS linear FROM pg_proc WHERE proname = 'js_fin_deep'; -- a finally body that throws runs once, not again from the error path DO LANGUAGE plxjs $p$ @@ -196,3 +198,71 @@ return b; $q$; SELECT js_bs_escape() LIKE '%set_config%' AS literal_kept, current_setting('plx.test_pwned', true) IS NULL AS not_run; + +-- finally runs on return, break and continue, and after an error, once each +CREATE FUNCTION js_fin_return() RETURNS int LANGUAGE plxjs AS $$ + let x = 1; + try { return x; } finally { x = 99; raise("warning", "finally after return"); } +$$; +SELECT js_fin_return(); -- 1: the value is taken before finally runs +CREATE FUNCTION js_fin_loop() RETURNS int LANGUAGE plxjs AS $$ + let n = 0; let i = 0; + while (i < 10) { + i = i + 1; + try { if (i == 2) { continue; } if (i == 5) { break; } n = n + i; } + finally { n = n + 100; } + } + return n; +$$; +SELECT js_fin_loop(); -- 508 +CREATE FUNCTION js_fin_nested() RETURNS text LANGUAGE plxjs AS $$ + try { try { return "r"; } finally { raise("warning", "inner"); } } + finally { raise("warning", "outer"); } +$$; +SELECT js_fin_nested(); +CREATE FUNCTION js_fin_label() RETURNS int LANGUAGE plxjs AS $$ + let n = 0; + outer: for (let i = 0; i < 3; i++) { + try { + for (let j = 0; j < 3; j++) { if (j == 1) { break; } n = n + 1; } + if (i == 1) { break outer; } + } finally { n = n + 10; } + } + return n; +$$; +SELECT js_fin_label(); -- 22 +-- an error is re-raised after finally with its SQLSTATE, detail and hint +CREATE FUNCTION js_fin_fail() RETURNS int LANGUAGE plpgsql AS $$ +BEGIN RAISE EXCEPTION USING ERRCODE = '22012', MESSAGE = 'boom', DETAIL = 'd1', HINT = 'h1'; END $$; +CREATE FUNCTION js_fin_err() RETURNS int LANGUAGE plxjs AS $$ + try { return js_fin_fail(); } finally { raise("warning", "cleanup"); } +$$; +DO $d$ DECLARE st text; dt text; ht text; BEGIN PERFORM js_fin_err(); +EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS st = RETURNED_SQLSTATE, dt = PG_EXCEPTION_DETAIL, ht = PG_EXCEPTION_HINT; + RAISE WARNING 'caught: % % % %', st, SQLERRM, dt, ht; +END $d$; +-- a return in finally replaces the error; a return from catch runs finally +CREATE FUNCTION js_fin_override() RETURNS int LANGUAGE plxjs AS $$ + try { return js_fin_fail(); } finally { return 7; } +$$; +SELECT js_fin_override(); +CREATE FUNCTION js_fin_catch() RETURNS int LANGUAGE plxjs AS $$ + try { return js_fin_fail(); } catch (e) { return -1; } finally { raise("warning", "after catch"); } +$$; +SELECT js_fin_catch(); +-- a trigger returning NEW, and a set-returning function's bare return +CREATE TABLE js_fin_t(a int); +CREATE FUNCTION js_fin_trg() RETURNS trigger LANGUAGE plxjs AS $$ + try { NEW.a = NEW.a * 2; return NEW; } finally { raise("warning", "trigger finally"); } +$$; +CREATE TRIGGER js_fin_t1 BEFORE INSERT ON js_fin_t FOR EACH ROW EXECUTE FUNCTION js_fin_trg(); +INSERT INTO js_fin_t VALUES (21); +SELECT * FROM js_fin_t; +DROP TABLE js_fin_t CASCADE; +CREATE FUNCTION js_fin_srf() RETURNS SETOF int LANGUAGE plxjs AS $$ + emit 1; + try { emit 2; return; } finally { emit 3; } + emit 4; +$$; +SELECT * FROM js_fin_srf(); diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index e057e15..47a48a2 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -171,3 +171,16 @@ DO $d$ BEGIN EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n' || repeat(E'#:: int\n', 40000) || E'$b$'; EXCEPTION WHEN syntax_error OR feature_not_supported THEN NULL; END $d$; RESET statement_timeout; +-- finally runs on return and continue +CREATE FUNCTION py_fin_return(x int) RETURNS int LANGUAGE plxpython3 AS $$ +for i in range(1, 10): + try: + if i == 3: + return i * x + if i == 1: + continue + finally: + raise("warning", f"py finally {i}") +return 0 +$$; +SELECT py_fin_return(5); diff --git a/test/sql/plxruby.sql b/test/sql/plxruby.sql index 65a76db..22d384d 100644 --- a/test/sql/plxruby.sql +++ b/test/sql/plxruby.sql @@ -295,3 +295,12 @@ END $d$; -- (not called: plpgsql's own compile of 60,000 variables is slow, and is not plx's) SELECT count(*) FROM pg_proc WHERE proname = 'rb_many_locals'; RESET statement_timeout; +-- ensure runs on return +CREATE FUNCTION rb_ensure_return(x int) RETURNS int LANGUAGE plxruby AS $$ + begin + return x * 2 + ensure + raise warning: "ruby ensure" + end +$$; +SELECT rb_ensure_return(4); From 0d254079aa9cb9533d6351e98046e15f6efa5a19 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 19:40:37 +0000 Subject: [PATCH 5/8] Add a try/finally differential check against real Python test/finally_diff.py generates random programs mixing nested try/except/finally, loops, return/break/continue (also inside finally) and raised errors, runs each in Python and as a plxpython3 function, and requires the results to agree. `make differentialcheck` runs 300 cases after the existing differential check, so CI covers it. Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 + test/finally_diff.py | 89 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100755 test/finally_diff.py diff --git a/Makefile b/Makefile index f01d495..ea06457 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,8 @@ PLX_PYTHON ?= python3 .PHONY: differentialcheck differentialcheck: PLX_PSQL="$(shell $(PG_CONFIG) --bindir)/psql" $(PLX_PYTHON) test/differential.py + PLX_PSQL="$(shell $(PG_CONFIG) --bindir)/psql" PGDATABASE=contrib_regression \ + $(PLX_PYTHON) test/finally_diff.py 300 1 # Check META.json against the PGXN Meta Spec. PGXN validates at upload time, # which is after a release is tagged and published, so check it before then. diff --git a/test/finally_diff.py b/test/finally_diff.py new file mode 100755 index 0000000..e215116 --- /dev/null +++ b/test/finally_diff.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Differential check of try/except/finally control flow. + +Generates random Python programs that mix nested try/except/finally, for +loops, return/break/continue (also inside finally) and raised errors, runs +each in real Python and as a plxpython3 function, and requires the results to +agree. `acc` folds in every executed statement, so a result encodes the order +of execution, not just the last value. + +Needs the extension installed and a running server, like installcheck: + PGDATABASE=contrib_regression python3 test/finally_diff.py [cases] [seed] +""" +import os, random, subprocess, sys, warnings + +warnings.simplefilter("ignore") # Python warns about return in finally +random.seed(int(sys.argv[2]) if len(sys.argv) > 2 else 1) +N = int(sys.argv[1]) if len(sys.argv) > 1 else 200 +K = [0] +def k(): + K[0] += 1; return K[0] % 7 + 1 +def block(d, inloop, ind): + out = [] + for _ in range(random.randint(1, 3)): + out += stmt(d, inloop, ind) + return out +def stmt(d, inloop, ind): + p = " " * ind + r = random.random() + if d >= 4 or r < 0.30: + return [f"{p}acc = (acc * 3 + {k()}) % 1000003"] + if r < 0.40: + return [f"{p}if acc % 2 == 0:"] + [f"{p} return acc"] + if r < 0.48 and inloop: + return [f"{p}if acc % 3 == 0:", f"{p} break"] + if r < 0.56 and inloop: + return [f"{p}if acc % 5 == 1:", f"{p} continue"] + if r < 0.62: + return [f"{p}if acc % 4 == 1:", f"{p} raise ValueError(\"x\")"] + if r < 0.75: + v = f"i{d}" + return [f"{p}for {v} in range(0, 3):"] + block(d + 1, True, ind + 4) + # try + lines = [f"{p}try:"] + block(d + 1, inloop, ind + 4) + form = random.choice(["exc", "fin", "both"]) + if form in ("exc", "both"): + lines += [f"{p}except Exception as e:"] + block(d + 1, inloop, ind + 4) + if form in ("fin", "both"): + lines += [f"{p}finally:"] + block(d + 1, inloop, ind + 4) + return lines +def prog(): + return ["acc = 1"] + block(0, False, 0) + ["return acc"] +def run_py(body): + src = "def f():\n" + "\n".join(" " + l for l in body) + "\n" + ns = {} + exec(src, ns) + try: + return str(ns["f"]()) + except ValueError: + return "ERROR" +cases = [] +for n in range(N): + body = prog() + cases.append((n, body, run_py(body))) +sql = ["SET client_min_messages = warning;"] +for n, body, _ in cases: + b = "\n".join(body) + sql.append(f"CREATE OR REPLACE FUNCTION fd{n}() RETURNS bigint LANGUAGE plxpython3 AS $plx$\nacc #:: bigint\n{b}\n$plx$;") + sql.append(f"DO $d$ BEGIN RAISE WARNING 'R{n}=%', fd{n}(); EXCEPTION WHEN OTHERS THEN RAISE WARNING 'R{n}=ERROR %', SQLERRM; END $d$;") +r = subprocess.run([os.environ.get("PLX_PSQL", "psql"), "-q", "-X"], input="\n".join(sql), + capture_output=True, text=True) +got = {} +for line in r.stderr.splitlines(): + if "R" in line and "=" in line and "WARNING" in line: + tag = line.split("WARNING:")[1].strip() + key, val = tag.split("=", 1) + got[int(key[1:])] = val +other = [l for l in r.stderr.splitlines() if "WARNING" not in l] +bad = 0 +for n, body, exp in cases: + g = got.get(n, "MISSING") + gg = "ERROR" if g.startswith("ERROR") else g + if gg != exp: + bad += 1 + if bad <= 3: + print(f"--- case {n}: python={exp} plx={g}\n" + "\n".join(body)) +print(f"{N} cases, {bad} mismatches; other stderr lines: {len(other)}") +for l in other[:5]: + print(" ", l) +sys.exit(1 if bad or other else 0) From 223aae11bf366e851851a8cca4383d2c07bad05f Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 23:51:11 +0000 Subject: [PATCH 6/8] test: put the type annotation after the initializer in js_fin_deep "let x /*:: int */ = 0;" drops the "= 0" (#20 makes that form an error). The test's result does not depend on it, but it should use the documented form so it keeps passing once #20 is merged. Co-Authored-By: Claude Opus 5.5 --- test/expected/plxjs.out | 2 +- test/sql/plxjs.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index d43c1cd..82ccd88 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -256,7 +256,7 @@ SELECT js_ann_cond(3), js_ann_cond(-3); -- finally inside finally is capped (each level doubles the generated body) DO $d$ DECLARE s text := 'x = 1;'; BEGIN FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; - EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' || s || ' return x; $b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; WARNING: caught: plxjs: finally/ensure blocks nested too deeply inside one another diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index 9e3730c..0c0f1f8 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -177,7 +177,7 @@ SELECT js_ann_cond(3), js_ann_cond(-3); -- finally inside finally is capped (each level doubles the generated body) DO $d$ DECLARE s text := 'x = 1;'; BEGIN FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; - EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' || s || ' return x; $b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; From 769f818887dac502ba97d30b5d6b1c1bac273369 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 10 Nov 2026 00:06:35 +0000 Subject: [PATCH 7/8] Free try bodies once emitted and cap indentation, so deep nesting stays linear A return (or break/continue) at every level of nested try/finally made memory grow with the cube of the depth: each level kept its own copy of the text of every level inside it until the end of the transpile, and the text itself grew with the square of the depth because of indentation. 490 levels in a 15 KB plxjs body took the backend to 3.6 GB in 3.4 s; any role can send that, including in a DO block. - plx_emit_try now frees body, arms and ensure_body (and its resolved copies) once they are appended; the callers free each catch handler's text once it is added to the arms. - plx_indent stops at 32 levels (indentation is cosmetic in plpgsql). The same body now takes 31 MB and 0.37 s, and the generated code shrinks from 5.8 MB to 1.2 MB. A plxjs test checks the output size at 300 levels. Co-Authored-By: Claude Opus 5.5 --- src/plx_dialect_python.c | 3 ++- src/plx_dialect_ruby.c | 3 ++- src/plx_engine.h | 2 +- src/plx_parse_brace.c | 3 ++- src/plx_transpile.c | 41 +++++++++++++++++++++++++++++----------- test/expected/plxjs.out | 14 ++++++++++++++ test/sql/plxjs.sql | 10 ++++++++++ 7 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/plx_dialect_python.c b/src/plx_dialect_python.c index 6f5efbc..32b5688 100644 --- a/src/plx_dialect_python.c +++ b/src/plx_dialect_python.c @@ -345,10 +345,11 @@ parse_py_try(Ctx *cx, int ind) cx->exc_var = save_ev; cx->exc_varlen = save_evl; - appendStringInfoSpaces(&arms, (ind + 1) * 2); + plx_indent(&arms, ind + 1); appendStringInfo(&arms, "WHEN %s THEN\n", cond); appendStringInfoString(&arms, pfx); appendStringInfoString(&arms, hb); + pfree(hb); nrescue++; } plx_fin_pop(cx, fin); diff --git a/src/plx_dialect_ruby.c b/src/plx_dialect_ruby.c index 139d145..2304ead 100644 --- a/src/plx_dialect_ruby.c +++ b/src/plx_dialect_ruby.c @@ -476,10 +476,11 @@ parse_begin(Ctx *cx, int ind) cx->exc_var = save_ev; cx->exc_varlen = save_evl; - appendStringInfoSpaces(&arms, (ind + 1) * 2); + plx_indent(&arms, ind + 1); appendStringInfo(&arms, "WHEN %s THEN\n", cond); appendStringInfoString(&arms, pfx); appendStringInfoString(&arms, hb); + pfree(hb); } nrescue++; } diff --git a/src/plx_engine.h b/src/plx_engine.h index d72d382..69e1733 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -182,7 +182,7 @@ void plx_label_note(Ctx *cx, const char *name, int len); void plx_emit_exit(Ctx *cx, StringInfo o, int ind, PlxExitKind kind, const char *label, const char *value, const char *cond); void plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, - char *arms, const char *ensure_body, PlxFin *f); + char *arms, char *ensure_body, PlxFin *f); /* frees its strings */ PlxLocal2 *plx_local_add(Ctx *cx, const char *name, int len); PlxLocal2 *plx_local_find(Ctx *cx, const char *name, int len); void plx_locals_reset(Ctx *cx); diff --git a/src/plx_parse_brace.c b/src/plx_parse_brace.c index b925ca9..fba2be9 100644 --- a/src/plx_parse_brace.c +++ b/src/plx_parse_brace.c @@ -417,10 +417,11 @@ parse_try_brace(Ctx *cx, int ind) cx->exc_var = save_ev; cx->exc_varlen = save_evl; - appendStringInfoSpaces(&arms, (ind + 1) * 2); + plx_indent(&arms, ind + 1); appendStringInfo(&arms, "WHEN %s THEN\n", cond); appendStringInfoString(&arms, pfx); appendStringInfoString(&arms, hb); + pfree(hb); } nrescue++; plx_skip_seps(cx); diff --git a/src/plx_transpile.c b/src/plx_transpile.c index e6772fd..ef1a3db 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -1622,13 +1622,21 @@ const_literal_type(Ctx *cx, int a, int b) /* ---------------------------------------------------------------- indent */ +/* + * Indentation is cosmetic in plpgsql, so it stops growing past + * PLX_MAX_INDENT levels. Without the cap, generated code grows with the + * square of the nesting depth: 490 nested try blocks in a 15 KB body produced + * 5.8 MB of plpgsql, nearly all of it leading spaces. + */ +#define PLX_MAX_INDENT 32 + void plx_indent(StringInfo o, int n) { - int i; - - for (i = 0; i < n; i++) - appendStringInfoString(o, " "); + if (n > PLX_MAX_INDENT) + n = PLX_MAX_INDENT; + if (n > 0) + appendStringInfoSpaces(o, n * 2); } /* ------------------------------------------------------- try/finally frames */ @@ -1847,18 +1855,22 @@ fin_resolve(Ctx *cx, PlxFin *f, const char *s, bool routed, int *ncodes) * re-raised with its SQLSTATE, message, detail and hint), and on a return, * break or continue out of the try. An error raised by the finally body, or a * return from it, replaces the pending one, as in the source languages. + * + * body, arms and ensure_body (NULL when there is no finally) must be palloc'd; + * this function frees them. Each nesting level holds the text of every level + * inside it, so keeping those copies until the end of the transpile made + * memory grow with the cube of the depth (3.6 GB for 490 levels). */ void plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, - char *arms, const char *ensure_body, PlxFin *f) + char *arms, char *ensure_body, PlxFin *f) { bool fin = (ensure_body != NULL); int in = fin ? ind + 1 : ind; int ncodes = 0; int code; - - body = fin_resolve(cx, f, body, fin, &ncodes); - arms = fin_resolve(cx, f, arms, fin, &ncodes); + char *rbody = fin_resolve(cx, f, body, fin, &ncodes); + char *rarms = fin_resolve(cx, f, arms, fin, &ncodes); if (fin) { @@ -1891,15 +1903,21 @@ plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, } plx_indent(o, in); appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); + appendStringInfoString(o, rbody); if (nrescue) { plx_indent(o, in); appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms); + appendStringInfoString(o, rarms); } plx_indent(o, in); appendStringInfoString(o, "END;\n"); + if (rbody != body) + pfree(rbody); + if (rarms != arms) + pfree(rarms); + pfree(body); + pfree(arms); if (!fin) return; @@ -1912,6 +1930,7 @@ plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, plx_indent(o, ind); appendStringInfoString(o, "END;\n"); appendStringInfoString(o, ensure_body); + pfree(ensure_body); /* re-raise; a RAISE option may not be NULL, and '' would print an empty line */ plx_indent(o, ind); @@ -2069,7 +2088,7 @@ plx_diag_prefix(int mask, int ind) for (i = 0; i < (int) (sizeof(t) / sizeof(t[0])); i++) if (mask & t[i].bit) { - appendStringInfoSpaces(&p, ind * 2); + plx_indent(&p, ind); appendStringInfo(&p, "GET STACKED DIAGNOSTICS __plx_%s = %s;\n", t[i].fld, t[i].item); } return p.data; diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index 33943c5..b988414 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -266,6 +266,20 @@ SELECT js_fin_deep(), length(prosrc) < 200000 AS linear FROM pg_proc WHERE prona 1 | t (1 row) +-- a return at every level of deeply nested try/finally: the generated code +-- stays linear in the source (indentation is capped), and each level's text is +-- freed once emitted (it used to be kept, which took 3.6 GB at 490 levels) +DO $d$ DECLARE s text := 'x = 1;'; BEGIN + FOR i IN 1..300 LOOP s := 'try { if (x > 5) { return 1; } ' || s || ' } finally { x = x + 1; }'; END LOOP; + EXECUTE 'CREATE FUNCTION js_fin_ret_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' + || s || ' return x; $b$'; +END $d$; +SELECT js_fin_ret_deep(), length(prosrc) < 1200000 AS linear FROM pg_proc WHERE proname = 'js_fin_ret_deep'; + js_fin_ret_deep | linear +-----------------+-------- + 301 | t +(1 row) + -- a finally body that throws runs once, not again from the error path DO LANGUAGE plxjs $p$ try { raise("warning", "body"); } diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index 5e45920..50ed3aa 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -183,6 +183,16 @@ DO $d$ DECLARE s text := 'x = 1;'; BEGIN END $d$; SELECT js_fin_deep(), length(prosrc) < 200000 AS linear FROM pg_proc WHERE proname = 'js_fin_deep'; +-- a return at every level of deeply nested try/finally: the generated code +-- stays linear in the source (indentation is capped), and each level's text is +-- freed once emitted (it used to be kept, which took 3.6 GB at 490 levels) +DO $d$ DECLARE s text := 'x = 1;'; BEGIN + FOR i IN 1..300 LOOP s := 'try { if (x > 5) { return 1; } ' || s || ' } finally { x = x + 1; }'; END LOOP; + EXECUTE 'CREATE FUNCTION js_fin_ret_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' + || s || ' return x; $b$'; +END $d$; +SELECT js_fin_ret_deep(), length(prosrc) < 1200000 AS linear FROM pg_proc WHERE proname = 'js_fin_ret_deep'; + -- a finally body that throws runs once, not again from the error path DO LANGUAGE plxjs $p$ try { raise("warning", "body"); } From 39f726e867a106e22dcd75e8648ad86eb66a5757 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 10 Nov 2026 19:16:21 +0000 Subject: [PATCH 8/8] Address review: keep object names on re-raise, scope loop labels - The error re-raised after a finally body now keeps the schema, table, column, constraint and type names, as well as the SQLSTATE, message, detail and hint. They are passed only when at least one is non-empty: an empty RAISE option is sent to the client as a present field (psql prints it in verbose mode), so passing all five on every re-raise would have added five empty fields to every error, a plain raise included. - A loop label is now in scope only inside its loop. A loop inside the try that reused the label of the enclosing loop was found first, so a break/continue to the enclosing loop was judged not to cross the finally and skipped it. - finally_diff.py compares the SQLSTATE and message of an error, not just that some error happened. - The deep-nesting test checks that twice the depth gives at most 2.5 times the code, instead of a fixed size bound. - Docs and CHANGELOG: the finally body does not run on a query cancel or a failed assert, which WHEN OTHERS does not catch. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++++--- doc/plxjs.md | 9 ++++ doc/plxphp.md | 10 ++++ doc/plxpython3.md | 9 ++++ doc/plxruby.md | 9 ++++ src/plx_dialect_python.c | 8 ++- src/plx_dialect_ruby.c | 8 ++- src/plx_parse_brace.c | 8 ++- src/plx_transpile.c | 94 +++++++++++++++++++++++++++--------- test/expected/plxjs.out | 65 ++++++++++++++++++++++--- test/expected/plxpython3.out | 17 +++++++ test/expected/plxruby.out | 20 ++++++++ test/finally_diff.py | 10 ++-- test/sql/plxjs.sql | 50 +++++++++++++++++-- test/sql/plxpython3.sql | 12 +++++ test/sql/plxruby.sql | 15 ++++++ 16 files changed, 313 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9308f..2a7f971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,12 +72,18 @@ PUBLIC has USAGE by default). finally body is now emitted once: an exit from the try (or from a catch arm) records which exit is pending, leaves the block, runs the finally body, then completes the exit; an error is caught, the finally body runs, and the error - is re-raised with its SQLSTATE, message, detail and hint. A `return` or an - error in the finally body replaces the pending exit or error, as in the - source languages. Behaviour difference to be aware of: an error re-raised - after `finally` carries the location of the re-raise in its `CONTEXT`, not - the original statement's. Only functions created or replaced after upgrading - are affected; stored functions keep the plpgsql they were created with. + is re-raised with its SQLSTATE, message, detail, hint and object names + (schema, table, column, constraint, type). A `return` or an error in the + finally body replaces the pending exit or error, as in the source languages. + Behaviour differences to be aware of: an error re-raised after `finally` + carries the location of the re-raise in its `CONTEXT`, not the original + statement's; when it names any object, the object names it does not have + are sent as empty fields rather than left out (psql shows them in verbose + mode). As before, the finally body does not run when the statement is + cancelled (a query cancel or `statement_timeout`) or an `assert` fails: + `WHEN OTHERS` does not catch those two errors. Only functions created or + replaced after upgrading are affected; stored functions keep the plpgsql + they were created with. ### Fixed diff --git a/doc/plxjs.md b/doc/plxjs.md index daf5b7e..c44076d 100644 --- a/doc/plxjs.md +++ b/doc/plxjs.md @@ -205,6 +205,15 @@ try { `e.table`, `e.schema`, `e.datatype` to the matching `GET STACKED DIAGNOSTICS` fields. +The `finally` body runs once, whether the `try` block completes, returns, +breaks, continues or raises an error. After an error it runs and then raises +the error again, with its SQLSTATE, message, detail, hint and the names of +the objects it is about (schema, table, column, constraint, type); the +`CONTEXT` of the raised error points at the re-raise, not the original +statement. It does not run when the statement is cancelled (a query cancel +or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not +catch those two errors; for the same reason `catch (e)` does not catch them. + ### Assertions ```sql diff --git a/doc/plxphp.md b/doc/plxphp.md index 8bbd8be..138550e 100644 --- a/doc/plxphp.md +++ b/doc/plxphp.md @@ -208,6 +208,16 @@ Accessors: `$e->message` to `SQLERRM`, `$e->sqlstate` to `SQLSTATE`, and `$e->detail`, `$e->hint`, `$e->constraint`, `$e->column`, `$e->table`, `$e->schema`, `$e->datatype` to the matching `GET STACKED DIAGNOSTICS` fields. +The `finally` body runs once, whether the `try` block completes, returns, +breaks, continues or raises an error. After an error it runs and then raises +the error again, with its SQLSTATE, message, detail, hint and the names of +the objects it is about (schema, table, column, constraint, type); the +`CONTEXT` of the raised error points at the re-raise, not the original +statement. It does not run when the statement is cancelled (a query cancel +or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not +catch those two errors; for the same reason `catch (\Exception $e)` does not +catch them. + ### Assertions ```sql diff --git a/doc/plxpython3.md b/doc/plxpython3.md index 71bcb48..4b5c741 100644 --- a/doc/plxpython3.md +++ b/doc/plxpython3.md @@ -192,6 +192,15 @@ to `unique_violation`. Accessors: `e.message` to `SQLERRM`, `e.sqlstate` to `SQLSTATE`, and `e.detail`, `e.hint`, `e.constraint`, `e.column`, `e.table`, `e.schema`, `e.datatype` to the matching `GET STACKED DIAGNOSTICS` fields. +The `finally` body runs once, whether the `try` block completes, returns, +breaks, continues or raises an error. After an error it runs and then raises +the error again, with its SQLSTATE, message, detail, hint and the names of +the objects it is about (schema, table, column, constraint, type); the +`CONTEXT` of the raised error points at the re-raise, not the original +statement. It does not run when the statement is cancelled (a query cancel +or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not +catch those two errors; for the same reason `except Exception` does not catch them. + ### Assertions ```sql diff --git a/doc/plxruby.md b/doc/plxruby.md index 22a2c79..c433d51 100644 --- a/doc/plxruby.md +++ b/doc/plxruby.md @@ -233,6 +233,15 @@ Accessors: `e.message` to `SQLERRM`, `e.sqlstate` to `SQLSTATE`, and `e.detail`, `e.hint`, `e.constraint`, `e.column`, `e.table`, `e.schema`, `e.datatype` to the matching `GET STACKED DIAGNOSTICS` fields. +The `ensure` body runs once, whether the `begin` block completes, returns, +breaks, continues or raises an error. After an error it runs and then raises +the error again, with its SQLSTATE, message, detail, hint and the names of +the objects it is about (schema, table, column, constraint, type); the +`CONTEXT` of the raised error points at the re-raise, not the original +statement. It does not run when the statement is cancelled (a query cancel +or `statement_timeout`) or an `assert` fails, because `WHEN OTHERS` does not +catch those two errors; for the same reason `rescue` does not catch them. + ### Assertions ```sql diff --git a/src/plx_dialect_python.c b/src/plx_dialect_python.c index 32b5688..b4b3527 100644 --- a/src/plx_dialect_python.c +++ b/src/plx_dialect_python.c @@ -430,11 +430,17 @@ parse_py_stmt_inner(Ctx *cx, int ind, bool toplevel) cx->t[cx->pos + 2].kind == T_KW && (cx->t[cx->pos + 2].kw == KW_FOR || cx->t[cx->pos + 2].kw == KW_WHILE)) { + PlxLabel *mark = cx->labels; + plx_indent(&cx->out, ind); appendStringInfo(&cx->out, "<<%.*s>>\n", tk->len, tk->s); plx_label_note(cx, tk->s, tk->len); cx->pos += 2; - tk = &cx->t[cx->pos]; + /* the label is in scope only inside its loop: a later loop may + * reuse the name, and a break must not resolve to a closed one */ + parse_py_stmt_inner(cx, ind, toplevel); + cx->labels = mark; + return; } if (tk->kind == T_KW) { diff --git a/src/plx_dialect_ruby.c b/src/plx_dialect_ruby.c index 2304ead..e8eaf40 100644 --- a/src/plx_dialect_ruby.c +++ b/src/plx_dialect_ruby.c @@ -634,11 +634,17 @@ parse_stmt_inner(Ctx *cx, int ind, bool toplevel) (cx->t[cx->pos + 2].kw == KW_FOR || cx->t[cx->pos + 2].kw == KW_WHILE || cx->t[cx->pos + 2].kw == KW_UNTIL || cx->t[cx->pos + 2].kw == KW_LOOP)) { + PlxLabel *mark = cx->labels; + plx_indent(&cx->out, ind); appendStringInfo(&cx->out, "<<%.*s>>\n", tk->len, tk->s); plx_label_note(cx, tk->s, tk->len); cx->pos += 2; - tk = &cx->t[cx->pos]; + /* the label is in scope only inside its loop: a later loop may + * reuse the name, and a break must not resolve to a closed one */ + parse_stmt_inner(cx, ind, toplevel); + cx->labels = mark; + return; } if (tk->kind == T_KW) { diff --git a/src/plx_parse_brace.c b/src/plx_parse_brace.c index fba2be9..c0405e9 100644 --- a/src/plx_parse_brace.c +++ b/src/plx_parse_brace.c @@ -623,11 +623,17 @@ parse_brace_stmt_inner(Ctx *cx, int ind, bool toplevel) cx->t[cx->pos + 2].kind == T_KW && (cx->t[cx->pos + 2].kw == KW_FOR || cx->t[cx->pos + 2].kw == KW_WHILE)) { + PlxLabel *mark = cx->labels; + plx_indent(&cx->out, ind); appendStringInfo(&cx->out, "<<%.*s>>\n", tk->len, tk->s); plx_label_note(cx, tk->s, tk->len); cx->pos += 2; /* consume name and ':' */ - tk = &cx->t[cx->pos]; + /* the label is in scope only inside its loop: a later loop may + * reuse the name, and a break must not resolve to a closed one */ + parse_brace_stmt_inner(cx, ind, toplevel); + cx->labels = mark; + return; } if (tk->kind == T_KW) { diff --git a/src/plx_transpile.c b/src/plx_transpile.c index ef1a3db..88fead1 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -1852,9 +1852,11 @@ fin_resolve(Ctx *cx, PlxFin *f, const char *s, bool routed, int *ncodes) * IF __plx_pend_N = 1 THEN RETURN __plx_ret; ELSIF ... END IF; * * So the finally body runs on normal completion, on an error (which is then - * re-raised with its SQLSTATE, message, detail and hint), and on a return, - * break or continue out of the try. An error raised by the finally body, or a - * return from it, replaces the pending one, as in the source languages. + * re-raised with its SQLSTATE, message, detail, hint and object names), and + * on a return, break or continue out of the try. An error raised by the + * finally body, or a return from it, replaces the pending one, as in the + * source languages. It does not run on a query cancel or a failed ASSERT, + * which WHEN OTHERS does not catch. * * body, arms and ensure_body (NULL when there is no finally) must be palloc'd; * this function frees them. Each nesting level holds the text of every level @@ -1875,10 +1877,11 @@ plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, if (fin) { PlxLocal2 *l; - static const char *const errv[] = {"es", "em", "ed", "eh"}; + static const char *const errv[] = {"es", "em", "ed", "eh", "ecol", + "econ", "etyp", "etab", "esch"}; int i; - for (i = 0; i < 4; i++) + for (i = 0; i < lengthof(errv); i++) { char *nm = psprintf("__plx_%s_%d", errv[i], f->id); @@ -1924,32 +1927,77 @@ plx_emit_try(Ctx *cx, StringInfo o, int ind, char *body, int nrescue, plx_indent(o, ind); appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); plx_indent(o, ind + 1); - appendStringInfo(o, "GET STACKED DIAGNOSTICS __plx_es_%d = RETURNED_SQLSTATE, " - "__plx_em_%d = MESSAGE_TEXT, __plx_ed_%d = PG_EXCEPTION_DETAIL, " - "__plx_eh_%d = PG_EXCEPTION_HINT;\n", f->id, f->id, f->id, f->id); + appendStringInfo(o, "GET STACKED DIAGNOSTICS __plx_es_%1$d = RETURNED_SQLSTATE, " + "__plx_em_%1$d = MESSAGE_TEXT, __plx_ed_%1$d = PG_EXCEPTION_DETAIL, " + "__plx_eh_%1$d = PG_EXCEPTION_HINT, __plx_ecol_%1$d = COLUMN_NAME, " + "__plx_econ_%1$d = CONSTRAINT_NAME, __plx_etyp_%1$d = PG_DATATYPE_NAME, " + "__plx_etab_%1$d = TABLE_NAME, __plx_esch_%1$d = SCHEMA_NAME;\n", + f->id); plx_indent(o, ind); appendStringInfoString(o, "END;\n"); appendStringInfoString(o, ensure_body); pfree(ensure_body); - /* re-raise; a RAISE option may not be NULL, and '' would print an empty line */ + /* + * Re-raise. A RAISE option may not be NULL, and an empty one is not the + * same as an absent one: an empty DETAIL or HINT prints an empty line, + * and an empty object name is sent to the client as a present field (psql + * prints it in verbose mode). Absent fields come back from GET STACKED + * DIAGNOSTICS as '', so pass each group only when it is non-empty: DETAIL + * and HINT each, and the five object names as one group, which keeps this + * to eight RAISEs. The cost: an error that names some objects (a unique + * violation names the schema, table and constraint) is re-raised with the + * others present but empty. + */ plx_indent(o, ind); appendStringInfo(o, "IF __plx_es_%d IS NOT NULL THEN\n", f->id); plx_indent(o, ind + 1); - appendStringInfo(o, "IF __plx_ed_%1$d <> '' AND __plx_eh_%1$d <> '' THEN " - "RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, MESSAGE = __plx_em_%1$d, " - "DETAIL = __plx_ed_%1$d, HINT = __plx_eh_%1$d;\n", f->id); - plx_indent(o, ind + 1); - appendStringInfo(o, "ELSIF __plx_ed_%1$d <> '' THEN " - "RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, MESSAGE = __plx_em_%1$d, " - "DETAIL = __plx_ed_%1$d;\n", f->id); - plx_indent(o, ind + 1); - appendStringInfo(o, "ELSIF __plx_eh_%1$d <> '' THEN " - "RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, MESSAGE = __plx_em_%1$d, " - "HINT = __plx_eh_%1$d;\n", f->id); - plx_indent(o, ind + 1); - appendStringInfo(o, "ELSE RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, " - "MESSAGE = __plx_em_%1$d;\n", f->id); + appendStringInfo(o, "IF __plx_ecol_%1$d || __plx_econ_%1$d || __plx_etyp_%1$d || " + "__plx_etab_%1$d || __plx_esch_%1$d <> '' THEN\n", f->id); + { + int k; + + for (k = 7; k >= 0; k--) + { + bool obj = (k & 4) != 0, + det = (k & 2) != 0, + hnt = (k & 1) != 0; + + if (k == 3) + { + plx_indent(o, ind + 1); + appendStringInfoString(o, "ELSE\n"); + } + plx_indent(o, ind + 2); + if ((k & 3) == 0) + appendStringInfoString(o, "ELSE "); + else + { + appendStringInfoString(o, (k & 3) == 3 ? "IF " : "ELSIF "); + if (det) + appendStringInfo(o, "__plx_ed_%d <> ''%s", f->id, hnt ? " AND " : ""); + if (hnt) + appendStringInfo(o, "__plx_eh_%d <> ''", f->id); + appendStringInfoString(o, " THEN "); + } + appendStringInfo(o, "RAISE EXCEPTION USING ERRCODE = __plx_es_%1$d, " + "MESSAGE = __plx_em_%1$d", f->id); + if (det) + appendStringInfo(o, ", DETAIL = __plx_ed_%d", f->id); + if (hnt) + appendStringInfo(o, ", HINT = __plx_eh_%d", f->id); + if (obj) + appendStringInfo(o, ", COLUMN = __plx_ecol_%1$d, CONSTRAINT = __plx_econ_%1$d, " + "DATATYPE = __plx_etyp_%1$d, TABLE = __plx_etab_%1$d, " + "SCHEMA = __plx_esch_%1$d", f->id); + appendStringInfoString(o, ";\n"); + if ((k & 3) == 0) + { + plx_indent(o, ind + 2); + appendStringInfoString(o, "END IF;\n"); + } + } + } plx_indent(o, ind + 1); appendStringInfoString(o, "END IF;\n"); plx_indent(o, ind); diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index b988414..4aed34d 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -270,14 +270,22 @@ SELECT js_fin_deep(), length(prosrc) < 200000 AS linear FROM pg_proc WHERE prona -- stays linear in the source (indentation is capped), and each level's text is -- freed once emitted (it used to be kept, which took 3.6 GB at 490 levels) DO $d$ DECLARE s text := 'x = 1;'; BEGIN - FOR i IN 1..300 LOOP s := 'try { if (x > 5) { return 1; } ' || s || ' } finally { x = x + 1; }'; END LOOP; - EXECUTE 'CREATE FUNCTION js_fin_ret_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' - || s || ' return x; $b$'; + FOR i IN 1..300 LOOP + s := 'try { if (x > 5) { return 1; } ' || s || ' } finally { x = x + 1; }'; + IF i IN (150, 300) THEN + EXECUTE 'CREATE FUNCTION js_fin_ret_deep' || i || '() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' + || s || ' return x; $b$'; + END IF; + END LOOP; END $d$; -SELECT js_fin_ret_deep(), length(prosrc) < 1200000 AS linear FROM pg_proc WHERE proname = 'js_fin_ret_deep'; - js_fin_ret_deep | linear ------------------+-------- - 301 | t +-- twice the depth gives about twice the code (2.06 times: the first levels are +-- less indented); growing indentation made it over three times +SELECT js_fin_ret_deep300(), + (SELECT length(prosrc) FROM pg_proc WHERE proname = 'js_fin_ret_deep300') < + 2.5 * (SELECT length(prosrc) FROM pg_proc WHERE proname = 'js_fin_ret_deep150') AS linear; + js_fin_ret_deep300 | linear +--------------------+-------- + 301 | t (1 row) -- a finally body that throws runs once, not again from the error path @@ -288,7 +296,7 @@ $p$; WARNING: body WARNING: finally ran ERROR: boom -CONTEXT: PL/pgSQL function inline_code_block line 18 at RAISE +CONTEXT: PL/pgSQL function inline_code_block line 23 at RAISE -- a string ending in a lone backslash cannot escape its quotes: the second -- assignment stays an inert string instead of running set_config CREATE FUNCTION js_bs_escape() RETURNS text LANGUAGE plxjs AS $q$ @@ -358,6 +366,25 @@ SELECT js_fin_label(); -- 22 22 (1 row) +-- a loop label reused inside the try still crosses the finally: a break or +-- continue resolves to the enclosing loop once the inner one has closed +CREATE FUNCTION js_fin_label_reuse(c bool) RETURNS int LANGUAGE plxjs AS $$ + let r = 0; + outer: for (let i = 0; i < 2; i++) { + try { + outer: for (let j = 0; j < 1; j++) { r = r * 10 + 1; } + if (c) { continue outer; } + break outer; + } finally { r = r * 10 + 9; } + } + return r; +$$; +SELECT js_fin_label_reuse(false), js_fin_label_reuse(true); -- 19, 1919 + js_fin_label_reuse | js_fin_label_reuse +--------------------+-------------------- + 19 | 1919 +(1 row) + -- an error is re-raised after finally with its SQLSTATE, detail and hint CREATE FUNCTION js_fin_fail() RETURNS int LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION USING ERRCODE = '22012', MESSAGE = 'boom', DETAIL = 'd1', HINT = 'h1'; END $$; @@ -371,6 +398,28 @@ EXCEPTION WHEN OTHERS THEN END $d$; WARNING: cleanup WARNING: caught: 22012 boom d1 h1 +-- ... and with the names of the objects it is about (table, constraint, type) +CREATE TABLE js_fin_objt (id int CONSTRAINT js_fin_objt_pk PRIMARY KEY); +INSERT INTO js_fin_objt VALUES (1); +CREATE DOMAIN js_fin_pos AS int CONSTRAINT js_fin_pos_ck CHECK (VALUE > 0); +CREATE FUNCTION js_fin_objs(k int) RETURNS void LANGUAGE plxjs AS $$ + try { + if (k == 1) { execute("INSERT INTO js_fin_objt VALUES (1)"); } + else { execute("SELECT (-1)::js_fin_pos"); } + } finally { let z = 1; } +$$; +DO $d$ DECLARE st text; sc text; tb text; co text; ty text; cl text; BEGIN + FOR k IN 1..2 LOOP + BEGIN PERFORM js_fin_objs(k); + EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS st = RETURNED_SQLSTATE, sc = SCHEMA_NAME, tb = TABLE_NAME, + co = CONSTRAINT_NAME, ty = PG_DATATYPE_NAME, cl = COLUMN_NAME; + RAISE WARNING 'caught %: schema=% table=% constraint=% type=% column=%', st, sc, tb, co, ty, cl; + END; + END LOOP; +END $d$; +WARNING: caught 23505: schema=public table=js_fin_objt constraint=js_fin_objt_pk type= column= +WARNING: caught 23514: schema=public table= constraint=js_fin_pos_ck type=js_fin_pos column= -- a return in finally replaces the error; a return from catch runs finally CREATE FUNCTION js_fin_override() RETURNS int LANGUAGE plxjs AS $$ try { return js_fin_fail(); } finally { return 7; } diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index ac7e914..d3f777f 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -258,3 +258,20 @@ WARNING: py finally 3 15 (1 row) +CREATE FUNCTION py_fin_label_reuse() RETURNS int LANGUAGE plxpython3 AS $$ +r = 0 +outer: for i in range(0, 2): + try: + outer: for j in range(0, 1): + r = r * 10 + 1 + break outer + finally: + r = r * 10 + 9 +return r +$$; +SELECT py_fin_label_reuse(); -- 19 + py_fin_label_reuse +-------------------- + 19 +(1 row) + diff --git a/test/expected/plxruby.out b/test/expected/plxruby.out index 4418b04..6bb90c8 100644 --- a/test/expected/plxruby.out +++ b/test/expected/plxruby.out @@ -430,3 +430,23 @@ WARNING: ruby ensure 8 (1 row) +CREATE FUNCTION rb_ensure_label_reuse() RETURNS int LANGUAGE plxruby AS $$ + r = 0 + outer: for i in 0..1 + begin + outer: for j in 0..0 + r = r * 10 + 1 + end + break outer + ensure + r = r * 10 + 9 + end + end + return r +$$; +SELECT rb_ensure_label_reuse(); -- 19 + rb_ensure_label_reuse +----------------------- + 19 +(1 row) + diff --git a/test/finally_diff.py b/test/finally_diff.py index e215116..d081711 100755 --- a/test/finally_diff.py +++ b/test/finally_diff.py @@ -55,8 +55,9 @@ def run_py(body): exec(src, ns) try: return str(ns["f"]()) - except ValueError: - return "ERROR" + except ValueError as e: + # plx raises it as P0001 with the same message + return f"ERROR P0001:{e}" cases = [] for n in range(N): body = prog() @@ -65,7 +66,7 @@ def run_py(body): for n, body, _ in cases: b = "\n".join(body) sql.append(f"CREATE OR REPLACE FUNCTION fd{n}() RETURNS bigint LANGUAGE plxpython3 AS $plx$\nacc #:: bigint\n{b}\n$plx$;") - sql.append(f"DO $d$ BEGIN RAISE WARNING 'R{n}=%', fd{n}(); EXCEPTION WHEN OTHERS THEN RAISE WARNING 'R{n}=ERROR %', SQLERRM; END $d$;") + sql.append(f"DO $d$ BEGIN RAISE WARNING 'R{n}=%', fd{n}(); EXCEPTION WHEN OTHERS THEN RAISE WARNING 'R{n}=ERROR %:%', SQLSTATE, SQLERRM; END $d$;") r = subprocess.run([os.environ.get("PLX_PSQL", "psql"), "-q", "-X"], input="\n".join(sql), capture_output=True, text=True) got = {} @@ -78,8 +79,7 @@ def run_py(body): bad = 0 for n, body, exp in cases: g = got.get(n, "MISSING") - gg = "ERROR" if g.startswith("ERROR") else g - if gg != exp: + if g != exp: bad += 1 if bad <= 3: print(f"--- case {n}: python={exp} plx={g}\n" + "\n".join(body)) diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index 50ed3aa..2f6856a 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -187,11 +187,19 @@ SELECT js_fin_deep(), length(prosrc) < 200000 AS linear FROM pg_proc WHERE prona -- stays linear in the source (indentation is capped), and each level's text is -- freed once emitted (it used to be kept, which took 3.6 GB at 490 levels) DO $d$ DECLARE s text := 'x = 1;'; BEGIN - FOR i IN 1..300 LOOP s := 'try { if (x > 5) { return 1; } ' || s || ' } finally { x = x + 1; }'; END LOOP; - EXECUTE 'CREATE FUNCTION js_fin_ret_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' - || s || ' return x; $b$'; + FOR i IN 1..300 LOOP + s := 'try { if (x > 5) { return 1; } ' || s || ' } finally { x = x + 1; }'; + IF i IN (150, 300) THEN + EXECUTE 'CREATE FUNCTION js_fin_ret_deep' || i || '() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' + || s || ' return x; $b$'; + END IF; + END LOOP; END $d$; -SELECT js_fin_ret_deep(), length(prosrc) < 1200000 AS linear FROM pg_proc WHERE proname = 'js_fin_ret_deep'; +-- twice the depth gives about twice the code (2.06 times: the first levels are +-- less indented); growing indentation made it over three times +SELECT js_fin_ret_deep300(), + (SELECT length(prosrc) FROM pg_proc WHERE proname = 'js_fin_ret_deep300') < + 2.5 * (SELECT length(prosrc) FROM pg_proc WHERE proname = 'js_fin_ret_deep150') AS linear; -- a finally body that throws runs once, not again from the error path DO LANGUAGE plxjs $p$ @@ -241,6 +249,20 @@ CREATE FUNCTION js_fin_label() RETURNS int LANGUAGE plxjs AS $$ return n; $$; SELECT js_fin_label(); -- 22 +-- a loop label reused inside the try still crosses the finally: a break or +-- continue resolves to the enclosing loop once the inner one has closed +CREATE FUNCTION js_fin_label_reuse(c bool) RETURNS int LANGUAGE plxjs AS $$ + let r = 0; + outer: for (let i = 0; i < 2; i++) { + try { + outer: for (let j = 0; j < 1; j++) { r = r * 10 + 1; } + if (c) { continue outer; } + break outer; + } finally { r = r * 10 + 9; } + } + return r; +$$; +SELECT js_fin_label_reuse(false), js_fin_label_reuse(true); -- 19, 1919 -- an error is re-raised after finally with its SQLSTATE, detail and hint CREATE FUNCTION js_fin_fail() RETURNS int LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION USING ERRCODE = '22012', MESSAGE = 'boom', DETAIL = 'd1', HINT = 'h1'; END $$; @@ -252,6 +274,26 @@ EXCEPTION WHEN OTHERS THEN GET STACKED DIAGNOSTICS st = RETURNED_SQLSTATE, dt = PG_EXCEPTION_DETAIL, ht = PG_EXCEPTION_HINT; RAISE WARNING 'caught: % % % %', st, SQLERRM, dt, ht; END $d$; +-- ... and with the names of the objects it is about (table, constraint, type) +CREATE TABLE js_fin_objt (id int CONSTRAINT js_fin_objt_pk PRIMARY KEY); +INSERT INTO js_fin_objt VALUES (1); +CREATE DOMAIN js_fin_pos AS int CONSTRAINT js_fin_pos_ck CHECK (VALUE > 0); +CREATE FUNCTION js_fin_objs(k int) RETURNS void LANGUAGE plxjs AS $$ + try { + if (k == 1) { execute("INSERT INTO js_fin_objt VALUES (1)"); } + else { execute("SELECT (-1)::js_fin_pos"); } + } finally { let z = 1; } +$$; +DO $d$ DECLARE st text; sc text; tb text; co text; ty text; cl text; BEGIN + FOR k IN 1..2 LOOP + BEGIN PERFORM js_fin_objs(k); + EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS st = RETURNED_SQLSTATE, sc = SCHEMA_NAME, tb = TABLE_NAME, + co = CONSTRAINT_NAME, ty = PG_DATATYPE_NAME, cl = COLUMN_NAME; + RAISE WARNING 'caught %: schema=% table=% constraint=% type=% column=%', st, sc, tb, co, ty, cl; + END; + END LOOP; +END $d$; -- a return in finally replaces the error; a return from catch runs finally CREATE FUNCTION js_fin_override() RETURNS int LANGUAGE plxjs AS $$ try { return js_fin_fail(); } finally { return 7; } diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index 47a48a2..26a45a8 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -184,3 +184,15 @@ for i in range(1, 10): return 0 $$; SELECT py_fin_return(5); +CREATE FUNCTION py_fin_label_reuse() RETURNS int LANGUAGE plxpython3 AS $$ +r = 0 +outer: for i in range(0, 2): + try: + outer: for j in range(0, 1): + r = r * 10 + 1 + break outer + finally: + r = r * 10 + 9 +return r +$$; +SELECT py_fin_label_reuse(); -- 19 diff --git a/test/sql/plxruby.sql b/test/sql/plxruby.sql index 22d384d..9b9a7d7 100644 --- a/test/sql/plxruby.sql +++ b/test/sql/plxruby.sql @@ -304,3 +304,18 @@ CREATE FUNCTION rb_ensure_return(x int) RETURNS int LANGUAGE plxruby AS $$ end $$; SELECT rb_ensure_return(4); +CREATE FUNCTION rb_ensure_label_reuse() RETURNS int LANGUAGE plxruby AS $$ + r = 0 + outer: for i in 0..1 + begin + outer: for j in 0..0 + r = r * 10 + 1 + end + break outer + ensure + r = r * 10 + 9 + end + end + return r +$$; +SELECT rb_ensure_label_reuse(); -- 19