Skip to content

Commit 25b16fa

Browse files
committed
feat(tesseract): apply view default value filters in QueryPropertiesCompiler
Default-value filters declared on a view (CORE-357) are now materialized into the query during `QueryPropertiesCompiler::compile_filters`: - Active views are detected from compiled MemberSymbols (`compiled_path().cube_name()` for every dimension / time-dim / measure / filter-member). Each view's `filters()` becomes a candidate. - A candidate is dispatched through the existing `FilterCompiler::add_item` so default filters share the same operator/typing path as explicit ones, and get routed into the right bucket (dimension / measure / time-dim). - `unless` is filter-only: a member referenced only by projection does not release the default, since changing what columns the user selects must not silently change the row set. Only an explicit filter on the same member overrides the default. JS evaluator (CubeEvaluator.prepareViewFilters) resolves `member` / `unless` to view-scoped paths (`<view>.<member>`) so they line up with `MemberSymbol::full_name` on the Rust side. Test coverage: - Unit (cube_evaluator-level): default filter applies when view is active; projection alone does not trigger `unless`; explicit filter on the unless-member releases the default; filter applies when unless-member is absent; filter applies even when member is in dimensions if `unless` is not declared. - Postgres integration: virtual `type: switch` currency dim plus a `case`-switch measure on `country`; covers union-collapse via default filter, projection-not-triggering-unless, explicit-filter-overrides, default-filter-with-switch-measure, default-filter-when-unless-member- is-absent. The Postgres seed (`view_default_filters_tables.sql`) deliberately ships without a `currency` column — currency is purely virtual; `country` drives the switch-case measure.
1 parent 367b24a commit 25b16fa

17 files changed

Lines changed: 682 additions & 16 deletions

packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,17 +221,18 @@ export class CubeEvaluator extends CubeSymbols {
221221

222222
const included = (cube.includedMembers as ViewIncludedMember[] | undefined) || [];
223223

224+
// Always returns view-scoped path `<view>.<member>` to match
225+
// MemberSymbol::full_name on the Rust side, which is what `unless` and
226+
// filter dispatch compare against.
224227
const resolveViewMember = (memberType: string, reference: string): string | null => {
225228
let lookupName = reference;
226229
let lookupPath: string | null = null;
227230

228231
if (reference.indexOf('.') !== -1) {
229232
const parts = reference.split('.');
230233
if (parts[0] === cube.name) {
231-
// Identifier form resolved via view's own namespace, e.g. 'orders_view.currency'
232234
lookupName = parts.slice(1).join('.');
233235
} else {
234-
// Fully-qualified member path, e.g. 'orders.currency'
235236
lookupPath = reference;
236237
}
237238
}
@@ -246,7 +247,7 @@ export class CubeEvaluator extends CubeSymbols {
246247
);
247248
return null;
248249
}
249-
return match.memberPath;
250+
return `${cube.name}.${match.name}`;
250251
};
251252

252253
for (const filter of cube.filters as ViewDefaultValueFilter[]) {

packages/cubejs-schema-compiler/test/unit/schema.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -575,17 +575,17 @@ describe('Schema Testing', () => {
575575
expect(filters).toHaveLength(3);
576576

577577
expect(filters[0].operator).toBe('equals');
578-
expect(filters[0].memberReference).toBe('orders.currency');
578+
expect(filters[0].memberReference).toBe('orders_view.currency');
579579
expect(filters[0].valuesReferences).toEqual(['USD']);
580-
expect(filters[0].unlessReferences).toEqual(['orders.currency', 'orders.country']);
580+
expect(filters[0].unlessReferences).toEqual(['orders_view.currency', 'orders_view.country']);
581581

582582
expect(filters[1].operator).toBe('set');
583-
expect(filters[1].memberReference).toBe('orders.country');
583+
expect(filters[1].memberReference).toBe('orders_view.country');
584584
expect(filters[1].valuesReferences).toBeUndefined();
585585
expect(filters[1].unlessReferences).toBeUndefined();
586586

587587
expect(filters[2].operator).toBe('in');
588-
expect(filters[2].memberReference).toBe('orders.id');
588+
expect(filters[2].memberReference).toBe('orders_view.id');
589589
// Values are coerced to strings to match the FilterItem contract used
590590
// by regular query filters on the Rust side.
591591
expect(filters[2].valuesReferences).toEqual(['1', '2', 'true', 'draft', null]);
@@ -619,9 +619,9 @@ describe('Schema Testing', () => {
619619

620620
const filters = cubeEvaluator.evaluatedCubes.orders_view.filters!;
621621
expect(filters.map(f => f.memberReference)).toEqual([
622-
'orders.currency',
623-
'orders.currency',
624-
'orders.currency',
622+
'orders_view.currency',
623+
'orders_view.currency',
624+
'orders_view.currency',
625625
]);
626626
});
627627

rust/cube/cubesqlplanner/cubesqlplanner/src/planner/filter/compiler.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ impl<'a> FilterCompiler<'a> {
6262
)
6363
}
6464

65+
/// Iterator over every compiled filter item across the three buckets.
66+
pub fn iter_all_items(&self) -> impl Iterator<Item = &FilterItem> {
67+
self.dimension_filters
68+
.iter()
69+
.chain(self.time_dimension_filters.iter())
70+
.chain(self.measures_filters.iter())
71+
}
72+
6573
fn compile_item(
6674
&mut self,
6775
item: &NativeFilterItem,

rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
//! resolves member/segment/filter/order references against the cube
33
//! evaluator and folds them into the typed builder.
44
5+
use std::collections::HashSet;
56
use std::rc::Rc;
67

78
use cubenativeutils::CubeError;
89
use itertools::Itertools;
910

10-
use crate::cube_bridge::base_query_options::BaseQueryOptions;
11+
use crate::cube_bridge::base_query_options::{BaseQueryOptions, FilterItem as NativeFilterItem};
1112
use crate::cube_bridge::member_expression::{
1213
MemberExpressionDefinition, MemberExpressionExpressionDef,
1314
};
1415
use crate::cube_bridge::options_member::OptionsMember;
16+
use crate::cube_bridge::view_filter_definition::ViewFilterDefinition;
1517

1618
use super::filter::compiler::FilterCompiler;
1719
use super::filter::{BaseSegment, FilterItem};
@@ -47,8 +49,14 @@ impl QueryPropertiesCompiler {
4749
let measures = self.compile_measures(&mut evaluator_compiler, options)?;
4850
let segments = self.compile_segments(&mut evaluator_compiler, options)?;
4951

50-
let (dimensions_filters, time_dimensions_filters, measures_filters) =
51-
self.compile_filters(&mut evaluator_compiler, options, &time_dimensions_raw)?;
52+
let (dimensions_filters, time_dimensions_filters, measures_filters) = self
53+
.compile_filters(
54+
&mut evaluator_compiler,
55+
options,
56+
&dimensions,
57+
&time_dimensions_raw,
58+
&measures,
59+
)?;
5260

5361
// FIXME may be this filter should be applied on other place
5462
let time_dimensions = Self::filter_time_dimensions_with_granularity(time_dimensions_raw);
@@ -371,13 +379,17 @@ impl QueryPropertiesCompiler {
371379
}
372380

373381
// Returns `(dimension_filters, time_dimension_filters, measure_filters)`.
374-
// Includes both the explicit `options.filters` entries and the implicit
375-
// `dateRange` filter carried by each time dimension.
382+
// Includes:
383+
// - explicit `options.filters` entries,
384+
// - the implicit `dateRange` filter carried by each time dimension,
385+
// - default-value filters declared on any view active in the query.
376386
fn compile_filters(
377387
&self,
378388
evaluator_compiler: &mut Compiler,
379389
options: &dyn BaseQueryOptions,
390+
dimensions: &[Rc<MemberSymbol>],
380391
time_dimensions: &[Rc<MemberSymbol>],
392+
measures: &[Rc<MemberSymbol>],
381393
) -> Result<(Vec<FilterItem>, Vec<FilterItem>, Vec<FilterItem>), CubeError> {
382394
let mut filter_compiler = FilterCompiler::new(evaluator_compiler, self.query_tools.clone());
383395
if let Some(filters) = &options.static_data().filters {
@@ -388,9 +400,98 @@ impl QueryPropertiesCompiler {
388400
for time_dimension in time_dimensions {
389401
filter_compiler.add_time_dimension_item(time_dimension)?;
390402
}
403+
self.apply_view_default_filters(
404+
&mut filter_compiler,
405+
dimensions,
406+
time_dimensions,
407+
measures,
408+
)?;
391409
Ok(filter_compiler.extract_result())
392410
}
393411

412+
// Adds default-value filters declared on any view that the query touches.
413+
//
414+
// A view is "active" when any compiled member of the query (a dimension,
415+
// time dimension, measure, or member referenced by an explicit filter)
416+
// is owned by a cube with `is_view == true`. We read this directly off
417+
// each `MemberSymbol::compiled_path().cube_name()` — no second pass over
418+
// the raw `options` string paths.
419+
//
420+
// A default filter is applied unless `unless_references` is provided
421+
// and at least one of those member paths is mentioned in the query —
422+
// "mentioned" being the full_name of any compiled member symbol.
423+
fn apply_view_default_filters(
424+
&self,
425+
filter_compiler: &mut FilterCompiler,
426+
dimensions: &[Rc<MemberSymbol>],
427+
time_dimensions: &[Rc<MemberSymbol>],
428+
measures: &[Rc<MemberSymbol>],
429+
) -> Result<(), CubeError> {
430+
// Filter members are materialized once — we can't keep an immutable
431+
// borrow on `filter_compiler` while later calling `add_item` (mutable
432+
// borrow) on it inside the apply loop below.
433+
let filter_members: Vec<Rc<MemberSymbol>> = filter_compiler
434+
.iter_all_items()
435+
.flat_map(|f| f.all_member_evaluators())
436+
.collect();
437+
438+
// `unless` is intentionally filter-only: adding a member to the
439+
// projection (dimension / measure / time dimension) should never
440+
// silently change the row set, so a projection alone is not enough
441+
// to drop the default filter. Only an explicit filter on the member
442+
// counts as an "override" and releases the guard.
443+
let mentioned_in_filters: HashSet<String> =
444+
filter_members.iter().map(|s| s.full_name()).collect();
445+
446+
// View activation, by contrast, looks at every compiled member: if
447+
// the query touches a view in any way (projection or filter), its
448+
// default filters become candidates. De-duplicated by view name so
449+
// each view is inspected at most once.
450+
let cube_evaluator = self.query_tools.cube_evaluator();
451+
let mut visited_cubes: HashSet<String> = HashSet::new();
452+
let mut pending_view_filters: Vec<Rc<dyn ViewFilterDefinition>> = Vec::new();
453+
454+
for sym in dimensions
455+
.iter()
456+
.chain(time_dimensions)
457+
.chain(measures)
458+
.chain(filter_members.iter())
459+
{
460+
let cube_name = sym.compiled_path().cube_name();
461+
if !visited_cubes.insert(cube_name.clone()) {
462+
continue;
463+
}
464+
let cube_def = cube_evaluator.cube_from_path(cube_name.clone())?;
465+
if !cube_def.static_data().is_view.unwrap_or(false) {
466+
continue;
467+
}
468+
if let Some(view_filters) = cube_def.filters()? {
469+
pending_view_filters.extend(view_filters);
470+
}
471+
}
472+
473+
for vf in pending_view_filters {
474+
let s = vf.static_data();
475+
let should_apply = match &s.unless_references {
476+
None => true,
477+
Some(refs) => !refs.iter().any(|r| mentioned_in_filters.contains(r)),
478+
};
479+
if !should_apply {
480+
continue;
481+
}
482+
let native_filter = NativeFilterItem {
483+
or: None,
484+
and: None,
485+
member: Some(s.member_reference.clone()),
486+
dimension: None,
487+
operator: Some(s.operator.clone()),
488+
values: s.values_references.clone(),
489+
};
490+
filter_compiler.add_item(&native_filter)?;
491+
}
492+
Ok(())
493+
}
494+
394495
// Drop time-dimension symbols that have no granularity. Non-time-
395496
// dimension symbols pass through unchanged.
396497
fn filter_time_dimensions_with_granularity(

rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::test_fixtures::cube_bridge::yaml::YamlSchema;
22
use crate::test_fixtures::cube_bridge::{
33
MockBaseTools, MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockDriverTools,
44
MockGranularityDefinition, MockJoinGraph, MockJoinItemDefinition, MockMeasureDefinition,
5-
MockPreAggregationDescription, MockSegmentDefinition,
5+
MockPreAggregationDescription, MockSegmentDefinition, MockViewFilterDefinition,
66
};
77
use cubenativeutils::CubeError;
88
use std::collections::HashMap;
@@ -340,6 +340,7 @@ impl MockSchemaBuilder {
340340
measures: HashMap::new(),
341341
dimensions: HashMap::new(),
342342
segments: HashMap::new(),
343+
default_filters: Vec::new(),
343344
}
344345
}
345346

@@ -468,6 +469,7 @@ pub struct MockViewBuilder {
468469
measures: HashMap<String, Rc<MockMeasureDefinition>>,
469470
dimensions: HashMap<String, Rc<MockDimensionDefinition>>,
470471
segments: HashMap<String, Rc<MockSegmentDefinition>>,
472+
default_filters: Vec<MockViewFilterDefinition>,
471473
}
472474

473475
impl MockViewBuilder {
@@ -522,6 +524,11 @@ impl MockViewBuilder {
522524
self
523525
}
524526

527+
pub fn add_default_filter(mut self, filter: MockViewFilterDefinition) -> Self {
528+
self.default_filters.push(filter);
529+
self
530+
}
531+
525532
pub fn finish_view(mut self) -> MockSchemaBuilder {
526533
let mut all_dimensions = self.dimensions;
527534
let mut all_measures = self.measures;
@@ -634,6 +641,7 @@ impl MockViewBuilder {
634641
let view_def = MockCubeDefinition::builder()
635642
.name(self.view_name.clone())
636643
.is_view(Some(true))
644+
.filters(self.default_filters)
637645
.build();
638646

639647
let view_cube = MockCube {

rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/schema.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::test_fixtures::cube_bridge::yaml::{
44
};
55
use crate::test_fixtures::cube_bridge::{
66
MockCubeDefinition, MockJoinItemDefinition, MockSchema, MockSchemaBuilder,
7+
MockViewFilterDefinition,
78
};
89
use cubenativeutils::CubeError;
910
use serde::Deserialize;
@@ -77,6 +78,21 @@ struct YamlPreAggregationEntry {
7778
struct YamlView {
7879
name: String,
7980
cubes: Vec<YamlViewCube>,
81+
#[serde(default)]
82+
filters: Vec<YamlViewFilter>,
83+
}
84+
85+
#[derive(Debug, Deserialize)]
86+
struct YamlViewFilter {
87+
// Member references must be supplied in the view's own namespace
88+
// (`<view>.<member>`); the YAML harness does not duplicate the JS
89+
// evaluator's resolution logic.
90+
member: String,
91+
operator: String,
92+
#[serde(default)]
93+
values: Option<Vec<Option<String>>>,
94+
#[serde(default)]
95+
unless: Option<Vec<String>>,
8096
}
8197

8298
#[derive(Debug, Deserialize)]
@@ -172,6 +188,18 @@ impl YamlSchema {
172188
view_builder.include_cube_with_prefix(view_cube.join_path, includes, prefix);
173189
}
174190

191+
for filter in view.filters {
192+
// TypedBuilder uses a type-state chain, so set both optional
193+
// legs in a single expression even when one of them is None.
194+
let mock_filter = MockViewFilterDefinition::builder()
195+
.operator(filter.operator)
196+
.member_reference(filter.member)
197+
.values_references(filter.values)
198+
.unless_references(filter.unless)
199+
.build();
200+
view_builder = view_builder.add_default_filter(mock_filter);
201+
}
202+
175203
builder = view_builder.finish_view();
176204
}
177205

0 commit comments

Comments
 (0)