Skip to content

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

Merged
waralexrom merged 4 commits into
masterfrom
tesseract-parenthesize-member-sql-in-filters
Aug 21, 2026
Merged

fix(tesseract): parenthesize member SQL spliced into filter templates#11502
waralexrom merged 4 commits 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`

@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.52%. Comparing base (259188d) to head (641645b).
⚠️ Report is 47 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11502      +/-   ##
==========================================
- Coverage   83.96%   79.52%   -4.44%     
==========================================
  Files         257      484     +227     
  Lines       80919    99739   +18820     
  Branches        0     3672    +3672     
==========================================
+ Hits        67940    79317   +11377     
- Misses      12979    19874    +6895     
- Partials        0      548     +548     
Flag Coverage Δ
cube-backend 59.15% <ø> (?)
cubesql 84.05% <ø> (+0.09%) ⬆️

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.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 2m 52s —— View job


Re-review of c260acd: both prior findings resolved. 1 low (non-blocking), nothing else outstanding.

Full review
  • Gather context (read new commit, current file state)
  • Re-check the line-comment finding from the prior review
  • Re-check the test-gating finding
  • Verify no call site bypasses member_sql()
  • Resolve stale threads / post review

What the follow-up commit changed

c260acd addresses both findings from the previous pass, and does it the way the shape of the problem suggests rather than by patching around it.

Finding #1 (medium) — fixed. as_operand now asks the comment question first (filter_sql_context.rs:55-66):

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)

This is the right ordering, and the comment above it now states the right reason — the wrap is forced by the closing parenthesis needing a line, not by precedence, so atomicity is irrelevant to that branch. The reported case (sql: "amount -- as is" under gt) now renders (amount -- as is\n) > $1 instead of the predicate-swallowing amount -- as is > $1. Covered by a new atomic expression test under a member SQL ending in a line comment describe, with a row-level assertion (idRows(1, 3, 5), matching amount > 50 over the inline data) rather than a shape-only check.

Finding #2 (low) — fixed. The 26 if (!getEnv('nativeSqlPlanner')) return; guards are gone, replaced by a single module-scope read plus (tesseract ? describe : describe.skip). I checked that this is safe here: the planner flag is not mutated by test/global-setup.ts (which only sets TZ and two MySQL/MSSQL timezone vars), so a module-scope getEnv reads the same value a per-test call would. Under the legacy planner the file now reports as skipped, which was the point. Note this is the first describe.skip gate in test/integration/postgres/ — every other file uses the per-test guard — so it diverges from local style, but in the direction that makes the runner output honest.

Call sites. Re-grepped: all 21 reads go through ctx.member_sql(), and the three construction sites in typed_filter.rs all use FilterSqlContext::new. No struct literal remains — and since the field is private, one could not be added from outside the module anyway. FilterSqlContext::new("", …) for the FILTER_PARAMS-callback path still yields "" (both predicates return false on the empty string, and the scanner has an explicit ends_in_line_comment("") test).

Finding

# 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_compound treats :: as a top-level operator, so common shapes like sql: "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 confirming packages/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 leaving parenthesize.rs and convert_tz out 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.

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

`as_operand` decides the comment question before the atomicity one, and that
ordering is what keeps a trailing line comment from swallowing the closing
parenthesis. Only the Postgres integration suite covered it, so swapping the two
blocks back would compile and pass `cargo test`.

The function is a pure `&str -> String`, so state the contract next to it:
atomic stays bare, compound gets wrapped, and a trailing line comment forces the
wrap with the parenthesis on a line of its own however atomic the expression is.

Drop the data table from the integration test's header comment. It restated the
inline `VALUES` twenty lines below, plus the per-row truth values derived from
them — three copies of one fact, and the copy nobody runs is the one that rots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 2m 1s —— View job


Re-review of 8f11f9d: prior finding closed. Nothing outstanding — no new issues.

Full review
  • Read the new commit
  • Verify the new Rust tests pin the ordering contract
  • Re-check the integration test after the comment trim
  • Resolve stale threads / post review

What 8f11f9d changed

Two things, both narrow.

The last open finding is closed. filter_sql_context.rs now carries a #[cfg(test)] mod tests next to as_operand, and it pins the part that mattered — the ordering. trailing_line_comment_wraps_on_its_own_line asserts as_operand("amount -- as is") == "(amount -- as is\n)", and amount alone is atomic (atomic_expression_stays_bare asserts exactly that), so swapping the two blocks back makes this case return amount -- as is and the test fails. That's the property the integration suite was the only witness to, now stated in the crate where a refactor sees it.

The three cases divide the input space the way the function does: atomic (amount, sum(amount), ""), compound (amount > 50, sum(amount) IS NOT NULL — the reported model's shape), comment-terminated. The third test's last assertion is the useful one to have written down: as_operand("amount -- note\n + 1") == "(amount -- note\n + 1)" — comment present but closed by the newline, so it takes the ordinary compound path and does not get the extra newline. That distinguishes "ends in a line comment" from "contains one", which is precisely what Tokenizer::open_line_comment tracks via skip_to_line_end's assign-not-or-assign. Traced each expectation against the scanner by hand and all five hold, including "" → both predicates false → "".

Small mechanics, all fine: the private associated fn is reachable from the child module, the local fn as_operand wrapper keeps the assertions readable, and as_operand stays used by new so no dead_code warning appears under cfg(test).

Integration-test header trimmed. The 14-line data table and derived truth-value lists are gone from filter-member-sql-parens.test.ts. The reasoning in the commit message is sound — the table restated the inline VALUES twenty lines below, and a hand-maintained copy of data the test itself defines is the copy that goes stale. The cost is that idRows(2, 3, 4, 6) for big_and_flag equals false is now a derivation rather than a lookup: reader has to evaluate amount > 50 AND flag over the six rows themselves. One step from data that is right there, and the describe names say which member and which operator, so I'd call it an even trade rather than a regression. Not worth an inline comment either way.

Findings

None. The as_operand contract is now pinned at both levels, and nothing in this commit touches planning behavior — it is a test-only diff plus a comment deletion.

Resolved my one remaining thread, since this commit addresses it. Two notes carried forward from the earlier passes, unchanged and neither an objection:

  • Emitted-SQL blast radius. is_top_level_compound treats :: as a top-level operator, so sql: "status::text" now renders (status::text) = $1. Semantically identical, but the set of models whose emitted SQL moves is wider than the reported bug. Still worth confirming packages/cubejs-testing-drivers (per-driver SQL snapshots) is green in CI rather than reasoning about it.
  • Unterminated /*. sql: "amount + 1 /* note"(amount + 1 /* note), unhandled — but that input is not valid SQL standalone either, so the asymmetry is justified. Same for leaving parenthesize.rs and convert_tz out of scope.

Not verified: cargo test -p cubesqlplanner did not run in this session — the sandbox declined the command both times. The new tests are reviewed by reading them against the scanner, not by execution; the pass counts in the description are taken as reported.

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

// An expression ending in a line comment swallows whatever the template
// appends on that line, so it needs the wrapping — and a line of its own
// for the closing parenthesis — however atomic it otherwise is.
if ends_in_line_comment(member_sql) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@claude could you showcase an example of SQL? Why is this needed?

@claude claude Bot Aug 18, 2026

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.

Claude encountered an error after 10s —— View job


I'll analyze this and get back to you.

@ovr ovr Aug 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@waralexrom, about what comments is this code about?)

- name: test
   sql: "amount + 1 -- this comment?"
   type: number

such comments? ☝️

@claude claude Bot Aug 18, 2026

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.

Claude finished @ovr's task in 1m 1s —— View job


SQL examples

Sure — here's what actually changes on the wire. Every filter template splices the member's sql next to an operator of its own ({{ column }} = {{ value }}, {{ column }} > {{ value }}, …), so the member's own top-level operator ends up competing with the template's for the same operands.

1. The reported case — hard syntax error (Trino/Athena)

- name: total
  sql: amount
  type: sum
- name: total_is_set
  sql: "{total} IS NOT NULL"
  type: boolean

total_is_set equals true:

-- before
HAVING (sum("orders".amount) IS NOT NULL = CAST(? AS BOOLEAN))
-- Trino: mismatched input '=' — `IS NOT NULL` binds looser than `=`,
-- so `=` grabs only `NULL`, and `x IS NOT (NULL = ?)` is not a thing.

-- after
HAVING ((sum("orders".amount) IS NOT NULL) = CAST(? AS BOOLEAN))

Same shape on Postgres with a comparison member (sql: "amount > 50", equals false) — comparisons are non-associative there, so amount > 50 = $1 is syntax error at or near "=".

2. The reason this is worth fixing beyond the error — silent wrong rows

This is the one I'd point at. With a top-level AND/OR, the mis-parse is valid SQL and just returns a different answer, on every dialect:

- name: big_and_flag
  sql: "amount > 50 AND flag"
  type: boolean

big_and_flag equals false:

-- before
WHERE (amount > 50 AND flag = CAST($1 AS BOOLEAN))
--     ^ parses as: amount > 50 AND (flag = false)

-- after
WHERE ((amount > 50 AND flag) = CAST($1 AS BOOLEAN))

Over the test data (6 rows) that's 1 row before vs. 4 rows after — no error, no warning, just a wrong number in a dashboard. Covered by row-level assertions in filter-member-sql-parens.test.ts:139-181 (equals, notEquals, IN/NOT IN, set, notSet) rather than SQL-shape checks, precisely because a shape check proves nothing when the broken SQL still runs.

The repo already had one such shape encoded in an assertion — base-query.test.ts expected WHERE (1 = 1 = ?), which Postgres rejects; it now reads WHERE ((1 = 1) = ?). That was the only assertion in the whole repo that moved, which is the evidence that the wrapping isn't spreading.

3. Why the separate line-comment branch (this thread)

An expression ending in -- … needs the wrap for a different reason than precedence — the closing ) can't sit on the same line:

- name: amount_commented
  sql: "amount -- as is"    # atomic! no top-level operator at all
  type: number

amount_commented gt 50:

-- before
WHERE (amount -- as is > $1)     -- `> $1` is inside the comment; predicate is gone

-- naive wrap on one line
WHERE ((amount -- as is) > $1)   -- still swallowed, now the `)` too

-- after
WHERE ((amount -- as is
) > $1)

That's why ends_in_line_comment is checked before is_top_level_compoundamount is atomic, so the atomicity check alone would return it bare. Pinned in the crate at filter_sql_context.rs:290-298 and end-to-end at filter-member-sql-parens.test.ts:306-312.

4. What deliberately does not change

"orders".amount = $1          -- plain column: unchanged
sum("orders".amount) > $1     -- aggregate: unchanged

Atomic members (plain columns, aggregates, CAST, CASE, already-parenthesized expressions) keep their exact shape, decided by the same is_top_level_compound scanner ParenthesizeSqlNode already uses for SqlCall arguments. Two negative tests guard that (filter-member-sql-parens.test.ts:317-335) so the diff can't quietly widen later.

One caveat worth stating explicitly, from my earlier passes: :: counts as a top-level operator, so a common shape like sql: "status::text" now renders (status::text) = $1. Semantically identical, but it means emitted SQL moves for more models than the reported bug alone — worth confirming packages/cubejs-testing-drivers (per-driver SQL snapshots) is green in CI.
· branch tesseract-parenthesize-member-sql-in-filters

The fix was only exercised from the JS side, behind a native rebuild and an
env-gated Postgres suite. Two crate-level layers cover it where it lives.

`tests/filter/member_operand.rs` pins the rendered filter SQL per operator over
a compound member — equality, IN lists, nullability, comparison, LIKE, date
range, and the reported model's calculated boolean in HAVING — plus the two
members that must stay bare and the one ending in a line comment. No database,
so it runs in every `cargo test`.

`tests/integration/filter_member_operand.rs` checks rows instead, because the
dangerous form of the mis-parse is valid SQL over a different row set and the
emitted text cannot tell it from the intended reading. The AND member returns 5
rows where the mis-parse silently returns none, and the OR member under `notSet`
returns the one customer whose expression is NULL where the mis-parse returns the
two it degenerates to. Both row sets were derived independently against the seed
before the snapshots were accepted.

The AND member carries parentheses around its second operand on purpose: without
them the mis-parse chains two comparisons, which Postgres rejects outright, and
the test would prove the error case the comparison member already covers instead
of the silent one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of 641645b: crate-level coverage checks out. 1 low (unused fixture member), nothing blocking.

Full review
  • Read the new commit's crate tests
  • Verify the fixtures and snapshots
  • Check the integration row-set claims against the seed
  • Resolve stale threads / post review

What 641645b adds

Two crate-level layers over a fix that until now was only exercised from JS behind a native rebuild and an env-gated Postgres suite. Both layers land where the code lives, and they divide the work along the axis the bug actually has.

tests/filter/member_operand.rs — pins the text, no database. 15 tests over a dedicated common/filter_operand.yaml fixture, one per operator family: equality, IN/NOT IN, set/notSet, gt/lte, contains/startsWith, inDateRange, plus the reported model's calculated boolean in HAVING (((sum("orders".amount) IS NOT NULL) = $_0_$::boolean) — exactly the shape Trino rejected unwrapped). The two negative guards carried over from the JS suite are here too (test_atomic_dimension_is_not_wrapped, test_aggregate_measure_is_not_wrapped), so the "wrapping must not spread" property is now stated in the crate as well.

The notEquals expectations are the ones I'd have wanted written down — ({AND} <> $_0_$::boolean OR {AND} IS NULL) repeats the wrapped operand on both sides of the template's own OR, which is where a partial fix (wrapping at one call site, not at construction) would show up.

Checked the helper plumbing: build_filter_sql chains dimensions_filters + time_dimensions_filters + measures_filters, so the member:-keyed measure tests genuinely go through the HAVING path rather than rendering nothing. The file defines its own local build rather than reusing filter/mod.rs::build_filter — that helper hardcodes visitors.count, so a fixture with an orders cube can't use it. Right call over widening the shared helper's signature for one caller.

tests/integration/filter_member_operand.rs — checks rows, because the text can't tell. This is the layer that matters for the silent failure mode, and each claim in the comments holds against integration_basic_tables.sql. I re-derived all four independently:

Test Correct reading Mis-parse Snapshot
equals_false_on_and_dimension amount > 100 AND (status='completed') false for orders 1,3,5,7,9 → 5 amount>100 AND ((status='completed')=false) → big-but-not-completed → none 5
not_set_on_or_dimension city='New York' OR name LIKE 'Alice%' is NULL only for Charlie Brown (city NULL, name no match) → customer 3 city='NY' OR ((name LIKE 'Alice%') IS NULL); right side never NULL since name NOT NULLcustomers 1, 4 3
equals_true_on_comparison_dimension amount > 100 → orders 2,4,6,8 → 4 Postgres rejects amount > 100 = $1 outright 4
equals_on_calculated_boolean_measure sum(amount) IS NOT NULL true for all statuses PG puts IS above comparison, so this one parses the intended way — shape guard only, as the comment says cancelled/completed/pending

The commit message's note about is_big_completed carrying its own parentheses around (status = 'completed') is the detail that makes the first test earn its keep: without them the mis-parse chains two comparisons and Postgres errors, which would prove the loud failure the third test already covers. Deliberately arranging the fixture so the mis-parse stays valid SQL is the right instinct — that's the case where a text snapshot proves nothing.

One thing I checked because it's easy to get wrong: the shared common/integration_basic.yaml gained three members and is read by 11 other integration modules. Nothing else in the crate references is_ny_or_alice, is_big_completed, is_big, or total_amount_is_set, and no test in that directory enumerates a cube's members, so no existing snapshot moves. MockSchema::from_yaml_file reads by path, so the new filter_operand.yaml needs no registration.

Findings

# Severity Where Issue
1 Low filter_operand.yaml:32 The big dimension is unused — orders.big[^_] matches nothing in the crate, so the "Top-level comparison" comment describes a case only the measure-side test covers (inline)

No prior thread of mine is still open — all three were resolved on earlier passes and the current diff keeps them addressed. Two notes carried forward, unchanged and neither an objection:

  • Emitted-SQL blast radius. is_top_level_compound treats :: as a top-level operator, so sql: "status::text" now renders (status::text) = $1. Semantically identical, but wider than the reported bug. Still worth confirming packages/cubejs-testing-drivers (per-driver SQL snapshots) is green in CI rather than reasoning about it.
  • Unterminated /*. sql: "amount + 1 /* note"(amount + 1 /* note), unhandled — that input is not valid SQL standalone either, so the asymmetry is justified. Same for leaving parenthesize.rs and convert_tz out of scope.

Also worth stating: the JS suite (filter-member-sql-parens.test.ts, 26 tests) now overlaps the crate tests substantially. That's defensible — it's the only layer that exercises the JS→native boundary — but if it starts costing CI time, the crate tests are the ones that should stay.

Not verified: cargo test -p cubesqlplanner did not run in this session — the sandbox declined the command. Every expectation above was traced by hand against the fixture, the seed data, and the template output; the pass counts in the description are taken as reported.

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

Comment on lines +30 to +32
# Top-level comparison — a second one next to it is a syntax error on
# Postgres and Trino alike.
- name: big

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: big is not referenced by any test — grepping orders.big[^_] across the crate returns nothing, so this fixture member and its comment describe a case the crate does not actually exercise. The comparison shape is covered, but only on the measure side (total_over_150, test_equals_comparison_measure) and in the integration fixture (orders.is_big).

Either drop it, or add the two-line dimension counterpart so the comment stays true:

#[test]
fn test_equals_comparison_dimension() {
    let result = build(indoc! {"
        filters:
          - dimension: orders.big
            operator: equals
            values:
              - \"true\"
    "});
    assert_filter(&result, r#"(("orders".amount > 50) = $_0_$::boolean)"#, &["true"]);
}

Not blocking.

Fix this →

@waralexrom
waralexrom merged commit e9f5407 into master Aug 21, 2026
163 of 165 checks passed
@waralexrom
waralexrom deleted the tesseract-parenthesize-member-sql-in-filters branch August 21, 2026 14:42
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.

3 participants