From 38a7d12d2bef22aff22ffed552e22f1d247c92b9 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Tue, 25 Aug 2026 15:22:25 +0200 Subject: [PATCH 1/2] fix(schema-compiler): resolve inherited definitions per cube MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extends` hands the extending cube the very definitions of the cube it extends: `allDefinitions` merges the parent's member objects by reference, and a view's `default_filters` reach the extending view through the prototype. References resolved per cube were written into those shared objects, so the last cube prepared won and every other cube ended up carrying member paths of a cube that is not its own. Multi-stage members were hit through their nested `grain` and `filter` objects — `prepareMembers` copies the member itself, but not what hangs off it — which broke every multi-stage measure of the extended cube, including queries that never mention the extending one. A parent's `grain.include: [d]` came out as `["child.d"]`, and planning then either found no join path to that cube or reported the grain dimension as unreachable, naming a dimension the model already declares. The same aliasing hit two more places: a view's default filter resolved to a member of the view extending it, and a pre-aggregation's `outputColumnTypes` names were scoped to the extending cube. Copy each of them before writing the resolved references. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/compiler/CubeEvaluator.ts | 50 +++- .../unit/extends-shared-definitions.test.ts | 255 ++++++++++++++++++ 2 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 packages/cubejs-schema-compiler/test/unit/extends-shared-definitions.test.ts diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index 4b2fa35409886..2909d43bf8c2e 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -299,6 +299,12 @@ export class CubeEvaluator extends CubeSymbols { return `${cube.name}.${match.name}`; }; + // A view extending another one inherits its `default_filters` entries by + // reference, so the resolved references have to go into a copy owned by this + // view. Written in place they would resolve to whichever view is prepared + // last, pointing the other views' filters at members they do not include. + cube.defaultFilters = (cube.defaultFilters as ViewDefaultValueFilter[]).map(f => ({ ...f })); + for (const filter of cube.defaultFilters as ViewDefaultValueFilter[]) { const rawMember = this.evaluateReferences(cube.name, filter.member); const resolved = resolveViewMember('member', rawMember); @@ -641,24 +647,33 @@ export class CubeEvaluator extends CubeSymbols { : {}), })); } + // `filter` and `grain` are nested objects, and a cube extending another + // one inherits them by reference, so the resolved references have to go + // into a copy owned by this cube. Written in place they would resolve to + // whichever cube is prepared last, pointing every member of the other + // cubes at that cube's dimensions. if (member.filter) { - if (typeof member.filter.exclude === 'function') { - member.filter.excludeReferences = this.evaluateReferences(cubeName, member.filter.exclude); + const filter = { ...member.filter }; + if (typeof filter.exclude === 'function') { + filter.excludeReferences = this.evaluateReferences(cubeName, filter.exclude); } - if (typeof member.filter.keepOnly === 'function') { - member.filter.keepOnlyReferences = this.evaluateReferences(cubeName, member.filter.keepOnly); + if (typeof filter.keepOnly === 'function') { + filter.keepOnlyReferences = this.evaluateReferences(cubeName, filter.keepOnly); } + member.filter = filter; } if (member.grain) { - if (typeof member.grain.exclude === 'function') { - member.grain.excludeReferences = this.evaluateReferences(cubeName, member.grain.exclude); + const grain = { ...member.grain }; + if (typeof grain.exclude === 'function') { + grain.excludeReferences = this.evaluateReferences(cubeName, grain.exclude); } - if (typeof member.grain.keepOnly === 'function') { - member.grain.keepOnlyReferences = this.evaluateReferences(cubeName, member.grain.keepOnly); + if (typeof grain.keepOnly === 'function') { + grain.keepOnlyReferences = this.evaluateReferences(cubeName, grain.keepOnly); } - if (typeof member.grain.include === 'function') { - member.grain.includeReferences = this.evaluateReferences(cubeName, member.grain.include); + if (typeof grain.include === 'function') { + grain.includeReferences = this.evaluateReferences(cubeName, grain.include); } + member.grain = grain; } } } @@ -711,7 +726,7 @@ export class CubeEvaluator extends CubeSymbols { protected preparePreAggregations(cube: any, errorReporter: ErrorReporter) { if (cube.preAggregations) { // eslint-disable-next-line no-restricted-syntax - for (const preAggregation of Object.values(cube.preAggregations) as any) { + for (const [preAggregationName, preAggregation] of Object.entries(cube.preAggregations) as any) { // preAggregation is actually (PreAggregationDefinitionRollup | PreAggregationDefinitionOriginalSql) if (preAggregation.timeDimension) { preAggregation.timeDimensionReference = preAggregation.timeDimension; @@ -767,10 +782,17 @@ export class CubeEvaluator extends CubeSymbols { delete preAggregation.buildRangeEnd; } + // `outputColumnTypes` names are resolved against this cube, and a cube + // extending another one inherits the pre-aggregation by reference, so + // both the entry and its columns have to be copies owned by this cube. if (preAggregation.outputColumnTypes) { - preAggregation.outputColumnTypes.forEach(column => { - column.name = this.evaluateReferences(cube.name, column.member, { originalSorting: true }); - }); + cube.preAggregations[preAggregationName] = { + ...preAggregation, + outputColumnTypes: preAggregation.outputColumnTypes.map(column => ({ + ...column, + name: this.evaluateReferences(cube.name, column.member, { originalSorting: true }), + })), + }; } } } diff --git a/packages/cubejs-schema-compiler/test/unit/extends-shared-definitions.test.ts b/packages/cubejs-schema-compiler/test/unit/extends-shared-definitions.test.ts new file mode 100644 index 0000000000000..0c239f617dd56 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/extends-shared-definitions.test.ts @@ -0,0 +1,255 @@ +import { PostgresQuery } from '../../src/adapter/PostgresQuery'; +import { prepareYamlCompiler } from './PrepareCompiler'; + +// `extends` hands the extending cube the very definitions of the cube it extends, +// so every reference resolved per cube — a multi-stage `grain:`/`filter:`, a view +// default filter, a pre-aggregation's output column names — has to be written into +// an object owned by that cube. Written into the shared one it resolves to whichever +// cube is prepared last, and the other cubes end up carrying member paths of a cube +// that is not theirs. +describe('Multi-stage members of a cube that another cube extends', () => { + const baseFact = ` + - name: base_fact + sql: "SELECT 1 AS id, 1 AS dim_id, '2026-01-01'::date AS d, 10 AS v" + joins: + - name: dims + sql: "{CUBE}.dim_id = {dims}.id" + relationship: many_to_one + dimensions: + - name: id + sql: "{CUBE}.id" + type: number + primary_key: true + - name: d + sql: "{CUBE}.d" + type: time + - name: v + sql: "{CUBE}.v" + type: number + measures: + - name: v_sum + sql: "{v}" + type: sum + - name: daily_v + multi_stage: true + sql: "{v_sum}" + type: number + - name: linked + multi_stage: true + sql: "{daily_v}" + type: sum + grain: + include: + - d + - name: combining + multi_stage: true + sql: "CASE WHEN {d} IS NOT NULL THEN {daily_v} ELSE 0 END" + type: max + grain: + include: + - d + - name: outer_combining + multi_stage: true + sql: "{combining}" + type: number + - name: v_sum_all_dates + multi_stage: true + sql: "{v_sum}" + type: number + filter: + exclude: + - d +`; + + const dims = ` + - name: dims + sql: "SELECT 1 AS id, 'a' AS name" + dimensions: + - name: id + sql: "{CUBE}.id" + type: number + primary_key: true + - name: name + sql: "{CUBE}.name" + type: string +`; + + const childFact = ` + - name: child_fact + extends: base_fact + sql: "SELECT 1 AS id, 1 AS dim_id, '2026-01-01'::date AS d, 10 AS v, 'x' AS tag" + dimensions: + - name: tag + sql: "{CUBE}.tag" + type: string +`; + + const model = (withChild: boolean) => `cubes:${dims}${baseFact}${withChild ? childFact : ''}`; + + const compile = async (withChild: boolean) => { + const compilers = prepareYamlCompiler(model(withChild)); + await compilers.compiler.compile(); + return compilers; + }; + + const buildSql = async (withChild: boolean, query: any, useNativeSqlPlanner: boolean) => { + const compilers = await compile(withChild); + return new PostgresQuery(compilers, { + timezone: 'UTC', + useNativeSqlPlanner, + ...query, + }).buildSqlAndParams(); + }; + + it('resolves grain references against the cube that declares the member', async () => { + const { cubeEvaluator } = await compile(true); + + expect(cubeEvaluator.evaluatedCubes.base_fact.measures.linked.grain?.includeReferences) + .toEqual(['base_fact.d']); + expect(cubeEvaluator.evaluatedCubes.child_fact.measures.linked.grain?.includeReferences) + .toEqual(['child_fact.d']); + }); + + it('resolves filter references against the cube that declares the member', async () => { + const { cubeEvaluator } = await compile(true); + + expect(cubeEvaluator.evaluatedCubes.base_fact.measures.v_sum_all_dates.filter?.excludeReferences) + .toEqual(['base_fact.d']); + expect(cubeEvaluator.evaluatedCubes.child_fact.measures.v_sum_all_dates.filter?.excludeReferences) + .toEqual(['child_fact.d']); + }); + + describe.each([ + ['native', true], + ['legacy', false], + ])('%s planner', (_name, useNativeSqlPlanner) => { + // Planning the parent's member must not depend on the extending cube being + // there at all, so the plan is compared against the same model without it. + const expectSamePlanWithAndWithoutChild = async (query: any) => { + const [withoutChild] = await buildSql(false, query, useNativeSqlPlanner); + const [withChild] = await buildSql(true, query, useNativeSqlPlanner); + expect(withChild).toEqual(withoutChild); + }; + + it('plans a multi-stage measure with an explicit grain', async () => { + await expectSamePlanWithAndWithoutChild({ measures: ['base_fact.linked'] }); + await expect(buildSql(true, { measures: ['child_fact.linked'] }, useNativeSqlPlanner)).resolves.toBeDefined(); + }); + + it('plans a chained multi-stage measure reading a grain dimension', async () => { + await expectSamePlanWithAndWithoutChild({ measures: ['base_fact.outer_combining'] }); + await expectSamePlanWithAndWithoutChild({ + measures: ['base_fact.outer_combining'], + dimensions: ['dims.name'], + }); + }); + + it('plans a multi-stage measure with a filter directive', async () => { + await expectSamePlanWithAndWithoutChild({ + measures: ['base_fact.v_sum_all_dates'], + timeDimensions: [{ + dimension: 'base_fact.d', + granularity: 'month', + dateRange: ['2026-01-01', '2026-01-31'], + }], + }); + }); + + it('plans a plain measure of a cube that another cube extends', async () => { + await expect(buildSql(true, { measures: ['base_fact.v_sum'] }, useNativeSqlPlanner)).resolves.toBeDefined(); + }); + }); +}); + +describe('View default filters of a view that another view extends', () => { + const model = ` +cubes: + - name: orders + sql: "SELECT 1 AS id, 'usd' AS currency" + dimensions: + - name: id + sql: "{CUBE}.id" + type: number + primary_key: true + - name: currency + sql: "{CUBE}.currency" + type: string + measures: + - name: count + type: count + +views: + - name: base_view + cubes: + - join_path: orders + includes: + - currency + - count + default_filters: + - member: currency + operator: equals + values: ["usd"] + + - name: child_view + extends: base_view +`; + + it('resolves the filter member against the view that declares the filter', async () => { + const { compiler, cubeEvaluator } = prepareYamlCompiler(model); + await compiler.compile(); + + expect(cubeEvaluator.evaluatedCubes.base_view.defaultFilters?.map(f => f.memberReference)) + .toEqual(['base_view.currency']); + expect(cubeEvaluator.evaluatedCubes.child_view.defaultFilters?.map(f => f.memberReference)) + .toEqual(['child_view.currency']); + }); +}); + +describe('Pre-aggregations of a cube that another cube extends', () => { + const model = ` +cubes: + - name: base + sql: "SELECT 1 AS id, '2026-01-01'::date AS d" + dimensions: + - name: id + sql: "{CUBE}.id" + type: number + primary_key: true + - name: d + sql: "{CUBE}.d" + type: time + measures: + - name: count + type: count + pre_aggregations: + - name: main + dimensions: + - id + measures: + - count + time_dimension: d + granularity: day + output_column_types: + - member: id + type: integer + + - name: child + extends: base + sql: "SELECT 1 AS id, '2026-01-01'::date AS d, 'x' AS tag" + dimensions: + - name: tag + sql: "{CUBE}.tag" + type: string +`; + + it('resolves output column names against the cube that declares the pre-aggregation', async () => { + const { compiler, cubeEvaluator } = prepareYamlCompiler(model); + await compiler.compile(); + + const names = (cube: string) => (cubeEvaluator.evaluatedCubes[cube].preAggregations.main as any) + .outputColumnTypes.map((c: any) => c.name); + + expect(names('base')).toEqual(['base.id']); + expect(names('child')).toEqual(['child.id']); + }); +}); From 5123f13900c0e68c11c843e0e346eabba25c5491 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Tue, 25 Aug 2026 15:22:43 +0200 Subject: [PATCH 2/2] fix(tesseract): name the grain in the unreachable-dimension report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report for a dimension a multi-stage member reads outside its grain named the member, the dimension, and the declaration that fixes it — all of which the model already says — so a grain holding something else was indistinguishable from a grain the reader believed was right. Spell out the grain the member is computed at. Granularities are named separately (`orders.created_at (month)`) instead of being folded into the symbol name, and the one case a listing cannot disambiguate — the grain carrying the read dimension only under a granularity — gets a sentence of its own. Co-Authored-By: Claude Opus 5 (1M context) --- .../multi_stage/multi_stage_query_planner.rs | 89 ++++++++++++++++++- .../common/integration_multi_stage.yaml | 10 +++ .../integration/multi_stage/dimension_deps.rs | 30 +++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs index 845bbe7cba12b..97f9b488d943c 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/multi_stage_query_planner.rs @@ -414,15 +414,100 @@ impl MultiStageQueryPlanner { }) { return Ok(()); } + let grain = Self::grain_members(grain_state, parent_state); + // A granularity of the very dimension the sql reads looks like a match in + // the listing, so the one case where reading the grain is not enough to + // tell them apart is spelled out. + let target = dimension.clone().resolve_reference_chain().full_name(); + let hint = if grain + .iter() + .any(|m| Self::granular_time_dimension_base(m).as_ref() == Some(&target)) + { + format!( + " The grain carries {target} at a granularity, which is a value of its own and not \ + the dimension itself." + ) + } else { + String::new() + }; + let grain = if grain.is_empty() { + "no dimensions".to_string() + } else { + grain + .iter() + .map(Self::describe_grain_member) + .collect::>() + .join(", ") + }; Err(CubeError::user(format!( "Multi-stage member {member} reads dimension {dimension}, which is not part of the \ - grain it is computed at. Add {dimension} to `grain.include` of {member}, or remove \ - it from the member's sql.", + grain it is computed at ({grain}).{hint} Add {dimension} to `grain.include` of \ + {member}, or remove it from the member's sql.", member = member.full_name(), dimension = dimension.full_name(), ))) } + // The grain the member is computed at: the stage's own dimensions and, when + // the assembly broadcasts back onto the query grid, the keys side. + fn grain_members( + grain_state: &QueryProperties, + parent_state: &QueryProperties, + ) -> Vec> { + let mut members: Vec> = Vec::new(); + for state in [grain_state, parent_state] { + for dimension in state + .dimensions() + .iter() + .chain(state.time_dimensions().iter()) + { + let resolved = dimension.clone().resolve_reference_chain(); + if !members + .iter() + .any(|m| m.full_name() == resolved.full_name()) + { + members.push(resolved); + } + } + } + members + } + + // A time dimension carries its granularity inside its name, which reads as a + // member of its own; the granularity is named separately instead. + fn describe_grain_member(member: &Rc) -> String { + match member.as_ref() { + MemberSymbol::TimeDimension(time_dimension) => match time_dimension.granularity() { + Some(granularity) => format!( + "{} ({})", + time_dimension + .base_symbol() + .clone() + .resolve_reference_chain() + .full_name(), + granularity + ), + None => member.full_name(), + }, + _ => member.full_name(), + } + } + + fn granular_time_dimension_base(member: &Rc) -> Option { + match member.as_ref() { + MemberSymbol::TimeDimension(time_dimension) => { + time_dimension.granularity().as_ref().map(|_| { + time_dimension + .base_symbol() + .clone() + .resolve_reference_chain() + .full_name() + }) + } + _ => None, + } + } + /// Plans CASE-SWITCH dependencies: collects, per dependency, the /// union of switch values it covers and renders each dependency /// under a state with an equality filter on the switch member diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_stage.yaml b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_stage.yaml index 43f33edd26866..86cd98c2bfddb 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_stage.yaml +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_stage.yaml @@ -414,6 +414,16 @@ cubes: include: - orders.created_at + # The declared grain is a dimension of another cube, so it does not + # supply the dimension the sql reads. + - name: amount_first_half_of_month_grain_of_other_cube + type: sum + sql: "CASE WHEN EXTRACT(DAY FROM {CUBE.created_at}) <= 15 THEN {CUBE.total_amount} ELSE 0 END" + multi_stage: true + grain: + include: + - customers.city + # Undeclared read one stage further out: the dimension is read by a # measure whose aggregated dependency is itself multi-stage. - name: amount_prev_month_first_half diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_stage/dimension_deps.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_stage/dimension_deps.rs index c5383476b0e58..aa8f17d2b6796 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_stage/dimension_deps.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_stage/dimension_deps.rs @@ -58,6 +58,36 @@ async fn test_undeclared_time_dimension_read_is_reported() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn test_report_spells_out_the_grain_the_member_is_computed_at() { + let message = expect_error("amount_first_half_of_month"); + + assert!( + message.contains("orders.created_at (month)"), + "The error must spell out the grain the member is computed at:\n{}", + message + ); + assert!( + message.contains("at a granularity"), + "A granularity of the dimension the sql reads must not read as a match:\n{}", + message + ); +} + +/// A grain declared at another cube's dimension: naming the read dimension +/// alone would repeat what the model already says, so the grain the member is +/// actually computed at is what tells the two apart. +#[tokio::test(flavor = "multi_thread")] +async fn test_report_names_a_grain_declared_at_another_cube() { + let message = expect_error("amount_first_half_of_month_grain_of_other_cube"); + + assert!( + message.contains("orders.created_at") && message.contains("customers.city"), + "The error must name both the dimension the sql reads and the declared grain:\n{}", + message + ); +} + /// The reading member consumes a time-shifted multi-stage measure, so its own /// grain is settled one stage above the leaf that would have to carry the /// dimension.