Skip to content

Fix placeholder bugs by refactoring to make them impossible - #7

Open
mloughran wants to merge 10 commits into
jgaskins:masterfrom
concentric-health:fix-placeholder-bugs
Open

mloughran wants to merge 10 commits into
jgaskins:masterfrom
concentric-health:fix-placeholder-bugs

Conversation

@mloughran

@mloughran mloughran commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Sorry about the size of this PR!

The commits are intended to be read in order: they form a narrative that isolates the complex change to a single core commit, with small fixes and refactors on either side. Every commit passes the full spec suite, and of the specs this PR adds, 10 fail on unmodified master (7 failures + 3 errors), demonstrating the bugs being fixed.

For motivation, this bug class has a history of fixes: e.g. 213a8ab and 99529ac. This PR handles read-path bugs; the write-path numbering has similar issues, and is handled by a (thankfully simpler!) follow-up PR.

Core commit: "Fix placeholder bugs and make impossible by numbering at render time"

Two key insights, which together should make placeholder collisions impossible:

  1. Changing the internal representation of QueryExpression to Array(Part) where Part = String | Any – raw SQL text (String) interleaved with the values to bind (Any).

    Raw fragments from user code (where/order_by/distinct) are parsed on init, with $n resolving to the fragment's own values. After that, fragments carry no numbers at all, and placeholder numbers are only assigned at the point of rendering the SQL.

  2. QueryBuilder no longer stores an args array alongside the SQL.

    Keeping the two in sync by hand was the shared cause of most of the bugs fixed here – it was easy to emit a placeholder and forget its value, or bind a value the rendered clause never referenced. Now every read path builds SQL and args in one pass, and QueryExpression#to_sql(io, args) appends to both in one place.

The commit message c982c51 has the full detail.

Narrative

First, "Add missing spec coverage" 2f17f99 adds specs that pass throughout, and "Refactor subquery embedding onto Subquery" 5614676 moves some duplication out of the way.

Several of the bugs fixed by the core commit were simple enough to extract as pre-commits, avoiding landing everything in one massive change, so these come next:

  • Fix renumbering of multi-digit placeholders 2935dfb
  • Fix binding of LIMIT & OFFSET args in iterator and compound query 04fdd3e
  • Fix to avoid binding ORDER BY values in any?/none? queries 998eedb

Two refactors are really part of the core commit, but were extracted to reduce its size: "Refactor to key OrderBy Hash by QueryExpression" c453d88 immediately before it, and "Refactor to remove placeholder numbering from QueryValue internals" c5f973d immediately after.

Finally, "Fix renumbering of $n inside string literals" cd606af fixes a remaining parsing bug which would have been hard to fix while the code for this was scattered in several places. Finally f57fea2 is a simplification I noticed late in the day, and could be folded into the core commit.

Visible behaviour changes

Nothing here is intended to change the results of a query that already worked, but a few things are visible:

  • A $n in a raw fragment referencing no value raises ArgumentError when the query is built, rather than a Postgres bind error at execution.
    Same for a $n in a distinct(on:) expression, which has no values of its own; a parameterized ORDER BY expression doesn't need repeating there, since its key is added to the DISTINCT ON subclause automatically.
  • A $n repeated within one fragment renders as one placeholder per reference: where("a = $1 OR b = $1", [v]) becomes a = $1 OR b = $2. Identical semantics, different SQL text.
  • CompoundQuery wraps each side in parentheses, required once a side can carry its own LIMIT/OFFSET.
  • Internal API surface: QueryExpression is now constructed from parts, or from raw SQL via QueryExpression.parse(fragment, values) – the expression/values constructor is gone, though #values remains (now derived).
    QueryValue.new no longer takes a placeholder index, Interro::OrderBy is Hash(QueryExpression, String), and QueryBuilder's args property is gone.
  • QueryExpression.parse only recognises ordinary single-quoted literals (cd606af), so a $n inside an E'' string, a dollar-quoted string, or a quoted identifier is still taken for a placeholder and now fails loudly – where master left it untouched if no args preceded, and silently corrupted it if any did. The pattern that recognises every literal form Postgres accepts is big enough to deserve its own review, so I have that as a follow-up – happy to bring it into this PR if you'd prefer.

@jgaskins jgaskins left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still working my way through this. I've looked at all the commits individually and they make sense on their own. I'm currently looking at the full diff and trying to wrap my mind around the big picture.

One thing that kept coming to mind for me in reviewing this PR so far is that it's getting more and more flexible, which I do feel like we need, but I'm wondering if that flexibility needs to be captured in Interro specifically. Last year I wrote a SQL parser/builder specifically for Postgres for unrelated reasons1 but I've been toying with the idea of bringing it into Interro to solve some of the same problems you're solving in this PR. The string concatenation I've been doing here clearly has some brittle edges.

I added a quick example of a SQL query builder (not like an Interro::QueryBuilder, just a generic one to get the idea across) using that shard here and I'd love to get your thoughts on the idea as someone who's been working to fix some of these edge cases.

Footnotes

  1. I wanted to instrument SQL queries in an app that runs a nontrivial amount of of hand-written SQL and some of those queries have literals in them, so I needed to sanitize the query to group identical query plans together. ↩

Comment thread spec/interro_spec.cr
Comment thread spec/interro_spec.cr Outdated
Comment thread spec/interro_spec.cr Outdated
Comment thread spec/interro_spec.cr Outdated
@mloughran

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing, sorry it's taken me a while to get back to this...

I wanted to instrument SQL queries in an app that runs a nontrivial amount of of hand-written SQL and some of those queries have literals in them, so I needed to sanitize the query to group identical query plans together.

Interesting stuff!

I added a quick example of a SQL query builder (not like an Interro::QueryBuilder, just a generic one to get the idea across) using that shard here and I'd love to get your thoughts on the idea as someone who's been working to fix some of these edge cases.

I haven't really had time to give this proper consideration. My gut feel is that if one wanted to do more complex symbolic manipulation of the SQL query then yes, absolutely, but as a way to build fairly simple queries it's a lot of added code and complexity. I'm hoping that with this change the placeholder interpolation bugs should at least be gone!

One observation is that your AST approach still has manual numbering (QueryBuilder.param(1)), so perhaps the approaches are complementary. Even if Interro was built on an AST representation, a just-in-time placeholder numbering approach (as this PR adds) might still be a good way to make numbering bugs impossible.

In particular this adds test coverage for:

- Queries combining LIMIT and OFFSET

- Queries combining DISTINCT ON with ORDER BY (including the parameterized case)
The code was previously taking the 2nd character of the matched string, instead of the first match group.

It's the same fix in lots of places; just adding one spec since this code is simplified later in the branch by removing the need to renumber entirely and refactoring parsing into one place.
Limit and offset args were not being provided in the following cases, resulting in errors like 'bind message supplies 1 parameters, but prepared statement requires 2':

- the iterator form of each (e.g. `query.first(5).each`)
- both sides of a CompoundQuery

The abstracted `QueryBuilder#bind_args` is removed entirely later in this branch when this bug becomes impossible, so I didn't worry too much about its naming.

This fix revealed that limited/offset sides of a compound query generated invalid SQL (due to missing parentheses), so that's also fixed here, and is a change which survives the later refactor.
`QueryBuilder#none?` only renders the WHERE clause, but args could contain ORDER BY values.

This bug also becomes impossible later in this branch.
This is required by the next commit, which moves placeholder numbering to render time and so needs ORDER BY keys to be expression objects rather than pre-numbered SQL text.

Rendering is byte-identical, since QueryExpression#to_sql still just writes the stored text. The one nuance is that Hash key equality now compares values as well as SQL text, so two ORDER BY entries with identical text but different values no longer deduplicate — but any query that could hit that already produced a broken query.
Two key insights, which together should make placeholder collisions impossible:

1. Changing the internal representation of `QueryExpression` to `Array(Part)` where `Part = String | Any` — raw SQL text (`String`) interleaved with the values to bind (`Any`).

   Raw fragments from user code (where/order_by/distinct) are parsed on init, with `$n` resolving to the fragment's own values.
   After that, fragments carry no numbers at all, and placeholder numbers are only assigned at the point of rendering the SQL.

2. `QueryBuilder` no longer stores an args array alongside the SQL.

   Keeping the two in sync by hand was the shared cause of most of the bugs fixed here — it was easy to emit a placeholder and forget its value, or bind a value the rendered clause never referenced.
   Now every read path builds SQL and args in one pass, and `QueryExpression#to_sql(io, args)` appends to both in one place.

The specs added in interro_spec.cr describe cases that previously failed on master.

The trick is `QueryExpression#to_sql(io : IO, args : Array(Any)) : Nil`; this is passed an io and args to mutate, and appends to both on consecutive lines (`args << part` & `io << '$' << args.size`), making it impossible to change one without the other. Every read path now builds SQL and args in that one pass: `each`, `to_a`, `first?` and `to_sql` via the shared `QueryBuilder#render`, with `scalar`, `none?` and `CompoundQuery` threading their own args array through the same method. That retires `bind_args` and the renumbering each call site did against the builder's args.

One wrinkle falls out of numbering late: an expression appearing in both the DISTINCT ON subclause and ORDER BY has to render identically in both, placeholder numbers included, or Postgres rejects the statement. That was automatic while numbers were baked into the expression; now each expression is rendered via a little `render_once` cache so they get the same placeholder ids.

Behaviour notes:

- A $n repeated within one fragment renders as one placeholder per reference, each bound to the same value — different SQL text, identical semantics.
- A $n referencing a missing value raises ArgumentError on init, rather than surfacing as a Postgres bind error at execution.
- A $n in a DISTINCT ON expression now raises, since such an expression carries no values of its own. A parameterized ORDER BY expression doesn't need repeating there to satisfy Postgres, as its key is added to the DISTINCT ON subclause anyway.

So that parsing raw SQL is explicit, the constructor taking SQL text and values is gone, and internals must call `QueryExpression.parse` directly. QueryValue is refactored in the next commit to remove the remaining four-argument constructor.
Separated from the last commit for clarity; `QueryValue` now constructs `QueryExpression` parts directly rather than parsing a numbered expression.

By removing the now unused `QueryExpression.new(lhs, comparator, rhs, values)` constructor, all SQL expression parsing always goes through `QueryExpression.parse` (making it easy to spot), and is now only used for raw fragments from user code.
Before this branch, a $n inside a SQL string literal risked being silently renumbered along with the real placeholders. Nothing failed visibly, because the renumbering only rewrote text and left the number of arguments sent to Postgres unchanged — the only damage was to the contents of the string.

After the core refactor the same mistake becomes visible, which is arguably an improvement: either parsing raises, because the misread placeholder has no value to resolve to, or the value it swallows is bound to a placeholder Postgres cannot see inside the quotes, and Postgres rejects the statement.

With this fix a $n inside an ordinary single-quoted literal is left alone, and ArgumentError is raised only where it should be: when a $n references a value that was not provided.

Note that only ordinary single-quoted literals are recognised. A $n inside an E'' string, a dollar-quoted string, or a quoted identifier is still taken for a placeholder. Covering those means matching every form of literal Postgres accepts, which is a much larger pattern, and left till later.
Simplifies callers by avoiding the need to upcast to `Array(Any)` first, and by defaulting to empty values.

Includes adding missing spec coverage for ORDER BY on a raw expression with no args.
@mloughran
mloughran force-pushed the fix-placeholder-bugs branch from 35182b0 to 57b0b70 Compare October 1, 2026 21:14
@mloughran

Copy link
Copy Markdown
Contributor Author

Rebased on master.

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