Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { getEnv } from '@cubejs-backend/shared';
import { prepareYamlCompiler } from '../../unit/PrepareCompiler';
import { dbRunner } from './PostgresDBRunner';

// A multi-stage measure that declares both `grain` and `rolling_window`.
// The grain says at what grain the value inside a bucket is computed; the
// window says which rows land in the bucket. Both have to hold at once.
//
// Inline data: two equally weighted rows per day, so the weighted daily
// factor equals the row factor — 1.10, 1.20, 0.50. Linked over the three
// days that is exactly -0.34, and every window below is wide enough to
// cover all three, so at any grain coarser than a day the windowed measure
// has to agree with the plain one.
//
// The measures round to six digits so the assertions compare exact strings
// rather than IEEE noise from EXP/LN.
describe('Multi-Stage grain with rolling window', () => {
jest.setTimeout(200000);

const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler(`
cubes:
- name: returns
sql: >
SELECT '2024-01-01'::date as DAY, 'A' as SECURITY, 10.0 as IRR, 100.0 as WEIGHT
union all
SELECT '2024-01-01'::date, 'B', 10.0, 100.0
union all
SELECT '2024-01-02'::date, 'A', 20.0, 100.0
union all
SELECT '2024-01-02'::date, 'B', 20.0, 100.0
union all
SELECT '2024-01-03'::date, 'A', -50.0, 100.0
union all
SELECT '2024-01-03'::date, 'B', -50.0, 100.0

dimensions:
- name: day
sql: DAY
type: time

- name: security
sql: SECURITY
type: string

- name: irr
sql: IRR
type: number

- name: weight
sql: WEIGHT
type: number

measures:
- name: weight_sum
sql: "{CUBE.weight}"
type: sum

- name: irr_weight_sum
sql: "{CUBE.irr} * {CUBE.weight}"
type: sum

- name: daily_irr_weight
multi_stage: true
sql: "{CUBE.irr_weight_sum}"
type: number

- name: daily_weight
multi_stage: true
sql: "{CUBE.weight_sum}"
type: number

- name: log_return_sum
multi_stage: true
sql: "LN(1.0 + ({CUBE.daily_irr_weight} / NULLIF({CUBE.daily_weight}, 0)) / 100.0)"
type: sum
grain:
include:
- returns.day

- name: twr
multi_stage: true
sql: "ROUND((EXP({CUBE.log_return_sum}) - 1)::numeric, 6)"
type: number

- name: log_return_sum_ytd
multi_stage: true
sql: "LN(1.0 + ({CUBE.daily_irr_weight} / NULLIF({CUBE.daily_weight}, 0)) / 100.0)"
type: sum
grain:
include:
- returns.day
rolling_window:
type: to_date
granularity: year

- name: twr_ytd
multi_stage: true
sql: "ROUND((EXP({CUBE.log_return_sum_ytd}) - 1)::numeric, 6)"
type: number

- name: log_return_sum_1y
multi_stage: true
sql: "LN(1.0 + ({CUBE.daily_irr_weight} / NULLIF({CUBE.daily_weight}, 0)) / 100.0)"
type: sum
grain:
include:
- returns.day
rolling_window:
trailing: 1 year
offset: end

- name: twr_1y
multi_stage: true
sql: "ROUND((EXP({CUBE.log_return_sum_1y}) - 1)::numeric, 6)"
type: number
`);

if (getEnv('nativeSqlPlanner')) {
it('day granularity: the window accumulates over the declared grain', async () => dbRunner.runQueryTest({
measures: ['returns.twr', 'returns.twr_ytd', 'returns.twr_1y'],
timeDimensions: [{
dimension: 'returns.day',
granularity: 'day',
dateRange: ['2024-01-01', '2024-01-03'],
}],
order: [{ id: 'returns.day' }],
timezone: 'UTC',
}, [
// Per-day factors 1.10 / 1.20 / 0.50; the windows link them up.
{ returns__day_day: '2024-01-01T00:00:00.000Z', returns__twr: '0.100000', returns__twr_ytd: '0.100000', returns__twr_1y: '0.100000' },
{ returns__day_day: '2024-01-02T00:00:00.000Z', returns__twr: '0.200000', returns__twr_ytd: '0.320000', returns__twr_1y: '0.320000' },
{ returns__day_day: '2024-01-03T00:00:00.000Z', returns__twr: '-0.500000', returns__twr_ytd: '-0.340000', returns__twr_1y: '-0.340000' },
], { joinGraph, cubeEvaluator, compiler }));

it('month granularity: query grain does not replace the declared grain', async () => dbRunner.runQueryTest({
measures: ['returns.twr', 'returns.twr_ytd', 'returns.twr_1y'],
timeDimensions: [{
dimension: 'returns.day',
granularity: 'month',
dateRange: ['2024-01-01', '2024-01-31'],
}],
order: [{ id: 'returns.day' }],
timezone: 'UTC',
}, [
{ returns__day_month: '2024-01-01T00:00:00.000Z', returns__twr: '-0.340000', returns__twr_ytd: '-0.340000', returns__twr_1y: '-0.340000' },
], { joinGraph, cubeEvaluator, compiler }));

it('no time dimension: the window is one bucket over the whole range', async () => dbRunner.runQueryTest({
measures: ['returns.twr', 'returns.twr_ytd', 'returns.twr_1y'],
timezone: 'UTC',
}, [
{ returns__twr: '-0.340000', returns__twr_ytd: '-0.340000', returns__twr_1y: '-0.340000' },
], { joinGraph, cubeEvaluator, compiler }));

it('non-time dimension: the grain survives a plain group by', async () => dbRunner.runQueryTest({
measures: ['returns.twr', 'returns.twr_ytd', 'returns.twr_1y'],
dimensions: ['returns.security'],
order: [{ id: 'returns.security' }],
timezone: 'UTC',
}, [
{ returns__security: 'A', returns__twr: '-0.340000', returns__twr_ytd: '-0.340000', returns__twr_1y: '-0.340000' },
{ returns__security: 'B', returns__twr: '-0.340000', returns__twr_ytd: '-0.340000', returns__twr_1y: '-0.340000' },
], { joinGraph, cubeEvaluator, compiler }));
} else {
test.skip('day granularity: the window accumulates over the declared grain', () => { expect(1).toBe(1); });
test.skip('month granularity: query grain does not replace the declared grain', () => { expect(1).toBe(1); });
test.skip('no time dimension: the window is one bucket over the whole range', () => { expect(1).toBe(1); });
test.skip('non-time dimension: the grain survives a plain group by', () => { expect(1).toBe(1); });
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,38 @@ impl MultiStageQueryPlanner {
}
}

// Of the grain keys only `include` reaches the rolling
// assembly. It extends the grain the values inside a bucket are
// computed at, which the base CTE carries anyway. `exclude` and
// `keep_only` narrow the grain the value is *reported* at, and a
// narrowed value has to be broadcast back onto the query grid;
// the rolling window node has no side enumerating that grid, so
// there is nothing to broadcast from. Reject them instead of
// computing at the unnarrowed grain and calling it the answer.
//
// `reduce_by` and `group_by` compile into the same two lists, so
// the message names both spellings — the model may contain
// neither of the words the grain keys are called by here.
let grain = measure
.multi_stage()
.map(|ms| ms.grain.clone())
.unwrap_or_default();
// `keep_only` is an intersection, so declaring it empty narrows
// the grain to nothing rather than meaning "no key given";
// `exclude` subtracts, so an empty list really does nothing.
let narrows_grain = grain.exclude.as_ref().is_some_and(|v| !v.is_empty())
|| grain.keep_only.is_some();
if narrows_grain {
return Err(CubeError::user(format!(
"Measure {} declares `rolling_window` together with `grain.exclude` / \
`reduce_by` or `grain.keep_only` / `group_by`, which is not supported. \
Only `grain.include` (`add_group_by`) can be combined with a rolling \
window.",
member.full_name(),
)));
}
let grain_include = grain.include.clone().unwrap_or_default();
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

let ungrouped = measure.is_rolling_window() && !measure.is_additive();

let mut time_dimensions = self
Expand Down Expand Up @@ -828,8 +860,14 @@ impl MultiStageQueryPlanner {
scope,
)?
} else {
// Without a time dimension there is no series to walk,
// so the window collapses to a single bucket: no frame
// to build, and no outer stage to carry the aggregation.
// What is left is the measure's own multi-stage
// definition — aggregation and grain included — over the
// base state prepared above.
self.make_queries_descriptions(
base_member,
MemberSymbol::new_measure(transforms::strip_rolling_window(&measure)),
base_state,
descriptions,
resolved_multi_stage_dimensions,
Expand Down Expand Up @@ -859,6 +897,18 @@ impl MultiStageQueryPlanner {
state.clone(),
)?;

// `grain.include` extends the grain the values inside the window
// are computed at. The frame still keys off the base time
// dimension, so the extension only splits rows the outer
// aggregation merges back together.
let base_rolling_state = if grain_include.is_empty() {
base_rolling_state
} else {
let mut extended = base_rolling_state.as_ref().clone();
extended.add_dimensions(grain_include);
Rc::new(extended)
};

let time_series =
self.add_time_series(time_dimension.clone(), state.clone(), descriptions)?;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -711,13 +711,24 @@ impl QueryProperties {
}

/// Append `dimensions` to the existing list, deduplicating by
/// reference-chain-resolved full name.
/// reference-chain-resolved full name. A dimension the grain already
/// carries as a time dimension is dropped rather than appended: a time
/// dimension's full name pins its granularity, so an entry that matches
/// one would render the very same column under the very same alias.
pub fn add_dimensions(&mut self, dimensions: Vec<Rc<MemberSymbol>>) {
let time_dimension_names = self
.time_dimensions
.iter()
.map(|d| d.clone().resolve_reference_chain().full_name())
.collect::<HashSet<_>>();
let added = dimensions.into_iter().filter(|d| {
!time_dimension_names.contains(&d.clone().resolve_reference_chain().full_name())
});
self.dimensions = self
.dimensions
.iter()
.cloned()
.chain(dimensions.into_iter())
.chain(added)
.unique_by(|d| d.clone().resolve_reference_chain().full_name())
Comment on lines 718 to 732

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.

The dedup itself is sound — TimeDimensionSymbol::full_name() is format!("{}_{}", base.full_name(), granularity_or_day) (time_dimension_symbol.rs:79), and QueryProperties.time_dimensions only ever holds granular entries (query_properties_compiler.rs:66 filters the rest out), so a match really does mean "the same column under the same alias is already grouped".

Two things worth noting for the general-purpose setter:

  1. This is now a silently dropping add_dimensions, and the other caller is the non-rolling multi-stage path (multi_stage_query_planner.rs:677). There it runs after set_time_dimensions(partition_filter(...)), so ordering is correct — a time dim removed by keep_only/exclude is no longer in time_dimensions and the include re-adds it. That ordering dependency is load-bearing and invisible from here; a one-line note in the doc comment ("callers must apply grain narrowing to time_dimensions before adding") would pin it.

  2. The asymmetry the doc doesn't spell out: a bare grain.include: [returns.day] resolves to returns.day, never to returns.day_day, so it is never deduped against a query granularity — it adds the full-resolution column as an extra key. That's pre-existing grain semantics, but it is the reason the two fixture measures (include: returns.day vs include: returns.day.day) take different code paths here, and the fixture data can't tell them apart (see my note on the seed).

.collect_vec();
self.invalidate_join_groups_cache();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,17 @@ pub fn unroll_rolling(measure: &MeasureSymbol) -> Rc<MeasureSymbol> {
render_modifier: None,
})
}

/// Returns a copy of the measure with the rolling window dropped and
/// the rest of its definition — aggregation kind, multi-stage grain —
/// left in place. What remains is the measure as it would have been
/// declared without a window, which is what a window that resolves to
/// a single bucket computes.
pub fn strip_rolling_window(measure: &MeasureSymbol) -> Rc<MeasureSymbol> {
let mut result = measure.clone();
result.rolling_window = None;
// The render modifier is stamped against the rolling form and has
// nothing to modify once the window is gone.
result.render_modifier = None;
Rc::new(result)
}
Loading
Loading