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);