[ { "area": "string-interpolation-and-quoting", "ruby": "greeting = \"hello #{name} welcome\" # name is NULL at runtime", "wrong_output": "greeting := 'hello ' || (name)::text || ' welcome';", "why_wrong": "In Ruby nil interpolates as the empty string, so the value is \"hello welcome\". In SQL, ANY operand of || that is NULL makes the whole expression NULL, so greeting becomes NULL \u2014 the entire string silently vanishes. This is a divergence-producing silent-wrong-result, and it also breaks the dynamic execute() path (execute(\"... #{x}\") builds a NULL SQL string and EXECUTE NULL raises), and the fetch/perform interpolation-into-value paths.", "corrected_rule": "Each interpolated slot in the || path must match Ruby's nil->\"\" by emitting COALESCE((e)::text, '') rather than (e)::text. (RAISE's %-placeholder path already tolerates NULL by printing , which is a second, inconsistent divergence worth documenting.)", "severity": "high" }, { "area": "string-interpolation-and-quoting", "ruby": "report = \"line1\\nline2\\tcol\"", "wrong_output": "report := 'line1\\nline2\\tcol';", "why_wrong": "The rule lists double-quoted escapes (\\n \\t ...) as recognized by the lexer but never says the literal chunk is re-encoded for plpgsql. With standard_conforming_strings=on (the default), '...\\n...' is a literal backslash-n, NOT a newline. So Ruby's newline/tab become backslash-letter sequences \u2014 wrong value with no error. The heredoc example correctly used E'\\n', proving E-strings are needed, but that treatment is missing from the general double-quoted rule.", "corrected_rule": "Decode Ruby double-quoted escapes to their actual bytes, then emit the literal chunk as an escape-string E'...' (re-escaping the control chars) or embed the raw control bytes. A plain single-quoted plpgsql literal cannot carry \\n/\\t under standard_conforming_strings.", "severity": "high" }, { "area": "string-interpolation-and-quoting", "ruby": "path = \"C:\\\\tmp\\\\x\" # Ruby value is C:\\tmp\\x (single backslashes)", "wrong_output": "path := 'C:\\\\tmp\\\\x'; -- or raw passthrough of the token bytes", "why_wrong": "Naive passthrough copies the source bytes of the string token (which still contain Ruby's doubled backslashes) into a plpgsql single-quoted literal. Under standard_conforming_strings=on, '\\\\' is TWO backslashes, so the value gains extra backslashes vs Ruby's one. The same double-counting hits single-quoted Ruby strings ('a\\\\b' -> Ruby a\\b) and the \\' / \\\" escapes. Ruby string escape semantics and plpgsql literal escape semantics are different alphabets; you cannot memcpy the token.", "corrected_rule": "Always fully DECODE the Ruby string token to its abstract character sequence first (resolving \\\\, \\', \\\", \\n, etc. per single- vs double-quote rules), then RE-ENCODE for the target literal form actually chosen (double every ' ; if an E'...' string is used, re-escape every backslash). Never splice raw source bytes.", "severity": "high" }, { "area": "string-interpolation-and-quoting", "ruby": "query(\"SELECT * FROM #{tbl} WHERE id = #{uid}\").each do |r|\n emit(r.id)\nend", "wrong_output": "FOR r IN SELECT * FROM tbl WHERE id = uid LOOP\n RETURN NEXT r.id;\nEND LOOP;", "why_wrong": "The static-query rule turns every #{...} into a bare plpgsql name reference. That is only sound when the interpolation sits in a VALUE position (a bind is fine and safer than Ruby's textual splice). In IDENTIFIER position (#{tbl} as a table/column/keyword) plpgsql reads the literal name 'tbl' as the relation, not the value of the local variable tbl -> 'relation \"tbl\" does not exist' or, worse, a real table coincidentally named tbl. Ruby did a textual substitution; the name-ref path cannot. The transpiler parses no SQL, so it cannot tell value position from identifier position and picks value silently.", "corrected_rule": "Value-position name-ref is the only sound static lowering. Identifier/fragment interpolation must go through the dynamic path (FOR r IN EXECUTE format('SELECT * FROM %I WHERE id = $1', tbl) USING uid) or be rejected. Since position is undecidable without a SQL parse, document the restriction and require execute()/format+quote_ident for identifier interpolation; do not silently name-ref it.", "severity": "high" }, { "area": "string-interpolation-and-quoting", "ruby": "x = \"has star */ inside\" # any body byte-sequence containing */", "wrong_output": "... BEGIN\n x := 'has star */ inside';\nEND;\n/*plx-orig$x = \"has star */ inside\"$plx-orig*/", "why_wrong": "The final-assembly contract appends the verbatim Ruby source inside a C-style comment /*plx-orig$ ... $plx-orig*/. Any '*/' occurring in the original body (inside a string literal, a comment, or `a */ b` from an unsupported op) closes the comment early; the trailing bytes become live plpgsql and the stored prosrc is corrupt or fails to compile. The $plx-orig sentinel can likewise be forged by body text. This is a quoting/escaping failure of the embedded-original mechanism.", "corrected_rule": "The embedded original must be encoded so it cannot contain the comment terminator or the sentinel: escape every '*/' (e.g. to '*\\/') on the way in and reverse on read, or base64/hex-encode the whole blob. A verbatim splice of arbitrary user bytes into a /* */ comment is never safe.", "severity": "high" }, { "area": "string-interpolation-and-quoting", "ruby": "row = \"v=#{f('a', 'b')}\"", "wrong_output": "row := 'v=' || (f(''a'', ''b''))::text;", "why_wrong": "If the '->'' quote-doubling is applied to the whole double-quoted string rather than only to the literal chunks, the single quotes belonging to SQL string literals INSIDE the #{...} expression get doubled too, producing invalid SQL (''a'' is not a valid literal in that position). The interpolated expression text is emitted verbatim as an expression and must never be quote-processed. This is the classic naive-substitution trap.", "corrected_rule": "Split the string into literal chunks and interpolation spans FIRST. Apply ' -> '' (and, for RAISE, % -> %%) ONLY to literal-chunk text. Interpolation spans are handed to plx_rewrite_expr and emitted as-is; their internal quotes/percents are left untouched.", "severity": "medium" }, { "area": "string-interpolation-and-quoting", "ruby": "tmpl = \"use \\#{var} as a placeholder\"", "wrong_output": "tmpl := 'use ' || (var)::text || ' as a placeholder';", "why_wrong": "In Ruby, \\#{ is an escaped interpolation: the string value is the literal text 'use #{var} as a placeholder' with NO interpolation. If the lexer only special-cases #{ and ignores a preceding backslash, it wrongly interpolates var (and often fails at compile time because var is undeclared). Ruby's \\# escape is being dropped.", "corrected_rule": "In double-quoted strings honor the \\# escape: a backslash immediately before #{ suppresses interpolation, yielding the literal two characters #{ in the chunk. Only an unescaped #{ opens an interpolation.", "severity": "medium" }, { "area": "string-interpolation-and-quoting", "ruby": "raise notice: \"processed #{a % b} of 100% (#{f('x')})\"", "wrong_output": "RAISE NOTICE 'processed %% of 100%% (%)', a %% b, f(''x'');", "why_wrong": "Two coupled bugs when the RAISE format/arg split is done by whole-string substitution: (1) the % that is the modulo operator inside #{a % b} gets doubled to %% and lands in the ARGUMENT expression, corrupting it; (2) the literal-100%-vs-placeholder distinction and the '->'' doubling leak into the argument f('x') producing f(''x''). Only the literal 'of 100% ' chunk should have its % doubled; the % inside interpolations and the quotes inside arg expressions must be left alone.", "corrected_rule": "Build the RAISE format string from literal chunks only: within literal chunks double ' and double %, and replace each interpolation with a single % placeholder. Collect the interpolation expressions, verbatim (no quote/percent processing), as the ordered argument list. Never run the format-escaping over argument text.", "severity": "medium" }, { "area": "string-interpolation-and-quoting", "ruby": "s = \"prefix#{}suffix\"", "wrong_output": "s := 'prefix' || ()::text || 'suffix';", "why_wrong": "Ruby allows an empty interpolation #{} which contributes the empty string. Lowering it as (e)::text with an empty e yields ()::text \u2014 empty parentheses are a SQL syntax error at CREATE/compile time. A lone \"#{}\" would likewise emit ()::text on its own.", "corrected_rule": "Treat an empty or whitespace-only #{} as an empty literal chunk: drop it (or emit ''). Never emit ()::text for an empty interpolation.", "severity": "low" }, { "area": "The || concat-vs-or ambiguity (Ruby logical-or vs SQL string-concat, both spelled ||)", "ruby": "greeting = \"Hi #{name}\"\nif admin || superuser\n greeting = \"Hello #{name}\"\nend", "wrong_output": "greeting := 'Hi ' OR (name)::text;\nIF admin OR superuser THEN\n greeting := 'Hello ' OR (name)::text;\nEND IF;", "why_wrong": "The transpiler uses the token || for TWO opposite purposes: (a) it EMITS || as SQL string concatenation from every interpolation/`+`/`<<` lowering, and (b) it must MAP source Ruby || to SQL ` OR `. If ||->OR is implemented as a substitution over the body (or the interpolation output is re-scanned by the rewrite pass), the concat || that interpolation just produced is destroyed into ` OR `, yielding `'Hi ' OR (name)::text` which is invalid plpgsql (OR requires boolean). The exact same token needs opposite treatment depending on whether it is source-authored or transpiler-generated \u2014 a plain text/token pass cannot tell them apart after the fact.", "corrected_rule": "Do NOT implement ||->OR as a rewrite over post-interpolation text. Rewriting and interpolation-splicing must be ONE left-to-right pass over the SOURCE tokens: a source || OP token emits ` OR ` into the output buffer; concat || is emitted straight into the output buffer and is NEVER re-scanned. Origin (source-position vs generated) is the sole discriminator; track it, never re-lex generated concat.", "severity": "high" }, { "area": "The || concat-vs-or ambiguity (Ruby logical-or vs SQL string-concat, both spelled ||)", "ruby": "role = requested_role || \"guest\"\nreturn config_name || default_name", "wrong_output": "role := requested_role OR 'guest';\nRETURN config_name OR default_name;", "why_wrong": "The rule ||-> OR is applied unconditionally, but Ruby's || in VALUE position is a coalescing operator (returns the first truthy operand, not a boolean). `x = a || b` is the idiomatic default-value pattern. Lowered to `a OR b`, plpgsql rejects it at compile time when a/b are text/int ('argument of OR must be type boolean'), or \u2014 if a/b happen to be boolean \u2014 silently computes a boolean instead of selecting a value. This is invalid-plpgsql for the common case and silent-wrong for the boolean case.", "corrected_rule": "Only rewrite ||/&& to OR/AND in BOOLEAN CONTEXT (the condition slots of if/unless/while/until and their modifier forms). In value/assignment/return context, || and && are not boolean ops: reject with a precise ereport pointing at COALESCE(a,b) (documenting the false-vs-NULL divergence: Ruby || treats false as falsy, COALESCE only catches NULL) or an explicit ternary. The blanket \u00a73 rule 'if->=, ||->OR everywhere' is wrong for value position.", "severity": "high" }, { "area": "The || concat-vs-or ambiguity (Ruby logical-or vs SQL string-concat, both spelled ||)", "ruby": "label = \"user:#{uname}\"\nmsg = \"#{a}-#{b}\"", "wrong_output": "label := 'user:' || (uname)::text;\nmsg := (a)::text || '-' || (b)::text;", "why_wrong": "Interpolation lowers to || concatenation, but SQL || propagates NULL: if uname IS NULL the whole result is NULL, so `label` becomes NULL instead of the string 'user:'. Ruby interpolation coerces nil to the empty string ('user:'), never nulls the surrounding literal. Any interpolation whose spliced value can be NULL silently loses the entire string \u2014 a data-corruption-class wrong result, not a crash.", "corrected_rule": "Interpolation must NOT lower to bare ||. To match Ruby nil->\"\", either wrap each spliced chunk as COALESCE((e)::text,'') or lower the whole interpolation via concat(...) (which ignores NULLs). Document that plexcellent interpolation follows Ruby (nil->'') and therefore diverges from raw SQL || NULL-propagation.", "severity": "high" }, { "area": "The || concat-vs-or ambiguity (Ruby logical-or vs SQL string-concat, both spelled ||)", "ruby": "flags = flags << bit\nbuf << \"x\"", "wrong_output": "flags := flags || bit;\nbuf || 'x';", "why_wrong": "The rule `a << b -> a || b` is blind to operand type and statement shape. (1) For integers, Ruby << is left bit-shift and PostgreSQL ALSO spells bit-shift as << \u2014 rewriting to || turns a valid shift into `integer || integer`, which has no operator in PG (invalid plpgsql). (2) Ruby << mutates its receiver in place; `buf << \"x\"` is a statement with a side effect, but `buf || 'x';` is a bare expression that assigns nothing \u2014 the append is silently dropped (and is not even a legal plpgsql statement).", "corrected_rule": "Do not blanket-rewrite << to ||. Leave << verbatim (PG << is integer bit-shift) OR reject it, since the shovel/append meaning cannot be recovered without types and, as a bare statement, has no plpgsql equivalent. String/array building must go through interpolation or an explicit `x = x || y` assignment.", "severity": "medium" }, { "area": "The || concat-vs-or ambiguity (Ruby logical-or vs SQL string-concat, both spelled ||)", "ruby": "full = first + last\ncount = base + \"0\"", "wrong_output": "full := first + last;\ncount := base || '0';", "why_wrong": "The `+ -> ||` heuristic keys only on 'is one operand token a string literal', which misfires both ways. (1) `first + last` (two text vars, no literal token) stays numeric `+`, producing a runtime error 'operator does not exist: text + text' \u2014 the failure is deferred to runtime, not caught at CREATE. (2) `base + \"0\"` where the intent is numeric produces `base || '0'` (string concat) because \"0\" is a literal token \u2014 a silently wrong result/type. The generated || here is again the concat-vs-or surface: the heuristic manufactures || from + with no type knowledge.", "corrected_rule": "Drop the literal-token `+ -> ||` heuristic as a silent default (it is wrong in both directions). Make interpolation the sanctioned concat path; either infer + vs || from resolved operand types (DECLARE/meta), or leave + verbatim and require || only via interpolation, documenting that + is always SQL numeric +.", "severity": "medium" }, { "area": "The || concat-vs-or ambiguity (Ruby logical-or vs SQL string-concat, both spelled ||)", "ruby": "ok = validate(x) or return false\nflag = a or b", "wrong_output": "ok := validate(x) or RETURN false; -- (and) flag := a or b;", "why_wrong": "The rewrite set converts && and || but says nothing about Ruby's low-precedence keyword forms `and`/`or`/`not` (which the scanner already recognizes as KW_AND/KW_OR/KW_NOT). Passed through verbatim they become SQL AND/OR/NOT, but Ruby `and`/`or` bind LOWER than assignment: `flag = a or b` parses in Ruby as `(flag = a) or b`, whereas SQL evaluates `flag := a OR b`. Same class of OR-ambiguity: the operator survives but its precedence relative to = silently changes the result; and `x or return` (control-flow-as-value) has no SQL form at all.", "corrected_rule": "Treat keyword `and`/`or`/`not` explicitly. In boolean condition context map to AND/OR/NOT like &&/||/!. In value/assignment context reject them (their Ruby precedence below `=` and their use with control-flow like `or return` cannot be faithfully lowered) rather than passing through verbatim.", "severity": "low" }, { "area": "declare-hoisting and OUT-param/loop-var shadowing", "ruby": "query(\"SELECT id, amount FROM orders\").each do |row|\n total = total + row.amount\nend", "wrong_output": "BEGIN\n FOR row IN SELECT id, amount FROM orders LOOP\n total := total + row.amount;\n END LOOP;\nEND; -- no DECLARE for row", "why_wrong": "The declare pass's default rule says record/loop FOR vars are 'implicitly declared by plpgsql' and must NOT be hoisted, and even flags the ARCHITECTURE example's `row RECORD;` as non-default opt-in. That is false: plpgsql auto-declares ONLY the integer/range FOR var (`FOR i IN a..b`). A query FOR (`FOR row IN