fix(tesseract): parenthesize member SQL spliced into filter templates - #11502
fix(tesseract): parenthesize member SQL spliced into filter templates#11502waralexrom wants to merge 2 commits into
Conversation
A filter template places its own operator next to the member's rendered SQL
(`{{ column }} = {{ value }}`, `{{ column }} IS NOT NULL`, …). A member whose
`sql` is a bare expression then re-associates: when its own top-level operator
binds weaker than the template's, that operator captures only the tail of the
member expression. A measure `sql: "{total} IS NOT NULL"` filtered by
`equals true` rendered `HAVING (sum(...) IS NOT NULL = CAST(? AS BOOLEAN))`,
which Trino and Athena reject. Aggregate-typed measures were safe by accident,
being wrapped in their own function call.
Where the member's top level is `AND`/`OR` the mis-parse stays valid SQL and
silently returns a different row set — in `WHERE` over a dimension as much as in
`HAVING` over a measure.
Render the member as one operand when `FilterSqlContext` is built, so every
operator receives it already pinned, and keep the field private so a new call
site cannot bypass it. Atomicity is decided by the existing expression scanner:
plain columns, aggregates, casts and CASE keep their shape, so the wrapping does
not spread through filters that never had the hazard.
An expression ending in a line comment gets its closing parenthesis on a line of
its own — on the same line the comment would swallow it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 3m 54s —— View job Review: sound fix, well-targeted — 1 medium, 2 low. No blocking issues.Full review
What the fix gets rightThe shape is the right one. Rendering the operand once in Reusing The The test file is stronger than most SQL-shape tests in this repo — real row-level assertions against inline Findings
On #3. Two smaller notes, neither worth acting on:
Legacy planner divergence is called out in the description and matches the repo's stated direction (Tesseract is the default, legacy is deprecated), so the same model producing broken SQL under The Not verified: I did not run |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11502 +/- ##
==========================================
- Coverage 83.96% 83.95% -0.01%
==========================================
Files 257 257
Lines 80919 80919
==========================================
- Hits 67940 67935 -5
- Misses 12979 12984 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Deciding atomicity first left the line-comment guard unreachable for the case that needs it just as much: `sql: "amount -- note"` has no top-level operator, so it went in bare and the template appended its operator to the commented line — `amount -- note > $1`, where the predicate silently disappears. Ask the comment question first and let it force the wrapping regardless of atomicity, since the reason is the closing parenthesis rather than precedence. Gate the test file on the planner once at the describe level: a bare `return` per test reported as a pass under the legacy planner, so a regression that read the flag as false everywhere would have turned the file green instead of red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 2m 52s —— View job Re-review of
|
| # | Severity | Where | Issue |
|---|---|---|---|
| 1 | Low | filter_sql_context.rs:55-66 |
The guard ordering is the load-bearing detail of the fix, and no Rust-level test pins it — only the env-gated Postgres integration suite (inline) |
Two notes carried over from the prior pass, neither changed by this commit and neither an objection:
- Emitted-SQL blast radius.
is_top_level_compoundtreats::as a top-level operator, so common shapes likesql: "status::text"now render(status::text) = $1. Semantically identical, but the set of models whose emitted SQL moves is much wider than the reported bug. Still worth confirmingpackages/cubejs-testing-drivers(per-driver SQL snapshots) is green in CI rather than reasoning about it — that is the one suite where a broad shape change surfaces. - Unterminated
/*.sql: "amount + 1 /* note"→(amount + 1 /* note), unhandled. Unlike a trailing--, that input is not valid SQL standalone either, so the asymmetry is justified. Same for leavingparenthesize.rsandconvert_tzout of scope.
Resolved both of my prior threads, since the current diff addresses them.
Not verified: cargo test and the Jest suites did not run in this session — the sandbox declined the commands. The pass counts in the description, and the CI result for the driver-snapshot suite, are taken as reported.
| return member_sql.to_string(); | ||
| } | ||
| format!("({})", member_sql) | ||
| } |
There was a problem hiding this comment.
The ordering here is now the load-bearing detail of the whole fix — comment question before atomicity question — and nothing in the Rust crate pins it. The only coverage is filter-member-sql-parens.test.ts, which needs a live Postgres and is gated on getEnv('nativeSqlPlanner'), so a future refactor that swapped these two blocks back would compile, pass cargo test, and only surface in the integration suite.
as_operand is a pure &str -> String, so a #[cfg(test)] mod tests in this file is nearly free and states the contract locally:
#[test]
fn operand_wrapping() {
assert_eq!(FilterSqlContext::as_operand("amount"), "amount");
assert_eq!(FilterSqlContext::as_operand("amount > 50"), "(amount > 50)");
// Atomic, but a trailing line comment still forces the wrap.
assert_eq!(FilterSqlContext::as_operand("amount -- as is"), "(amount -- as is\n)");
assert_eq!(FilterSqlContext::as_operand(""), "");
}Non-blocking.
Summary
A filter template places its own operator next to the member's rendered SQL (
{{ column }} = {{ value }},{{ column }} IS NOT NULL, …). A member whosesqlis a bare expression then re-associates: when its own top-level operator binds weaker than the template's, that operator captures only the tail of the member expression.Reported on Athena/Trino (CORE-726):
measure_2 equals truerenderedHAVING (sum("orders".amount) IS NOT NULL = CAST(? AS BOOLEAN)), which Trino rejects withmismatched input '='. Aggregate-typed measures were safe by accident, being wrapped in their own function call.Two aggravating factors: it is not measure-only (a dimension
sql: "amount IS NOT NULL"breaks the same way inWHERE), and where the member's top level isAND/ORthe mis-parse stays valid SQL that silently returns a different row set — no error at all.Tesseract only, per the ticket; the legacy planner is left as is.
Changes
FilterSqlContextrenders the member as a single operand at construction time, so every filter operator receives it already pinned. The field is now private behindmember_sql()so a new call site cannot bypass the wrapping.sql_expression_scanner::is_top_level_compound— the same scannerParenthesizeSqlNodealready uses forSqlCallarguments. Plain columns, aggregates, casts,CASEand already-parenthesized expressions keep their shape, so the wrapping does not spread through the filters that never had the hazard. Across the whole repo exactly one existing assertion changed:base-query.test.tsrequiredWHERE (1 = 1 = ?), i.e. it had encoded invalid SQL.sql_expression_scanner::ends_in_line_comment: an expression ending in-- …gets its closing parenthesis on a line of its own, since on the same line the comment would swallow it. (Not a regression — such a member was already fatal in filters,amount + 1 -- note > $1does not parse either — but the wrapping is the natural place to close it.)Testing
New
packages/cubejs-schema-compiler/test/integration/postgres/filter-member-sql-parens.test.ts— 26 tests, 22 of which failed before the fix.Postgres works as a full-fidelity polygon because it exhibits both failure modes:
a > b = cis a syntax error there (comparisons are non-associative), so a membersql: "amount > 50"underequals/notEqualshard-fails — inWHERE, and inHAVINGoversum(...). Closest analogue of the reported Trino error.AND/ORdiverges silently: row-level assertions cover equals / notEquals / IN / NOT IN / set / notSet against real data.x IS NOT NULL = FALSEhappens to parse the intended way on Postgres, so the ticket's literal shape is pinned on the emitted SQL, with a comment explaining why.gt/gte/lt/lte, the LIKE family and the date operators cannot be made to diverge on Postgres (its precedence agrees), so those assert the whole rendered predicate — which also pins the operator and wildcard shape, not just the parentheses.filter-member-sql-parens(Tesseract)getEnv('nativeSqlPlanner')dist/test/integration/postgres(Tesseract)dist/test/unit(Tesseract)cargo test -p cubesqlplannercargo test -p cubesqlplanner --features integration-postgresNote for reviewers
The same trailing-line-comment shape exists in
sql_nodes/parenthesize.rsandsql_templates/plan.rs::convert_tz. Left untouched to keep this diff scoped — happy to fold in if preferred.