Skip to content

feat(tesseract): Introduce named multistage time shifts for use with calendars #9773

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 14, 2025
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
15 changes: 9 additions & 6 deletions packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@ export type DimensionDefinition = {
};

export type TimeShiftDefinition = {
timeDimension?: (...args: Array<unknown>) => ToString,
interval: string,
type: 'next' | 'prior',
timeDimension?: (...args: Array<unknown>) => ToString;
name?: string;
interval?: string;
type?: 'next' | 'prior';
};

export type TimeShiftDefinitionReference = {
timeDimension?: string,
interval: string,
type: 'next' | 'prior',
timeDimension?: string;
name?: string;
interval?: string;
type?: 'next' | 'prior';
};

export type MeasureDefinition = {
Expand Down Expand Up @@ -393,6 +395,7 @@ export class CubeEvaluator extends CubeSymbols {
}
if (member.timeShift) {
member.timeShiftReferences = member.timeShift.map((s): TimeShiftDefinitionReference => ({
name: s.name,
interval: s.interval,
type: s.type,
...(typeof s.timeDimension === 'function'
Expand Down
25 changes: 17 additions & 8 deletions packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,9 +576,12 @@ const timeShiftItemRequired = Joi.object({

const timeShiftItemOptional = Joi.object({
timeDimension: Joi.func(), // not required
interval: regexTimeInterval.required(),
type: Joi.string().valid('next', 'prior').required(),
});
interval: regexTimeInterval,
name: identifier,
type: Joi.string().valid('next', 'prior'),
})
.xor('name', 'interval')
.and('interval', 'type');

const MeasuresSchema = Joi.object().pattern(identifierRegex, Joi.alternatives().conditional(Joi.ref('.multiStage'), [
{
Expand Down Expand Up @@ -623,6 +626,16 @@ const MeasuresSchema = Joi.object().pattern(identifierRegex, Joi.alternatives().
]
));

const CalendarTimeShiftItem = Joi.object({
name: identifier,
interval: regexTimeInterval,
type: Joi.string().valid('next', 'prior'),
sql: Joi.func().required(),
})
.or('name', 'interval')
.with('interval', 'type')
.with('type', 'interval');

const DimensionsSchema = Joi.object().pattern(identifierRegex, Joi.alternatives().try(
inherit(BaseDimensionWithoutSubQuery, {
case: Joi.object().keys({
Expand Down Expand Up @@ -667,11 +680,7 @@ const DimensionsSchema = Joi.object().pattern(identifierRegex, Joi.alternatives(
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(),
})),
timeShift: Joi.array().items(CalendarTimeShiftItem),
})
));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ cubes:
- interval: 1 year
type: prior

- name: count_shifted_y_named
type: number
multi_stage: true
sql: "{count}"
time_shift:
- name: one_year

- name: count_shifted_y1d_named
type: number
multi_stage: true
sql: "{count}"
time_shift:
- name: one_year_and_one_day

- name: count_shifted_calendar_m
type: number
multi_stage: true
Expand Down Expand Up @@ -244,6 +258,12 @@ cubes:
type: prior
sql: "{CUBE}.retail_date_prev_year"

- name: one_year
sql: "{CUBE}.retail_date_prev_year"

- name: one_year_and_one_day
sql: "({CUBE}.retail_date_prev_year + interval '1 day')"

##### Retail Dates ####
- name: retail_date
sql: retail_date
Expand Down Expand Up @@ -282,6 +302,12 @@ cubes:
type: prior
sql: "{CUBE}.retail_date_prev_year"

- name: one_year
sql: "{CUBE}.retail_date_prev_year"

- name: one_year_and_one_day
sql: "({CUBE}.retail_date_prev_year + interval '1 day')"

- name: retail_year
sql: "{CUBE}.retail_year_name"
type: string
Expand Down Expand Up @@ -576,6 +602,22 @@ cubes:
},
]));

it('Count shifted by retail year (custom named shift + custom granularity)', async () => runQueryTest({
measures: ['calendar_orders.count', 'calendar_orders.count_shifted_y_named'],
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_y_named: '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: [{
Expand Down Expand Up @@ -677,6 +719,23 @@ cubes:
custom_calendar__retail_date_week: '2025-04-06T00:00:00.000Z',
},
]));

it('Count shifted by retail year and another custom calendar year (2 custom named shifts + custom granularity)', async () => runQueryTest({
measures: ['calendar_orders.count', 'calendar_orders.count_shifted_y_named', 'calendar_orders.count_shifted_y1d_named'],
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_y_named: '39',
calendar_orders__count_shifted_y1d_named: '39',
custom_calendar__retail_date_year: '2025-02-02T00:00:00.000Z',
},
]));
});

describe('PK dimension time-shifts', () => {
Expand All @@ -696,6 +755,22 @@ cubes:
},
]));

it.skip('Count shifted by retail year (custom named shift + custom granularity)1', async () => runQueryTest({
measures: ['calendar_orders.count', 'calendar_orders.count_shifted_y_named'],
timeDimensions: [{
dimension: 'custom_calendar.date_val',
granularity: 'year',
dateRange: ['2025-02-02', '2026-02-01']
}],
order: [{ id: 'custom_calendar.date_val' }]
}, [
{
calendar_orders__count: '37',
calendar_orders__count_shifted_y_named: '39',
custom_calendar__date_val_year: '2025-02-02T00:00:00.000Z',
},
]));

it.skip('Count shifted by retail month (custom shift + common granularity)', async () => runQueryTest({
measures: ['calendar_orders.count', 'calendar_orders.count_shifted_calendar_m'],
timeDimensions: [{
Expand Down Expand Up @@ -797,6 +872,23 @@ cubes:
custom_calendar__date_val_week: '2025-04-06T00:00:00.000Z',
},
]));

it.skip('Count shifted by retail year and another custom calendar year (2 custom named shifts + custom granularity)', async () => runQueryTest({
measures: ['calendar_orders.count', 'calendar_orders.count_shifted_y_named', 'calendar_orders.count_shifted_y1d_named'],
timeDimensions: [{
dimension: 'custom_calendar.date_val',
granularity: 'year',
dateRange: ['2025-02-02', '2026-02-01']
}],
order: [{ id: 'custom_calendar.date_val' }]
}, [
{
calendar_orders__count: '37',
calendar_orders__count_shifted_y_named: '39',
calendar_orders__count_shifted_y1d_named: '39',
custom_calendar__date_val_year: '2025-02-02T00:00:00.000Z',
},
]));
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,8 @@ Object {
"type": "prior",
},
Object {
"interval": "1 year",
"name": "retail_date_prev_year",
"sql": [Function],
"type": "prior",
},
Object {
"interval": "2 year",
Expand Down Expand Up @@ -273,9 +272,8 @@ Object {
"type": "prior",
},
Object {
"interval": "1 year",
"name": "retail_date_prev_year",
"sql": [Function],
"type": "prior",
},
Object {
"interval": "2 year",
Expand Down Expand Up @@ -1894,6 +1892,7 @@ Object {
"timeShiftReferences": Array [
Object {
"interval": "1 year",
"name": undefined,
"timeDimension": "createdAt",
"type": "prior",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ cubes:
interval: 1 year
type: prior

- name: count_shifted_named
type: count
multi_stage: true
sql: "{count}"
time_shift:
- name: retail_date_prev_year

- name: completed_count
type: count
filters:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ cube(`custom_calendar_js`, {
sql: `{CUBE.retail_date_prev_month}`,
},
{
interval: '1 year',
type: 'prior',
name: 'retail_date_prev_year',
sql: `{CUBE.retail_date_prev_year}`,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ cubes:
type: prior
sql: "{CUBE.retail_date_prev_month}"

- interval: 1 year
type: prior
- name: retail_date_prev_year
sql: "{CUBE.retail_date_prev_year}"

# All needed intervals should be defined explicitly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use std::rc::Rc;

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub struct TimeShiftReference {
pub interval: String,
pub interval: Option<String>,
pub name: Option<String>,
#[serde(rename = "type")]
pub shift_type: Option<String>,
#[serde(rename = "timeDimension")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ use std::rc::Rc;

#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)]
pub struct TimeShiftDefinitionStatic {
pub interval: String,
pub interval: Option<String>,
#[serde(rename = "type")]
pub timeshift_type: String,
pub timeshift_type: Option<String>,
pub name: Option<String>,
}

#[nativebridge::native_bridge(TimeShiftDefinitionStatic)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ impl PrettyPrint for MultiStageAppliedState {
&format!(
"- {}: {}",
time_shift.dimension.full_name(),
time_shift.interval.to_sql()
if let Some(interval) = &time_shift.interval {
interval.to_sql()
} else if let Some(name) = &time_shift.name {
format!("{} (named)", name.to_string())
} else {
"None".to_string()
}
),
&details_state,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ impl PrettyPrint for MultiStageLeafMeasure {
&format!(
"- {}: {}",
time_shift.dimension.full_name(),
time_shift.interval.to_sql()
if let Some(interval) = &time_shift.interval {
interval.to_sql()
} else if let Some(name) = &time_shift.name {
format!("{} (named)", name.to_string())
} else {
"None".to_string()
}
),
&details_state,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,24 @@ impl PhysicalPlanBuilderContext {
.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()));
} else if let Some(calendar_pk) = dimension.time_shift_pk_full_name() {
let mut shift = shift.clone();
shift.interval = shift.interval.inverse();
return Either::Left((calendar_pk, shift.clone()));
if let Some(dim_shift_name) = &shift.name {
if let Some((dim_key, cts)) =
dimension.calendar_time_shift_for_named_interval(dim_shift_name)
{
return Either::Right((dim_key.clone(), cts.clone()));
} else if let Some(_calendar_pk) = dimension.time_shift_pk_full_name() {
// TODO: Handle case when named shift is not found
}
} else if let Some(dim_shift_interval) = &shift.interval {
if let Some((dim_key, cts)) =
dimension.calendar_time_shift_for_interval(dim_shift_interval)
{
return Either::Right((dim_key.clone(), cts.clone()));
} else if let Some(calendar_pk) = dimension.time_shift_pk_full_name() {
let mut shift = shift.clone();
shift.interval = Some(dim_shift_interval.inverse());
return Either::Left((calendar_pk, shift.clone()));
}
}
}
Either::Left((key.clone(), shift.clone()))
Expand Down
Loading
Loading