From a83f8128094b339b8be3326426bb0e69bc531f22 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Wed, 13 May 2026 12:47:46 +0200 Subject: [PATCH 1/4] feat(tesseract): add default value filter schema for views Adds a view-only top-level `filters` field accepting entries with `member`, `operator`, `values`, and optional `unless`. Wires the new member/values/unless paths into the JS and YAML transpilers, and rejects `filters` on regular cubes via Joi's unknown-key validation. --- .../src/compiler/CubeValidator.ts | 36 +++++ .../transpilers/CubePropContextTranspiler.ts | 3 + .../test/unit/cube-validator.test.ts | 142 ++++++++++++++++++ 3 files changed, 181 insertions(+) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index b4d5b90b8572a..17760cfcc32ee 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -1130,6 +1130,41 @@ const folderSchema = Joi.object().keys({ ]).required(), }).id('folderSchema'); +const ViewFilterSchema = Joi.object().keys({ + member: Joi.func().required(), + operator: Joi.any().valid( + 'equals', + 'notEquals', + 'contains', + 'notContains', + 'startsWith', + 'notStartsWith', + 'endsWith', + 'notEndsWith', + 'in', + 'notIn', + 'gt', + 'gte', + 'lt', + 'lte', + 'set', + 'notSet', + 'inDateRange', + 'notInDateRange', + 'onTheDate', + 'beforeDate', + 'beforeOrOnDate', + 'afterDate', + 'afterOrOnDate', + ).required(), + values: Joi.when('operator', { + is: Joi.valid('set', 'notSet'), + then: Joi.func().optional(), + otherwise: Joi.func().required() + }), + unless: Joi.func(), +}); + const viewSchema = inherit(baseSchema, { isView: Joi.boolean().strict(), viewGroup: Joi.alternatives([Joi.string(), Joi.func()]), @@ -1160,6 +1195,7 @@ const viewSchema = inherit(baseSchema, { }) ), folders: Joi.array().items(folderSchema), + filters: Joi.array().items(ViewFilterSchema), }); function formatErrorMessageFromDetails(explain, d) { diff --git a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts index 518099362fb87..c1d9564d1c2af 100644 --- a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts @@ -37,6 +37,9 @@ export const transpiledFieldsPatterns: Array = [ /^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.member$/, /^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.values$/, /^(accessPolicy|access_policy)\.[0-9]+\.conditions.[0-9]+\.if$/, + /^filters\.[0-9]+\.member$/, + /^filters\.[0-9]+\.values$/, + /^filters\.[0-9]+\.unless$/, /^(measures|dimensions)\.[_a-zA-Z][_a-zA-Z0-9]*\.mask\.sql$/, ]; diff --git a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts index d4e3a727fd5e1..1a31b7002486e 100644 --- a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts @@ -210,6 +210,148 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeFalsy(); }); + describe('view default value filters', () => { + const silentReporter = new ConsoleErrorReporter(); + + it('view with default value filter - correct', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders_view', + isView: true, + fileName: 'fileName', + filters: [ + { + member: () => 'currency', + operator: 'equals', + values: () => ['USD'], + }, + ], + }; + + const validationResult = cubeValidator.validate(cube, silentReporter); + + expect(validationResult.error).toBeFalsy(); + }); + + it('view with default value filter and unless - correct', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders_view', + isView: true, + fileName: 'fileName', + filters: [ + { + member: () => 'currency', + operator: 'equals', + values: () => ['USD'], + unless: () => ['currency'], + }, + ], + }; + + const validationResult = cubeValidator.validate(cube, silentReporter); + + expect(validationResult.error).toBeFalsy(); + }); + + it('view filter with set operator does not require values - correct', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders_view', + isView: true, + fileName: 'fileName', + filters: [ + { + member: () => 'currency', + operator: 'set', + }, + ], + }; + + const validationResult = cubeValidator.validate(cube, silentReporter); + + expect(validationResult.error).toBeFalsy(); + }); + + it('view filter with missing required values - error', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders_view', + isView: true, + fileName: 'fileName', + filters: [ + { + member: () => 'currency', + operator: 'equals', + }, + ], + }; + + const validationResult = cubeValidator.validate(cube, silentReporter); + + expect(validationResult.error).toBeTruthy(); + }); + + it('view filter with missing member - error', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders_view', + isView: true, + fileName: 'fileName', + filters: [ + { + operator: 'equals', + values: () => ['USD'], + }, + ], + }; + + const validationResult = cubeValidator.validate(cube, silentReporter); + + expect(validationResult.error).toBeTruthy(); + }); + + it('view filter with invalid operator - error', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders_view', + isView: true, + fileName: 'fileName', + filters: [ + { + member: () => 'currency', + operator: 'someInvalidOperator', + values: () => ['USD'], + }, + ], + }; + + const validationResult = cubeValidator.validate(cube, silentReporter); + + expect(validationResult.error).toBeTruthy(); + }); + + it('regular cube with default value filter - error', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders', + sql: () => 'SELECT * FROM orders', + fileName: 'fileName', + filters: [ + { + member: () => 'currency', + operator: 'equals', + values: () => ['USD'], + }, + ], + }; + + const validationResult = cubeValidator.validate(cube, silentReporter); + + expect(validationResult.error).toBeTruthy(); + }); + }); + it('refreshKey alternatives', async () => { const cubeValidator = new CubeValidator(new CubeSymbols()); const cube = { From 367b24af96dcbf49af9597f5d9b50edc8df77a3a Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Wed, 13 May 2026 18:28:34 +0200 Subject: [PATCH 2/4] feat(tesseract): expose view default value filters to Tesseract Wires the view-only `filters` field (added to the Joi schema in a703f81) end-to-end: - CubeEvaluator.prepareViewFilters resolves `member`/`unless` against the view's includedMembers (short, real-cube and view-prefixed paths all normalize to the real cube path) and stringifies `values` to match the existing FilterItem.values contract. - New ViewFilterDefinition bridge in cubesqlplanner (static-only, no trait fields) plus an optional `filters()` getter on CubeDefinition. - MockViewFilterDefinition and `filters` field on MockCubeDefinition for Rust-side test fixtures. - backend-native bridge_registry registration with fixture, expected field set and round-trip coverage for the new struct. --- .../src/bridge_test_exports.rs | 11 ++ .../test/bridge/bridge-fixtures.ts | 11 ++ .../bridge/object-bridges-coverage.test.ts | 15 +- .../src/compiler/CubeEvaluator.ts | 78 ++++++++ .../src/compiler/CubeSymbols.ts | 11 ++ .../test/unit/schema.test.ts | 174 +++++++++++++++++- .../cubejs-schema-compiler/test/unit/utils.ts | 53 ++++++ .../src/cube_bridge/cube_definition.rs | 4 + .../cubesqlplanner/src/cube_bridge/mod.rs | 1 + .../src/cube_bridge/view_filter_definition.rs | 26 +++ .../cube_bridge/mock_cube_definition.rs | 80 +++++++- .../mock_view_filter_definition.rs | 97 ++++++++++ .../src/test_fixtures/cube_bridge/mod.rs | 2 + 13 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/view_filter_definition.rs create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_view_filter_definition.rs diff --git a/packages/cubejs-backend-native/src/bridge_test_exports.rs b/packages/cubejs-backend-native/src/bridge_test_exports.rs index 6bc9cd8389407..0954aabd98a0e 100644 --- a/packages/cubejs-backend-native/src/bridge_test_exports.rs +++ b/packages/cubejs-backend-native/src/bridge_test_exports.rs @@ -91,6 +91,9 @@ use cubesqlplanner::cube_bridge::{ timeshift_definition::{ time_shift_definition_bridge_fields_meta, NativeTimeShiftDefinition, TimeShiftDefinition, }, + view_filter_definition::{ + view_filter_definition_bridge_fields_meta, NativeViewFilterDefinition, + }, }; use neon::prelude::*; use std::any::Any; @@ -451,6 +454,7 @@ bridge_registry! { "sqlUtils" => NativeSqlUtils, sql_utils_bridge_fields_meta, invoke_sql_utils; "structWithSqlMember" => NativeStructWithSqlMember, struct_with_sql_member_bridge_fields_meta, invoke_struct_with_sql_member; "timeShiftDefinition" => NativeTimeShiftDefinition, time_shift_definition_bridge_fields_meta, invoke_time_shift_definition; + "viewFilterDefinition" => NativeViewFilterDefinition, view_filter_definition_bridge_fields_meta, invoke_view_filter_definition; } fn list_bridge_fields_inner( @@ -708,10 +712,17 @@ fn invoke_time_shift_definition(b: &NativeTimeShiftDefinition( + _b: &NativeViewFilterDefinition, +) -> InvokeResult { + InvokeResult::new() +} + fn invoke_cube_definition(b: &NativeCubeDefinition) -> InvokeResult { let mut r = InvokeResult::new(); r.record("sql_table", b.sql_table()); r.record("sql", b.sql()); + r.record("filters", b.filters()); r } diff --git a/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts b/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts index 327d635ace8ad..4abd724658665 100644 --- a/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts +++ b/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts @@ -131,10 +131,20 @@ export const preAggregationDescriptionFixture = (): unknown => ({ // measure_references, dimension_references, etc — all optional getters }); +export const viewFilterDefinitionFixture = (): unknown => ({ + operator: 'equals', + memberReference: 'orders.currency', + // Values are stringified by CubeEvaluator.prepareViewFilters before reaching + // Tesseract; nulls are kept to exercise the Option>> shape. + valuesReferences: ['USD', null], + unlessReferences: ['orders.currency'], +}); + export const cubeDefinitionFixture = (): unknown => ({ name: 'Orders', // sqlAlias, isView, isCalendar, joinMap optional // sql_table, sql optional getters + filters: [viewFilterDefinitionFixture()], }); export const dimensionDefinitionFixture = (): unknown => ({ @@ -270,4 +280,5 @@ export const FIXTURES: Record = { sqlUtils: sqlUtilsFixture, structWithSqlMember: structWithSqlMemberFixture, timeShiftDefinition: timeShiftDefinitionFixture, + viewFilterDefinition: viewFilterDefinitionFixture, }; diff --git a/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts index 9207a1ed1c30a..13fc0c4c011ab 100644 --- a/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts +++ b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts @@ -87,7 +87,16 @@ const BRIDGES: BridgeSpec[] = [ { name: 'caseSwitchItem', expected: ['sql', 'value'] }, { name: 'cubeDefinition', - expected: ['is_calendar', 'is_view', 'join_map', 'name', 'sql', 'sql_alias', 'sql_table'], + expected: [ + 'filters', + 'is_calendar', + 'is_view', + 'join_map', + 'name', + 'sql', + 'sql_alias', + 'sql_table', + ], }, { name: 'cubeEvaluator', @@ -215,6 +224,10 @@ const BRIDGES: BridgeSpec[] = [ { name: 'sqlUtils', expected: [] }, { name: 'structWithSqlMember', expected: ['sql'] }, { name: 'timeShiftDefinition', expected: ['interval', 'name', 'sql', 'timeshift_type'] }, + { + name: 'viewFilterDefinition', + expected: ['member_reference', 'operator', 'unless_references', 'values_references'], + }, ]; const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index 2866c6f5d29a0..1d273b72a1999 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -13,6 +13,7 @@ import { PreAggregationDefinition, PreAggregationDefinitionRollup, type ToString, + ViewDefaultValueFilter, ViewIncludedMember } from './CubeSymbols'; import { UserError } from './UserError'; @@ -147,6 +148,7 @@ export type EvaluatedCube = { accessPolicy?: AccessPolicyDefinition[]; isView?: boolean; includedMembers?: ViewIncludedMember[]; + filters?: ViewDefaultValueFilter[]; }; export class CubeEvaluator extends CubeSymbols { @@ -207,10 +209,86 @@ export class CubeEvaluator extends CubeSymbols { this.prepareFolders(cube, errorReporter); this.prepareAccessPolicy(cube, errorReporter); + this.prepareViewFilters(cube, errorReporter); return cube; } + private prepareViewFilters(cube: any, errorReporter: ErrorReporter) { + if (!cube.filters) { + return; + } + + const included = (cube.includedMembers as ViewIncludedMember[] | undefined) || []; + + const resolveViewMember = (memberType: string, reference: string): string | null => { + let lookupName = reference; + let lookupPath: string | null = null; + + if (reference.indexOf('.') !== -1) { + const parts = reference.split('.'); + if (parts[0] === cube.name) { + // Identifier form resolved via view's own namespace, e.g. 'orders_view.currency' + lookupName = parts.slice(1).join('.'); + } else { + // Fully-qualified member path, e.g. 'orders.currency' + lookupPath = reference; + } + } + + const match = lookupPath + ? included.find((m) => m.memberPath === lookupPath) + : included.find((m) => m.name === lookupName); + + if (!match) { + errorReporter.error( + `Member '${reference}' used as ${memberType} in default value filter is not included in view '${cube.name}'` + ); + return null; + } + return match.memberPath; + }; + + for (const filter of cube.filters as ViewDefaultValueFilter[]) { + const rawMember = this.evaluateReferences(cube.name, filter.member); + const resolved = resolveViewMember('member', rawMember); + if (resolved !== null) { + filter.memberReference = resolved; + } + + if (filter.values) { + const evaluated = filter.values(); + if (!Array.isArray(evaluated)) { + errorReporter.error( + `'values' in default value filter for view '${cube.name}' must evaluate to an array, got: ${typeof evaluated}` + ); + } else { + // Coerce to strings to match the FilterItem.values contract used by + // regular query filters (Option>> on the Rust side). + filter.valuesReferences = evaluated.map( + (v) => (v === null || v === undefined ? null : String(v)) + ); + } + } + + if (filter.unless) { + const rawUnless = this.evaluateReferences( + cube.name, + filter.unless, + { originalSorting: true } + ); + const resolvedUnless: string[] = []; + for (const ref of rawUnless) { + const r = resolveViewMember('unless', ref); + if (r !== null) { + resolvedUnless.push(r); + } + } + filter.unlessReferences = resolvedUnless; + } + } + } + private allMembersOrList(cube: any, specifier: string | string[]): string[] { const types = ['measures', 'dimensions', 'segments']; if (specifier === '*') { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index e34ac15082c42..6d12a55035418 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -148,6 +148,16 @@ export type AccessPolicyDefinition = { }[] }; +export type ViewDefaultValueFilter = { + member: (...args: Array) => ToString; + memberReference?: string; + operator: string; + values?: (...args: Array) => Array; + valuesReferences?: Array; + unless?: (...args: Array) => Array; + unlessReferences?: string[]; +}; + export type ViewIncludedMember = { type: string; memberPath: string; @@ -216,6 +226,7 @@ export interface CubeDefinition { isView?: boolean; viewGroup?: string | ((...args: any[]) => any); viewGroups?: string[] | ((...args: any[]) => any); + filters?: ViewDefaultValueFilter[]; calendar?: boolean; isSplitView?: boolean; includedMembers?: ViewIncludedMember[]; diff --git a/packages/cubejs-schema-compiler/test/unit/schema.test.ts b/packages/cubejs-schema-compiler/test/unit/schema.test.ts index 9a37dcf487f32..750affb3bc491 100644 --- a/packages/cubejs-schema-compiler/test/unit/schema.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/schema.test.ts @@ -1,7 +1,12 @@ import fs from 'fs'; import path from 'path'; import { prepareCompiler, prepareJsCompiler, prepareYamlCompiler } from './PrepareCompiler'; -import { createCubeSchema, createCubeSchemaWithCustomGranularitiesAndTimeShift, createCubeSchemaWithAccessPolicy } from './utils'; +import { + createCubeSchema, + createCubeSchemaWithCustomGranularitiesAndTimeShift, + createCubeSchemaWithAccessPolicy, + createViewSchemaWithDefaultValueFilter, +} from './utils'; const CUBE_COMPONENTS = ['dimensions', 'measures', 'segments', 'hierarchies', 'preAggregations', 'joins']; @@ -558,6 +563,173 @@ describe('Schema Testing', () => { }); describe('Views', () => { + it('default value filters resolve member/values/unless references', async () => { + const { compiler, cubeEvaluator } = prepareJsCompiler([ + createViewSchemaWithDefaultValueFilter(), + ]); + await compiler.compile(); + compiler.throwIfAnyErrors(); + + const view = cubeEvaluator.evaluatedCubes.orders_view; + const filters = view.filters!; + expect(filters).toHaveLength(3); + + expect(filters[0].operator).toBe('equals'); + expect(filters[0].memberReference).toBe('orders.currency'); + expect(filters[0].valuesReferences).toEqual(['USD']); + expect(filters[0].unlessReferences).toEqual(['orders.currency', 'orders.country']); + + expect(filters[1].operator).toBe('set'); + expect(filters[1].memberReference).toBe('orders.country'); + expect(filters[1].valuesReferences).toBeUndefined(); + expect(filters[1].unlessReferences).toBeUndefined(); + + expect(filters[2].operator).toBe('in'); + expect(filters[2].memberReference).toBe('orders.id'); + // Values are coerced to strings to match the FilterItem contract used + // by regular query filters on the Rust side. + expect(filters[2].valuesReferences).toEqual(['1', '2', 'true', 'draft', null]); + }); + + it('default value filter: short, real-path and view-path forms all resolve to the real member path', async () => { + const schema = ` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + dimensions: { + id: { type: \`number\`, sql: \`id\`, primaryKey: true, public: true }, + currency: { type: \`string\`, sql: \`currency\`, public: true }, + }, + measures: { + count: { type: \`count\` }, + }, + }) + + view(\`orders_view\`, { + cubes: [{ join_path: orders, includes: '*' }], + filters: [ + { member: \`currency\`, operator: 'set' }, + { member: \`orders.currency\`, operator: 'set' }, + { member: \`orders_view.currency\`, operator: 'set' }, + ], + }) + `; + const { compiler, cubeEvaluator } = prepareJsCompiler([schema]); + await compiler.compile(); + compiler.throwIfAnyErrors(); + + const filters = cubeEvaluator.evaluatedCubes.orders_view.filters!; + expect(filters.map(f => f.memberReference)).toEqual([ + 'orders.currency', + 'orders.currency', + 'orders.currency', + ]); + }); + + it('default value filter: member not included in view raises an error', async () => { + const schema = ` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + dimensions: { + id: { type: \`number\`, sql: \`id\`, primaryKey: true, public: true }, + currency: { type: \`string\`, sql: \`currency\`, public: true }, + country: { type: \`string\`, sql: \`country\`, public: true }, + }, + measures: { + count: { type: \`count\` }, + }, + }) + + view(\`orders_view\`, { + cubes: [{ + join_path: orders, + includes: ['id', 'currency'], + }], + filters: [ + { member: \`country\`, operator: 'set' }, + ], + }) + `; + const { compiler } = prepareJsCompiler([schema]); + + try { + await compiler.compile(); + compiler.throwIfAnyErrors(); + throw new Error('should throw earlier'); + } catch (e: any) { + expect(e.toString()).toMatch( + /Member 'country' used as member in default value filter is not included in view 'orders_view'/ + ); + } + }); + + it('default value filter: unless references must also be included in the view', async () => { + const schema = ` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + dimensions: { + id: { type: \`number\`, sql: \`id\`, primaryKey: true, public: true }, + currency: { type: \`string\`, sql: \`currency\`, public: true }, + country: { type: \`string\`, sql: \`country\`, public: true }, + }, + measures: { + count: { type: \`count\` }, + }, + }) + + view(\`orders_view\`, { + cubes: [{ + join_path: orders, + includes: ['id', 'currency'], + }], + filters: [ + { member: \`currency\`, operator: 'set', unless: [\`country\`] }, + ], + }) + `; + const { compiler } = prepareJsCompiler([schema]); + + try { + await compiler.compile(); + compiler.throwIfAnyErrors(); + throw new Error('should throw earlier'); + } catch (e: any) { + expect(e.toString()).toMatch( + /Member 'country' used as unless in default value filter is not included in view 'orders_view'/ + ); + } + }); + + it('default value filter: fully-qualified path from a non-included cube raises an error', async () => { + const schema = ` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + dimensions: { + id: { type: \`number\`, sql: \`id\`, primaryKey: true, public: true }, + currency: { type: \`string\`, sql: \`currency\`, public: true }, + }, + measures: { count: { type: \`count\` } }, + }) + + view(\`orders_view\`, { + cubes: [{ join_path: orders, includes: '*' }], + filters: [ + { member: \`other.currency\`, operator: 'set' }, + ], + }) + `; + const { compiler } = prepareJsCompiler([schema]); + + try { + await compiler.compile(); + compiler.throwIfAnyErrors(); + throw new Error('should throw earlier'); + } catch (e: any) { + expect(e.toString()).toMatch( + /Member 'other\.currency' used as member in default value filter is not included in view 'orders_view'/ + ); + } + }); + it('extends custom granularities and timeshifts', async () => { const { compiler, cubeEvaluator } = prepareJsCompiler([ createCubeSchemaWithCustomGranularitiesAndTimeShift('orders') diff --git a/packages/cubejs-schema-compiler/test/unit/utils.ts b/packages/cubejs-schema-compiler/test/unit/utils.ts index acc73c2a24e6d..c2a1a76c983da 100644 --- a/packages/cubejs-schema-compiler/test/unit/utils.ts +++ b/packages/cubejs-schema-compiler/test/unit/utils.ts @@ -377,6 +377,59 @@ export function createCubeSchemaWithCustomGranularitiesAndTimeShift(name: string })`; } +export function createViewSchemaWithDefaultValueFilter(): string { + return ` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + dimensions: { + id: { + type: \`number\`, + sql: \`id\`, + primaryKey: true, + public: true, + }, + currency: { + type: \`string\`, + sql: \`currency\`, + public: true, + }, + country: { + type: \`string\`, + sql: \`country\`, + public: true, + }, + }, + measures: { + count: { type: \`count\` }, + }, + }) + + view(\`orders_view\`, { + cubes: [{ + join_path: orders, + includes: '*', + }], + filters: [ + { + member: \`currency\`, + operator: 'equals', + values: [\`USD\`], + unless: [\`currency\`, \`country\`], + }, + { + member: \`country\`, + operator: 'set', + }, + { + member: \`id\`, + operator: 'in', + values: [1, 2, true, \`draft\`, null], + }, + ], + }) + `; +} + export type CreateSchemaOptions = { cubes?: unknown[], views?: unknown[] diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs index d21841b95d5d9..c404edf2a74b0 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs @@ -1,7 +1,9 @@ use super::member_sql::{MemberSql, NativeMemberSql}; +use super::view_filter_definition::{NativeViewFilterDefinition, ViewFilterDefinition}; use cubenativeutils::wrappers::serializer::{ NativeDeserialize, NativeDeserializer, NativeSerialize, }; +use cubenativeutils::wrappers::NativeArray; use cubenativeutils::wrappers::NativeContextHolder; use cubenativeutils::wrappers::NativeObjectHandle; use cubenativeutils::CubeError; @@ -38,4 +40,6 @@ pub trait CubeDefinition { fn sql_table(&self) -> Result>, CubeError>; #[nbridge(field, optional)] fn sql(&self) -> Result>, CubeError>; + #[nbridge(field, optional, vec)] + fn filters(&self) -> Result>>, CubeError>; } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs index 508c2841db880..5ee207c8b00e6 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs @@ -37,3 +37,4 @@ pub mod sql_utils; pub mod string_or_sql; pub mod struct_with_sql_member; pub mod timeshift_definition; +pub mod view_filter_definition; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/view_filter_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/view_filter_definition.rs new file mode 100644 index 0000000000000..4d2e894a5c74f --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/view_filter_definition.rs @@ -0,0 +1,26 @@ +use cubenativeutils::wrappers::serializer::{NativeDeserialize, NativeSerialize}; +use cubenativeutils::wrappers::NativeContextHolder; +use cubenativeutils::wrappers::NativeObjectHandle; +use cubenativeutils::CubeError; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::rc::Rc; + +// `values_references` mirrors the contract of `FilterItem.values` from +// `base_query_options.rs` — query filters arriving from the API are already +// stringified there, so the Tesseract planner treats filter values as +// `Option>>` everywhere. The JS evaluator coerces with +// `String(v)` before populating this field. +#[derive(Serialize, Deserialize, Debug, Clone, nativebridge::NativeBridgeStatic)] +pub struct ViewFilterDefinitionStatic { + pub operator: String, + #[serde(rename = "memberReference")] + pub member_reference: String, + #[serde(rename = "valuesReferences")] + pub values_references: Option>>, + #[serde(rename = "unlessReferences")] + pub unless_references: Option>, +} + +#[nativebridge::native_bridge(ViewFilterDefinitionStatic, with_static_meta)] +pub trait ViewFilterDefinition {} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs index e605231aa6627..4c6054327dc46 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_cube_definition.rs @@ -1,7 +1,10 @@ use crate::cube_bridge::cube_definition::{CubeDefinition, CubeDefinitionStatic}; use crate::cube_bridge::member_sql::MemberSql; +use crate::cube_bridge::view_filter_definition::ViewFilterDefinition; use crate::impl_static_data; -use crate::test_fixtures::cube_bridge::{MockJoinItemDefinition, MockMemberSql}; +use crate::test_fixtures::cube_bridge::{ + MockJoinItemDefinition, MockMemberSql, MockViewFilterDefinition, +}; use cubenativeutils::CubeError; use std::any::Any; use std::collections::HashMap; @@ -27,6 +30,9 @@ pub struct MockCubeDefinition { #[builder(default)] joins: HashMap, + + #[builder(default)] + filters: Vec, } impl_static_data!( @@ -64,6 +70,23 @@ impl CubeDefinition for MockCubeDefinition { } } + fn has_filters(&self) -> Result { + Ok(!self.filters.is_empty()) + } + + fn filters(&self) -> Result>>, CubeError> { + if self.filters.is_empty() { + Ok(None) + } else { + Ok(Some( + self.filters + .iter() + .map(|f| Rc::new(f.clone()) as Rc) + .collect(), + )) + } + } + fn as_any(self: Rc) -> Rc { self } @@ -276,4 +299,59 @@ mod tests { assert_eq!(cube.joins().len(), 0); assert!(cube.get_join("any").is_none()); } + + #[test] + fn test_view_without_filters() { + let view = MockCubeDefinition::builder() + .name("orders_view".to_string()) + .is_view(Some(true)) + .sql("SELECT * FROM orders".to_string()) + .build(); + + assert!(!view.has_filters().unwrap()); + assert!(view.filters().unwrap().is_none()); + } + + #[test] + fn test_view_with_default_value_filters() { + let view = MockCubeDefinition::builder() + .name("orders_view".to_string()) + .is_view(Some(true)) + .sql("SELECT * FROM orders".to_string()) + .filters(vec![ + MockViewFilterDefinition::builder() + .operator("equals".to_string()) + .member_reference("orders.currency".to_string()) + .values_references(Some(vec![Some("USD".to_string())])) + .unless_references(Some(vec!["orders.currency".to_string()])) + .build(), + MockViewFilterDefinition::builder() + .operator("set".to_string()) + .member_reference("orders.country".to_string()) + .build(), + ]) + .build(); + + assert!(view.has_filters().unwrap()); + let filters = view.filters().unwrap().unwrap(); + assert_eq!(filters.len(), 2); + + let first = filters[0].static_data(); + assert_eq!(first.operator, "equals"); + assert_eq!(first.member_reference, "orders.currency"); + assert_eq!( + first.values_references.as_ref().unwrap(), + &vec![Some("USD".to_string())] + ); + assert_eq!( + first.unless_references.as_ref().unwrap(), + &vec!["orders.currency".to_string()] + ); + + let second = filters[1].static_data(); + assert_eq!(second.operator, "set"); + assert_eq!(second.member_reference, "orders.country"); + assert!(second.values_references.is_none()); + assert!(second.unless_references.is_none()); + } } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_view_filter_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_view_filter_definition.rs new file mode 100644 index 0000000000000..552b95b4b16c9 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_view_filter_definition.rs @@ -0,0 +1,97 @@ +use crate::cube_bridge::view_filter_definition::{ + ViewFilterDefinition, ViewFilterDefinitionStatic, +}; +use crate::impl_static_data; +use std::any::Any; +use std::rc::Rc; +use typed_builder::TypedBuilder; + +#[derive(Clone, Debug, TypedBuilder)] +pub struct MockViewFilterDefinition { + operator: String, + member_reference: String, + #[builder(default)] + values_references: Option>>, + #[builder(default)] + unless_references: Option>, +} + +impl_static_data!( + MockViewFilterDefinition, + ViewFilterDefinitionStatic, + operator, + member_reference, + values_references, + unless_references +); + +impl ViewFilterDefinition for MockViewFilterDefinition { + crate::impl_static_data_method!(ViewFilterDefinitionStatic); + + fn as_any(self: Rc) -> Rc { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimal_filter() { + let filter = MockViewFilterDefinition::builder() + .operator("set".to_string()) + .member_reference("orders.currency".to_string()) + .build(); + + let data = filter.static_data(); + assert_eq!(data.operator, "set"); + assert_eq!(data.member_reference, "orders.currency"); + assert!(data.values_references.is_none()); + assert!(data.unless_references.is_none()); + } + + #[test] + fn test_filter_with_values_and_unless() { + let filter = MockViewFilterDefinition::builder() + .operator("equals".to_string()) + .member_reference("orders.currency".to_string()) + .values_references(Some(vec![Some("USD".to_string())])) + .unless_references(Some(vec![ + "orders.currency".to_string(), + "orders.country".to_string(), + ])) + .build(); + + let data = filter.static_data(); + assert_eq!(data.operator, "equals"); + assert_eq!(data.member_reference, "orders.currency"); + assert_eq!( + data.values_references.as_ref().unwrap(), + &vec![Some("USD".to_string())] + ); + assert_eq!( + data.unless_references.as_ref().unwrap(), + &vec!["orders.currency".to_string(), "orders.country".to_string()] + ); + } + + #[test] + fn test_filter_values_keep_nulls() { + let filter = MockViewFilterDefinition::builder() + .operator("in".to_string()) + .member_reference("orders.status".to_string()) + .values_references(Some(vec![ + Some("draft".to_string()), + Some("paid".to_string()), + None, + ])) + .build(); + + let values = filter.static_data().values_references.unwrap(); + assert_eq!(values.len(), 3); + assert_eq!(values[0], Some("draft".to_string())); + assert_eq!(values[1], Some("paid".to_string())); + assert_eq!(values[2], None); + } +} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs index 10c430a9345bb..0feba75e80af0 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -36,6 +36,7 @@ mod mock_sql_templates_render; mod mock_sql_utils; mod mock_struct_with_sql_member; mod mock_timeshift_definition; +mod mock_view_filter_definition; pub mod time_series; pub use base_query_options::{members_from_strings, MockBaseQueryOptions}; @@ -70,3 +71,4 @@ pub use mock_sql_templates_render::MockSqlTemplatesRender; pub use mock_sql_utils::MockSqlUtils; pub use mock_struct_with_sql_member::MockStructWithSqlMember; pub use mock_timeshift_definition::MockTimeShiftDefinition; +pub use mock_view_filter_definition::MockViewFilterDefinition; From 25b16fafe99556e4a06b654280761d3e4795c942 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Fri, 15 May 2026 15:39:53 +0200 Subject: [PATCH 3/4] feat(tesseract): apply view default value filters in QueryPropertiesCompiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`.`) 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. --- .../src/compiler/CubeEvaluator.ts | 7 +- .../test/unit/schema.test.ts | 14 +- .../src/planner/filter/compiler.rs | 8 + .../src/planner/query_properties_compiler.rs | 111 +++++++++- .../test_fixtures/cube_bridge/mock_schema.rs | 10 +- .../test_fixtures/cube_bridge/yaml/schema.rs | 28 +++ .../common/view_default_filters.yaml | 79 +++++++ .../seeds/view_default_filters_tables.sql | 21 ++ .../src/tests/integration/mod.rs | 1 + ...rs__explicit_filter_overrides_default.snap | 8 + ...itch_case_measure_with_default_filter.snap | 12 ++ ..._does_not_trigger_on_projection_alone.snap | 8 + ...ult_filter_when_member_is_not_touched.snap | 12 ++ ...switch_default_filter_collapses_union.snap | 8 + .../tests/integration/view_default_filters.rs | 176 ++++++++++++++++ .../cubesqlplanner/src/tests/mod.rs | 1 + .../src/tests/view_default_filters.rs | 194 ++++++++++++++++++ 17 files changed, 682 insertions(+), 16 deletions(-) create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/view_default_filters.yaml create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/seeds/view_default_filters_tables.sql create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__explicit_filter_overrides_default.snap create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__switch_case_measure_with_default_filter.snap create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_does_not_trigger_on_projection_alone.snap create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_keeps_default_filter_when_member_is_not_touched.snap create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__virtual_switch_default_filter_collapses_union.snap create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/view_default_filters.rs diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index 1d273b72a1999..173286be336b9 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -221,6 +221,9 @@ export class CubeEvaluator extends CubeSymbols { const included = (cube.includedMembers as ViewIncludedMember[] | undefined) || []; + // Always returns view-scoped path `.` to match + // MemberSymbol::full_name on the Rust side, which is what `unless` and + // filter dispatch compare against. const resolveViewMember = (memberType: string, reference: string): string | null => { let lookupName = reference; let lookupPath: string | null = null; @@ -228,10 +231,8 @@ export class CubeEvaluator extends CubeSymbols { if (reference.indexOf('.') !== -1) { const parts = reference.split('.'); if (parts[0] === cube.name) { - // Identifier form resolved via view's own namespace, e.g. 'orders_view.currency' lookupName = parts.slice(1).join('.'); } else { - // Fully-qualified member path, e.g. 'orders.currency' lookupPath = reference; } } @@ -246,7 +247,7 @@ export class CubeEvaluator extends CubeSymbols { ); return null; } - return match.memberPath; + return `${cube.name}.${match.name}`; }; for (const filter of cube.filters as ViewDefaultValueFilter[]) { diff --git a/packages/cubejs-schema-compiler/test/unit/schema.test.ts b/packages/cubejs-schema-compiler/test/unit/schema.test.ts index 750affb3bc491..1277ced23d079 100644 --- a/packages/cubejs-schema-compiler/test/unit/schema.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/schema.test.ts @@ -575,17 +575,17 @@ describe('Schema Testing', () => { expect(filters).toHaveLength(3); expect(filters[0].operator).toBe('equals'); - expect(filters[0].memberReference).toBe('orders.currency'); + expect(filters[0].memberReference).toBe('orders_view.currency'); expect(filters[0].valuesReferences).toEqual(['USD']); - expect(filters[0].unlessReferences).toEqual(['orders.currency', 'orders.country']); + expect(filters[0].unlessReferences).toEqual(['orders_view.currency', 'orders_view.country']); expect(filters[1].operator).toBe('set'); - expect(filters[1].memberReference).toBe('orders.country'); + expect(filters[1].memberReference).toBe('orders_view.country'); expect(filters[1].valuesReferences).toBeUndefined(); expect(filters[1].unlessReferences).toBeUndefined(); expect(filters[2].operator).toBe('in'); - expect(filters[2].memberReference).toBe('orders.id'); + expect(filters[2].memberReference).toBe('orders_view.id'); // Values are coerced to strings to match the FilterItem contract used // by regular query filters on the Rust side. expect(filters[2].valuesReferences).toEqual(['1', '2', 'true', 'draft', null]); @@ -619,9 +619,9 @@ describe('Schema Testing', () => { const filters = cubeEvaluator.evaluatedCubes.orders_view.filters!; expect(filters.map(f => f.memberReference)).toEqual([ - 'orders.currency', - 'orders.currency', - 'orders.currency', + 'orders_view.currency', + 'orders_view.currency', + 'orders_view.currency', ]); }); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/filter/compiler.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/filter/compiler.rs index e897a3fab1f08..95ffca4a5d022 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/filter/compiler.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/filter/compiler.rs @@ -62,6 +62,14 @@ impl<'a> FilterCompiler<'a> { ) } + /// Iterator over every compiled filter item across the three buckets. + pub fn iter_all_items(&self) -> impl Iterator { + self.dimension_filters + .iter() + .chain(self.time_dimension_filters.iter()) + .chain(self.measures_filters.iter()) + } + fn compile_item( &mut self, item: &NativeFilterItem, diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs index f6c29bdcb24c1..db0eeb5aba605 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_properties_compiler.rs @@ -2,16 +2,18 @@ //! resolves member/segment/filter/order references against the cube //! evaluator and folds them into the typed builder. +use std::collections::HashSet; use std::rc::Rc; use cubenativeutils::CubeError; use itertools::Itertools; -use crate::cube_bridge::base_query_options::BaseQueryOptions; +use crate::cube_bridge::base_query_options::{BaseQueryOptions, FilterItem as NativeFilterItem}; use crate::cube_bridge::member_expression::{ MemberExpressionDefinition, MemberExpressionExpressionDef, }; use crate::cube_bridge::options_member::OptionsMember; +use crate::cube_bridge::view_filter_definition::ViewFilterDefinition; use super::filter::compiler::FilterCompiler; use super::filter::{BaseSegment, FilterItem}; @@ -47,8 +49,14 @@ impl QueryPropertiesCompiler { let measures = self.compile_measures(&mut evaluator_compiler, options)?; let segments = self.compile_segments(&mut evaluator_compiler, options)?; - let (dimensions_filters, time_dimensions_filters, measures_filters) = - self.compile_filters(&mut evaluator_compiler, options, &time_dimensions_raw)?; + let (dimensions_filters, time_dimensions_filters, measures_filters) = self + .compile_filters( + &mut evaluator_compiler, + options, + &dimensions, + &time_dimensions_raw, + &measures, + )?; // FIXME may be this filter should be applied on other place let time_dimensions = Self::filter_time_dimensions_with_granularity(time_dimensions_raw); @@ -371,13 +379,17 @@ impl QueryPropertiesCompiler { } // Returns `(dimension_filters, time_dimension_filters, measure_filters)`. - // Includes both the explicit `options.filters` entries and the implicit - // `dateRange` filter carried by each time dimension. + // Includes: + // - explicit `options.filters` entries, + // - the implicit `dateRange` filter carried by each time dimension, + // - default-value filters declared on any view active in the query. fn compile_filters( &self, evaluator_compiler: &mut Compiler, options: &dyn BaseQueryOptions, + dimensions: &[Rc], time_dimensions: &[Rc], + measures: &[Rc], ) -> Result<(Vec, Vec, Vec), CubeError> { let mut filter_compiler = FilterCompiler::new(evaluator_compiler, self.query_tools.clone()); if let Some(filters) = &options.static_data().filters { @@ -388,9 +400,98 @@ impl QueryPropertiesCompiler { for time_dimension in time_dimensions { filter_compiler.add_time_dimension_item(time_dimension)?; } + self.apply_view_default_filters( + &mut filter_compiler, + dimensions, + time_dimensions, + measures, + )?; Ok(filter_compiler.extract_result()) } + // Adds default-value filters declared on any view that the query touches. + // + // A view is "active" when any compiled member of the query (a dimension, + // time dimension, measure, or member referenced by an explicit filter) + // is owned by a cube with `is_view == true`. We read this directly off + // each `MemberSymbol::compiled_path().cube_name()` — no second pass over + // the raw `options` string paths. + // + // A default filter is applied unless `unless_references` is provided + // and at least one of those member paths is mentioned in the query — + // "mentioned" being the full_name of any compiled member symbol. + fn apply_view_default_filters( + &self, + filter_compiler: &mut FilterCompiler, + dimensions: &[Rc], + time_dimensions: &[Rc], + measures: &[Rc], + ) -> Result<(), CubeError> { + // Filter members are materialized once — we can't keep an immutable + // borrow on `filter_compiler` while later calling `add_item` (mutable + // borrow) on it inside the apply loop below. + let filter_members: Vec> = filter_compiler + .iter_all_items() + .flat_map(|f| f.all_member_evaluators()) + .collect(); + + // `unless` is intentionally filter-only: adding a member to the + // projection (dimension / measure / time dimension) should never + // silently change the row set, so a projection alone is not enough + // to drop the default filter. Only an explicit filter on the member + // counts as an "override" and releases the guard. + let mentioned_in_filters: HashSet = + filter_members.iter().map(|s| s.full_name()).collect(); + + // View activation, by contrast, looks at every compiled member: if + // the query touches a view in any way (projection or filter), its + // default filters become candidates. De-duplicated by view name so + // each view is inspected at most once. + let cube_evaluator = self.query_tools.cube_evaluator(); + let mut visited_cubes: HashSet = HashSet::new(); + let mut pending_view_filters: Vec> = Vec::new(); + + for sym in dimensions + .iter() + .chain(time_dimensions) + .chain(measures) + .chain(filter_members.iter()) + { + let cube_name = sym.compiled_path().cube_name(); + if !visited_cubes.insert(cube_name.clone()) { + continue; + } + let cube_def = cube_evaluator.cube_from_path(cube_name.clone())?; + if !cube_def.static_data().is_view.unwrap_or(false) { + continue; + } + if let Some(view_filters) = cube_def.filters()? { + pending_view_filters.extend(view_filters); + } + } + + for vf in pending_view_filters { + let s = vf.static_data(); + let should_apply = match &s.unless_references { + None => true, + Some(refs) => !refs.iter().any(|r| mentioned_in_filters.contains(r)), + }; + if !should_apply { + continue; + } + let native_filter = NativeFilterItem { + or: None, + and: None, + member: Some(s.member_reference.clone()), + dimension: None, + operator: Some(s.operator.clone()), + values: s.values_references.clone(), + }; + filter_compiler.add_item(&native_filter)?; + } + Ok(()) + } + // Drop time-dimension symbols that have no granularity. Non-time- // dimension symbols pass through unchanged. fn filter_time_dimensions_with_granularity( diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs index ee647804fd8ff..aa5b1d17555e9 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_schema.rs @@ -2,7 +2,7 @@ use crate::test_fixtures::cube_bridge::yaml::YamlSchema; use crate::test_fixtures::cube_bridge::{ MockBaseTools, MockCubeDefinition, MockCubeEvaluator, MockDimensionDefinition, MockDriverTools, MockGranularityDefinition, MockJoinGraph, MockJoinItemDefinition, MockMeasureDefinition, - MockPreAggregationDescription, MockSegmentDefinition, + MockPreAggregationDescription, MockSegmentDefinition, MockViewFilterDefinition, }; use cubenativeutils::CubeError; use std::collections::HashMap; @@ -340,6 +340,7 @@ impl MockSchemaBuilder { measures: HashMap::new(), dimensions: HashMap::new(), segments: HashMap::new(), + default_filters: Vec::new(), } } @@ -468,6 +469,7 @@ pub struct MockViewBuilder { measures: HashMap>, dimensions: HashMap>, segments: HashMap>, + default_filters: Vec, } impl MockViewBuilder { @@ -522,6 +524,11 @@ impl MockViewBuilder { self } + pub fn add_default_filter(mut self, filter: MockViewFilterDefinition) -> Self { + self.default_filters.push(filter); + self + } + pub fn finish_view(mut self) -> MockSchemaBuilder { let mut all_dimensions = self.dimensions; let mut all_measures = self.measures; @@ -634,6 +641,7 @@ impl MockViewBuilder { let view_def = MockCubeDefinition::builder() .name(self.view_name.clone()) .is_view(Some(true)) + .filters(self.default_filters) .build(); let view_cube = MockCube { diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/schema.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/schema.rs index d370edffeed5f..a219315db5e68 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/schema.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/yaml/schema.rs @@ -4,6 +4,7 @@ use crate::test_fixtures::cube_bridge::yaml::{ }; use crate::test_fixtures::cube_bridge::{ MockCubeDefinition, MockJoinItemDefinition, MockSchema, MockSchemaBuilder, + MockViewFilterDefinition, }; use cubenativeutils::CubeError; use serde::Deserialize; @@ -77,6 +78,21 @@ struct YamlPreAggregationEntry { struct YamlView { name: String, cubes: Vec, + #[serde(default)] + filters: Vec, +} + +#[derive(Debug, Deserialize)] +struct YamlViewFilter { + // Member references must be supplied in the view's own namespace + // (`.`); the YAML harness does not duplicate the JS + // evaluator's resolution logic. + member: String, + operator: String, + #[serde(default)] + values: Option>>, + #[serde(default)] + unless: Option>, } #[derive(Debug, Deserialize)] @@ -172,6 +188,18 @@ impl YamlSchema { view_builder.include_cube_with_prefix(view_cube.join_path, includes, prefix); } + for filter in view.filters { + // TypedBuilder uses a type-state chain, so set both optional + // legs in a single expression even when one of them is None. + let mock_filter = MockViewFilterDefinition::builder() + .operator(filter.operator) + .member_reference(filter.member) + .values_references(filter.values) + .unless_references(filter.unless) + .build(); + view_builder = view_builder.add_default_filter(mock_filter); + } + builder = view_builder.finish_view(); } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/view_default_filters.yaml b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/view_default_filters.yaml new file mode 100644 index 0000000000000..909027bfb3e43 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/view_default_filters.yaml @@ -0,0 +1,79 @@ +cubes: + - name: orders + sql: "SELECT * FROM dvf_orders" + dimensions: + - name: id + type: number + sql: id + primary_key: true + # Real dimension over the `country` column — used as the switch + # discriminator for `amount_in_country`. + - name: country + type: string + sql: country + # Virtual switch dimension. No `sql:` — the planner cross-joins each + # base row with every value in `values`, so an unfiltered query + # multiplies the row count by 3. A default filter on this member is + # what collapses the union back to a single currency. + - name: currency + type: switch + values: + - USD + - EUR + - GBP + measures: + - name: total_amount_usd + type: sum + sql: amount_usd + - name: total_amount_eur + type: sum + sql: amount_eur + # Switch-case measure dispatched by `country`. Without a default + # filter and without `country` in the query, the planner falls back + # to the `else` branch (USD total) — a behaviour the customer + # didn't expect; see CORE-357. + - name: amount_in_country + type: number + multi_stage: true + case: + switch: "{CUBE.country}" + when: + - value: "US" + sql: "{CUBE.total_amount_usd}" + - value: "CA" + sql: "{CUBE.total_amount_usd}" + - value: "DE" + sql: "{CUBE.total_amount_eur}" + - value: "FR" + sql: "{CUBE.total_amount_eur}" + else: + sql: "{CUBE.total_amount_usd}" + - name: count + type: count + +views: + # Unconditional default filter on the virtual `currency` switch — pins the + # union to USD even when the user explicitly groups by `currency`. + - name: orders_view + cubes: + - join_path: orders + includes: "*" + filters: + - member: orders_view.currency + operator: equals + values: + - USD + + # `unless: [currency]` releases the guard when the user pivots by + # `currency`, so they can see the full union (USD/EUR/GBP × rows). + - name: orders_view_with_unless + cubes: + - join_path: orders + includes: "*" + filters: + - member: orders_view_with_unless.currency + operator: equals + values: + - USD + unless: + - orders_view_with_unless.currency diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/seeds/view_default_filters_tables.sql b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/seeds/view_default_filters_tables.sql new file mode 100644 index 0000000000000..5601e21d358b7 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/seeds/view_default_filters_tables.sql @@ -0,0 +1,21 @@ +DROP TABLE IF EXISTS dvf_orders CASCADE; + +-- `currency` lives only in the model as a virtual `type: switch` dimension — +-- there is no `currency` column here on purpose. `country` drives the +-- switch-case measure. +CREATE TABLE dvf_orders ( + id INTEGER PRIMARY KEY, + country TEXT NOT NULL, + amount_usd NUMERIC(10, 2) NOT NULL, + amount_eur NUMERIC(10, 2) NOT NULL +); + +-- Five base rows × 3 switch values = 15 cells in the union; the default +-- filter pins it to the USD branch, leaving 5 cells (one per base row). +-- Without the default filter the user would see 15. +INSERT INTO dvf_orders (id, country, amount_usd, amount_eur) VALUES + (1, 'US', 100.00, 92.00), + (2, 'CA', 50.00, 46.00), + (3, 'DE', 80.00, 75.00), + (4, 'FR', 30.00, 28.00), + (5, 'GB', 60.00, 56.00); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/mod.rs index 40dc4b4315be6..c46e864d6b34e 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/mod.rs @@ -18,4 +18,5 @@ mod single_cube; mod subquery_dimensions; mod time_dimensions; mod transitive_joins; +mod view_default_filters; mod views; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__explicit_filter_overrides_default.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__explicit_filter_overrides_default.snap new file mode 100644 index 0000000000000..cd1d88bd59383 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__explicit_filter_overrides_default.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs +assertion_line: 115 +expression: result +--- +orders_view_with_unless__currency | orders_view_with_unless__count +----------------------------------+------------------------------- +EUR | 5 diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__switch_case_measure_with_default_filter.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__switch_case_measure_with_default_filter.snap new file mode 100644 index 0000000000000..59a2370a18be1 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__switch_case_measure_with_default_filter.snap @@ -0,0 +1,12 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs +assertion_line: 134 +expression: result +--- +orders_view__country | orders_view__amount_in_country +---------------------+------------------------------- +CA | 50.00 +DE | 75.00 +FR | 28.00 +GB | 60.00 +US | 100.00 diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_does_not_trigger_on_projection_alone.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_does_not_trigger_on_projection_alone.snap new file mode 100644 index 0000000000000..5c75e550dc5c8 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_does_not_trigger_on_projection_alone.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs +assertion_line: 79 +expression: result +--- +orders_view_with_unless__currency | orders_view_with_unless__count +----------------------------------+------------------------------- +USD | 5 diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_keeps_default_filter_when_member_is_not_touched.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_keeps_default_filter_when_member_is_not_touched.snap new file mode 100644 index 0000000000000..ba39b298217e4 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__unless_keeps_default_filter_when_member_is_not_touched.snap @@ -0,0 +1,12 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs +assertion_line: 104 +expression: result +--- +orders_view_with_unless__country | orders_view_with_unless__count +---------------------------------+------------------------------- +CA | 1 +DE | 1 +FR | 1 +GB | 1 +US | 1 diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__virtual_switch_default_filter_collapses_union.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__virtual_switch_default_filter_collapses_union.snap new file mode 100644 index 0000000000000..a2924aad2f538 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__view_default_filters__virtual_switch_default_filter_collapses_union.snap @@ -0,0 +1,8 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs +assertion_line: 45 +expression: result +--- +orders_view__currency | orders_view__count +----------------------+------------------- +USD | 5 diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs new file mode 100644 index 0000000000000..31daad6b2c4f7 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/view_default_filters.rs @@ -0,0 +1,176 @@ +use crate::test_fixtures::cube_bridge::MockSchema; +use crate::test_fixtures::test_utils::TestContext; +use indoc::indoc; + +const SEED: &str = "view_default_filters_tables.sql"; + +fn create_context() -> TestContext { + let schema = MockSchema::from_yaml_file("common/view_default_filters.yaml"); + TestContext::new(schema).unwrap() +} + +// `currency` is a virtual `type: switch` dimension with values +// `[USD, EUR, GBP]`. Without a default filter the planner cross-joins every +// row with every value (5 rows × 3 currencies = 15 cells). The +// unconditional default filter `currency = USD` must collapse the union to +// the USD branch, leaving 5 rows. +// +// This is CORE-357: the customer expected a default value here, not the +// union; without the default filter `amount_in_currency` / `count` +// silently rolls up across all currencies. +#[tokio::test(flavor = "multi_thread")] +async fn test_virtual_switch_default_filter_collapses_union() { + let ctx = create_context(); + + let query = indoc! {" + measures: + - orders_view.count + dimensions: + - orders_view.currency + order: + - id: orders_view.currency + "}; + + let sql = ctx.build_sql(query).unwrap(); + assert!( + sql.contains("'USD' = $"), + "expected the default switch-value filter `'USD' = ?` in SQL, got: {sql}" + ); + assert!( + !sql.contains("'EUR'"), + "EUR branch must be pruned by the default filter, got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg(query, SEED).await { + insta::assert_snapshot!(result); + } +} + +// `unless` is filter-only: pulling the member into a projection does NOT +// release the default filter, because that would make the row count +// silently depend on which columns the user selects. Same view as the +// "with_unless" fixture, currency now in dimensions — and the default +// filter still applies. The user sees a single USD row, just like the +// unconditional view above. +#[tokio::test(flavor = "multi_thread")] +async fn test_unless_does_not_trigger_on_projection_alone() { + let ctx = create_context(); + + let query = indoc! {" + measures: + - orders_view_with_unless.count + dimensions: + - orders_view_with_unless.currency + order: + - id: orders_view_with_unless.currency + "}; + + let sql = ctx.build_sql(query).unwrap(); + assert!( + sql.contains("'USD' = $"), + "projection alone must not release the default filter, got: {sql}" + ); + assert!( + !sql.contains("'EUR'"), + "EUR branch must stay pruned when only projection touches `currency`, got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg(query, SEED).await { + insta::assert_snapshot!(result); + } +} + +// `unless` is filter-only: an explicit filter on the unless-member is the +// only thing that releases the default. The user filters by `EUR`, the +// `USD` default is dropped, and only EUR rows come back. +#[tokio::test(flavor = "multi_thread")] +async fn test_explicit_filter_overrides_default() { + let ctx = create_context(); + + let query = indoc! {" + measures: + - orders_view_with_unless.count + dimensions: + - orders_view_with_unless.currency + filters: + - member: orders_view_with_unless.currency + operator: equals + values: + - EUR + order: + - id: orders_view_with_unless.currency + "}; + + let sql = ctx.build_sql(query).unwrap(); + assert!( + !sql.contains("'USD' = $"), + "an explicit filter on the unless-member must release the default, got: {sql}" + ); + assert!( + sql.contains("'EUR' = $"), + "user-supplied EUR filter must reach the SQL as a `'EUR' = ?` switch filter, got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg(query, SEED).await { + insta::assert_snapshot!(result); + } +} + +// `unless: [currency]` — but the user doesn't touch `currency`, so the +// default filter is still in effect. The query groups by `country`, the +// virtual switch is not in dimensions, so there is no cross-join — the +// default filter still fires (visible as `'USD' = $`) but the result +// table is just the per-country counts. +#[tokio::test(flavor = "multi_thread")] +async fn test_unless_keeps_default_filter_when_member_is_not_touched() { + let ctx = create_context(); + + let query = indoc! {" + measures: + - orders_view_with_unless.count + dimensions: + - orders_view_with_unless.country + order: + - id: orders_view_with_unless.country + "}; + + let sql = ctx.build_sql(query).unwrap(); + assert!( + sql.contains("'USD' = $"), + "default filter must apply when `unless` member is absent, got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg(query, SEED).await { + insta::assert_snapshot!(result); + } +} + +// `amount_in_country` is a switch-case measure dispatched by `country`. +// With `country` in dimensions every branch resolves correctly (US/CA → +// USD amounts, DE/FR → EUR amounts, GB falls through to `else`). The +// unconditional default filter on virtual `currency` rides along — it +// must still appear in the SQL to confirm the filter wiring reaches +// switch-case measures the same way it reaches plain counts. +#[tokio::test(flavor = "multi_thread")] +async fn test_switch_case_measure_with_default_filter() { + let ctx = create_context(); + + let query = indoc! {" + measures: + - orders_view.amount_in_country + dimensions: + - orders_view.country + order: + - id: orders_view.country + "}; + + let sql = ctx.build_sql(query).unwrap(); + assert!( + sql.contains("'USD' = $"), + "default filter must apply alongside a switch-case measure, got: {sql}" + ); + + if let Some(result) = ctx.try_execute_pg(query, SEED).await { + insta::assert_snapshot!(result); + } +} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/mod.rs index 5b5cba83ac091..46ec50e758885 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/mod.rs @@ -12,5 +12,6 @@ mod member_expressions_on_views; mod string_measures; mod subquery_dimensions; mod utils; +mod view_default_filters; mod integration; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/view_default_filters.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/view_default_filters.rs new file mode 100644 index 0000000000000..90ac4776239ce --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/view_default_filters.rs @@ -0,0 +1,194 @@ +use crate::planner::filter::FilterItem; +use crate::test_fixtures::cube_bridge::{ + MockDimensionDefinition, MockMeasureDefinition, MockSchemaBuilder, MockViewFilterDefinition, +}; +use crate::test_fixtures::test_utils::TestContext; +use indoc::indoc; + +fn build_schema_with_default_filter(filter: MockViewFilterDefinition) -> TestContext { + let schema = MockSchemaBuilder::new() + .add_cube("orders") + .add_dimension( + "id", + MockDimensionDefinition::builder() + .dimension_type("number".to_string()) + .sql("id".to_string()) + .primary_key(Some(true)) + .build(), + ) + .add_dimension( + "currency", + MockDimensionDefinition::builder() + .dimension_type("string".to_string()) + .sql("currency".to_string()) + .build(), + ) + .add_dimension( + "country", + MockDimensionDefinition::builder() + .dimension_type("string".to_string()) + .sql("country".to_string()) + .build(), + ) + .add_measure( + "count", + MockMeasureDefinition::builder() + .measure_type("count".to_string()) + .sql("COUNT(*)".to_string()) + .build(), + ) + .finish_cube() + .add_view("orders_view") + .include_cube( + "orders", + vec![ + "id".to_string(), + "currency".to_string(), + "country".to_string(), + "count".to_string(), + ], + ) + .add_default_filter(filter) + .finish_view() + .build(); + + TestContext::new(schema).unwrap() +} + +fn extract_member_paths(filters: &[FilterItem]) -> Vec { + filters + .iter() + .flat_map(|f| f.all_member_evaluators()) + .map(|m| m.full_name()) + .collect() +} + +#[test] +fn test_default_filter_applies_when_view_is_active() { + let ctx = build_schema_with_default_filter( + MockViewFilterDefinition::builder() + .operator("equals".to_string()) + .member_reference("orders_view.currency".to_string()) + .values_references(Some(vec![Some("USD".to_string())])) + .build(), + ); + + let query = indoc! {" + measures: + - orders_view.count + "}; + + let props = ctx.create_query_properties(query).unwrap(); + let mentioned = extract_member_paths(props.dimensions_filters()); + assert_eq!(mentioned, vec!["orders_view.currency".to_string()]); +} + +#[test] +fn test_default_filter_keeps_applying_when_unless_member_is_only_in_dimensions() { + // `unless` is intentionally filter-only: pulling the member into the + // projection does not release the guard, because doing so would make + // the row set silently depend on which columns the user selects. + let ctx = build_schema_with_default_filter( + MockViewFilterDefinition::builder() + .operator("equals".to_string()) + .member_reference("orders_view.currency".to_string()) + .values_references(Some(vec![Some("USD".to_string())])) + .unless_references(Some(vec!["orders_view.currency".to_string()])) + .build(), + ); + + let query = indoc! {" + measures: + - orders_view.count + dimensions: + - orders_view.currency + "}; + + let props = ctx.create_query_properties(query).unwrap(); + let mentioned = extract_member_paths(props.dimensions_filters()); + assert_eq!(mentioned, vec!["orders_view.currency".to_string()]); +} + +#[test] +fn test_default_filter_released_when_explicit_filter_on_unless_member() { + // Explicit filter on the same member overrides the default — that is + // the scenario `unless` is meant to handle. + let ctx = build_schema_with_default_filter( + MockViewFilterDefinition::builder() + .operator("equals".to_string()) + .member_reference("orders_view.currency".to_string()) + .values_references(Some(vec![Some("USD".to_string())])) + .unless_references(Some(vec!["orders_view.currency".to_string()])) + .build(), + ); + + let query = indoc! {" + measures: + - orders_view.count + filters: + - member: orders_view.currency + operator: equals + values: + - EUR + "}; + + let props = ctx.create_query_properties(query).unwrap(); + // Only the user-supplied filter remains; the default `currency = USD` + // has been released by `unless`. + let mentioned = extract_member_paths(props.dimensions_filters()); + assert_eq!(mentioned, vec!["orders_view.currency".to_string()]); + assert_eq!( + props.dimensions_filters().len(), + 1, + "default filter must be released when an explicit filter on the same member is present, \ + got {} filter(s)", + props.dimensions_filters().len(), + ); +} + +#[test] +fn test_default_filter_applies_when_unless_member_is_not_mentioned() { + let ctx = build_schema_with_default_filter( + MockViewFilterDefinition::builder() + .operator("equals".to_string()) + .member_reference("orders_view.currency".to_string()) + .values_references(Some(vec![Some("USD".to_string())])) + .unless_references(Some(vec!["orders_view.currency".to_string()])) + .build(), + ); + + let query = indoc! {" + measures: + - orders_view.count + dimensions: + - orders_view.country + "}; + + let props = ctx.create_query_properties(query).unwrap(); + let mentioned = extract_member_paths(props.dimensions_filters()); + assert_eq!(mentioned, vec!["orders_view.currency".to_string()]); +} + +#[test] +fn test_default_filter_applies_even_when_member_is_in_dimensions_without_unless() { + // With no `unless`, the filter is unconditional once the view is active — + // user explicitly opted into that behavior by omitting the guard. + let ctx = build_schema_with_default_filter( + MockViewFilterDefinition::builder() + .operator("equals".to_string()) + .member_reference("orders_view.currency".to_string()) + .values_references(Some(vec![Some("USD".to_string())])) + .build(), + ); + + let query = indoc! {" + measures: + - orders_view.count + dimensions: + - orders_view.currency + "}; + + let props = ctx.create_query_properties(query).unwrap(); + let mentioned = extract_member_paths(props.dimensions_filters()); + assert_eq!(mentioned, vec!["orders_view.currency".to_string()]); +} From 3671fb69a19efd42f173fde3cfe634e7d579961f Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Fri, 15 May 2026 16:22:03 +0200 Subject: [PATCH 4/4] test(tesseract): e2e Postgres tests for view default value filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New file scaffolded on calendars.test.ts: a single inline YAML model with both a real string dimension (`country`) and a virtual `type: switch` dimension (`currency`), each exposed through two views — `_unconditional` and `_with_unless`. Tests run through the full JS pipeline (Joi schema, YAML transpiler, CubeEvaluator.prepareViewFilters, BaseQuery, Tesseract planner) against testcontainers Postgres, gated on `nativeSqlPlanner`. Six cases — three per filter flavour: default applies without `unless`, projection alone does not release the default, an explicit filter on the unless-member overrides the default. YamlCompiler fix in the same commit: `filters.[N].member` (string) and `filters.[N].unless` (array) are now wrapped as f-string literals, like `values`. They are member references in the view's own namespace, not Python expressions — and the view's `includedMembers` are not resolvable at transpile time, so the previous default path treated names like `country` as undefined identifiers and blew up at runtime with `country is not defined`. --- .../src/compiler/YamlCompiler.ts | 14 +- .../view-default-value-filters.test.ts | 235 ++++++++++++++++++ 2 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 packages/cubejs-schema-compiler/test/integration/postgres/view-default-value-filters.test.ts diff --git a/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts b/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts index a8f4b2e451d5e..078308a6efe25 100644 --- a/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts @@ -187,15 +187,27 @@ export class YamlCompiler { for (const p of transpiledFieldsPatterns) { const fullPath = propertyPath.join('.'); if (fullPath.match(p)) { + // View default filter `member` / `unless` are member references in + // the view's own namespace — not Python expressions — so they go + // through the same f-string path as `values`. The view's + // `includedMembers` are not resolvable at transpile time, so + // running them through the Python parser would treat the name + // as an undefined identifier. + const isViewFilterMember = /^filters\.\d+\.member$/.test(fullPath); + const isViewFilterUnless = /^filters\.\d+\.unless$/.test(fullPath); if (typeof obj === 'string' && ['sql', 'sqlTable'].includes(propertyPath[propertyPath.length - 1])) { return this.parsePythonIntoArrowFunction(`f"${this.escapeDoubleQuotes(obj)}"`, cubeName, obj, errorsReport); + } else if (typeof obj === 'string' && isViewFilterMember) { + return this.parsePythonIntoArrowFunction(`f"${this.escapeDoubleQuotes(obj)}"`, cubeName, obj, errorsReport); } else if (typeof obj === 'string') { return this.parsePythonIntoArrowFunction(obj, cubeName, obj, errorsReport); } else if (Array.isArray(obj)) { + const treatAsLiteral = + propertyPath[propertyPath.length - 1] === 'values' || isViewFilterUnless; const resultAst = t.program([t.expressionStatement(t.arrayExpression(obj.map(code => { let ast: t.Program | t.NullLiteral | t.BooleanLiteral | t.NumericLiteral | null = null; // Special case for accessPolicy.rowLevel.filter.values and other values-like fields - if (propertyPath[propertyPath.length - 1] === 'values') { + if (treatAsLiteral) { if (typeof code === 'string') { ast = this.parsePythonAndTranspileToJs(`f"${this.escapeDoubleQuotes(code)}"`, errorsReport); } else if (typeof code === 'boolean') { diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/view-default-value-filters.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/view-default-value-filters.test.ts new file mode 100644 index 0000000000000..3ef0d5f68f8c3 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/postgres/view-default-value-filters.test.ts @@ -0,0 +1,235 @@ +import { getEnv } from '@cubejs-backend/shared'; +import { PostgresQuery } from '../../../src/adapter'; +import { prepareYamlCompiler } from '../../unit/PrepareCompiler'; +import { dbRunner } from './PostgresDBRunner'; + +describe('View default value filters', () => { + jest.setTimeout(200000); + + // Two flavours of default filter live side-by-side: + // + // * `orders_view_*_real` — `country` is a real string dimension, the + // default filter rewrites the WHERE clause. + // * `orders_view_*_switch` — `currency` is a virtual `type: switch` + // dimension; the default filter pins the switch union to one branch. + // + // Each cube exposes both an `_unconditional` view (no `unless`) and a + // `_with_unless` view (`unless: []`). The seed has five rows with + // mixed `country` so we can spot bugs by row count alone. + // + // language=YAML + const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler(` +cubes: + - name: orders + sql: > + SELECT * FROM (VALUES + (1, 'US', 100, 92), + (2, 'CA', 50, 46), + (3, 'DE', 80, 75), + (4, 'FR', 30, 28), + (5, 'GB', 60, 56) + ) AS t(id, country, amount_usd, amount_eur) + + dimensions: + - name: id + sql: id + type: number + primary_key: true + public: true + + - name: country + sql: country + type: string + + - name: currency + type: switch + values: + - USD + - EUR + - GBP + + measures: + - name: count + type: count + + - name: total_amount_usd + type: sum + sql: amount_usd + + - name: total_amount_eur + type: sum + sql: amount_eur + +views: + - name: orders_view_real_unconditional + cubes: + - join_path: orders + includes: "*" + filters: + - member: country + operator: equals + values: + - US + + - name: orders_view_real_with_unless + cubes: + - join_path: orders + includes: "*" + filters: + - member: country + operator: equals + values: + - US + unless: + - country + + - name: orders_view_switch_unconditional + cubes: + - join_path: orders + includes: "*" + filters: + - member: currency + operator: equals + values: + - USD + + - name: orders_view_switch_with_unless + cubes: + - join_path: orders + includes: "*" + filters: + - member: currency + operator: equals + values: + - USD + unless: + - currency + `); + + async function runQueryTest(q: any, expectedResult: any) { + // Default value filters are wired only through the Tesseract planner. + if (!getEnv('nativeSqlPlanner')) { + return; + } + + await compiler.compile(); + const query = new PostgresQuery( + { joinGraph, cubeEvaluator, compiler }, + { ...q, timezone: 'UTC', preAggregationsSchema: '' } + ); + + const qp = query.buildSqlAndParams(); + const res = await dbRunner.testQuery(qp); + + expect(res).toEqual(expectedResult); + } + + describe('Real dimension default filter', () => { + // Default filter pins `country = US`. Five rows in the cube, one with + // country='US' — `count` must be 1. + it('applies when no `unless` and no relevant projection', async () => runQueryTest( + { + measures: ['orders_view_real_unconditional.count'], + }, + [ + { + orders_view_real_unconditional__count: '1', + }, + ] + )); + + // Projection adds `country` to the SELECT but does NOT release the + // default — only the US row survives. + it('keeps applying when `unless: [country]` and country is only in projection', async () => runQueryTest( + { + measures: ['orders_view_real_with_unless.count'], + dimensions: ['orders_view_real_with_unless.country'], + order: [{ id: 'orders_view_real_with_unless.country' }], + }, + [ + { + orders_view_real_with_unless__country: 'US', + orders_view_real_with_unless__count: '1', + }, + ] + )); + + // Explicit filter on `country` releases the default — only the user's + // FR row remains. + it('is released when `unless: [country]` and explicit filter on country', async () => runQueryTest( + { + measures: ['orders_view_real_with_unless.count'], + filters: [ + { + member: 'orders_view_real_with_unless.country', + operator: 'equals', + values: ['FR'], + }, + ], + }, + [ + { + orders_view_real_with_unless__count: '1', + }, + ] + )); + }); + + describe('Virtual switch dimension default filter', () => { + // No filter at all would unfold five base rows × three switch values + // = 15 cells. The default `currency = USD` pins the union to the USD + // branch, leaving five rows-as-cells, so count=5. + it('collapses the switch union when no `unless`', async () => runQueryTest( + { + measures: ['orders_view_switch_unconditional.count'], + dimensions: ['orders_view_switch_unconditional.currency'], + order: [{ id: 'orders_view_switch_unconditional.currency' }], + }, + [ + { + orders_view_switch_unconditional__currency: 'USD', + orders_view_switch_unconditional__count: '5', + }, + ] + )); + + // Projection of `currency` does not release the default with + // `unless: [currency]`: union is still pinned to USD only. + it('keeps the union pinned when `unless: [currency]` and currency is only in projection', async () => runQueryTest( + { + measures: ['orders_view_switch_with_unless.count'], + dimensions: ['orders_view_switch_with_unless.currency'], + order: [{ id: 'orders_view_switch_with_unless.currency' }], + }, + [ + { + orders_view_switch_with_unless__currency: 'USD', + orders_view_switch_with_unless__count: '5', + }, + ] + )); + + // Explicit filter `currency = EUR` releases the default and replaces + // it: only the EUR branch survives. + it('is released when `unless: [currency]` and explicit filter on currency', async () => runQueryTest( + { + measures: ['orders_view_switch_with_unless.count'], + dimensions: ['orders_view_switch_with_unless.currency'], + filters: [ + { + member: 'orders_view_switch_with_unless.currency', + operator: 'equals', + values: ['EUR'], + }, + ], + order: [{ id: 'orders_view_switch_with_unless.currency' }], + }, + [ + { + orders_view_switch_with_unless__currency: 'EUR', + orders_view_switch_with_unless__count: '5', + }, + ] + )); + }); +});