Skip to content

chore: release 3.2.0 — parenthesize operator operands, add Expr::Paren#13

Merged
emilt27 merged 4 commits into
masterfrom
release/3.2.0
Jul 15, 2026
Merged

chore: release 3.2.0 — parenthesize operator operands, add Expr::Paren#13
emilt27 merged 4 commits into
masterfrom
release/3.2.0

Conversation

@emilt27

@emilt27 emilt27 commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Renderers emitted no grouping, so an operand that was itself an operator expression got re-associated by the engine's precedence rules — two different ASTs rendered to the same SQL.

Cast(JsonPathText(data, 'age'), "bigint") produced data->>'age'::bigint, which PostgreSQL reads as data ->> ('age'::bigint) and rejects. Worse, Binary(Binary(1, +, 2), *, 3) produced 1 + 2 * 3 and silently evaluated to 7 instead of 9, with no error from the database.

Operands of Binary, Unary, Cast, Collate, JsonPathText and of both sides of a comparison are now bracketed when they are themselves Binary, Unary, Collate, JsonPathText or Window. Bracketing is structural rather than driven by a precedence table, because precedence is dialect-specific: SQLite binds || tighter than *, PostgreSQL binds it looser than +.

Raw and Custom are never bracketed automatically — their contents are opaque and need not be an expression at all. The new Expr::Paren variant groups them explicitly.

emiltem added 4 commits July 14, 2026 18:50
Renderers emitted no grouping, so an operand that was itself an operator
expression got re-associated by the engine's precedence rules — two different
ASTs rendered to the same SQL.

Cast(JsonPathText(data, 'age'), "bigint") produced `data->>'age'::bigint`,
which PostgreSQL reads as `data ->> ('age'::bigint)` and rejects. Worse,
Binary(Binary(1, +, 2), *, 3) produced `1 + 2 * 3` and silently evaluated to 7
instead of 9, with no error from the database.

Operands of Binary, Unary, Cast, Collate, JsonPathText and of both sides of a
comparison are now bracketed when they are themselves Binary, Unary, Collate,
JsonPathText or Window. Bracketing is structural rather than driven by a
precedence table, because precedence is dialect-specific: SQLite binds || tighter
than *, PostgreSQL binds it looser than +.

Raw and Custom are never bracketed automatically — their contents are opaque and
need not be an expression at all. The new Expr::Paren variant groups them
explicitly.
Three gaps in the parenthesization fix, all of the same class:

- A FieldRef whose FieldDef carries a child renders a JSON path chain
  ("data"->'age'), structurally an operator expression like JsonPathText, but
  Expr::Field was not in the predicate. Cast(Field(data->age), "bigint") still
  produced "data"->'age'::bigint — the original bug, reached through the other
  spelling of a JSON path. Plain fields are bare identifiers and stay unbracketed.

- PostgreSQL's ?| / ?& fed their right operand straight into render_expr, with a
  trailing ::text[] on top of it; SQLite's IRegex fed its right operand into the
  RHS of a || concatenation. Both now go through render_operand.

- delegate_renderer! had no arm for render_operand, so a wrapping renderer fell
  back to the trait default and lost the inner dialect's rule.

The predicate, not render_operand, is now the extension point:
Renderer::needs_operand_parens() defaults to Expr::needs_operand_parens(), and
SQLite overrides only that to exempt power()/XOR, which delimit themselves. The
bracketing logic exists once, so a change to the rule — like Field-with-child
above — reaches both dialects instead of silently bypassing SQLite.

Tests: reuse the existing render_expr_pg / render_expr_sqlite helpers instead of
rebuilding QueryStmt, drop the redundant sq_ prefixes, stop seeding tables for
SQLite queries with no FROM, and collapse four cloned PostgreSQL databases into
one for the precedence group.
Expr::Paren was a protheses for one decision: not bracketing Raw/Custom
operands. It earned its keep nowhere else, and it cost a lot.

A caller who needs grouping around a raw fragment simply writes it there —
Expr::raw("(price * qty)") — which is what they reach for anyway, and a
CustomExpr author controls their own rendering. So the variant bought nothing,
while it:

- defeated every shape check that matches on the right operand. IN with a
  parenthesized array rendered `IN (?)` with the whole array bound as one
  scalar (silently zero rows); BETWEEN rendered `BETWEEN (?)` with the AND half
  missing, and bypassed the "requires exactly 2 values" guard. LIKE and IsNull
  turned into render errors.
- made the release source-breaking (a new variant in a pub enum without
  #[non_exhaustive] breaks every downstream exhaustive match), forcing 4.0.0.
- required an arm in every Expr walker, forever.

Also drop the render_operand arm from delegate_renderer!: it re-roots dispatch
at the inner renderer, so a wrapper's render_expr override is skipped in operand
positions. The trait default already recurses through self, which is correct
proxy behavior. The needs_operand_parens arm stays — that one is load-bearing.

And fix a test that asserted SQL PostgreSQL rejects: `?|` with a text concat
right operand renders `("a" || "b")::text[]`, which fails with `malformed array
literal`. The only meaningful compound operand there is an array concat, now
asserted and executed against a live PostgreSQL.
Expr::Custom and ConditionNode::Custom returned "must be handled by a wrapping
renderer" — but that wrapper could not be written.

delegate_renderer! emits every trait method, so combining it with an override is
a duplicate definition (E0201): the pattern in docs/extensibility.md never
compiled. And even a hand-written wrapper forwarding all 26 methods never saw
the call, because a dialect's render_query recurses through its own concrete
self and never comes back out. I confirmed both: the documented example fails to
compile, and a hand-rolled wrapper still returns Err(CustomExpr).

So the knowledge moves into the node. Every Custom* trait gains

    fn render(&self, renderer: &dyn Renderer, ctx: &mut RenderCtx)

with an erroring default, and CustomExpr also gains needs_operand_parens()
(default true — conservative, since an infix node like `x AT TIME ZONE 'UTC'`
must be bracketed as the operand of a cast, which is exactly the bug class this
release exists to kill). The node is handed the renderer, so it recurses back
through it: sub-expressions, quoting and parameter numbering all flow through
the same dialect and the same RenderCtx.

No wrapper needed. PgVectorOp (<->, <#>, <=>, <+>) is now implemented through
the same public mechanism users get, replacing its hardcoded downcast.

Tested: a user-defined AT TIME ZONE node renders in a real query, is bracketed
under a cast, numbers its parameters in document order alongside the rest of the
statement, and executes against a live PostgreSQL. Custom binary operators and
custom conditions likewise. A node without render() still errors.
@emilt27
emilt27 merged commit 71348b5 into master Jul 15, 2026
4 checks passed
@emilt27
emilt27 deleted the release/3.2.0 branch July 15, 2026 12:13
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