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..173286be336b9 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,87 @@ 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) || []; + + // 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; + + if (reference.indexOf('.') !== -1) { + const parts = reference.split('.'); + if (parts[0] === cube.name) { + lookupName = parts.slice(1).join('.'); + } else { + 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 `${cube.name}.${match.name}`; + }; + + 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/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/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/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/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', + }, + ] + )); + }); +}); 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 = { diff --git a/packages/cubejs-schema-compiler/test/unit/schema.test.ts b/packages/cubejs-schema-compiler/test/unit/schema.test.ts index 9a37dcf487f32..1277ced23d079 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_view.currency'); + expect(filters[0].valuesReferences).toEqual(['USD']); + expect(filters[0].unlessReferences).toEqual(['orders_view.currency', 'orders_view.country']); + + expect(filters[1].operator).toBe('set'); + 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_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]); + }); + + 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_view.currency', + 'orders_view.currency', + 'orders_view.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/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_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_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/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; 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()]); +}