Skip to content

Commit a5f8a2e

Browse files
authored
feat(tesseract): Allow named calendar timeshifts for common intervals (#9777)
* throw error if named timeshift not found * feat(tesseract): Allow named calendar timeshifts for common intervals * add tests * cargo fmt * update snapshots * fix * simplify builder by moving extract_time_shifts to time_shift_state
1 parent 720f048 commit a5f8a2e

File tree

9 files changed

+164
-67
lines changed

9 files changed

+164
-67
lines changed

packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -626,15 +626,26 @@ const MeasuresSchema = Joi.object().pattern(identifierRegex, Joi.alternatives().
626626
]
627627
));
628628

629-
const CalendarTimeShiftItem = Joi.object({
630-
name: identifier,
631-
interval: regexTimeInterval,
632-
type: Joi.string().valid('next', 'prior'),
633-
sql: Joi.func().required(),
634-
})
635-
.or('name', 'interval')
636-
.with('interval', 'type')
637-
.with('type', 'interval');
629+
const CalendarTimeShiftItem = Joi.alternatives().try(
630+
Joi.object({
631+
name: identifier.required(),
632+
interval: regexTimeInterval.required(),
633+
type: Joi.string().valid('next', 'prior').required(),
634+
sql: Joi.forbidden()
635+
}),
636+
Joi.object({
637+
name: identifier.required(),
638+
sql: Joi.func().required(),
639+
interval: Joi.forbidden(),
640+
type: Joi.forbidden()
641+
}),
642+
Joi.object({
643+
interval: regexTimeInterval.required(),
644+
type: Joi.string().valid('next', 'prior').required(),
645+
sql: Joi.func().required(),
646+
name: Joi.forbidden()
647+
})
648+
);
638649

639650
const DimensionsSchema = Joi.object().pattern(identifierRegex, Joi.alternatives().try(
640651
inherit(BaseDimensionWithoutSubQuery, {

packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ cubes:
8080
time_shift:
8181
- name: one_year
8282
83+
- name: count_shifted_y_named_common_interval
84+
type: number
85+
multi_stage: true
86+
sql: "{count}"
87+
time_shift:
88+
- name: one_year_common_interval
89+
8390
- name: count_shifted_y1d_named
8491
type: number
8592
multi_stage: true
@@ -261,6 +268,10 @@ cubes:
261268
- name: one_year
262269
sql: "{CUBE}.retail_date_prev_year"
263270
271+
- name: one_year_common_interval
272+
interval: 1 year
273+
type: prior
274+
264275
- name: one_year_and_one_day
265276
sql: "({CUBE}.retail_date_prev_year + interval '1 day')"
266277
@@ -305,6 +316,10 @@ cubes:
305316
- name: one_year
306317
sql: "{CUBE}.retail_date_prev_year"
307318
319+
- name: one_year_common_interval
320+
interval: 1 year
321+
type: prior
322+
308323
- name: one_year_and_one_day
309324
sql: "({CUBE}.retail_date_prev_year + interval '1 day')"
310325
@@ -736,6 +751,22 @@ cubes:
736751
custom_calendar__retail_date_year: '2025-02-02T00:00:00.000Z',
737752
},
738753
]));
754+
755+
it('Count shifted by year (custom named shift with common interval + custom granularity)', async () => runQueryTest({
756+
measures: ['calendar_orders.count', 'calendar_orders.count_shifted_y_named_common_interval'],
757+
timeDimensions: [{
758+
dimension: 'custom_calendar.retail_date',
759+
granularity: 'year',
760+
dateRange: ['2025-02-02', '2026-02-01']
761+
}],
762+
order: [{ id: 'custom_calendar.retail_date' }]
763+
}, [
764+
{
765+
calendar_orders__count: '37',
766+
calendar_orders__count_shifted_y_named_common_interval: '39',
767+
custom_calendar__retail_date_year: '2025-02-02T00:00:00.000Z',
768+
},
769+
]));
739770
});
740771

741772
describe('PK dimension time-shifts', () => {
@@ -889,6 +920,22 @@ cubes:
889920
custom_calendar__date_val_year: '2025-02-02T00:00:00.000Z',
890921
},
891922
]));
923+
924+
it.skip('Count shifted by year (custom named shift with common interval + custom granularity)', async () => runQueryTest({
925+
measures: ['calendar_orders.count', 'calendar_orders.count_shifted_y_named_common_interval'],
926+
timeDimensions: [{
927+
dimension: 'custom_calendar.date_val',
928+
granularity: 'year',
929+
dateRange: ['2025-02-02', '2026-02-01']
930+
}],
931+
order: [{ id: 'custom_calendar.date_val' }]
932+
}, [
933+
{
934+
calendar_orders__count: '37',
935+
calendar_orders__count_shifted_y_named_common_interval: '39',
936+
custom_calendar__retail_date_year: '2025-02-02T00:00:00.000Z',
937+
},
938+
]));
892939
});
893940
});
894941
});

packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,11 @@ Object {
275275
"name": "retail_date_prev_year",
276276
"sql": [Function],
277277
},
278+
Object {
279+
"interval": "1 year",
280+
"name": "prev_year_named_common_interval",
281+
"type": "prior",
282+
},
278283
Object {
279284
"interval": "2 year",
280285
"sql": [Function],

packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ cubes:
4848
- name: retail_date_prev_year
4949
sql: "{CUBE.retail_date_prev_year}"
5050

51+
- name: prev_year_named_common_interval
52+
interval: 1 year
53+
type: prior
54+
5155
# All needed intervals should be defined explicitly
5256
- interval: 2 year
5357
type: prior

rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs

Lines changed: 14 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ use crate::planner::planners::multi_stage::TimeShiftState;
55
use crate::planner::query_properties::OrderByItem;
66
use crate::planner::query_tools::QueryTools;
77
use crate::planner::sql_evaluator::sql_nodes::SqlNodesFactory;
8-
use crate::planner::sql_evaluator::symbols::CalendarDimensionTimeShift;
8+
use crate::planner::sql_evaluator::MemberSymbol;
99
use crate::planner::sql_evaluator::ReferencesBuilder;
10-
use crate::planner::sql_evaluator::{DimensionTimeShift, MemberSymbol};
1110
use crate::planner::sql_templates::PlanSqlTemplates;
1211
use crate::planner::BaseMemberHelper;
1312
use crate::planner::SqlJoinCondition;
1413
use crate::planner::{BaseMember, MemberSymbolRef};
1514
use cubenativeutils::CubeError;
16-
use itertools::{Either, Itertools};
15+
use itertools::Itertools;
1716
use std::collections::HashMap;
1817
use std::collections::HashSet;
1918
use std::rc::Rc;
@@ -31,41 +30,10 @@ struct PhysicalPlanBuilderContext {
3130
}
3231

3332
impl PhysicalPlanBuilderContext {
34-
pub fn make_sql_nodes_factory(&self) -> SqlNodesFactory {
33+
pub fn make_sql_nodes_factory(&self) -> Result<SqlNodesFactory, CubeError> {
3534
let mut factory = SqlNodesFactory::new();
3635

37-
let (time_shifts, calendar_time_shifts): (
38-
HashMap<String, DimensionTimeShift>,
39-
HashMap<String, CalendarDimensionTimeShift>,
40-
) = self
41-
.time_shifts
42-
.dimensions_shifts
43-
.iter()
44-
.partition_map(|(key, shift)| {
45-
if let Ok(dimension) = shift.dimension.as_dimension() {
46-
if let Some(dim_shift_name) = &shift.name {
47-
if let Some((dim_key, cts)) =
48-
dimension.calendar_time_shift_for_named_interval(dim_shift_name)
49-
{
50-
return Either::Right((dim_key.clone(), cts.clone()));
51-
} else if let Some(_calendar_pk) = dimension.time_shift_pk_full_name() {
52-
// TODO: Handle case when named shift is not found
53-
}
54-
} else if let Some(dim_shift_interval) = &shift.interval {
55-
if let Some((dim_key, cts)) =
56-
dimension.calendar_time_shift_for_interval(dim_shift_interval)
57-
{
58-
return Either::Right((dim_key.clone(), cts.clone()));
59-
} else if let Some(calendar_pk) = dimension.time_shift_pk_full_name() {
60-
let mut shift = shift.clone();
61-
shift.interval = Some(dim_shift_interval.inverse());
62-
return Either::Left((calendar_pk, shift.clone()));
63-
}
64-
}
65-
}
66-
Either::Left((key.clone(), shift.clone()))
67-
});
68-
36+
let (time_shifts, calendar_time_shifts) = self.time_shifts.extract_time_shifts()?;
6937
let common_time_shifts = TimeShiftState {
7038
dimensions_shifts: time_shifts,
7139
};
@@ -75,7 +43,7 @@ impl PhysicalPlanBuilderContext {
7543
factory.set_count_approx_as_state(self.render_measure_as_state);
7644
factory.set_ungrouped_measure(self.render_measure_for_ungrouped);
7745
factory.set_original_sql_pre_aggregations(self.original_sql_pre_aggregations.clone());
78-
factory
46+
Ok(factory)
7947
}
8048
}
8149

@@ -117,7 +85,7 @@ impl PhysicalPlanBuilder {
11785
let from = From::new_from_subselect(source.clone(), ORIGINAL_QUERY.to_string());
11886
let mut select_builder = SelectBuilder::new(from);
11987
select_builder.add_count_all(TOTAL_COUNT.to_string());
120-
let context_factory = context.make_sql_nodes_factory();
88+
let context_factory = context.make_sql_nodes_factory()?;
12189
Ok(Rc::new(select_builder.build(context_factory)))
12290
}
12391

@@ -142,7 +110,7 @@ impl PhysicalPlanBuilder {
142110
let mut render_references = HashMap::new();
143111
let mut measure_references = HashMap::new();
144112
let mut dimensions_references = HashMap::new();
145-
let mut context_factory = context.make_sql_nodes_factory();
113+
let mut context_factory = context.make_sql_nodes_factory()?;
146114
let from = match &logical_plan.source {
147115
SimpleQuerySource::LogicalJoin(join) => self.process_logical_join(
148116
&join,
@@ -400,7 +368,7 @@ impl PhysicalPlanBuilder {
400368
select_builder.set_offset(logical_plan.offset);
401369
select_builder.set_ctes(ctes);
402370

403-
let mut context_factory = context.make_sql_nodes_factory();
371+
let mut context_factory = context.make_sql_nodes_factory()?;
404372
context_factory.set_render_references(render_references);
405373

406374
Ok(Rc::new(select_builder.build(context_factory)))
@@ -735,7 +703,7 @@ impl PhysicalPlanBuilder {
735703
let mut join_builder =
736704
JoinBuilder::new_from_subselect(keys_query.clone(), keys_query_alias.clone());
737705

738-
let mut context_factory = context.make_sql_nodes_factory();
706+
let mut context_factory = context.make_sql_nodes_factory()?;
739707
let primary_keys_dimensions = &aggregate_multiplied_subquery
740708
.keys_subquery
741709
.primary_keys_dimensions;
@@ -873,7 +841,7 @@ impl PhysicalPlanBuilder {
873841
&measure_subquery.dimension_subqueries,
874842
&mut render_references,
875843
)?;
876-
let mut context_factory = context.make_sql_nodes_factory();
844+
let mut context_factory = context.make_sql_nodes_factory()?;
877845
let mut select_builder = SelectBuilder::new(from);
878846

879847
context_factory.set_ungrouped_measure(true);
@@ -936,7 +904,7 @@ impl PhysicalPlanBuilder {
936904

937905
select_builder.set_distinct();
938906
select_builder.set_filter(keys_subquery.filter.all_filters());
939-
let mut context_factory = context.make_sql_nodes_factory();
907+
let mut context_factory = context.make_sql_nodes_factory()?;
940908
context_factory.set_render_references(render_references);
941909
let res = Rc::new(select_builder.build(context_factory));
942910
Ok(res)
@@ -1022,7 +990,7 @@ impl PhysicalPlanBuilder {
1022990
&mut render_references,
1023991
)?;
1024992
let mut select_builder = SelectBuilder::new(from);
1025-
let mut context_factory = context.make_sql_nodes_factory();
993+
let mut context_factory = context.make_sql_nodes_factory()?;
1026994
let args = vec![get_date_range
1027995
.time_dimension
1028996
.clone()
@@ -1181,7 +1149,7 @@ impl PhysicalPlanBuilder {
11811149
on,
11821150
);
11831151

1184-
let mut context_factory = context.make_sql_nodes_factory();
1152+
let mut context_factory = context.make_sql_nodes_factory()?;
11851153
context_factory.set_rolling_window(true);
11861154
let from = From::new_from_join(join_builder.build());
11871155
let references_builder = ReferencesBuilder::new(from.clone());
@@ -1320,7 +1288,7 @@ impl PhysicalPlanBuilder {
13201288
);
13211289
}
13221290

1323-
let mut context_factory = context.make_sql_nodes_factory();
1291+
let mut context_factory = context.make_sql_nodes_factory()?;
13241292
let partition_by = measure_calculation
13251293
.partition_by
13261294
.iter()

rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,14 @@
11
use crate::plan::{FilterGroup, FilterItem};
22
use crate::planner::filter::FilterOperator;
3+
use crate::planner::planners::multi_stage::time_shift_state::TimeShiftState;
34
use crate::planner::sql_evaluator::{DimensionTimeShift, MeasureTimeShifts, MemberSymbol};
45
use crate::planner::{BaseDimension, BaseMember, BaseTimeDimension};
56
use cubenativeutils::CubeError;
67
use itertools::Itertools;
78
use std::cmp::PartialEq;
8-
use std::collections::HashMap;
99
use std::fmt::Debug;
1010
use std::rc::Rc;
1111

12-
#[derive(Clone, Default, Debug)]
13-
pub struct TimeShiftState {
14-
pub dimensions_shifts: HashMap<String, DimensionTimeShift>,
15-
}
16-
17-
impl TimeShiftState {
18-
pub fn is_empty(&self) -> bool {
19-
self.dimensions_shifts.is_empty()
20-
}
21-
}
22-
2312
#[derive(Clone)]
2413
pub struct MultiStageAppliedState {
2514
time_dimensions: Vec<Rc<BaseTimeDimension>>,

rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ mod member;
33
mod member_query_planner;
44
mod multi_stage_query_planner;
55
mod query_description;
6+
mod time_shift_state;
67

78
pub use applied_state::*;
89
pub use member::*;
910
pub use member_query_planner::MultiStageMemberQueryPlanner;
1011
pub use multi_stage_query_planner::MultiStageQueryPlanner;
1112
pub use query_description::MultiStageQueryDescription;
13+
pub use time_shift_state::TimeShiftState;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use crate::planner::sql_evaluator::symbols::CalendarDimensionTimeShift;
2+
use crate::planner::sql_evaluator::DimensionTimeShift;
3+
use cubenativeutils::CubeError;
4+
use std::collections::HashMap;
5+
6+
#[derive(Clone, Default, Debug)]
7+
pub struct TimeShiftState {
8+
pub dimensions_shifts: HashMap<String, DimensionTimeShift>,
9+
}
10+
11+
impl TimeShiftState {
12+
pub fn is_empty(&self) -> bool {
13+
self.dimensions_shifts.is_empty()
14+
}
15+
16+
pub fn extract_time_shifts(
17+
&self,
18+
) -> Result<
19+
(
20+
HashMap<String, DimensionTimeShift>,
21+
HashMap<String, CalendarDimensionTimeShift>,
22+
),
23+
CubeError,
24+
> {
25+
let mut time_shifts = HashMap::new();
26+
let mut calendar_time_shifts = HashMap::new();
27+
28+
for (key, shift) in self.dimensions_shifts.iter() {
29+
if let Ok(dimension) = shift.dimension.as_dimension() {
30+
// 1. Shift might be referenced by name or by interval
31+
// 2. Shift body might be defined in calendar dimension as:
32+
// * sql reference
33+
// * interval + type
34+
35+
if let Some(dim_shift_name) = &shift.name {
36+
if let Some((dim_key, cts)) =
37+
dimension.calendar_time_shift_for_named_interval(dim_shift_name)
38+
{
39+
calendar_time_shifts.insert(dim_key.clone(), cts.clone());
40+
} else if let Some(_calendar_pk) = dimension.time_shift_pk_full_name() {
41+
return Err(CubeError::internal(format!(
42+
"Time shift with name {} not found for dimension {}",
43+
dim_shift_name,
44+
dimension.full_name()
45+
)));
46+
}
47+
} else if let Some(dim_shift_interval) = &shift.interval {
48+
if let Some((dim_key, cts)) =
49+
dimension.calendar_time_shift_for_interval(dim_shift_interval)
50+
{
51+
calendar_time_shifts.insert(dim_key.clone(), cts.clone());
52+
} else if let Some(calendar_pk) = dimension.time_shift_pk_full_name() {
53+
let mut shift = shift.clone();
54+
shift.interval = Some(dim_shift_interval.inverse());
55+
time_shifts.insert(calendar_pk, shift.clone());
56+
} else {
57+
time_shifts.insert(key.clone(), shift.clone());
58+
}
59+
}
60+
} else {
61+
time_shifts.insert(key.clone(), shift.clone());
62+
}
63+
}
64+
65+
Ok((time_shifts, calendar_time_shifts))
66+
}
67+
}

0 commit comments

Comments
 (0)