Skip to content

fix(tesseract): parenthesize member SQL spliced into filter templates - #11502

Open
waralexrom wants to merge 1 commit into
masterfrom
tesseract-parenthesize-member-sql-in-filters
Open

fix(tesseract): parenthesize member SQL spliced into filter templates#11502
waralexrom wants to merge 1 commit into
masterfrom
tesseract-parenthesize-member-sql-in-filters

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

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.

Reported on Athena/Trino (CORE-726):

- name: measure_1
  sql: amount
  type: sum
- name: measure_2
  sql: "{measure_1} IS NOT NULL"
  type: boolean

measure_2 equals true rendered HAVING (sum("orders".amount) IS NOT NULL = CAST(? AS BOOLEAN)), which Trino rejects with mismatched 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 in WHERE), and where the member's top level is AND/OR the 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

  • FilterSqlContext renders the member as a single operand at construction time, so every filter operator receives it already pinned. The field is now private behind member_sql() so a new call site cannot bypass the wrapping.
  • Atomicity is decided by the existing sql_expression_scanner::is_top_level_compound — the same scanner ParenthesizeSqlNode already uses for SqlCall arguments. Plain columns, aggregates, casts, CASE and 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.ts required WHERE (1 = 1 = ?), i.e. it had encoded invalid SQL.
  • New 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 > $1 does 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 = c is a syntax error there (comparisons are non-associative), so a member sql: "amount > 50" under equals/notEquals hard-fails — in WHERE, and in HAVING over sum(...). Closest analogue of the reported Trino error.
  • A member with a top-level AND/OR diverges silently: row-level assertions cover equals / notEquals / IN / NOT IN / set / notSet against real data.
  • x IS NOT NULL = FALSE happens 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.
  • Two guards that the wrapping stays off atomic members (plain column dimension, aggregate measure), so the diff cannot quietly spread.
Suite Result
filter-member-sql-parens (Tesseract) 26/26 pass (22 failed before the fix)
same file under legacy planner skips cleanly via getEnv('nativeSqlPlanner')
dist/test/integration/postgres (Tesseract) 46 suites, 543 passed, 21 skipped
dist/test/unit (Tesseract) 36 suites, 716 passed
cargo test -p cubesqlplanner 1193 passed
cargo test -p cubesqlplanner --features integration-postgres 1193 passed

Note for reviewers

The same trailing-line-comment shape exists in sql_nodes/parenthesize.rs and sql_templates/plan.rs::convert_tz. Left untouched to keep this diff scoped — happy to fold in if preferred.

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>
@waralexrom
waralexrom requested a review from a team as a code owner August 7, 2026 15:23
@github-actions github-actions Bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code labels Aug 7, 2026
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 3m 54s —— View job


Review: sound fix, well-targeted — 1 medium, 2 low. No blocking issues.

Full review
  • Gather context (read commit, diff, surrounding code)
  • Review sql_expression_scanner.rs changes
  • Review filter_sql_context.rs and all operator call sites
  • Review tests
  • Post review

What the fix gets right

The shape is the right one. Rendering the operand once in FilterSqlContext::new and making member_sql private behind an accessor means the invariant is established at the single point where the context is built, and a future operator cannot re-introduce the bug by reaching for the raw field. I verified all 12 call sites migrated to member_sql() and that no path in physical_plan/filter/ still touches the field directly.

Reusing is_top_level_compound rather than adding a second atomicity notion is also correct — the same predicate already governs ParenthesizeSqlNode, so a member SQL is now classified identically whether it is spliced into an SqlCall argument or a filter template. Spot-checking the classifier against realistic member SQL, the atomic set holds where it matters: window functions (sum(x) OVER (…)), CAST/EXTRACT/CASE, AT TIME ZONE, interval literals and already-parenthesized expressions all keep their shape, so the diff genuinely does not spread into filters that never had the hazard.

The open_line_comment state machine is correct for the property it claims. skip_to_line_end assigns rather than or-assigns, so "the last line comment wins", and since next_token calls skip_trivia before its EOF check, a trailing comment is always consumed before ends_in_line_comment reads the flag. a -- x\n/* yfalse, a /* x */ -- ytrue, '-- not a comment'false; all three behave.

The test file is stronger than most SQL-shape tests in this repo — real row-level assertions against inline VALUES data for the AND/OR cases (where the bug is silent, so shape assertions alone would prove nothing), plus two negative guards that the wrapping stays off atomic members. That last pair is the part that keeps this diff from quietly widening later.

Findings

# Severity Where Issue
1 Medium filter_sql_context.rs:55-66 ends_in_line_comment is checked only on the compound branch, so an atomic member ending in -- … still swallows the template's operator (inline)
2 Low filter-member-sql-parens.test.ts:146 26× repeated if (!getEnv(…)) return; reports as pass, not skip, under the legacy planner (inline)
3 Low Emitted-SQL blast radius wider than the reported matrix; see below

On #3. is_top_level_compound treats :: as a top-level operator, so a very common real-world member shape — sql: "status::text", sql: "amount::numeric" — now renders as (status::text) = $1 where it previously rendered status::text = $1. Semantically identical, and arguably more correct, but it means the diff changes emitted SQL for a much larger set of models than the reported bug covers. The test matrix in the description covers cubesqlplanner and cubejs-schema-compiler, both of which I would expect to catch this; it does not mention packages/cubejs-testing-drivers, which carries per-driver SQL snapshots. Worth confirming that suite is green in CI before merge rather than reasoning about it — it is the one place a broad shape change like this would surface.

Two smaller notes, neither worth acting on:

  • as_operand tokenizes the input twice on the compound path (is_top_level_compound, then ends_in_line_comment). Once per filter per query, on strings the length of a member's SQL — not a real cost, and the clarity of two named predicates is worth more than fusing them.
  • The unterminated-/* analogue (sql: "amount + 1 /* note"(amount + 1 /* note)) is not handled, but unlike a trailing -- that input is not valid SQL standalone either, so the asymmetry is justified. Agreed with leaving parenthesize.rs and convert_tz out of scope; they are the same shape but a separate change.

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 CUBEJS_TESSERACT_SQL_PLANNER=false seems acceptable — flagging only so the decision is explicit rather than implicit.

The base-query.test.ts change is a genuine improvement, not a concession: the old expectation WHERE (1 = 1 = ?) had encoded SQL that Postgres itself rejects. That exactly one assertion in the repo moved is good evidence the classifier is tight.

Not verified: I did not run cargo test or the Jest suites in this session — the pass/fail counts in the description are taken as reported.

· branch `tesseract-parenthesize-member-sql-in-filters`

Comment on lines +55 to +66
fn as_operand(member_sql: &str) -> String {
if !is_top_level_compound(member_sql) {
return member_sql.to_string();
}
// An expression ending in a line comment would swallow the closing
// parenthesis, so that one gets a line of its own.
if ends_in_line_comment(member_sql) {
format!("({}\n)", member_sql)
} else {
format!("({})", member_sql)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The line-comment guard is unreachable for the atomic case, which is where it is also needed. A member whose SQL is atomic and ends in a line comment returns early on line 57 and the template's operator still lands on the commented line:

- name: amount_commented
  sql: "amount -- note"      # atomic: no top-level operator
  type: number

gt renders amount -- note > $1, and everything after -- is swallowed — the predicate silently disappears (or the statement fails to parse, depending on what follows on that line). Same for sql: "sum(amount) -- note" on a measure. This is pre-existing rather than introduced here, but the PR already carries ends_in_line_comment, and closing it is a reorder: decide the comment question first and let it force the wrap.

Suggested change
fn as_operand(member_sql: &str) -> String {
if !is_top_level_compound(member_sql) {
return member_sql.to_string();
}
// An expression ending in a line comment would swallow the closing
// parenthesis, so that one gets a line of its own.
if ends_in_line_comment(member_sql) {
format!("({}\n)", member_sql)
} else {
format!("({})", member_sql)
}
}
fn as_operand(member_sql: &str) -> String {
// An expression ending in a line comment would swallow whatever the
// template appends on that line, so it needs the wrapping — and its
// closing parenthesis on a line of its own — even when atomic.
if ends_in_line_comment(member_sql) {
return format!("({}\n)", member_sql);
}
if !is_top_level_compound(member_sql) {
return member_sql.to_string();
}
format!("({})", member_sql)
}

Fix this →


describe('WHERE — dimension whose SQL is a top-level AND/OR', () => {
it('equals', async () => {
if (!getEnv('nativeSqlPlanner')) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: the if (!getEnv('nativeSqlPlanner')) return; guard is repeated 26 times, and a bare return reports as a pass under the legacy planner rather than a skip — so a regression that made getEnv('nativeSqlPlanner') read false everywhere would turn this whole file green instead of red. Reading the flag once outside describe and gating with describe.skip makes the intent visible in the runner output and removes the repetition:

const tesseract = getEnv('nativeSqlPlanner');
(tesseract ? describe : describe.skip)('Filter member SQL parenthesization', () => {  });

Not blocking — the per-test guard is the prevailing style in this directory.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.46%. Comparing base (259188d) to head (0e4593f).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11502      +/-   ##
==========================================
- Coverage   83.96%   79.46%   -4.50%     
==========================================
  Files         257      480     +223     
  Lines       80919    98861   +17942     
  Branches        0     3636    +3636     
==========================================
+ Hits        67940    78564   +10624     
- Misses      12979    19777    +6798     
- Partials        0      520     +520     
Flag Coverage Δ
cube-backend 59.23% <ø> (?)
cubesql 83.95% <ø> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

javascript Pull requests that update Javascript code rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant