From d513eb77a737dda85ce6596d5d0b149cf8adee46 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Tue, 24 Jun 2025 16:46:57 +0300 Subject: [PATCH 1/7] update data model scheme for calendar time shifts + compilation tests --- .../src/compiler/CubeValidator.ts | 13 +++- .../unit/__snapshots__/schema.test.ts.snap | 64 +++++++++++++++++++ .../test/unit/fixtures/custom_calendar.js | 34 +++++++++- .../test/unit/fixtures/custom_calendar.yml | 29 +++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index bf604b83cceaa..9ca271d51b4bb 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -575,7 +575,7 @@ const timeShiftItemRequired = Joi.object({ }); const timeShiftItemOptional = Joi.object({ - timeDimension: Joi.func(), // не required + timeDimension: Joi.func(), // not required interval: regexTimeInterval.required(), type: Joi.string().valid('next', 'prior').required(), }); @@ -661,6 +661,17 @@ const DimensionsSchema = Joi.object().pattern(identifierRegex, Joi.alternatives( type: Joi.any().valid('number').required(), sql: Joi.func().required(), addGroupBy: Joi.func(), + }), + // TODO should be valid only for calendar cubes, but this requires significant refactoring + // of all schemas. Left for the future when we'll switch to zod. + inherit(BaseDimensionWithoutSubQuery, { + type: Joi.any().valid('time').required(), + sql: Joi.func().required(), + timeShift: Joi.array().items(Joi.object({ + interval: regexTimeInterval.required(), + type: Joi.string().valid('next', 'prior').required(), + sql: Joi.func().required(), + })), }) )); diff --git a/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap b/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap index 5857397186fc4..0ffe9c8cc20c4 100644 --- a/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap +++ b/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap @@ -80,8 +80,40 @@ Object { }, "ownedByCube": true, "sql": [Function], + "timeShift": Array [ + Object { + "interval": "1 month", + "sql": [Function], + "type": "prior", + }, + Object { + "interval": "1 year", + "sql": [Function], + "type": "prior", + }, + Object { + "interval": "2 year", + "sql": [Function], + "type": "prior", + }, + ], "type": "time", }, + "retail_date_prev_month": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_date_prev_prev_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_date_prev_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, "retail_month_begin_date": Object { "ownedByCube": true, "sql": [Function], @@ -232,6 +264,38 @@ Object { "sql": [Function], }, }, + "ownedByCube": true, + "sql": [Function], + "timeShift": Array [ + Object { + "interval": "1 month", + "sql": [Function], + "type": "prior", + }, + Object { + "interval": "1 year", + "sql": [Function], + "type": "prior", + }, + Object { + "interval": "2 year", + "sql": [Function], + "type": "prior", + }, + ], + "type": "time", + }, + "retail_date_prev_month": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_date_prev_prev_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_date_prev_year": Object { "ownedByCube": true, "sql": [Function], "type": "time", diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.js b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.js index 395e251a3ec36..78f434a20fdfe 100644 --- a/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.js +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.js @@ -33,7 +33,24 @@ cube(`custom_calendar_js`, { year: { sql: `{CUBE.retail_year_begin_date}`, } - } + }, + timeShift: [ + { + interval: '1 month', + type: 'prior', + sql: `{CUBE.retail_date_prev_month}`, + }, + { + interval: '1 year', + type: 'prior', + sql: `{CUBE.retail_date_prev_year}`, + }, + { + interval: '2 year', + type: 'prior', + sql: `{CUBE.retail_date_prev_prev_year}`, + } + ] }, retail_year: { @@ -86,6 +103,21 @@ cube(`custom_calendar_js`, { type: `string` }, + retail_date_prev_month: { + sql: `retail_date_prev_month`, + type: `string` + }, + + retail_date_prev_year: { + sql: `retail_date_prev_year`, + type: `string` + }, + + retail_date_prev_prev_year: { + sql: `retail_date_prev_prev_year`, + type: `string` + }, + fiscal_year: { sql: `fiscal_year`, type: `string` diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml index dee8571c0ac67..aa0a2271a8629 100644 --- a/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml @@ -37,6 +37,23 @@ cubes: interval: 2 week origin: "2025-01-01" + # This is similar to the syntax shown above for custom granularities. + # New `time_shift` parameter is introduced for time dimensions, + # with `interval`, `type`, and `sql` parameters + time_shift: + - interval: 1 month + type: prior + sql: "{CUBE.retail_date_prev_month}" + + - interval: 1 year + type: prior + sql: "{CUBE.retail_date_prev_year}" + + # All needed intervals should be defined explicitly + - interval: 2 year + type: prior + sql: "{CUBE.retail_date_prev_prev_year}" + - name: retail_year sql: "{CUBE}.retail_year_name" type: string @@ -77,6 +94,18 @@ cubes: sql: "{CUBE}.retail_week_in_month" type: string + - name: retail_date_prev_month + sql: "{CUBE}.retail_date_prev_month" + type: time + + - name: retail_date_prev_year + sql: "{CUBE}.retail_date_prev_year" + type: time + + - name: retail_date_prev_prev_year + sql: "{CUBE}.retail_date_prev_prev_year" + type: time + ##### Fiscal Dates #### - name: fiscal_year From cc2c894d47272df79dca07bb17a84535b047ebe6 Mon Sep 17 00:00:00 2001 From: Alexandr Romanenko Date: Thu, 3 Jul 2025 12:25:28 +0200 Subject: [PATCH 2/7] chore(tesseract): Refactoring of time shift without time dimension logic --- .../src/logical_plan/multistage/common.rs | 3 - .../logical_plan/multistage/leaf_measure.rs | 5 +- .../planners/multi_stage/applied_state.rs | 92 +++++++++++++------ .../sql_evaluator/sql_nodes/time_shift.rs | 11 +-- 4 files changed, 70 insertions(+), 41 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/common.rs b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/common.rs index 7a3b86859b438..5ca1ddd776f6a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/common.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/common.rs @@ -53,9 +53,6 @@ impl PrettyPrint for MultiStageAppliedState { } result.println("time_shifts:", &state); - if let Some(common) = &self.time_shifts().common_time_shift { - result.println(&format!("- common: {}", common.to_sql()), &details_state); - } for (_, time_shift) in self.time_shifts().dimensions_shifts.iter() { result.println( &format!( diff --git a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/leaf_measure.rs b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/leaf_measure.rs index 394c79125451c..214ab412fb8d8 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/leaf_measure.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/logical_plan/multistage/leaf_measure.rs @@ -22,12 +22,9 @@ impl PrettyPrint for MultiStageLeafMeasure { if self.render_measure_for_ungrouped { result.println("render_measure_for_ungrouped: true", &state); } - if !self.time_shifts.dimensions_shifts.is_empty() { + if !self.time_shifts.is_empty() { result.println("time_shifts:", &state); let details_state = state.new_level(); - if let Some(common) = &self.time_shifts.common_time_shift { - result.println(&format!("- common: {}", common.to_sql()), &details_state); - } for (_, time_shift) in self.time_shifts.dimensions_shifts.iter() { result.println( &format!( diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs index 150cbf4a85265..8ccbec0c29b68 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/planners/multi_stage/applied_state.rs @@ -1,7 +1,7 @@ use crate::plan::{FilterGroup, FilterItem}; use crate::planner::filter::FilterOperator; use crate::planner::sql_evaluator::{DimensionTimeShift, MeasureTimeShifts, MemberSymbol}; -use crate::planner::{BaseDimension, BaseMember, BaseTimeDimension, SqlInterval}; +use crate::planner::{BaseDimension, BaseMember, BaseTimeDimension}; use itertools::Itertools; use std::cmp::PartialEq; use std::collections::HashMap; @@ -11,12 +11,11 @@ use std::rc::Rc; #[derive(Clone, Default, Debug)] pub struct TimeShiftState { pub dimensions_shifts: HashMap, - pub common_time_shift: Option, } impl TimeShiftState { pub fn is_empty(&self) -> bool { - self.dimensions_shifts.is_empty() && self.common_time_shift.is_none() + self.dimensions_shifts.is_empty() } } @@ -74,28 +73,28 @@ impl MultiStageAppliedState { } pub fn add_time_shifts(&mut self, time_shifts: MeasureTimeShifts) { - match time_shifts { - MeasureTimeShifts::Dimensions(dimensions) => { - for ts in dimensions.into_iter() { - if let Some(exists) = self - .time_shifts - .dimensions_shifts - .get_mut(&ts.dimension.full_name()) - { - exists.interval += ts.interval; - } else { - self.time_shifts - .dimensions_shifts - .insert(ts.dimension.full_name(), ts); - } - } - } - MeasureTimeShifts::Common(interval) => { - if let Some(common) = self.time_shifts.common_time_shift.as_mut() { - *common += interval; - } else { - self.time_shifts.common_time_shift = Some(interval); - } + let resolved_shifts = match time_shifts { + MeasureTimeShifts::Dimensions(dimensions) => dimensions, + MeasureTimeShifts::Common(interval) => self + .all_time_members() + .into_iter() + .map(|m| DimensionTimeShift { + interval: interval.clone(), + dimension: m, + }) + .collect_vec(), + }; + for ts in resolved_shifts.into_iter() { + if let Some(exists) = self + .time_shifts + .dimensions_shifts + .get_mut(&ts.dimension.full_name()) + { + exists.interval += ts.interval; + } else { + self.time_shifts + .dimensions_shifts + .insert(ts.dimension.full_name(), ts); } } } @@ -104,6 +103,40 @@ impl MultiStageAppliedState { &self.time_shifts } + fn all_time_members(&self) -> Vec> { + let mut filter_symbols = self.all_dimensions_symbols(); + for filter_item in self + .time_dimensions_filters + .iter() + .chain(self.dimensions_filters.iter()) + .chain(self.segments.iter()) + { + filter_item.find_all_member_evaluators(&mut filter_symbols); + } + + let time_symbols = filter_symbols + .into_iter() + .filter_map(|m| { + let symbol = if let Ok(time_dim) = m.as_time_dimension() { + time_dim.base_symbol().clone().resolve_reference_chain() + } else { + m.resolve_reference_chain() + }; + if let Ok(dim) = symbol.as_dimension() { + if dim.dimension_type() == "time" { + Some(symbol) + } else { + None + } + } else { + None + } + }) + .unique_by(|s| s.full_name()) + .collect_vec(); + time_symbols + } + pub fn time_dimensions_filters(&self) -> &Vec { &self.time_dimensions_filters } @@ -122,6 +155,14 @@ impl MultiStageAppliedState { .collect() } + pub fn all_dimensions_symbols(&self) -> Vec> { + self.time_dimensions + .iter() + .map(|d| d.member_evaluator().clone()) + .chain(self.dimensions.iter().map(|d| d.member_evaluator().clone())) + .collect() + } + pub fn dimensions_filters(&self) -> &Vec { &self.dimensions_filters } @@ -343,7 +384,6 @@ impl PartialEq for MultiStageAppliedState { && self.time_dimensions_filters == other.time_dimensions_filters && self.dimensions_filters == other.dimensions_filters && self.measures_filters == other.measures_filters - && self.time_shifts.common_time_shift == other.time_shifts.common_time_shift && self.time_shifts.dimensions_shifts == other.time_shifts.dimensions_shifts } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_shift.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_shift.rs index d658fad328906..2f30043a0df38 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_shift.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_shift.rs @@ -4,7 +4,6 @@ use crate::planner::query_tools::QueryTools; use crate::planner::sql_evaluator::MemberSymbol; use crate::planner::sql_evaluator::SqlEvaluatorVisitor; use crate::planner::sql_templates::PlanSqlTemplates; -use crate::planner::SqlInterval; use cubenativeutils::CubeError; use std::any::Any; use std::rc::Rc; @@ -43,16 +42,12 @@ impl SqlNode for TimeShiftSqlNode { let res = match node.as_ref() { MemberSymbol::Dimension(ev) => { if !ev.is_reference() && ev.dimension_type() == "time" { - let mut interval = self.shifts.common_time_shift.clone().unwrap_or_default(); if let Some(shift) = self.shifts.dimensions_shifts.get(&ev.full_name()) { - interval += &shift.interval; - } - if interval == SqlInterval::default() { - input - } else { - let shift = interval.to_sql(); + let shift = shift.interval.to_sql(); let res = templates.add_timestamp_interval(input, shift)?; format!("({})", res) + } else { + input } } else { input From a474838e682128ad37ad6cf486beed4f120a4ddb Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Mon, 7 Jul 2025 12:35:37 +0300 Subject: [PATCH 3/7] add timeshift definitions to rust side --- .../src/cube_bridge/dimension_definition.rs | 5 +++ .../cubesqlplanner/src/cube_bridge/mod.rs | 1 + .../src/cube_bridge/timeshift_definition.rs | 23 +++++++++++ .../sql_evaluator/symbols/dimension_symbol.rs | 41 +++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs index 9283854f7a45b..4765e8b6eba3b 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs @@ -1,9 +1,11 @@ use super::case_definition::{CaseDefinition, NativeCaseDefinition}; use super::geo_item::{GeoItem, NativeGeoItem}; use super::member_sql::{MemberSql, NativeMemberSql}; +use crate::cube_bridge::timeshift_definition::{NativeTimeShiftDefinition, TimeShiftDefinition}; 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,7 @@ pub trait DimensionDefinition { #[nbridge(field, optional)] fn longitude(&self) -> Result>, CubeError>; + + #[nbridge(field, vec, optional)] + fn time_shift(&self) -> Result>>, CubeError>; } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs index c0c6295b367af..69286f9c1a44b 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs @@ -30,3 +30,4 @@ pub mod segment_definition; pub mod sql_templates_render; pub mod sql_utils; pub mod struct_with_sql_member; +pub mod timeshift_definition; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs new file mode 100644 index 0000000000000..1f507831188c9 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs @@ -0,0 +1,23 @@ +use super::member_sql::{MemberSql, NativeMemberSql}; +use cubenativeutils::wrappers::serializer::{ + NativeDeserialize, NativeDeserializer, NativeSerialize, +}; +use cubenativeutils::wrappers::NativeContextHolder; +use cubenativeutils::wrappers::NativeObjectHandle; +use cubenativeutils::CubeError; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::rc::Rc; + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)] +pub struct TimeShiftDefinitionStatic { + pub interval: String, + #[serde(rename = "type")] + pub timeshift_type: String, +} + +#[nativebridge::native_bridge(TimeShiftDefinitionStatic)] +pub trait TimeShiftDefinition { + #[nbridge(field, optional)] + fn sql(&self) -> Result>, CubeError>; +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs index b2f12cdba339e..bda5f21f2a30c 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs @@ -6,6 +6,7 @@ use crate::cube_bridge::member_sql::MemberSql; use crate::planner::query_tools::QueryTools; use crate::planner::sql_evaluator::{sql_nodes::SqlNode, Compiler, SqlCall, SqlEvaluatorVisitor}; use crate::planner::sql_templates::PlanSqlTemplates; +use crate::planner::SqlInterval; use cubenativeutils::CubeError; use std::rc::Rc; @@ -24,6 +25,12 @@ pub struct DimensionCaseDefinition { pub else_label: DimenstionCaseLabel, } +#[derive(Clone)] +pub struct CalendarDimensionTimeShift { + pub interval: SqlInterval, + pub sql: Option>, +} + pub struct DimensionSymbol { cube_name: String, name: String, @@ -34,6 +41,7 @@ pub struct DimensionSymbol { definition: Rc, is_reference: bool, // Symbol is a direct reference to another symbol without any calculations is_view: bool, + time_shift: Vec, } impl DimensionSymbol { @@ -47,6 +55,7 @@ impl DimensionSymbol { longitude: Option>, case: Option, definition: Rc, + time_shift: Vec, ) -> Rc { Rc::new(Self { cube_name, @@ -58,6 +67,7 @@ impl DimensionSymbol { definition, case, is_view, + time_shift, }) } @@ -95,6 +105,10 @@ impl DimensionSymbol { &self.member_sql } + pub fn time_shift(&self) -> &Vec { + &self.time_shift + } + pub fn full_name(&self) -> String { format!("{}.{}", self.cube_name, self.name) } @@ -320,6 +334,31 @@ impl SymbolFactory for DimensionSymbolFactory { } else { None }; + + let time_shift = if let Some(time_shift) = definition.time_shift()? { + time_shift + .iter() + .map(|item| -> Result<_, CubeError> { + let interval = item.static_data().interval.parse::()?; + let interval = if item.static_data().timeshift_type == "next" { + -interval + } else { + interval + }; + let sql = if let Some(sql) = item.sql()? { + Some(compiler.compile_sql_call(&cube_name, sql)?) + } else { + None + }; + Ok(CalendarDimensionTimeShift { interval, sql }) + }) + .collect::, _>>()? + } else { + vec![] + }; + + println!("time_shift.count: {:?}", time_shift.len()); + let cube = cube_evaluator.cube_from_path(cube_name.clone())?; let is_view = cube.static_data().is_view.unwrap_or(false); let owned_by_cube = definition.static_data().owned_by_cube.unwrap_or(true); @@ -333,6 +372,7 @@ impl SymbolFactory for DimensionSymbolFactory { && latitude.is_none() && longitude.is_none() && !is_multi_stage); + Ok(MemberSymbol::new_dimension(DimensionSymbol::new( cube_name, name, @@ -343,6 +383,7 @@ impl SymbolFactory for DimensionSymbolFactory { longitude, case, definition, + time_shift, ))) } } From 48b7f5b76a57a69070c57809df750a0396652cd0 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Mon, 7 Jul 2025 22:45:57 +0300 Subject: [PATCH 4/7] implement time shifts over calendar tables --- .../src/physical_plan_builder/builder.rs | 32 +++++++- .../sql_nodes/calendar_time_shift.rs | 79 +++++++++++++++++++ .../sql_evaluator/sql_nodes/factory.rs | 17 ++++ .../planner/sql_evaluator/sql_nodes/mod.rs | 1 + .../sql_evaluator/symbols/dimension_symbol.rs | 56 ++++++++++++- .../src/planner/sql_evaluator/symbols/mod.rs | 5 +- 6 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/calendar_time_shift.rs diff --git a/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs b/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs index 2c66e0977b4a6..752b5e53da6fc 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs @@ -5,17 +5,19 @@ use crate::planner::planners::multi_stage::TimeShiftState; use crate::planner::query_properties::OrderByItem; use crate::planner::query_tools::QueryTools; use crate::planner::sql_evaluator::sql_nodes::SqlNodesFactory; -use crate::planner::sql_evaluator::MemberSymbol; +use crate::planner::sql_evaluator::symbols::CalendarDimensionTimeShift; use crate::planner::sql_evaluator::ReferencesBuilder; +use crate::planner::sql_evaluator::{DimensionTimeShift, MemberSymbol}; use crate::planner::sql_templates::PlanSqlTemplates; use crate::planner::BaseMemberHelper; use crate::planner::SqlJoinCondition; use crate::planner::{BaseMember, MemberSymbolRef}; use cubenativeutils::CubeError; -use itertools::Itertools; +use itertools::{Either, Itertools}; use std::collections::HashMap; use std::collections::HashSet; use std::rc::Rc; + const TOTAL_COUNT: &str = "total_count"; const ORIGINAL_QUERY: &str = "original_query"; @@ -31,7 +33,31 @@ struct PhysicalPlanBuilderContext { impl PhysicalPlanBuilderContext { pub fn make_sql_nodes_factory(&self) -> SqlNodesFactory { let mut factory = SqlNodesFactory::new(); - factory.set_time_shifts(self.time_shifts.clone()); + + let (time_shifts, calendar_time_shifts): ( + HashMap, + HashMap, + ) = self + .time_shifts + .dimensions_shifts + .iter() + .partition_map(|(key, shift)| { + if let Ok(dimension) = shift.dimension.as_dimension() { + if let Some((dim_key, cts)) = + dimension.calendar_time_shift_for_interval(&shift.interval) + { + return Either::Right((dim_key.clone(), cts.clone())); + } + } + Either::Left((key.clone(), shift.clone())) + }); + + let common_time_shifts = TimeShiftState { + dimensions_shifts: time_shifts, + }; + + factory.set_time_shifts(common_time_shifts); + factory.set_calendar_time_shifts(calendar_time_shifts); factory.set_count_approx_as_state(self.render_measure_as_state); factory.set_ungrouped_measure(self.render_measure_for_ungrouped); factory.set_original_sql_pre_aggregations(self.original_sql_pre_aggregations.clone()); diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/calendar_time_shift.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/calendar_time_shift.rs new file mode 100644 index 0000000000000..8d4d6f718c0c7 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/calendar_time_shift.rs @@ -0,0 +1,79 @@ +use super::SqlNode; +use crate::planner::query_tools::QueryTools; +use crate::planner::sql_evaluator::symbols::CalendarDimensionTimeShift; +use crate::planner::sql_evaluator::MemberSymbol; +use crate::planner::sql_evaluator::SqlEvaluatorVisitor; +use crate::planner::sql_templates::PlanSqlTemplates; +use cubenativeutils::CubeError; +use std::any::Any; +use std::collections::HashMap; +use std::rc::Rc; + +pub struct CalendarTimeShiftSqlNode { + shifts: HashMap, // Key is the full pk name of the calendar cube + input: Rc, +} + +impl CalendarTimeShiftSqlNode { + pub fn new( + shifts: HashMap, + input: Rc, + ) -> Rc { + Rc::new(Self { shifts, input }) + } + + pub fn input(&self) -> &Rc { + &self.input + } +} + +impl SqlNode for CalendarTimeShiftSqlNode { + fn to_sql( + &self, + visitor: &SqlEvaluatorVisitor, + node: &Rc, + query_tools: Rc, + node_processor: Rc, + templates: &PlanSqlTemplates, + ) -> Result { + let input = self.input.to_sql( + visitor, + node, + query_tools.clone(), + node_processor.clone(), + templates, + )?; + let res = match node.as_ref() { + MemberSymbol::Dimension(ev) => { + if !ev.is_reference() { + if let Some(shift) = self.shifts.get(&ev.full_name()) { + if let Some(sql) = &shift.sql { + sql.eval( + visitor, + node_processor.clone(), + query_tools.clone(), + templates, + )? + } else { + input + } + } else { + input + } + } else { + input + } + } + _ => input, + }; + Ok(res) + } + + fn as_any(self: Rc) -> Rc { + self.clone() + } + + fn childs(&self) -> Vec> { + vec![self.input.clone()] + } +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/factory.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/factory.rs index e14749ce2007d..127315617f97d 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/factory.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/factory.rs @@ -7,12 +7,15 @@ use super::{ }; use crate::plan::schema::QualifiedColumnName; use crate::planner::planners::multi_stage::TimeShiftState; +use crate::planner::sql_evaluator::sql_nodes::calendar_time_shift::CalendarTimeShiftSqlNode; +use crate::planner::sql_evaluator::symbols::CalendarDimensionTimeShift; use std::collections::{HashMap, HashSet}; use std::rc::Rc; #[derive(Clone)] pub struct SqlNodesFactory { time_shifts: TimeShiftState, + calendar_time_shifts: HashMap, ungrouped: bool, ungrouped_measure: bool, count_approx_as_state: bool, @@ -34,6 +37,7 @@ impl SqlNodesFactory { pub fn new() -> Self { Self { time_shifts: TimeShiftState::default(), + calendar_time_shifts: HashMap::new(), ungrouped: false, ungrouped_measure: false, count_approx_as_state: false, @@ -56,6 +60,13 @@ impl SqlNodesFactory { self.time_shifts = time_shifts; } + pub fn set_calendar_time_shifts( + &mut self, + calendar_time_shifts: HashMap, + ) { + self.calendar_time_shifts = calendar_time_shifts; + } + pub fn set_ungrouped(&mut self, value: bool) { self.ungrouped = value; } @@ -262,6 +273,12 @@ impl SqlNodesFactory { let input: Rc = AutoPrefixSqlNode::new(input, self.cube_name_references.clone()); + let input = if !self.calendar_time_shifts.is_empty() { + CalendarTimeShiftSqlNode::new(self.calendar_time_shifts.clone(), input) + } else { + input + }; + let input = if !self.time_shifts.is_empty() { TimeShiftSqlNode::new(self.time_shifts.clone(), input) } else { diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/mod.rs index e7ef1829b5a26..222765f6adbba 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/mod.rs @@ -1,4 +1,5 @@ pub mod auto_prefix; +pub mod calendar_time_shift; pub mod case_dimension; pub mod evaluate_sql; pub mod factory; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs index bda5f21f2a30c..425dae28750c4 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs @@ -42,6 +42,7 @@ pub struct DimensionSymbol { is_reference: bool, // Symbol is a direct reference to another symbol without any calculations is_view: bool, time_shift: Vec, + time_shift_pk: Option>, } impl DimensionSymbol { @@ -56,6 +57,7 @@ impl DimensionSymbol { case: Option, definition: Rc, time_shift: Vec, + time_shift_pk: Option>, ) -> Rc { Rc::new(Self { cube_name, @@ -68,6 +70,7 @@ impl DimensionSymbol { case, is_view, time_shift, + time_shift_pk, }) } @@ -109,6 +112,10 @@ impl DimensionSymbol { &self.time_shift } + pub fn time_shift_pk(&self) -> Option> { + self.time_shift_pk.clone() + } + pub fn full_name(&self) -> String { format!("{}.{}", self.cube_name, self.name) } @@ -217,6 +224,22 @@ impl DimensionSymbol { pub fn name(&self) -> &String { &self.name } + + pub fn calendar_time_shift_for_interval( + &self, + interval: &SqlInterval, + ) -> Option<(String, CalendarDimensionTimeShift)> { + if let Some(ts) = self + .time_shift + .iter() + .find(|shift| shift.interval == *interval) + { + if let Some(pk) = &self.time_shift_pk { + return Some((pk.full_name(), ts.clone())); + } + } + None + } } pub struct DimensionSymbolFactory { @@ -357,7 +380,37 @@ impl SymbolFactory for DimensionSymbolFactory { vec![] }; - println!("time_shift.count: {:?}", time_shift.len()); + let time_shift_pk = if !time_shift.is_empty() { + let pk_member = cube_evaluator + .static_data() + .primary_keys + .get(&cube_name) + .cloned() + .unwrap_or_else(|| vec![]) + .into_iter() + .map(|primary_key| -> Result<_, CubeError> { + let key_dimension_name = format!("{}.{}", cube_name, primary_key); + let pk_member = compiler.add_dimension_evaluator(key_dimension_name)?; + + Ok(pk_member) + }) + .collect::, _>>()? + .into_iter() + .filter(|pk_member| { + if let Ok(pk_dimension) = pk_member.as_dimension() { + if pk_dimension.dimension_type() == "time" { + return true; + } + } + false + }) + .collect::>() + .first() + .cloned(); + pk_member + } else { + None + }; let cube = cube_evaluator.cube_from_path(cube_name.clone())?; let is_view = cube.static_data().is_view.unwrap_or(false); @@ -384,6 +437,7 @@ impl SymbolFactory for DimensionSymbolFactory { case, definition, time_shift, + time_shift_pk, ))) } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/mod.rs index ebd15bbac33f4..3f56a7086df9a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/mod.rs @@ -9,10 +9,7 @@ mod time_dimension_symbol; pub use cube_symbol::{ CubeNameSymbol, CubeNameSymbolFactory, CubeTableSymbol, CubeTableSymbolFactory, }; -pub use dimension_symbol::{ - DimensionCaseDefinition, DimensionCaseWhenItem, DimensionSymbol, DimensionSymbolFactory, - DimenstionCaseLabel, -}; +pub use dimension_symbol::*; pub use measure_symbol::{ DimensionTimeShift, MeasureSymbol, MeasureSymbolFactory, MeasureTimeShifts, }; From b12d4ede7643417d9ebff6affaef170f3cc1f77f Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Tue, 8 Jul 2025 19:38:26 +0300 Subject: [PATCH 5/7] add usefull comment --- .../src/planner/sql_evaluator/symbols/dimension_symbol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs index 425dae28750c4..35a3202fe1d15 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs @@ -398,7 +398,7 @@ impl SymbolFactory for DimensionSymbolFactory { .into_iter() .filter(|pk_member| { if let Ok(pk_dimension) = pk_member.as_dimension() { - if pk_dimension.dimension_type() == "time" { + if pk_dimension.dimension_type() == "time" { // TODO: What if calendar cube is joined via non-time dimension? return true; } } From fbd5fdcfff960b6031f6256c15094a6c041d6861 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 9 Jul 2025 17:03:46 +0300 Subject: [PATCH 6/7] fix correct sql gen for calendar custom gran but with common timeshift --- .../src/cube_bridge/cube_definition.rs | 2 + .../src/physical_plan_builder/builder.rs | 4 ++ .../sql_evaluator/symbols/dimension_symbol.rs | 60 +++++++++++-------- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs index 03b7331893578..2bf22f9719e37 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs @@ -16,6 +16,8 @@ pub struct CubeDefinitionStatic { pub sql_alias: Option, #[serde(rename = "isView")] pub is_view: Option, + #[serde(rename = "calendar")] + pub is_calendar: Option, } #[nativebridge::native_bridge(CubeDefinitionStatic)] diff --git a/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs b/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs index 752b5e53da6fc..dccb2aa5e6aa4 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/builder.rs @@ -47,6 +47,10 @@ impl PhysicalPlanBuilderContext { dimension.calendar_time_shift_for_interval(&shift.interval) { return Either::Right((dim_key.clone(), cts.clone())); + } else if let Some(calendar_pk) = dimension.time_shift_pk() { + let mut shift = shift.clone(); + shift.interval = shift.interval.inverse(); + return Either::Left((calendar_pk.full_name(), shift.clone())); } } Either::Left((key.clone(), shift.clone())) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs index 35a3202fe1d15..f9e35b6cbe85a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs @@ -380,40 +380,52 @@ impl SymbolFactory for DimensionSymbolFactory { vec![] }; - let time_shift_pk = if !time_shift.is_empty() { - let pk_member = cube_evaluator + let cube = cube_evaluator.cube_from_path(cube_name.clone())?; + let is_view = cube.static_data().is_view.unwrap_or(false); + let is_calendar = cube.static_data().is_calendar.unwrap_or(false); + + // If the cube is a calendar, we need to find the primary key member + // so that we can use it for time shifts processing. + let time_shift_pk = if is_calendar { + let pk_members = cube_evaluator .static_data() .primary_keys .get(&cube_name) .cloned() - .unwrap_or_else(|| vec![]) - .into_iter() - .map(|primary_key| -> Result<_, CubeError> { - let key_dimension_name = format!("{}.{}", cube_name, primary_key); - let pk_member = compiler.add_dimension_evaluator(key_dimension_name)?; + .unwrap_or_else(|| vec![]); - Ok(pk_member) - }) - .collect::, _>>()? - .into_iter() - .filter(|pk_member| { - if let Ok(pk_dimension) = pk_member.as_dimension() { - if pk_dimension.dimension_type() == "time" { // TODO: What if calendar cube is joined via non-time dimension? - return true; + if pk_members.iter().any(|pk| **pk == name) { + // To avoid evaluation loop. + None + } else { + let pk_member = pk_members + .into_iter() + .map(|primary_key| -> Result<_, CubeError> { + let key_dimension_name = format!("{}.{}", cube_name, primary_key); + let pk_member = compiler.add_dimension_evaluator(key_dimension_name)?; + + Ok(pk_member) + }) + .collect::, _>>()? + .into_iter() + .filter(|pk_member| { + if let Ok(pk_dimension) = pk_member.as_dimension() { + // TODO: What if calendar cube is joined via non-time dimension? + if pk_dimension.dimension_type() == "time" { + return true; + } } - } - false - }) - .collect::>() - .first() - .cloned(); - pk_member + false + }) + .collect::>() + .first() + .cloned(); + pk_member + } } else { None }; - let cube = cube_evaluator.cube_from_path(cube_name.clone())?; - let is_view = cube.static_data().is_view.unwrap_or(false); let owned_by_cube = definition.static_data().owned_by_cube.unwrap_or(true); let is_sub_query = definition.static_data().sub_query.unwrap_or(false); let is_multi_stage = definition.static_data().multi_stage.unwrap_or(false); From f3c874d39fc4503f968ab76be1bcf7278ed646a2 Mon Sep 17 00:00:00 2001 From: Konstantin Burkalev Date: Wed, 9 Jul 2025 20:14:12 +0300 Subject: [PATCH 7/7] add tests --- .../integration/postgres/calendars.test.ts | 471 ++++++++++++++---- 1 file changed, 385 insertions(+), 86 deletions(-) diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts index 85c6d44335650..21292548cf4de 100644 --- a/packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts @@ -16,14 +16,14 @@ cubes: 100 + gs.id AS user_id, (ARRAY['new', 'processed', 'shipped'])[(gs.id % 3) + 1] AS status, make_timestamp( - 2025, + 2024 + (case when gs.id < 41 then 0 else 1 end), (gs.id % 12) + 1, 1 + (gs.id * 7 % 25), 0, 0, 0 ) AS created_at - FROM generate_series(1, 40) AS gs(id) + FROM generate_series(1, 80) AS gs(id) joins: - name: custom_calendar @@ -57,7 +57,7 @@ cubes: type: count - name: count_shifted - type: count + type: number multi_stage: true sql: "{count}" time_shift: @@ -65,6 +65,30 @@ cubes: interval: 1 year type: prior + - name: count_shifted_calendar_y + type: number + multi_stage: true + sql: "{count}" + time_shift: + - interval: 1 year + type: prior + + - name: count_shifted_calendar_m + type: number + multi_stage: true + sql: "{count}" + time_shift: + - interval: 1 month + type: prior + + - name: count_shifted_calendar_w + type: number + multi_stage: true + sql: "{count}" + time_shift: + - interval: 1 week + type: prior + - name: completed_count type: count filters: @@ -81,56 +105,99 @@ cubes: trailing: unbounded - name: custom_calendar + # language=SQL sql: > - WITH base AS ( - SELECT - gs.n - 1 AS day_offset, - DATE '2025-02-02' + (gs.n - 1) AS date_val - FROM generate_series(1, 364) AS gs(n) - ), - retail_calc AS ( - SELECT - date_val, - date_val AS retail_date, - '2025' AS retail_year_name, - (day_offset / 7) + 1 AS retail_week, - -- Group of months 4-5-4 (13 weeks = 3 months) - ((day_offset / 7) / 13) + 1 AS retail_quarter, - (day_offset / 7) % 13 AS week_in_quarter, - DATE '2025-02-02' AS retail_year_begin_date - FROM base + WITH base AS (SELECT gs.n - 1 AS day_offset, + DATE '2024-02-04' + (gs.n - 1) AS date_val + FROM generate_series(1, 728) AS gs(n) ), - final AS ( - SELECT - date_val, - retail_date, - retail_year_name, - ('Retail Month ' || ((retail_quarter - 1) * 3 + - CASE - WHEN week_in_quarter < 4 THEN 1 - WHEN week_in_quarter < 9 THEN 2 - ELSE 3 - END)) AS retail_month_long_name, - ('WK' || LPAD(retail_week::text, 2, '0')) AS retail_week_name, - retail_year_begin_date, - ('Q' || retail_quarter || ' 2025') AS retail_quarter_year, - (SELECT MIN(date_val) FROM retail_calc r2 - WHERE r2.retail_quarter = r.retail_quarter - AND CASE - WHEN week_in_quarter < 4 THEN 1 - WHEN week_in_quarter < 9 THEN 2 - ELSE 3 - END = - CASE - WHEN r.week_in_quarter < 4 THEN 1 - WHEN r.week_in_quarter < 9 THEN 2 - ELSE 3 - END - ) AS retail_month_begin_date, - date_val - (extract(dow from date_val)::int) AS retail_week_begin_date, - ('2025-WK' || LPAD(retail_week::text, 2, '0')) AS retail_year_week - FROM retail_calc r - ) + retail_calc AS (SELECT date_val, + date_val AS retail_date, + + CASE + WHEN day_offset < 364 THEN '2024' + ELSE '2025' + END AS retail_year_name, + + (day_offset % 364) AS day_of_retail_year, + ((day_offset % 364) / 7) + 1 AS retail_week, + ((day_offset % 364) / 91) + 1 AS retail_quarter, + ((day_offset % 364) / 7) % 13 AS week_in_quarter, + + DATE '2024-02-04' + CASE + WHEN day_offset < 364 THEN 0 + ELSE 364 + END AS retail_year_begin_date, + + ((day_offset / 7) / 13) * 3 + + CASE + WHEN ((day_offset / 7) % 13) < 4 THEN 1 + WHEN ((day_offset / 7) % 13) < 9 THEN 2 + ELSE 3 + END AS global_month, + + ((day_offset / 7) / 13) + 1 AS global_quarter, + day_offset + 1 AS global_day_number, + + ((day_offset / 7) / 13) * 3 + + CASE + WHEN ((day_offset / 7) % 13) < 4 THEN 1 + WHEN ((day_offset / 7) % 13) < 9 THEN 2 + ELSE 3 + END - CASE + WHEN day_offset < 364 THEN 0 + ELSE 12 + END AS retail_month_in_year, + + row_number() OVER ( + PARTITION BY + ((day_offset / 7) / 13) * 3 + + CASE + WHEN ((day_offset / 7) % 13) < 4 THEN 1 + WHEN ((day_offset / 7) % 13) < 9 THEN 2 + ELSE 3 + END + ORDER BY date_val + ) AS day_in_retail_month, + + row_number() OVER ( + PARTITION BY ((day_offset / 7) / 13) + 1 + ORDER BY date_val + ) AS day_in_retail_quarter, + + row_number() OVER ( + ORDER BY date_val + ) AS day_in_retail_year + FROM base), + final AS (SELECT r.date_val::timestamp, + r.retail_date::timestamp, + r.retail_year_name, + ('Retail Month ' || r.retail_month_in_year) AS retail_month_long_name, + ('WK' || LPAD(r.retail_week::text, 2, '0')) AS retail_week_name, + r.retail_year_begin_date, + ('Q' || r.retail_quarter || ' ' || r.retail_year_name) AS retail_quarter_year, + + (SELECT MIN(date_val) + FROM retail_calc r2 + WHERE r2.global_month = r.global_month + AND r2.day_in_retail_month = 1) AS retail_month_begin_date, + + r.date_val - (extract(dow from r.date_val)::int) AS retail_week_begin_date, + (r.retail_year_name || '-WK' || LPAD(r.retail_week::text, 2, '0')) AS retail_year_week, + + r_prev_month.date_val::timestamp AS retail_date_prev_month, + r_prev_quarter.date_val::timestamp AS retail_date_prev_quarter, + r_prev_year.date_val::timestamp AS retail_date_prev_year + + FROM retail_calc r + LEFT JOIN retail_calc r_prev_month + ON r_prev_month.global_month = r.global_month - 1 + AND r_prev_month.day_in_retail_month = r.day_in_retail_month + LEFT JOIN retail_calc r_prev_quarter + ON r_prev_quarter.global_quarter = r.global_quarter - 1 + AND r_prev_quarter.day_in_retail_quarter = r.day_in_retail_quarter + LEFT JOIN retail_calc r_prev_year + ON r_prev_year.global_day_number = r.global_day_number - 364) SELECT * FROM final ORDER BY date_val @@ -151,16 +218,16 @@ cubes: granularities: - name: year - sql: "{CUBE.retail_year_begin_date}" + sql: "{CUBE}.retail_year_begin_date" - name: quarter - sql: "{CUBE.retail_quarter_year}" + sql: "{CUBE}.retail_quarter_year" - - name: month - sql: "{CUBE.retail_month_begin_date}" +# - name: month +# sql: "{CUBE}.retail_month_begin_date" - name: week - sql: "{CUBE.retail_week_begin_date}" + sql: "{CUBE}.retail_week_begin_date" # Casually defining custom granularities should also work. # While maybe not very sound from a business standpoint, @@ -169,6 +236,19 @@ cubes: interval: 2 week origin: "2025-01-01" + time_shift: + - interval: 1 month + type: prior + sql: "{CUBE}.retail_date_prev_month" + + - interval: 1 quarter + type: prior + sql: "{CUBE}.retail_date_prev_quarter" + + - interval: 1 year + type: prior + sql: "{CUBE}.retail_date_prev_year" + - name: retail_year sql: "{CUBE}.retail_year_name" type: string @@ -230,12 +310,12 @@ cubes: timeDimensions: [{ dimension: 'custom_calendar.retail_date', granularity: 'year', - dateRange: ['2025-02-01', '2026-03-01'] + dateRange: ['2025-02-02', '2026-02-01'] }], order: [{ id: 'custom_calendar.retail_date' }] }, [ { - calendar_orders__count: '36', + calendar_orders__count: '37', custom_calendar__retail_date_year: '2025-02-02T00:00:00.000Z', } ])); @@ -245,54 +325,54 @@ cubes: timeDimensions: [{ dimension: 'custom_calendar.retail_date', granularity: 'month', - dateRange: ['2025-02-01', '2026-03-01'] + dateRange: ['2025-02-02', '2026-02-01'] }], order: [{ id: 'custom_calendar.retail_date' }] }, [ { calendar_orders__count: '3', - custom_calendar__retail_date_month: '2025-02-02T00:00:00.000Z', + custom_calendar__retail_date_month: '2025-02-01T00:00:00.000Z', }, { - calendar_orders__count: '4', - custom_calendar__retail_date_month: '2025-03-02T00:00:00.000Z', + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-03-01T00:00:00.000Z', }, { - calendar_orders__count: '4', - custom_calendar__retail_date_month: '2025-04-06T00:00:00.000Z', + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-04-01T00:00:00.000Z', }, { - calendar_orders__count: '4', - custom_calendar__retail_date_month: '2025-05-04T00:00:00.000Z', + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-05-01T00:00:00.000Z', }, { calendar_orders__count: '4', custom_calendar__retail_date_month: '2025-06-01T00:00:00.000Z', }, { - calendar_orders__count: '2', - custom_calendar__retail_date_month: '2025-07-06T00:00:00.000Z', + calendar_orders__count: '4', + custom_calendar__retail_date_month: '2025-07-01T00:00:00.000Z', }, { - calendar_orders__count: '3', - custom_calendar__retail_date_month: '2025-08-03T00:00:00.000Z', + calendar_orders__count: '4', + custom_calendar__retail_date_month: '2025-08-01T00:00:00.000Z', }, { - calendar_orders__count: '3', - custom_calendar__retail_date_month: '2025-08-31T00:00:00.000Z', + calendar_orders__count: '4', + custom_calendar__retail_date_month: '2025-09-01T00:00:00.000Z', }, { calendar_orders__count: '3', - custom_calendar__retail_date_month: '2025-10-05T00:00:00.000Z', + custom_calendar__retail_date_month: '2025-10-01T00:00:00.000Z', }, { calendar_orders__count: '3', - custom_calendar__retail_date_month: '2025-11-02T00:00:00.000Z', + custom_calendar__retail_date_month: '2025-11-01T00:00:00.000Z', }, { calendar_orders__count: '3', - custom_calendar__retail_date_month: '2025-11-30T00:00:00.000Z', - } + custom_calendar__retail_date_month: '2025-12-01T00:00:00.000Z', + }, ])); it('Count by retail week', async () => runQueryTest({ @@ -300,7 +380,7 @@ cubes: timeDimensions: [{ dimension: 'custom_calendar.retail_date', granularity: 'week', - dateRange: ['2025-02-01', '2025-04-01'] + dateRange: ['2025-02-02', '2025-04-01'] }], order: [{ id: 'custom_calendar.retail_date' }] }, [ @@ -318,7 +398,7 @@ cubes: }, { calendar_orders__count: '1', - custom_calendar__retail_date_week: '2025-03-02T00:00:00.000Z', + custom_calendar__retail_date_week: '2025-02-23T00:00:00.000Z', }, { calendar_orders__count: '1', @@ -330,8 +410,8 @@ cubes: }, { calendar_orders__count: '1', - custom_calendar__retail_date_week: '2025-03-23T00:00:00.000Z', - } + custom_calendar__retail_date_week: '2025-03-30T00:00:00.000Z', + }, ])); it('Count by fortnight custom granularity', async () => runQueryTest({ @@ -339,25 +419,244 @@ cubes: timeDimensions: [{ dimension: 'custom_calendar.retail_date', granularity: 'fortnight', - dateRange: ['2025-02-01', '2025-04-01'] + dateRange: ['2025-02-02', '2025-04-01'] }], order: [{ id: 'custom_calendar.retail_date' }] }, [ { - calendar_orders__count: '2', + calendar_orders__count: '1', custom_calendar__retail_date_fortnight: '2025-01-29T00:00:00.000Z', // Notice it starts on 2025-01-29, not 2025-02-01 }, { - calendar_orders__count: '1', + calendar_orders__count: '2', custom_calendar__retail_date_fortnight: '2025-02-12T00:00:00.000Z', }, { - calendar_orders__count: '1', + calendar_orders__count: '2', custom_calendar__retail_date_fortnight: '2025-02-26T00:00:00.000Z', }, { - calendar_orders__count: '3', + calendar_orders__count: '1', custom_calendar__retail_date_fortnight: '2025-03-12T00:00:00.000Z', - } + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_fortnight: '2025-03-26T00:00:00.000Z', + }, ])); + + describe('Time-shifts', () => { + it('Count shifted by retail year (custom shift + custom granularity)', async () => runQueryTest({ + measures: ['calendar_orders.count', 'calendar_orders.count_shifted_calendar_y'], + timeDimensions: [{ + dimension: 'custom_calendar.retail_date', + granularity: 'year', + dateRange: ['2025-02-02', '2026-02-01'] + }], + order: [{ id: 'custom_calendar.retail_date' }] + }, [ + { + calendar_orders__count: '37', + calendar_orders__count_shifted_calendar_y: '39', + custom_calendar__retail_date_year: '2025-02-02T00:00:00.000Z', + }, + ])); + + it('Count shifted by retail month (custom shift common granularity)', async () => runQueryTest({ + measures: ['calendar_orders.count', 'calendar_orders.count_shifted_calendar_m'], + timeDimensions: [{ + dimension: 'custom_calendar.retail_date', + granularity: 'month', + dateRange: ['2025-02-02', '2026-02-01'] + }], + order: [{ id: 'custom_calendar.retail_date' }] + }, [ + { + calendar_orders__count: '3', + calendar_orders__count_shifted_calendar_m: '3', + custom_calendar__retail_date_month: '2025-02-01T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + calendar_orders__count_shifted_calendar_m: '4', + custom_calendar__retail_date_month: '2025-03-01T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + calendar_orders__count_shifted_calendar_m: '2', + custom_calendar__retail_date_month: '2025-04-01T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + calendar_orders__count_shifted_calendar_m: '2', + custom_calendar__retail_date_month: '2025-05-01T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + calendar_orders__count_shifted_calendar_m: '3', + custom_calendar__retail_date_month: '2025-06-01T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + calendar_orders__count_shifted_calendar_m: '4', + custom_calendar__retail_date_month: '2025-07-01T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + calendar_orders__count_shifted_calendar_m: '4', + custom_calendar__retail_date_month: '2025-08-01T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + calendar_orders__count_shifted_calendar_m: '3', + custom_calendar__retail_date_month: '2025-09-01T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + calendar_orders__count_shifted_calendar_m: '4', + custom_calendar__retail_date_month: '2025-10-01T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + calendar_orders__count_shifted_calendar_m: '3', + custom_calendar__retail_date_month: '2025-11-01T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + calendar_orders__count_shifted_calendar_m: '3', + custom_calendar__retail_date_month: '2025-12-01T00:00:00.000Z', + }, + ])); + + it('Count shifted by retail week (common shift custom granularity)', async () => runQueryTest({ + measures: ['calendar_orders.count', 'calendar_orders.count_shifted_calendar_w'], + timeDimensions: [{ + dimension: 'custom_calendar.retail_date', + granularity: 'week', + dateRange: ['2025-02-02', '2026-02-01'] + }], + order: [{ id: 'custom_calendar.retail_date' }] + }, [ + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-02-09T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-02-16T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-02-23T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-03-16T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-04-06T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-04-13T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-05-11T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-05-18T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-06-08T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-06-15T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-06-22T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-06-29T00:00:00.000Z', + }, + { + calendar_orders__count: '2', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-07-20T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '2', + custom_calendar__retail_date_week: '2025-07-27T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-08-03T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-08-10T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-08-17T00:00:00.000Z', + }, + { + calendar_orders__count: '2', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-09-07T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '2', + custom_calendar__retail_date_week: '2025-09-14T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-10-12T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-10-19T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-11-23T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-11-30T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + calendar_orders__count_shifted_calendar_w: '1', + custom_calendar__retail_date_week: '2025-12-21T00:00:00.000Z', + }, + ])); + }); });