Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }),
})),
};
}
Comment on lines 788 to 796

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix works (the preAggregations getter in CubeSymbols.createCube memoizes a fresh merged map per cube object, so writing the key back only affects this cube), but it's worth noting two things:

  1. It writes into a map whose setter is explicitly set preAggregations(_v) { /* Dont allow to modify */ }. Mutating the object the getter hands out sidesteps that intent and depends on an invariant declared in another file. A short reference to that invariant in the comment ("the map returned by the getter is per-cube, only its values are shared") would keep this from looking wrong to the next reader.
  2. The copy is conditional on outputColumnTypes. Everything above (lines 731–783) still mutates the shared pre-aggregation object in place — delete preAggregation.timeDimension, refreshRangeStart = buildRangeStart, etc. Those happen to be idempotent and not cube-scoped, so no correctness bug today, but the buildRangeStart/refreshRangeStart warning now fires only for whichever cube is prepared first. If you copied the entry unconditionally at the top of the loop body instead, the whole loop would operate on a cube-owned object and the class of bug this PR is fixing couldn't come back here.

Minor: object spread drops accessors/non-enumerable properties. This file itself attaches a non-enumerable maskSql getter to members (line 847), so the pattern isn't hypothetical — if a pre-aggregation ever grows one, it would silently vanish for the extending cube.

}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
Comment on lines +104 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good shape — asserting the parent's plan is identical with and without the extending cube is the right regression invariant, since "last cube prepared wins" is exactly what the bug was.

Two coverage gaps worth considering, both cheap to add to the existing model string:

  • Sibling cubes (child_a extends base_fact, child_b extends base_fact). With one child, the failure mode is "child overwrote base"; with two, it's also "child_b overwrote child_a", which is the case a per-site copy could still get wrong if any copy were shallow one level too few.
  • A chain (grandchild extends child_fact). The prototype chain is two deep there, and the map merge happens per level.

Also, the view-default-filter and pre-aggregation blocks only assert the resolved reference strings, not that a query/rollup match still builds — an expect(buildSqlAndParams()).toBeDefined() on the child view would make those two mirror the strength of this block.


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']);
});
});
Loading
Loading