From 17db3812b12749a00a3af9adc8bc8e4620174abd Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 10 Nov 2026 00:15:55 +0000 Subject: [PATCH] Don't fold interpolated initializers into DECLARE; reject constants that read locals A local's first top-level assignment of a string literal is folded into DECLARE as its initializer, which runs when the function starts. An interpolating string was folded too, so it read its variables before the statements above it ran: in plxjs, plxpython3, plxruby and plxphp, 'v = "v0"; v = "v2"; s = "got #{v}"' returned "got v0". Such an assignment now runs in place; a plain literal is still folded. A constant has the same timing by design (plpgsql CONSTANT), so a constant computed from a local read the local's initial value. A constant's initializer may now use only literals, parameters and earlier constants; reading another local is rejected with an explanation. Reported by the plxcsharp front end's review, which worked around the first case by parenthesizing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +++++ doc/plxjs.md | 5 ++ doc/plxphp.md | 5 ++ doc/plxpython3.md | 5 ++ doc/plxruby.md | 5 ++ src/plx_engine.h | 2 + src/plx_transpile.c | 123 ++++++++++++++++++++++++++++++++- test/expected/plx_errors.out | 18 +++++ test/expected/plx_features.out | 61 ++++++++++++++++ test/sql/plx_errors.sql | 15 ++++ test/sql/plx_features.sql | 39 +++++++++++ 11 files changed, 293 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930387..5848a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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] + +### Fixed + +- **An interpolated string as a local's first value read stale values.** The + first top-level assignment of a string literal is folded into the `DECLARE` + section as the variable's initializer, which runs when the function starts. + An interpolating string (Ruby `"#{v}"`, PHP `"$v"`, JS `` `${v}` ``, Python + `f"{v}"`) was folded too, so it read its variables before the statements + above it had run: `v = "v0"; v = "v2"; s = "got #{v}"` returned `got v0`. Such + an assignment now runs in place. A plain literal is still folded. +- **A constant computed from a local variable read the variable's initial + value.** A constant becomes a plpgsql `CONSTANT`, computed when the function + starts, so `v = 1; v = 2; c = v + 1 #:: int const` gave `c = 2`. A constant + may now use only literals, parameters and earlier constants; reading another + local is rejected at `CREATE FUNCTION`. + ## [2.0.1] - 2026-08-25 Packaging only. **The extension is unchanged at 2.0.0**, so there is no diff --git a/doc/plxjs.md b/doc/plxjs.md index daf5b7e..9174c40 100644 --- a/doc/plxjs.md +++ b/doc/plxjs.md @@ -54,6 +54,11 @@ A constant uses the `const` suffix in the annotation: let pi = 3.14159 /*:: numeric const */; ``` +A constant becomes a plpgsql `CONSTANT`, whose value is computed when the +function starts. So it can use literals, parameters and earlier constants, but +not another local variable: that would read the variable's initial value, not +the one assigned above it, and is rejected. + ## Control flow ### if / else if / else diff --git a/doc/plxphp.md b/doc/plxphp.md index 8bbd8be..4edf158 100644 --- a/doc/plxphp.md +++ b/doc/plxphp.md @@ -53,6 +53,11 @@ A constant uses the `const` suffix in the annotation: $pi = 3.14159 /*:: numeric const */; ``` +A constant becomes a plpgsql `CONSTANT`, whose value is computed when the +function starts. So it can use literals, parameters and earlier constants, but +not another local variable: that would read the variable's initial value, not +the one assigned above it, and is rejected. + ## Control flow ### if / elseif / else diff --git a/doc/plxpython3.md b/doc/plxpython3.md index 71bcb48..94eb20b 100644 --- a/doc/plxpython3.md +++ b/doc/plxpython3.md @@ -54,6 +54,11 @@ A constant uses the `const` suffix on the annotation: pi = 3.14159 #:: numeric const ``` +A constant becomes a plpgsql `CONSTANT`, whose value is computed when the +function starts. So it can use literals, parameters and earlier constants, but +not another local variable: that would read the variable's initial value, not +the one assigned above it, and is rejected. + ## Control flow ### if / elif / else diff --git a/doc/plxruby.md b/doc/plxruby.md index 22a2c79..84295ec 100644 --- a/doc/plxruby.md +++ b/doc/plxruby.md @@ -56,6 +56,11 @@ A constant uses the `const` suffix on the annotation: pi = 3.14159 #:: numeric const ``` +A constant becomes a plpgsql `CONSTANT`, whose value is computed when the +function starts. So it can use literals, parameters and earlier constants, but +not another local variable: that would read the variable's initial value, not +the one assigned above it, and is rejected. + ## Control flow ### if / elsif / else, unless diff --git a/src/plx_engine.h b/src/plx_engine.h index 434be02..981808a 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -97,6 +97,8 @@ typedef struct PlxCtx int exc_varlen; int diag_mask; /* stacked-diagnostics fields used in this handler */ bool diag_msg; /* rewriting a RAISE message: interpolate NULL as '' */ + int ninterp; /* string interpolations rendered so far; an + * interpolating initializer is not folded */ bool retset; MemoryContext mcx; /* caller-supplied scratch context (reserved) */ } Ctx; diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 0a23018..f100e2c 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -776,6 +776,7 @@ emit_string_value(Ctx *cx, Tok *tk, StringInfo out) need_e = false; } rw = plx_rewrite_expr(cx, exs, exl, false); + cx->ninterp++; s = nx; if (nparts++) appendStringInfoString(out, " || "); @@ -1501,6 +1502,102 @@ const_literal_type(Ctx *cx, int a, int b) } } +/* + * The first local variable, other than a constant declared before self, that + * the generated SQL expression text reads; NULL when there is none. String + * literals, quoted identifiers, field names (after '.') and type names (after + * '::') are skipped. Used to reject a constant whose initializer reads a + * variable: the initializer runs when the function starts, so it would see + * the variable's initial value, not the one the source shows. + */ +static const char * +first_local_ref(Ctx *cx, const char *s, PlxLocal2 *self) +{ + char prev = 0; /* last significant character before a word */ + bool after_cast = false; + + while (*s) + { + CHECK_FOR_INTERRUPTS(); + if (*s == '\'' || ((*s == 'E' || *s == 'e') && s[1] == '\'')) + { + bool esc = (*s != '\''); + + s += esc ? 2 : 1; + while (*s) + { + if (esc && *s == '\\' && s[1]) + s += 2; + else if (*s == '\'' && s[1] == '\'') + s += 2; + else if (*s == '\'') + { + s++; + break; + } + else + s++; + } + prev = '\''; + after_cast = false; + continue; + } + if (*s == '"') + { + for (s++; *s; s++) + if (*s == '"') + { + if (s[1] != '"') + { + s++; + break; + } + s++; + } + prev = '"'; + continue; + } + if ((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || *s == '_' || + IS_HIGHBIT_SET(*s)) + { + const char *w = s; + PlxLocal2 *l; + + while ((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || + (*s >= '0' && *s <= '9') || *s == '_' || *s == '$' || + IS_HIGHBIT_SET(*s)) + s++; + if (prev != '.' && !after_cast && + (l = plx_local_find(cx, w, (int) (s - w))) != NULL && + l != self && !l->is_const) + return l->name; + prev = 'a'; + after_cast = false; + continue; + } + if (*s == ':' && s[1] == ':') + { + after_cast = true; + s += 2; + continue; + } + if (*s >= '0' && *s <= '9') + { + while ((*s >= '0' && *s <= '9') || *s == '.' || *s == 'e' || *s == 'E') + s++; + prev = '0'; + continue; + } + if (*s != ' ' && *s != '\t' && *s != '\n' && *s != '\r') + { + prev = *s; + after_cast = false; + } + s++; + } + return NULL; +} + /* ---------------------------------------------------------------- indent */ void @@ -2441,6 +2538,14 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) plx_err(cx, t0->line, "constant \"%.*s\" requires a type annotation", t0->len, t0->s); l->is_const = true; l->init = plx_rewrite_expr(cx, rhs, (int) strlen(rhs), false); + { + const char *v = first_local_ref(cx, l->init, l); + + if (v) + plx_err(cx, t0->line, + "constant \"%.*s\" cannot use local variable \"%s\": a constant's value is computed when the function starts", + t0->len, t0->s, v); + } return; } @@ -2448,11 +2553,25 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) if (toplevel && firstseen && !compound && littyp && !l->is_record) { char *rhs = plx_span_text(cx, rhs_a, rhs_b); + int ninterp = cx->ninterp; + char *r = plx_rewrite_expr(cx, rhs, (int) strlen(rhs), false); if (!l->typ) l->typ = pstrdup(littyp); - l->init = plx_rewrite_expr(cx, rhs, (int) strlen(rhs), false); - return; /* statement folded into DECLARE; emit nothing */ + /* + * A DECLARE initializer runs when the function starts, before + * the statements above this one. An interpolated string would + * read its values (or call its functions) then, so it is + * assigned here instead. + */ + if (cx->ninterp == ninterp) + { + l->init = r; + return; /* statement folded into DECLARE; emit nothing */ + } + plx_indent(o, ind); + appendStringInfo(o, "%.*s := %s;\n", t0->len, t0->s, r); + return; } /* otherwise infer type if still unknown */ if (!l->typ && littyp) diff --git a/test/expected/plx_errors.out b/test/expected/plx_errors.out index fa7c4b0..d3f60f2 100644 --- a/test/expected/plx_errors.out +++ b/test/expected/plx_errors.out @@ -334,3 +334,21 @@ return 0xFFFFFFFFFFFFFFFFFF; $$; ERROR: plxjs: integer literal out of range: 0xFFFFFFFFFFFFFFFFFF DETAIL: at plxjs source line 0 +-- a constant's initializer runs when the function starts, so it cannot read a +-- local variable (it would see the initial value, not the one assigned above) +CREATE FUNCTION e_rb_const_local() RETURNS int LANGUAGE plxruby AS $$ +v = 1 +v = 2 +c = v + 1 #:: int const +return c +$$; +ERROR: plxruby: constant "c" cannot use local variable "v": a constant's value is computed when the function starts +DETAIL: at plxruby source line 4 +CREATE FUNCTION e_js_const_interp() RETURNS text LANGUAGE plxjs AS $$ +let v = "a"; +v = "b"; +let c = `got ${v}` /*:: text const */; +return c; +$$; +ERROR: plxjs: constant "c" cannot use local variable "v": a constant's value is computed when the function starts +DETAIL: at plxjs source line 4 diff --git a/test/expected/plx_features.out b/test/expected/plx_features.out index 859077e..f80c896 100644 --- a/test/expected/plx_features.out +++ b/test/expected/plx_features.out @@ -233,3 +233,64 @@ SELECT f_strict() AS strict_no_row; -1 (1 row) +-- a first assignment from an interpolated string is not folded into DECLARE: +-- the initializer would run when the function starts and read the old value +CREATE FUNCTION f_fold_js() RETURNS text LANGUAGE plxjs AS $$ +let v = "v0"; +v = "v2"; +let s = `got ${v}`; +return s; +$$; +CREATE FUNCTION f_fold_py() RETURNS text LANGUAGE plxpython3 AS $$ +v = "v0" +v = "v2" +s = f"got {v}" +return s +$$; +CREATE FUNCTION f_fold_rb() RETURNS text LANGUAGE plxruby AS $$ +v = "v0" +v = "v2" +s = "got #{v}" +return s +$$; +CREATE FUNCTION f_fold_php() RETURNS text LANGUAGE plxphp AS $$ +$v = "v0"; +$v = "v2"; +$s = "got $v"; +return $s; +$$; +SELECT f_fold_js(), f_fold_py(), f_fold_rb(), f_fold_php(); + f_fold_js | f_fold_py | f_fold_rb | f_fold_php +-----------+-----------+-----------+------------ + got v2 | got v2 | got v2 | got v2 +(1 row) + +-- a plain literal is still folded; a constant may use parameters and earlier +-- constants +CREATE FUNCTION f_fold_const(n int) RETURNS text LANGUAGE plxruby AS $$ +k = 10 #:: int const +c = "n=#{n} k=#{k}" #:: text const +s = "plain" +return "#{c} #{s}" +$$; +SELECT f_fold_const(5); + f_fold_const +---------------- + n=5 k=10 plain +(1 row) + +SELECT regexp_replace(prosrc, '/\*plx-orig.*', '') FROM pg_proc WHERE proname = 'f_fold_const'; + regexp_replace +--------------------------------------------------------------- + /*plx:v1:plxruby:784cd38a*/ + + DECLARE + + k CONSTANT int := 10; + + c CONSTANT text := 'n=' || (n)::text || ' k=' || (k)::text;+ + s text := 'plain'; + + BEGIN + + RETURN (c)::text || ' ' || (s)::text; + + END; + + +(1 row) + +DROP FUNCTION f_fold_js(), f_fold_py(), f_fold_rb(), f_fold_php(), f_fold_const(int); diff --git a/test/sql/plx_errors.sql b/test/sql/plx_errors.sql index 77aee59..d82c4ec 100644 --- a/test/sql/plx_errors.sql +++ b/test/sql/plx_errors.sql @@ -294,3 +294,18 @@ END $outer$; CREATE FUNCTION e_js_hex_overflow() RETURNS numeric LANGUAGE plxjs AS $$ return 0xFFFFFFFFFFFFFFFFFF; $$; + +-- a constant's initializer runs when the function starts, so it cannot read a +-- local variable (it would see the initial value, not the one assigned above) +CREATE FUNCTION e_rb_const_local() RETURNS int LANGUAGE plxruby AS $$ +v = 1 +v = 2 +c = v + 1 #:: int const +return c +$$; +CREATE FUNCTION e_js_const_interp() RETURNS text LANGUAGE plxjs AS $$ +let v = "a"; +v = "b"; +let c = `got ${v}` /*:: text const */; +return c; +$$; diff --git a/test/sql/plx_features.sql b/test/sql/plx_features.sql index 6e2699d..0b2f3cc 100644 --- a/test/sql/plx_features.sql +++ b/test/sql/plx_features.sql @@ -143,3 +143,42 @@ rescue => e end $$; SELECT f_strict() AS strict_no_row; + +-- a first assignment from an interpolated string is not folded into DECLARE: +-- the initializer would run when the function starts and read the old value +CREATE FUNCTION f_fold_js() RETURNS text LANGUAGE plxjs AS $$ +let v = "v0"; +v = "v2"; +let s = `got ${v}`; +return s; +$$; +CREATE FUNCTION f_fold_py() RETURNS text LANGUAGE plxpython3 AS $$ +v = "v0" +v = "v2" +s = f"got {v}" +return s +$$; +CREATE FUNCTION f_fold_rb() RETURNS text LANGUAGE plxruby AS $$ +v = "v0" +v = "v2" +s = "got #{v}" +return s +$$; +CREATE FUNCTION f_fold_php() RETURNS text LANGUAGE plxphp AS $$ +$v = "v0"; +$v = "v2"; +$s = "got $v"; +return $s; +$$; +SELECT f_fold_js(), f_fold_py(), f_fold_rb(), f_fold_php(); +-- a plain literal is still folded; a constant may use parameters and earlier +-- constants +CREATE FUNCTION f_fold_const(n int) RETURNS text LANGUAGE plxruby AS $$ +k = 10 #:: int const +c = "n=#{n} k=#{k}" #:: text const +s = "plain" +return "#{c} #{s}" +$$; +SELECT f_fold_const(5); +SELECT regexp_replace(prosrc, '/\*plx-orig.*', '') FROM pg_proc WHERE proname = 'f_fold_const'; +DROP FUNCTION f_fold_js(), f_fold_py(), f_fold_rb(), f_fold_php(), f_fold_const(int);