Skip to content

feat(tesseract): push a segment named in FILTER_PARAMS into the cube's sql - #11517

Merged
waralexrom merged 2 commits into
masterfrom
tesseract-filter-params-segment-pushdown
Aug 10, 2026
Merged

feat(tesseract): push a segment named in FILTER_PARAMS into the cube's sql#11517
waralexrom merged 2 commits into
masterfrom
tesseract-filter-params-segment-pushdown

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

FILTER_PARAMS.<cube>.<segment>.filter(...) compiled without error but rendered 1 = 1, so the segment predicate stayed in the outer WHERE after the joins. On a cube wrapping a large UNION ALL scan that predicate is the one that makes the scan viable, and the query never completes (Trino timeout). Segments are now matchable as FILTER_PARAMS targets under Tesseract.

A segment is compared to no value, so the filter() argument is the whole predicate rather than the left side of one. It is stated there rather than taken from the segment's own sql, which prefixes its columns with the cube — not in scope inside the sql that builds that cube, the same reason a dimension binding restates its column.

Before / after, for a query selecting the segment:

-- before
FROM (SELECT 'sites' AS src, * FROM events.dbo.sites_92
      WHERE 1 = 1 AND (date_created >= $1 AND date_created <= $2)) AS "events"
WHERE ("events".date_created >= $3 AND "events".date_created <= $4)
  AND ("events".evid = 115 AND "events".action_group = 'load')

-- after
FROM (SELECT 'sites' AS src, * FROM events.dbo.sites_92
      WHERE (evid = 115 AND action_group = 'load')
        AND (date_created >= $1 AND date_created <= $2)) AS "events"
WHERE ("events".date_created >= $3 AND "events".date_created <= $4)
  AND ("events".evid = 115 AND "events".action_group = 'load')

Resolves CORE-712.

Changes

  • planner/filter/tree.rsfind_subtree_for_members matches a Segment node when a target names it, instead of always skipping it. The same function drives SqlCallFilterParamsItem.active, so the binding activates without extra wiring.
  • planner/filter/base_segment.rsBaseSegment::matches_member_name matches the path the query asked for, then walks the reference chain, so a view re-exporting a segment activates the underlying cube's binding. The walk stops at the first symbol that is not a member expression: a segment whose sql is a bare reference resolves on to that dimension, whose binding states a column and not a predicate.
  • physical_plan/filter/base_segment.rs — a segment named by a binding in the filters context renders that binding's column: a string verbatim, a compiled callback evaluated with no values, a rest-parameter callback invoked with none. A compiled callback that declares parameters is a user error rather than a silent 1 = 1.
  • Docs: new "Example with segment" section under FILTER_PARAMS, noting it needs the default SQL planner.

A segment absent from the query still renders 1 = 1, and so does a different segment. The predicate now applies both inside the cube's sql and in the outer WHERE — an idempotent AND, the same duplication dimension bindings already produce.

Testing

  • rust/.../src/tests/filter_params_segment.rs — 5 cases: pushdown, no segment selected, a different segment selected, selection through a view, and a segment referencing a dimension not activating that dimension's binding. Mock DSL gained {FILTER_PARAMS_COLUMN:<cube>.<member>:<column>} for string columns.
  • packages/cubejs-schema-compiler/test/integration/postgres/filter-params-segment.test.ts — 7 cases including FILTER_GROUP and two row-count checks executed against Postgres, which cover the duplicated predicate.
  • Reverting the three planner files fails exactly the pushdown assertions; the negative and row-count cases pass either way by design.
  • Green: 1195 cubesqlplanner tests, cargo fmt, and cube-views / sql-generation / sql-generation-logic / yaml-compiler / pre-aggregations / multi-stage-time-shift-filter-params under CUBEJS_TESSERACT_SQL_PLANNER=true.

Not in scope

An unresolvable FILTER_PARAMS argument still renders 1 = 1 silently — tracked as an open question on the ticket, along with making "at least one segment" mandatory on a cube.

🤖 Generated with Claude Code

…s sql

`FILTER_PARAMS.<cube>.<segment>.filter(...)` compiled without error but
rendered `1 = 1`, leaving the segment predicate in the outer WHERE after
the joins. On a cube wrapping a large union scan that predicate is what
makes the scan viable, so the query never completes.

A segment is compared to no value, so the `filter()` argument is the whole
predicate rather than the left side of one. It is stated there rather than
taken from the segment's own `sql`, which prefixes its columns with the
cube — not in scope inside the sql that builds that cube, the same reason a
dimension binding restates its column.

`find_subtree_for_members` now matches a `Segment` node when a target names
it, which also activates the binding through the existing activity visitor,
and `BaseSegment` renders the binding's column when the filters context
carries one. A segment absent from the query still renders `1 = 1`.

The name is matched along the reference chain, so a view re-exporting a
segment activates the underlying cube's binding. The walk stops at the
first symbol that is not a member expression: a segment whose sql is a bare
reference resolves on to that dimension, whose binding states a column and
not a predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@waralexrom
waralexrom requested review from a team and keydunov as code owners August 10, 2026 14:16
@mintlify

mintlify Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
cubed3 🟢 Ready View Preview Aug 10, 2026, 2:18 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions github-actions Bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code labels Aug 10, 2026
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 7m 51s —— View job


Review complete — approach is sound; 2 medium, 4 low. 6 inline comments posted.

Full review
  • Gather context (branch has no merge base with origin/master in this checkout, so reviewed HEAD~1..HEAD, which is the PR's single commit)
  • Review Rust planner changes (tree.rs, planner/filter/base_segment.rs)
  • Review physical-plan rendering (physical_plan/filter/base_segment.rs)
  • Trace blast radius of the find_subtree_for_members change through both callers
  • Verify the documented legacy-planner behaviour against the legacy code
  • Review tests (Rust + Postgres integration) and mock DSL
  • Review docs
  • Post review

Overall

The mechanism is a good fit for the existing design: reusing find_subtree_for_members to drive both the filter-tree extraction and SqlCallFilterParamsItem.active means the binding activates with no new wiring, and the change is genuinely contained. I traced both callers of that function (sql_call.rs:502, static_filter.rs:144) and the new Segment arm can only fire when a binding name equals a segment path, so dimension-only bindings are unaffected; the AND/OR partial-match semantics also come out identical in content for the pre-existing cases. The docs claim about the legacy planner checks out — extractFilterMembers returns null for a segment filter, so renderFilterParams(null) yields ALWAYS_TRUE.

Rendering safety is fine: the raw string column is returned unparenthesised, but every FilterItem arm in physical_plan/filter/filter.rs wraps its child in (...), so a compound predicate can't bleed into surrounding SQL. No injection surface is added — the column text comes from the data model, same as a dimension binding.

I could not run cargo test or the Postgres suite in this environment (the commands need approval), so the green-test claims in the description are taken at face value; findings below are from reading the code.

Findings

# Sev Where Issue
1 Medium physical_plan/filter/base_segment.rs:66-73 Compiled column with declared params raises a user error, while the dimension path (typed_filter.rs:105) degrades to always_true(). A model that renders 1 = 1 today starts failing the query outright. Deliberate per the description — but it's a breaking change for existing models and isn't noted in the docs or changelog.
2 Medium physical_plan/filter/base_segment.rs:82 FilterParamsColumn::Callback also covers fixed-arity callbacks whose parameter list valueParamsAreCertain couldn't verify (bound/native fn, or a quote/comment in the list). Invoking those with zero args interpolates undefined into the cube's sql — silently, unlike finding 1.
3 Low physical_plan/filter/base_segment.rs:25-31 min_by_key tiebreaks bindings alphabetically; an exact self.full_name match is the more meaningful priority, and a get() first also avoids the per-render full-map scan (cf. base_filter.rs:24).
4 Low physical_plan/filter/base_segment.rs:25-42 The new early return precedes the reading_pre_aggregation branch. Unreachable today (eval_filter_group always builds a fresh SqlNodesFactory), but latent: a pre-agg-reading context would render the pushdown predicate instead of wrap_segment_filter.
5 Low test_fixtures/cube_bridge/mock_member_sql.rs:153-164 The new DSL directive only produces the String variant, so the Compiled arm and both new error paths have zero coverage in either suite.
6 Low (nit) planner/filter/base_segment.rs:75-86, filter-params-segment.test.ts:25 Redundant first loop iteration; and the events fixture uses two bare FILTER_PARAMS without FILTER_GROUP, which the docs page edited by this PR says is required.

Things done well

  • The reference-chain walk stopping at the first non-member-expression is the subtle part, and it's both explained in the doc comment and pinned by a dedicated negative test — that dimension-binding false positive would have been easy to ship.
  • Negative cases (segment absent, different segment selected) are tested, not just the happy path, which is what makes the 1 = 1 fallback trustworthy.
  • Two Postgres row-count assertions specifically cover the inside-sql/outer-WHERE duplication rather than asserting it's harmless in prose.
  • Docs section is concise, follows the page's existing <CodeGroup> pattern, and states the planner limitation.
· branch [`tesseract-filter-params-segment-pushdown`](https://github.com/cube-js/cube/tree/tesseract-filter-params-segment-pushdown)

Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/filter/base_segment.rs Outdated
Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/filter/base_segment.rs Outdated
Comment on lines 25 to 42
if let Some(item) = filters_ctx
.filter_params_columns
.iter()
.filter(|(name, _)| self.matches_member_name(name))
.min_by_key(|(name, _)| *name)
.map(|(_, item)| item)
{
return self.filter_params_column_sql(
item,
visitor,
node_processor,
query_tools,
templates,
);
}

let sql = visitor.apply(&self.member_evaluator(), node_processor, templates)?;
if filters_ctx.reading_pre_aggregation {

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.

Two smaller points on this lookup:

  1. Alphabetical order is a weaker tiebreak than specificity. When both a cube's own path and a view's re-export path have bindings in the same group, min_by_key picks the lexicographically smaller name, which happens to favour orders.completed over orders_view.completed but is arbitrary in general — the binding whose name is exactly what the query asked for (self.full_name) is the one the user meant. Trying filter_params_columns.get(&self.full_name) first and only then falling back to the chain scan also mirrors the dimension path (base_filter.rs:24 uses a plain get) and skips a full-map scan per segment render.

  2. This early return precedes the reading_pre_aggregation branch below. Unreachable today, since the only context carrying a non-empty filter_params_columns is new_for_filter_params, which eval_filter_group always builds with a fresh SqlNodesFactory::new() (so reading_pre_aggregation is false). But if a pre-agg-reading factory ever reaches this context, a segment would render the pushdown predicate against raw columns instead of wrap_segment_filter over the stored pre-aggregation column — invalid SQL rather than a planning error. Cheap to make explicit by gating the new block on !filters_ctx.reading_pre_aggregation.

Comment on lines +75 to +86
let mut current = Some(self.member_evaluator.clone());
while let Some(symbol) = current {
if symbol.as_member_expression().is_err() {
return false;
}
// A segment symbol lives in the `expr:` namespace, so the path is
// reassembled from the cube and member names it was compiled under.
if format!("{}.{}", symbol.cube_name(), symbol.name()) == member {
return true;
}
current = symbol.reference_member();
}

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 first iteration of the loop re-checks what line 72 already covered — for a cube-level segment member_evaluator's cube_name/name reassemble to the same path as self.full_name. Harmless, just slightly obscures that the loop exists purely for the view re-export hop.

The chain walk itself reads correctly to me, and the as_member_expression().is_err() stop is the right guard — worth noting the reasoning is well captured in the doc comment and pinned down by segment_referencing_a_dimension_does_not_activate_that_dimensions_binding.

One asymmetry worth being aware of: FilterItem::Item matching in tree.rs:195 compares only the end of the reference chain (resolve_reference_chain().full_name()), while segments match any hop. That's justified here (a dimension binding is about the column, a segment binding about the predicate), but the two matching rules now differ in kind, so a future change to one won't obviously carry to the other.

Comment on lines +25 to +26
sql: \`${events} WHERE \${FILTER_PARAMS.events.start_load.filter("evid = 115 AND action_group = 'load'")}
AND \${FILTER_PARAMS.events.region.filter('region')}\`,

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 events cube uses two FILTER_PARAMS in one sql without wrapping them in FILTER_GROUP, which the docs page this PR edits states is required ("If you use FILTER_PARAMS in your query more than once, you must wrap them with FILTER_GROUP"). It works here because the two are ANDed explicitly, and the grouped_events cube covers the grouped form — but a fixture that contradicts the documented rule is an odd thing to point future readers at. Splitting the second binding into its own cube (or wrapping both) keeps the fixture on-spec without losing any assertion.

@codecov

codecov Bot commented Aug 10, 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 (bb7897f).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11517      +/-   ##
==========================================
- Coverage   83.96%   79.46%   -4.50%     
==========================================
  Files         257      480     +223     
  Lines       80919    98861   +17942     
  Branches        0     3636    +3636     
==========================================
+ Hits        67940    78563   +10623     
- Misses      12979    19778    +6799     
- 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.

…o always-true

A column that takes filter values cannot render for a segment, which supplies
none. Raising a user error there fails a query that a model already serving
`1 = 1` used to answer, and it is louder than what the same mismatch gets on
the dimension path, which drops only the restatement inside the SQL. An
argument the binding cannot resolve at all stays silent too, so the arity is
not the place to start reporting.

Both callback variants now degrade. A callback declaring no parameters is
compiled with none, so the raw-callback variant only ever holds a column that
wants values — a rest parameter, or a parameter list that could not be read —
and invoking it with none interpolated `undefined` into the cube's sql.

The binding is looked up by the path the query asked for before scanning the
chain, which is both what the model meant and what the dimension path does.

Covers the compiled column in the Postgres suite, which the mock DSL cannot
express: the renderer parenthesizes a binding it reached, telling an
activated-then-dropped column apart from an unselected segment's bare `1 = 1`.
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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


Follow-up review of bb7897f — the two medium findings are resolved; 1 low + 2 nits still open. No new issues.

Full review
  • Read the incremental diff (bb7897f)
  • Re-check prior findings against the new code
  • Verify the JS-side arity classification the new arms depend on
  • Review new tests and docs wording
  • Resolve stale threads / report status

What changed

bb7897f replaces the user error with a degrade and folds both value-taking variants into one arm:

FilterParamsColumn::Compiled(compiled) if compiled.value_params_count == 0 => {}
// A column that takes filter values cannot render for a segment, which supplies none …
FilterParamsColumn::Compiled(_) | FilterParamsColumn::Callback(_) => templates.always_true(),

plus a get(&self.full_name()) fast path before the chain scan, a docs sentence, and two Postgres cases.

Prior findings — status

# Sev Status
1 Medium Fixed. value_params_count > 0 now returns templates.always_true(), matching typed_filter.rs:105. No longer a breaking change for models that render 1 = 1 today. Thread resolved.
2 Medium Fixed, and better than the symmetry I asked for. Collapsing Callback into the same arm means the undefined-interpolation path is gone by construction rather than by parsing harder on the JS side — no reliance on Function.length to tell a rest parameter from an unparseable list. Thread resolved.
3.1 Low Fixed. Exact-name get first, chain scan only as fallback, so the alphabetical tiebreak now only arbitrates between two non-exact chain matches.
3.2 Low Open. The matching_filter_params_column early return still precedes the reading_pre_aggregation branch (base_segment.rs:22-33). Still unreachable today, still a one-line guard. Thread left unresolved.
5 Low Fixed. The Postgres suite now covers both remaining arms — renders a callback column that takes no filter values hits Compiled with value_params_count == 0, and leaves a value-taking callback column always-true hits the degrade. Only the compiled_call.is_none() internal-invariant error is uncovered, which is fine. Thread resolved. The Rust mock DSL still emits String only; not worth adding now that the JS suite covers the arms.
6 Nit ×2 Open, and the new callback_events fixture repeats the un-grouped-FILTER_PARAMS shape. Threads left unresolved.

Verification of the new arms

I checked the classification the two arms depend on against MemberSqlTemplateCompiler.js:

  • () => "evid = 115"declaredValueParams gives count: 0, rest: false, valueParamsAreCertain is true → Compiled { valueParamsCount: 0 }. So the positive test genuinely exercises call.eval, and the comment "nothing else in this cube's sql states the predicate" is the right way to assert it.
  • (v) => 'evid = ' + vCompiled { valueParamsCount: 1 } → degrade.
  • Rest parameter / unparseable list → Callback → degrade.

The negative test's parenthesis check is a real discriminator, not incidental: an unselected binding returns templates.always_true() straight out of eval_filter_group (sql_call.rs:534) unwrapped, while an activated one goes through render_filter_item, which parenthesises. So AND \(1 = 1\) distinguishes activated-then-dropped from never-activated, exactly as the comment claims. The not.toMatch(/undefined|\{fpv:/) assertion pins finding 2 shut.

One consequence worth being aware of, not a defect: a genuinely zero-value rest-parameter column ((...vals) => "evid = 115") could have pushed down and now renders 1 = 1. The docs sentence ("as long as it takes no arguments") covers it, and preferring that over an undefined risk is the right trade.

Docs

The added paragraph is accurate and lands in the right place. Slightly understated in one respect: the degrade is silent, so a model author who writes a value-taking callback gets a working-but-unpushed query with no signal. Given finding 1 that's the deliberate choice; if you ever want a signal, a compile-time warning is the place for it, not render time.

I could not run cargo test or the Postgres suite here (both need approval), so the green-test claims are taken at face value; everything above is from reading the code.

· branch [`tesseract-filter-params-segment-pushdown`](https://github.com/cube-js/cube/tree/tesseract-filter-params-segment-pushdown)

@waralexrom
waralexrom merged commit a0723ac into master Aug 10, 2026
199 of 202 checks passed
@waralexrom
waralexrom deleted the tesseract-filter-params-segment-pushdown branch August 10, 2026 16:41
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.

2 participants