Skip to content

[3.0.0] Bind interpolated values in SQL arguments; add ident(), literal(), raw() - #19

Open
OffgridwithJD wants to merge 7 commits into
commandprompt:masterfrom
OffgridwithJD:feat/interp-binds
Open

OffgridwithJD wants to merge 7 commits into
commandprompt:masterfrom
OffgridwithJD:feat/interp-binds

Conversation

@OffgridwithJD

Copy link
Copy Markdown

Depends on #12. This branch is stacked on #12, so until #12 merges the diff also shows its three commits. Only the last two belong here. This is a breaking change, intended for 3.0.

Problem

An interpolation in the SQL argument of query / fetch_one / execute / open_cursor / return_query / perform was lowered three different ways:

  • Static SQL (a single literal, no binds): the interpolated expression was spliced in as a plpgsql variable reference. That is safe, but '#{x}' silently became the literal text 'x'.
  • Dynamic SQL (execute, or any call with a bind argument): the value was concatenated unquoted. Data could inject SQL, and adding a $1 to a safe call silently switched it to this form. The core audit's repro: fetch_one("... WHERE name = '#{uname}' LIMIT $1", lim) with uname = "nobody' OR name = 'bob" returned bob.

Change

A string-literal SQL argument is now a template:

Where the interpolation sits Static SQL Dynamic SQL
outside quotes: id = #{n} id = n (unchanged) id = $k, passed with USING after the call's own binds
a whole literal: '#{s}' (s) ($k)
inside a longer literal: '%#{s}%' ('%' || (s)::text || '%') ('%' || ($k)::text || '%') (an E'' literal stays E'')
inside a quoted identifier: "#{t}" rejected rejected

Utility statements: only planned statements take $n parameters (SELECT, INSERT, UPDATE, DELETE, MERGE, WITH, VALUES, TABLE, CALL, EXPLAIN; checked on 18.4). In any other dynamic statement (CREATE, ALTER, SET, COMMENT, GRANT, ...), a value is spliced through quote_nullable(), so it's still quoted.

Explicit helpers may wrap an interpolation (plxphp spells them {ident($t)}, {literal($v)}, {raw($s)}, recognized only inside a SQL argument). Each one makes the call dynamic:

  • ident(e) → quote_ident(e), for table and column names
  • literal(e) → quote_nullable(e), a quoted literal in any statement
  • raw(e) → e unquoted, for a trusted fragment such as a sort order

So data never becomes SQL unless the author writes raw().

fetch_one("SELECT id, name FROM users WHERE name = '#{uname}' LIMIT $1", lim)
→ EXECUTE 'SELECT id, name FROM users WHERE name = ($2) LIMIT $1' INTO u USING lim, uname;

execute("COMMENT ON TABLE #{ident(t)} IS '#{c}'")
→ EXECUTE 'COMMENT ON TABLE ' || quote_ident((t)::text) || ' IS ' || quote_nullable((c)::text);

Rejected with a hint:

  • an interpolation inside "..."
  • ident()/literal()/raw() inside a quoted string
  • a value inside a longer literal in a statement that takes no parameters

Not covered (documented): a string built elsewhere and passed in a variable is ordinary concatenation, because the call can't see the interpolation.

Implementation: one helper, plx_sql_arg, now backs every call site, including the query loops in the brace, Ruby and Python front ends, which each had their own copy of the static/dynamic switch.

Breaking

A body that interpolated an identifier (FROM #{t}) or a SQL fragment on purpose fails when re-created (syntax error at or near "$1") until it uses ident() or raw(). Stored functions are unaffected until their DDL is re-run. New page: doc/DYNAMIC-SQL.md, added to the mkdocs nav.

Two cookbook recipes interpolated a table name and are updated to use ident(). I re-ran every runnable example in the README, USERGUIDE, MIGRATION, DEBUGGING and all cookbooks against this build: no other new errors. Once this merges, #13's injection paragraphs (USERGUIDE "Trust and security") should point to the new page.

Testing

  • New plx_features cases:
    • Hostile values (quotes, a trailing backslash, '); DROP TABLE ...; --, $$-quoted RAISE) round-trip exactly through every position, in a planned and in a utility statement (COMMENT), and again with standard_conforming_strings = off. The table survives.
    • The generated SQL for the audit's repro.
    • Static '#{x}'.
    • ident(), including plxphp {ident($t)}.
    • raw().
    • All three rejections.
  • Template fuzzer: 1,500 random templates mixing ', '', ", E', \, $$, comments and all four interpolation kinds across the call forms. No crashes or internal errors.
  • make installcheck (13/13) and make differentialcheck pass on PG 18.4. test/fuzz.py and the annotation fuzzer: no crashes.

🤖 Generated with Claude Code

jdatcmd and others added 5 commits November 9, 2026 18:46
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…d 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 <noreply@anthropic.com>
An interpolation in the SQL argument of query/fetch_one/execute/
open_cursor/return_query/perform was concatenated into dynamic SQL
unquoted, so data could inject SQL, and adding a bind argument to an
otherwise static (safe) call switched it to that form silently. In
static SQL, '#{x}' produced the literal text 'x'.

The SQL argument, when it is a string literal, is now treated as a
template (plx_sql_arg, used by every call site, including the query
loops in the brace, Ruby and Python front ends):

- a value outside quotes: the plpgsql expression (static) or a $n
  parameter passed with USING, numbered after the call's own binds
  (dynamic);
- '#{x}': the value alone; inside a longer literal the literal is
  parenthesized and the value concatenated (E'' literals stay E'');
- in a statement that takes no $n parameters (anything but SELECT,
  INSERT, UPDATE, DELETE, MERGE, WITH, VALUES, TABLE, CALL, EXPLAIN),
  a value is spliced through quote_nullable() instead;
- ident(e) / literal(e) / raw(e) splice quote_ident(e) /
  quote_nullable(e) / e, and make the call dynamic (plxphp:
  {ident($t)}, recognized only inside a SQL argument);
- an interpolation inside a quoted identifier, ident()/raw() inside a
  quoted string, and a value inside a longer literal of a statement that
  takes no parameters are rejected with a hint.

Regression tests round-trip hostile values (quotes, backslashes,
statement terminators, dollar quotes) through every position, in both a
planned and a utility statement and with standard_conforming_strings
off, and check the generated SQL, static '#{x}', ident() (including
plxphp), raw(), and the rejections. doc/DYNAMIC-SQL.md documents the
rules and the upgrade path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plxphp and plxpython3 dynamic-SQL recipes interpolated a table name
directly ({$tbl}, {tbl}); under the new rules that is bound as a value
and the statement fails. Use {ident($tbl)} / {ident(tbl)}, and say why.
Every other runnable example in the README, USERGUIDE, MIGRATION,
DEBUGGING and the cookbooks runs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD OffgridwithJD changed the title 3.0: bind interpolated values in SQL arguments; add ident(), literal(), raw() [3.0.0] Bind interpolated values in SQL arguments; add ident(), literal(), raw() Nov 9, 2026
jdatcmd and others added 2 commits November 9, 2026 23:51
"let x /*:: int */ = 0;" drops the "= 0" (commandprompt#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 commandprompt#20 is merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants