Skip to content

fix(tesseract): resolve FILTER_PARAMS column callback references - #11460

Open
waralexrom wants to merge 9 commits into
masterfrom
tesseract-filter-params-measure-placeholder
Open

fix(tesseract): resolve FILTER_PARAMS column callback references#11460
waralexrom wants to merge 9 commits into
masterfrom
tesseract-filter-params-measure-placeholder

Conversation

@waralexrom

@waralexrom waralexrom commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

A FILTER_PARAMS.….filter(cb) column callback failed to render under the native planner with Placeholder {arg:0} out of bounds whenever the callback referenced a member. The callback is a SQL function in its own right, but its placeholders were resolved against the enclosing member's dependency list — a list already read by the time the callback recorded anything into it. It is now compiled into a SqlCall of its own, and the dependencies it reads reach the join the same way any other reference does.

Reported for a sum measure whose filters: entry was only a FILTER_PARAMS column; both the query and the pre-aggregation build SQL now match the legacy planner for that model.

Changes

  • A column callback compiles into a call of its own. Its declared parameters bind to new {fpv:N} value placeholders and its member references record into its own lists, so the placeholders it emits index its own dependencies. That fixes the reported error for every spelling of a reference — ${CUBE.x}, ${cube.x}, another cube — and gives the column its own parenthesisation contexts, so a compound member inside it is parenthesised correctly (the legacy planner still gets total - fee * 100 wrong here).
  • A column contributes its dependencies exactly where it renders. Each binding is marked by whether the query filters the members it renders from — settled per query and again per multi-stage subquery, since filter: include lets a subquery filter a member the query around it does not. An active column's dependencies flow into join hints and pull the cube it reads into the join; an inactive one contributes nothing, so a query that never uses that filter plans as though the column were absent. Where the join cannot be built, the join graph says so itself, in the same terms as for any other reference.
  • A column applies only as far as its filter reaches. A filter that cannot supply what the column takes leaves nothing to apply, whether it carries no values at all (set, notSet) or fewer than the column declares (a one-sided date operator against a column taking both bounds). The filter still reaches the query on its own; only its restatement inside the member's SQL is dropped. The legacy planner instead fills the missing bound in with the current time, which for beforeDate lands the given value on the opposite side of the range — leaving the measure empty for every row the query keeps, and making the SQL for one query change from day to day.
  • A member reference inside a cube's own sql is rejected. That sql builds the table the query reads from, so nothing a reference could resolve against is in scope. All three spellings — direct, string column, callback column — used to either recurse until the stack ran out or render a qualifier for a table nothing joins. Callbacks themselves stay allowed there, along with everything in them that needs no member in scope: the filter values and any security context value, so the row-level-security shape keeps working.

Left deliberately untouched, all outside the reported shape and unchanged from master: a callback taking its values through a rest parameter, which no fixed set of placeholders can express; a time shift on a compiled column, now refused explicitly instead of silently emptying a shifted CTE; and a nested FILTER_PARAMS inside a callback.

Behaviour worth a release note

The column callback moves from render time to compile time. It used to be invoked
once per query that actually used the filter; it is now compiled once per query
for every member whose sql declares it, whatever the query filters. For the
documented shape — interpolating the placeholders it is handed into a SQL string
— that is invisible. A callback that throws on an argument it did not expect, or
that is not referentially transparent, behaves differently.

A reference inside a callback is resolved at that point too, so a reference to a
member that no longer exists now fails every query touching that member rather
than only the ones supplying its filter. A model carrying a dead FILTER_PARAMS
branch will surface it on upgrade without any query changing.

Two new user-facing errors, both replacing something worse:

  • a member reference inside a cube's own sql is reported instead of recursing
    until the stack runs out, or rendering a qualifier for a table nothing joins;
  • a time shift applied to a compiled column is reported instead of being dropped,
    which used to restrict a shifted CTE to the current period and empty it.

Testing

test/unit/filter-params-callback-column.test.ts is the regression guard, written red before the fix and run against both planners: the reported model on the cube read and on the pre-aggregation build query; a column reading another cube, joined when the filter reaches the query and left out when it does not; a column reached through a segment, through a dimension named only in a filter, through a measure named only in a having filter, and through a FILTER_GROUP under an or filter; the value-count cases; and the cube-sql rejections next to the shapes that stay allowed. test/unit/member-sql-template-compiler.test.ts covers the compiler contract, including the parameter-list shapes that cannot be read in full and so stay on the render-time path.

Verified against the legacy planner as the oracle throughout — including that a hasMany join and a missing join path produce the same errors as legacy, and that pre-aggregation matching is unchanged for own-cube and cross-cube columns.

1118 Rust unit tests; the schema-compiler unit suite (only the pre-existing error-reporter ANSI snapshot failures remain); and cube-views, sql-generation, pre-aggregations, multi-stage, pre-aggregations-multi-stage, multi-stage-time-shift-filter-params Postgres integration suites under CUBEJS_TESSERACT_SQL_PLANNER=true.

@waralexrom
waralexrom requested a review from a team as a code owner August 3, 2026 17:40
@github-actions github-actions Bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code labels Aug 3, 2026
waralexrom and others added 4 commits August 3, 2026 19:41
…bers

A `FILTER_PARAMS.….filter(cb)` column callback is invoked at render time, and
the member references inside it have to resolve to that member's SQL — the same
as when the column is handed over as a template string.

The two `native planner` cases fail with `Placeholder {arg:0} out of bounds`
and stay red until the planner resolves those references; the `legacy planner`
cases pin the SQL that the fix has to produce.

Also adds FILTER_PARAMS support to the Rust mock member templates —
`{FILTER_PARAMS:<cube>.<member>:<column>}`, where `[path]` inside the column is
a member reference and `%N` is the Nth filter value — plus a planner-level test
that a callback column's placeholder is resolved against the dependency list.

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

A column callback is a SQL function in its own right: it has its own body, its
own member references and its own parameters. It was handed to the planner as an
opaque JS function and invoked at render time, at which point the references it
touched were recorded into a dependency list the planner had already read — so
the placeholders it emitted indexed nothing and rendering failed with
`Placeholder {arg:0} out of bounds`.

The callback is now compiled like any other member sql, with its declared
parameters bound to `{fpv:N}` value placeholders, and becomes a `SqlCall` with
its own template, dependencies and parenthesisation contexts. Rendering
substitutes the filter values into `{fpv:N}` and its own dependencies into
`{arg:N}`, so a reference resolves the same whichever way it is spelled, and a
compound member is parenthesised according to the context it lands in.

Two places keep the callback and render it as-is: a cube's own `sql`, which is
the innermost FROM and has no member in scope to resolve against, and a callback
taking its values through a rest parameter, whose count only the query knows.

A column renders only when its filter reaches the query, so the members it reads
are not dependencies of the enclosing member and cannot pull a cube into the
join. Reading outside the owning cube is therefore reported when the column
renders — leaving queries that never use that filter unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cube's `sql` builds the table the query reads from, so nothing a member
reference could resolve against is in scope inside it. Resolving one anyway
recursed between the cube table and the member until the stack ran out; the
legacy planner does not recurse but emits a qualifier for a table that is not
in the query, which the database rejects.

All three spellings — a direct `${CUBE.dimension}`, a string `FILTER_PARAMS`
column and a `FILTER_PARAMS` column callback — now report the reference instead.
The path is classified without building any symbol, so the report replaces the
recursion rather than following it.

A column callback itself stays allowed, and so does everything in it that needs
no member in scope: the filter values it takes and any security context value,
which becomes a query param. That keeps the row-level-security shape working, as
well as a reference to another cube's `sql`, which resolves to that cube's own
table expression.

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

A compiled column carries one value placeholder per parameter its callback
declares, while a filter supplies as many values as its operator has. The two
were never compared, so an operator carrying fewer values left trailing
placeholders unbound and the query failed.

A filter carrying no values now applies nothing, which is what `set` or `notSet`
on the filtered member amounts to and what the legacy planner does. Fewer values
than the column takes is reported: the legacy planner fills the missing bound in
with the current time, quietly widening the predicate to a range the filter never
asked for, which is worse than saying so.

Three further holes the same reading turned up:

- A cube named directly in a callback passed the own-cube check, since only
  member dependencies were examined and cube references were dropped. It now
  renders no qualifier for a table the query does not read. A cube's table
  expression stays exempt — it inlines the whole expression and needs no join.
- The parameter list is read out of the callback's source, which comes up short
  for a bound or native function and for a `)` inside a comment or a string
  default. Too few placeholders would have rendered the missing values as
  `undefined`, so a count that cannot account for every parameter now leaves the
  callback to render time instead.
- A compiled column no longer silently ignores the time shift the surrounding
  query applies, which would restrict a shifted CTE to the current period and
  empty it.

The cube reported by the own-cube check is picked deterministically; the names
arrive unordered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
waralexrom and others added 2 commits August 3, 2026 19:44
…pendencies

A column renders only where its filter reaches the query, so the members it
reads belong to the enclosing member's dependencies exactly there. Marking each
binding by whether the query filters the member it names lets an active column's
dependencies flow into join hints, which is what puts the cube it reads into the
join — and keeps them out everywhere else, so a query that never uses the filter
plans as though the column were not there.

That removes the restriction to the owning cube: a column may read whatever its
filter's arrival justifies joining. When the join cannot be built the join graph
says so on its own, in the same terms as for any other reference — row
multiplication for a hasMany, no join path when there is none — so the
restriction needed no replacement.

Activity is settled per query and again per multi-stage subquery, since `filter:
include` lets a subquery filter a member the query around it does not. It starts
off, so a symbol compiled for comparison rather than for a query carries no
dependency the query would not — a pre-aggregation is matched against the same
shape either way. Rewrites reach into an inactive column all the same, so it
never keeps a symbol every other reference to it has replaced.

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

A column renders wherever the symbol carrying it does, and a query reaches
symbols through more than its selected members. Activity was settled for
dimensions, time dimensions and measures only, so a column inside a segment, a
dimension named only in a filter, or a measure named only in a having filter
rendered while contributing nothing — and the cube it reads went unjoined,
leaving a qualifier with no table behind it. Filters, segments and order items
are now settled too, the way case pruning already covered them.

A `FILTER_GROUP` renders as one predicate, matched against every member it
names, while activity was decided per member. Since partial matching applies to
AND groups only, an OR group could match as a whole while a single member of it
did not, so the group rendered and none of its members contributed. Activity for
a group is now decided from the group's whole member list, which is the same
question the render asks — the two can no longer disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@waralexrom
waralexrom force-pushed the tesseract-filter-params-measure-placeholder branch from c1ea9ec to 038bc9a Compare August 3, 2026 18:00
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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


Review complete — no blockers; 1 medium (stale activity window), 3 low, 4 inline comments

Full review — findings, rationale, and what I checked
  • Gather context / read the diff (git diff origin/master...HEAD, 21 files)
  • Review JS: MemberSqlTemplateCompiler.js
  • Review Rust: sql_call / sql_call_builder / member_sql / typed_filter / query_properties / static_filter
  • Review tests (JS unit + Rust unit)
  • Post inline comments and summary

Overall

This is a careful, well-reasoned change. Compiling the column callback into a SqlCall of its own is the right fix for the reported Placeholder {arg:0} out of bounds — the previous design's failure was structural (a callback recording into a dependency list already read), and this removes the class rather than patching the symptom. The active flag is the honest way to keep the join graph from growing dependencies a query never renders, and the visit_deps / visit_deps_mut asymmetry (read only active, rewrite all) is both correct and documented at the point where a reader would otherwise trip on it.

The comment quality throughout is genuinely above average — deps.rs, sql_call_builder.rs and the Compiled arm of typed_filter.rs all explain why, not what. The "left deliberately untouched" list in the PR description (rest params, time shift on a compiled column, nested FILTER_PARAMS) matches what the code actually does, and each is refused explicitly rather than silently mis-rendered.

Test coverage is strong: both planners run the same assertions, the reported model is covered on both the cube read and the pre-aggregation build, and the parameter-list shapes that can't be parsed are pinned as staying on the render-time path.

Findings

# Severity Where Issue
1 Medium query_properties.rs:240-276 Activity settles at construction; render reads the live filter set. add_dimension_filter(s) / add_time_dimension_filters / keep_only_filters_for_members mutate afterwards without re-settling. Multi-stage re-settles the member, but not the stage's filter-item symbols; keys_sub_query.rs:46 renders against an independently-derived set. Asked for confirmation that render ⊆ settled.
2 Low MemberSqlTemplateCompiler.js:152-158 valueParamsAreCertain leans on Function.length, which is 0 once parameter #1 is defaulted — so (from = ')', to) => … under-counts to 1, passes the guard, and renders to as the literal undefined. The two tests that do catch a truncated parse both keep a non-defaulted first parameter. Suggested a narrowing of the guard inline.
3 Low MemberSqlTemplateCompiler.js:168-175 The callback now runs at compile time, once, unconditionally. A throwing callback fails model compilation instead of one query. Release-note material alongside the two new hard errors.
4 Low static_filter.rs:104-111 The AND FilterGroup (with a full to_vec()) is rebuilt per symbol, and apply_static_filters calls it once per member, filter item, segment and order item. Suggested an entry point taking a pre-built &FilterItem.

Two more I looked at and am satisfied by, noted here rather than inline:

  • FilterParamsColumn::CompiledNativeSerialize returns an internal error. I couldn't find a live path that serializes a FilterParamsColumn back to JS (only sql_call.rs and sql_call_builder.rs construct/read it), so this reads as an honest unreachable rather than a latent failure. Worth a second opinion from someone who knows whether the legacy bridge still round-trips these.
  • Behaviour changes that turn rendering models into errors — the values.len() < value_params_count check and the cube-sql member-reference rejection. Both are defensible (legacy silently widened the predicate / rendered a qualifier for an unjoined table), and both produce good, actionable messages. Flagging only that neither is documented in docs-mintlify in this PR.

Not verified

I could not run the suites: node_modules is not installed in this checkout, so neither the schema-compiler Jest tests nor cargo test were executed. All findings above come from reading the diff and surrounding code. The PR description reports 1118 Rust unit tests plus six Postgres integration suites green, which covers the paths I reasoned about.

· branch `tesseract-filter-params-measure-placeholder`

Comment thread packages/cubejs-schema-compiler/src/adapter/MemberSqlTemplateCompiler.js Outdated
Comment on lines 240 to +246
fn apply_static_filters(&mut self) -> Result<(), CubeError> {
let dimensions_filters = self.dimensions_filters.clone();
// A FILTER_PARAMS binding may name any filtered member, not only a
// dimension, so its activity is read from the whole set.
let all_filters = self.all_filter_items();
for dim in self.dimensions.iter_mut() {
*dim = transforms::apply_filter_params_activity_to_symbol(dim, &all_filters)?;

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.

Question on the staleness window. Activity is settled here, at construction, against all_filter_items() — but rendering (SqlCall::eval_filter_groupvisitor.all_filters(), base_filter.rs) reads the live filter set at plan time. Several mutators change that set afterwards and none re-settle:

  • add_dimension_filter / add_dimension_filters / add_time_dimension_filters — can turn a binding that renders 1 = 1 at settle time into one that renders its column. Its compiled_call deps were never contributed, so the cube it reads may not be in the join and the column renders a qualifier for a table nothing joins.
  • remove_filters_for_members / keep_only_filters_for_members — the benign direction (a stale active: true contributes a join that goes unused).

The multi-stage path is covered: make_queries_descriptions re-settles the member against the stage's own state after apply_filter_directive_to_state. Two gaps I couldn't rule out by reading:

  1. Inside a stage, only the member is re-settled — the state's own filter-item symbols (and order items) keep the outer query's activity, so a symbol reached only through a stage-local filter: include predicate could render an unsettled column.
  2. keys_sub_query.rs:46 renders with keys_subquery.filter().all_filters(), a set derived independently of the one settled here.

If both are provably subsets (render ⊆ settled), a comment saying so on apply_static_filters would save the next reader the same walk. If not, re-settling in add_*_filters (or asserting in eval_filter_group that a rendering binding is active) would turn a silent bad-SQL case into a loud one.

Comment on lines +168 to +175
const { count, rest } = declaredValueParams(column);
if (rest || !valueParamsAreCertain(column, count)) {
return column;
}

const values = [];
for (let i = 0; i < count; i++) {
values.push(placeholder(FILTER_VALUE_PREFIX, i));

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.

Worth noting in the changelog: this moves the user callback's invocation from render time (once per query that actually uses the filter) to compile time (once, unconditionally, for every member whose sql declares the column). Two consequences that don't show up in the tests:

  • A callback that throws — or that inspects its arguments as anything but opaque strings — now fails model compilation rather than the one query that uses it. The blast radius goes from a query to the whole schema.
  • A callback with side effects, or one that isn't referentially transparent, is now called exactly once and its output cached in the template.

Both are almost certainly fine for the documented shape (string interpolation of param placeholders), but they're user-visible enough to belong in the release note alongside the two new hard errors (value_params_count mismatch, and member references in a cube's sql).

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.47%. Comparing base (3728d62) to head (75d062b).
⚠️ Report is 11 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11460      +/-   ##
==========================================
+ Coverage   79.45%   79.47%   +0.01%     
==========================================
  Files         480      480              
  Lines       98810    98861      +51     
  Branches     3636     3636              
==========================================
+ Hits        78511    78567      +56     
+ Misses      19778    19773       -5     
  Partials      521      521              
Flag Coverage Δ
cube-backend 59.22% <100.00%> (+0.12%) ⬆️
cubesql 83.96% <ø> (+<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.

…omething vouches for it

`Function.length` was the witness that the parameter list had been read in full,
but it drops to zero at the first defaulted parameter and can vouch for nothing
after that — so a `)` inside a string default of the first parameter closed the
list early, the count came back short, and the trailing value rendered as the
literal `undefined`. Where `Function.length` cannot speak, the text itself does:
a list holding neither a string nor a comment has nothing for the scan to trip
over.

Also builds the filter set matched against once per pass instead of once per
symbol, and records on the pass itself where a multi-stage stage settles activity
against the wider set it may filter.

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

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of 503d0e4 — 2 of 4 prior findings resolved; the Function.length fix is one parameter short

Full review — what changed, what I verified, what's still open

Prior findings — status

# Prior finding Status
2 valueParamsAreCertain leans on Function.length, which is 0 once param #1 is defaulted Partly fixed — the reported case is closed and pinned by a test; the same class survives one parameter to the right (new inline comment)
4 AND FilterGroup rebuilt per symbol Fixedfilter_params_activity_filters builds it once per pass; apply_filter_params_activity_to_symbol and …_to_filter_item now take &FilterItem
1 Activity settles at construction, render reads the live filter set Documented, not closed — thread still open
3 Callback invocation moved to compile time; release-note material Open — no changelog/docs change in this PR

New finding — the guard still under-counts

fn.length > 0 short-circuits before the text check, but Function.length is a lower bound: it stops at the first default and can't vouch for anything after it either.

(from, to = ')', third) => `d >= ${from} AND d < ${to} AND x = ${third}`

fn.length is 1; the scan closes at the ) inside the string default → count: 2; 2 >= 1 passes and fn.length > 0 skips the inner test. Compiled with two placeholders, third renders as the literal undefined — the exact failure the guard exists to prevent. The two shapes the suite does catch are caught for other reasons: (from = ')', to) has fn.length === 0 and so reaches the text check, and (from, /* ) */ to, third) is rejected by count < fn.length because comments don't reduce Function.length.

Fix is to drop the short-circuit — return !/['"\]|/*|///.test(inner)` — which costs a quote-in-default callback its compiled form and sends it to the render-time path, where master has it today. Suggestion posted inline. Fix this →

Still open from before

  • Staleness window (query_properties.rs:240). The new comment is a real improvement — it states plainly that this runs once at construction and that mutators do not re-apply it, and it records why a multi-stage stage settles correctly (it builds its own QueryProperties). What it doesn't answer is the widening direction: add_dimension_filter(s) / add_time_dimension_filters can add a predicate after settling, turning a binding that settled inactive into one that renders its column with dependencies never contributed to the join. Thread left unresolved. Not a blocker if those callers are provably confined to members already in the join — worth one sentence saying so if they are.
  • Release note (finding ResultSet pivot methods documentation  #3). Compile-time invocation of the user callback, plus the two new hard errors (value_params_count mismatch, member references in a cube's sql), are user-visible and undocumented in this PR.

Verification

The Function.length analysis is by reading declaredValueParams and the guard directly — the sandbox here blocks node, so I traced the scan by hand rather than executing it. I could not run the Jest or cargo test suites (node_modules not installed in this checkout). The Rust changes in this commit are mechanical (a &[FilterItem]&FilterItem signature change with the group hoisted to the caller) and both call sites are updated.

Resolved

Two of my earlier threads — the Function.length hole as originally reported, and the per-symbol group rebuild — are addressed and resolved.

· branch `tesseract-filter-params-measure-placeholder`

Comment thread packages/cubejs-schema-compiler/src/adapter/MemberSqlTemplateCompiler.js Outdated
`Function.length` counts the parameters before the first defaulted one, so it
catches a scan that stopped short of that point and nothing after it. Reading it
as a witness for the whole list left a `)` inside any later default closing the
scan early, the count coming back short, and the trailing value rendering as the
literal `undefined`.

Past the first default only the text can vouch, so the check now applies
whatever `Function.length` says: a parameter list holding a string or a comment
holds the callback back to the render-time path, where every list that cannot be
read in full already goes.

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

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of 97a3ab0 — the guard hole is closed; 2 prior findings remain open, 1 contrived edge left

Full review — what changed, what I verified, what's still open
  • Read the new commit (97a3ab0) against my prior review
  • Verify the Function.length guard fix (MemberSqlTemplateCompiler.js)
  • Re-check the remaining open findings (activity staleness, release note)
  • Resolve the stale thread, post summary

What changed

97a3ab0 drops the fn.length > 0 short-circuit, exactly as suggested — valueParamsAreCertain now applies the text check unconditionally:

return !/['"`]|\/\*|\/\//.test(inner);

Traced by hand against MemberSqlTemplateCompiler.js:162-170 and declaredValueParams:

Shape fn.length scan count Verdict
(from, to = ')', third) 1 2 rejected by the text check ✓ (was silently compiled with 2 placeholders)
(from = ')', to) 0 1 rejected ✓
(from, /* ) */ to, third) 3 2 rejected by count < fn.length
(from, to) 2 2 compiled ✓
(from, to = 1) 1 2 compiled ✓ (numeric default, no quote)

The test-suite bookkeeping is consistent with the tightening: (from, to = 'x') moved from the compiled case to the uncompiled list, and the "counts a defaulted parameter as a filter value" test switched its default to 1 so it still exercises the compiled path. Both new cases — (from, to = ')', third) and the bare (from, to = 'x') — are pinned. I checked filter-params-callback-column.test.ts for callbacks with quoted defaults that would now silently move to the render-time path: there are none, so the integration assertions are unaffected.

The comment rewrite is an improvement on an already-good comment — it now states why Function.length can't speak past the first default, in both directions.

One contrived edge left (low, optional)

The text check screens quotes and comment openers, but not a regex literal:

FILTER_PARAMS.orders.a.filter((from, to = /)/, third) => `…`)

The depth scan closes on the ) inside the regex → inner = "from, to = /"count: 2, fn.length: 1, and /['"]|/*|///doesn't match a lone/. So thirdrenders as the literalundefined— the same failure class, one more character. Adding/ to the class closes it (/['"\/]/ subsumes both comment alternatives too); the cost is that (from, to = x/2) also falls back, which is the safe direction. Genuinely contrived — a regex default in a filter-param callback — so this is a nit, not a request. I'm not posting it inline; the previous two rounds on this line are enough.

Still open from before

  • Staleness window (query_properties.rs:250) — unchanged in this commit. Activity settles at construction against all_filter_items(); add_dimension_filter(s) / add_time_dimension_filters can widen the set afterwards without re-settling, turning a binding that settled inactive into one that renders its column with dependencies never contributed to the join. Not a blocker if those callers are provably confined to members already in the join — one sentence saying so would close it. Thread left unresolved.
  • Release note (prior finding ResultSet pivot methods documentation  #3) — unchanged. Compile-time invocation of the user callback (a throwing callback now fails model compilation, not one query) plus the two new hard errors (value_params_count mismatch, member references in a cube's sql) are user-visible and undocumented in this PR.

Verification

node is blocked in this sandbox and node_modules is not installed, so I could not execute the Jest suite or cargo test — the guard analysis above is by reading declaredValueParams and valueParamsAreCertain and tracing the scan by hand. The diff since 503d0e4 is two files, 17/-11, all in the JS compiler and its unit test; no Rust changed, so my prior Rust review stands as-is.

Resolved

My thread on the fn.length > 0 short-circuit is addressed and resolved. Two of my threads stay open (staleness, release note) — both are judgement calls for the author, neither blocks.

· branch `tesseract-filter-params-measure-placeholder`

…r reaches

A column applies what its filter supplies, and nothing when the filter cannot
supply what the column takes. That already covered an operator carrying no
values; it now covers one carrying fewer than the column declares — a one-sided
date operator against a column taking both bounds — instead of reporting it.

The filter still reaches the query on its own, so only its restatement inside
the member's SQL is dropped. That is narrower than binding a bound the filter
never gave: the legacy planner fills the missing one in with the current time,
which for `beforeDate` lands the given value on the opposite side of the range
and leaves the measure empty for every row the query keeps, and makes the SQL
for one query change from day to day.

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

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of 75d062b — the removed hard error is a net improvement; 2 prior threads stay open, 1 new low

Full review — what changed, what I verified, what's still open
  • Read the new commit (75d062b) against my prior reviews
  • Verify the values.len() < value_params_count behaviour change (typed_filter.rs)
  • Check for a dangling allocated param on the new early-return path
  • Re-check the remaining open findings (activity staleness, release note)
  • Post inline comment and summary

What changed

75d062b replaces the CubeError::user("… takes N values but the filter on it supplies M") with plan_templates.always_true(), folding the fewer-values case into the same rule as the no-values case, and flips the corresponding test from toThrow to asserting the restatement is gone.

I think this is the right call, and for a stronger reason than "narrower than inventing a bound". The previous commit's error fired on an ordinary query — beforeDate / afterDate / beforeOrOnDate / afterOrOnDate against any two-parameter column, which is the common (from, to) shape — so a model that worked on legacy would hard-fail on the native planner for a whole class of user filters. And the legacy behaviour it diverges from is provably broken, not merely loose: filterParams() in BaseFilter.ts:129 returns [inDbTimeZoneDateFrom(values[0]), inDbTimeZoneDateTo(values[1])], so for beforeDate the given value lands on the from side and the missing to becomes the current time — the column renders d >= value AND d < now() while the query renders d < value. Contradictory, empty, and different tomorrow. 1 = 1 beats both.

Verified on the way:

  • No dangling param. filter_param_values calls allocate_param before the length check, so the early return leaves an allocated-but-unrendered param behind. Harmless — ParamsAllocator::build_sql_and_params (params_allocator.rs:50-93) builds the result list by scanning $_N_$ occurrences in the finished SQL, so an unreferenced param is simply never collected and no index shifts.
  • 1 = 1 is the real rendering of filters/always_true (BaseQuery.js:4680), so the test assertion is meaningful.
  • No stale references to the removed message: the only remaining value_params_count uses are the check itself and the bridge field; nothing in the JS or Rust suites still expects the throw.
  • Values in excess of the count are still silently truncated to the first N (equals with two values against a one-parameter column). Pre-existing, unchanged by this commit, and legacy does the same — noting it only so it's not mistaken for a regression here.

New finding (low, test coverage)

The safety argument for the divergence is that the filter still reaches the query on its own — and that's the one thing the new test doesn't assert. Both its assertions are negative or weak (not.toMatch(PUSHED_DOWN_PREDICATE), plus toContain('1 = 1'), which any other always-true in the plan satisfies). Suggested a positive assertion inline so a future change that drops the outer predicate as well fails here rather than silently widening results.

Related and worth a sentence in the release note rather than a thread of its own: dropping the restatement is result-preserving only when the column restates the same member's predicate. A callback that maps the filter onto a differently-shaped expression — an SCD validity overlap, valid_from <= to AND valid_to > from — genuinely changes numbers when it renders 1 = 1, and for a partition-pruning column it's a silent full-scan. Still the better default, and the planner has no logging facility to warn through (no warn! anywhere under cubesqlplanner/src), so there's no third option to reach for; it just belongs in the notes.

Still open from before

  • Staleness window (query_properties.rs:250) — untouched by this commit. Activity settles at construction against all_filter_items(); add_dimension_filter(s) / add_time_dimension_filters can widen the set afterwards without re-settling, turning a binding that settled inactive into one that renders its column with dependencies never contributed to the join. Not a blocker if those callers are provably confined to members already in the join — one sentence saying so closes it. Thread left unresolved; not reposted.
  • Release note (prior finding ResultSet pivot methods documentation  #3) — untouched. Compile-time invocation of the user callback (a throwing callback now fails model compilation, not one query), the cube-sql member-reference rejection, and now this 1 = 1 divergence from legacy are all user-visible and undocumented in this PR. Thread left unresolved; not reposted.

Both are judgement calls for the author; neither blocks.

Verification

node_modules is not installed in this checkout and node is blocked in the sandbox, so I could not run the Jest suite or cargo test. The analysis above is by reading the diff, params_allocator.rs, plan.rs, BaseQuery.js and BaseFilter.ts. The diff since 97a3ab0 is two files (+22/−23) in typed_filter.rs and its JS test; my prior Rust and JS-compiler reviews stand unchanged.

Resolved

Nothing new to resolve — my two earlier MemberSqlTemplateCompiler.js threads and the static_filter.rs thread were already resolved in previous rounds; the two open ones remain applicable to the current diff.

· branch `tesseract-filter-params-measure-placeholder`

Comment on lines +144 to +145
expect(sql).not.toMatch(PUSHED_DOWN_PREDICATE);
expect(sql).toContain('1 = 1');

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 claim this test rests on — "the filter still reaches the query on its own", which is what makes dropping the restatement safe rather than result-changing — is the one thing it doesn't assert. Both assertions here are negative-or-weak: the restatement is absent, and 1 = 1 appears somewhere (satisfiable by any other always-true in the plan).

Worth pinning the positive half, so a future change that drops the outer predicate too fails here instead of silently widening the result set:

Suggested change
expect(sql).not.toMatch(PUSHED_DOWN_PREDICATE);
expect(sql).toContain('1 = 1');
expect(sql).not.toMatch(PUSHED_DOWN_PREDICATE);
expect(sql).toContain('1 = 1');
// The filter itself still reaches the query — that is what makes dropping
// only the restatement narrower than binding a bound the filter never gave.
expect(sql).toMatch(/"commission"\.reconciliation_date <=? \$\d+/);

(Adjust the operator to whatever beforeDate renders in this dialect.)

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