Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ebdc764
test(tesseract): pin FILTER_PARAMS behaviour under named calendar tim…
claude Sep 4, 2026
ec9e3a7
test(schema-compiler): FILTER_PARAMS push-down under a named calendar…
claude Sep 4, 2026
be026dd
fix(tesseract): reject FILTER_PARAMS string columns under a calendar …
claude Sep 4, 2026
6aa097b
fix(tesseract): carry interval-declared calendar shifts, probe the PK…
claude Sep 4, 2026
f259ace
refactor(tesseract): trim filter-params shift comments to what the co…
claude Sep 4, 2026
f690585
test(tesseract): cover the non-PK calendar binding; docs: FILTER_PARA…
claude Sep 4, 2026
1c57118
refactor(tesseract): fold the calendar arms, correct the PK-probe com…
claude Sep 4, 2026
bda197c
docs(tesseract): refresh the stale header on the calendar time-shift …
claude Sep 4, 2026
08f3495
test(tesseract): pin the non-PK probe test to this rejection, not any…
claude Sep 4, 2026
70ba23e
docs(schema-compiler): refresh the stale header on the YAML calendar …
claude Sep 4, 2026
ce09d2e
docs: pair the time-shift FILTER_PARAMS example with a JavaScript twin
claude Sep 4, 2026
94d3a60
docs: name the dialect in the time-shift FILTER_PARAMS example
claude Sep 4, 2026
c8a9739
docs: cast the filter values in the time-shift FILTER_PARAMS example
claude Sep 4, 2026
1fb4b70
docs: parenthesise the OR band in the time-shift FILTER_PARAMS example
claude Sep 4, 2026
30a9b6a
docs: show the calendar join in the time-shift FILTER_PARAMS example
claude Sep 4, 2026
3dea13a
docs: move the cast and paren notes below the fence
claude Sep 4, 2026
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
83 changes: 82 additions & 1 deletion docs-mintlify/reference/data-modeling/context-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,85 @@ binding always renders as `1 = 1`.

</Info>

### Time-shifted measures

A [`multi_stage` measure with a `time_shift`][ref-measures-time-shift] plans each
shift as its own query stage, and every stage re-reads the cube's `sql`. The
pushed-down column has to carry the same shift as the stage around it, otherwise
it filters the source rows by the unshifted range while the stage reads the
shifted one, and the stage comes back empty.

A shift declared as an `interval` is applied to the column for you. A shift
declared on a [calendar cube][ref-calendar-cubes] with its own `sql` cannot be:
it maps through a column on the calendar rather than offsetting the date, and
that column is not in scope inside the cube's `sql`. Passing the column as a
string in that case fails with a planning error naming the shift.

Pass a function instead. It receives the query's own filter values, so it can
widen the pushed-down range to cover the periods the shifted stages read.

`weekly_margin` joins a calendar cube whose `calendar_d` declares the
`prior_fiscal_year` shift, so the shifted stage reaches fact rows through that
mapping — which is why the band has to be widened by hand. The bracketing is
what carries across data sources; the date arithmetic is BigQuery syntax, so
adjust it for yours — here a fiscal prior year, bracketed at 371 and 364 days:
Comment thread
paveltiunov marked this conversation as resolved.

<CodeGroup>

```yaml title="YAML"
cubes:
- name: weekly_margin
sql: |
SELECT *
FROM weekly_margin
WHERE {FILTER_PARAMS.retail_calendar.calendar_d.filter(
Comment thread
paveltiunov marked this conversation as resolved.
lambda x, y: f"""
((week_end_d >= DATE(TIMESTAMP({x}))
AND week_end_d <= DATE(TIMESTAMP({y})))
OR (week_end_d >= DATE_SUB(DATE(TIMESTAMP({x})), INTERVAL 371 DAY)
AND week_end_d <= DATE_SUB(DATE(TIMESTAMP({y})), INTERVAL 364 DAY)))
"""
)}

joins:
- name: retail_calendar
sql: "{CUBE}.week_end_d = {retail_calendar.calendar_d}"
relationship: many_to_one
```

```javascript title="JavaScript"
cube(`weekly_margin`, {
sql: `
SELECT *
FROM weekly_margin
WHERE ${FILTER_PARAMS.retail_calendar.calendar_d.filter(
(x, y) => `
((week_end_d >= DATE(TIMESTAMP(${x}))
AND week_end_d <= DATE(TIMESTAMP(${y})))
OR (week_end_d >= DATE_SUB(DATE(TIMESTAMP(${x})), INTERVAL 371 DAY)
AND week_end_d <= DATE_SUB(DATE(TIMESTAMP(${y})), INTERVAL 364 DAY)))
`
)}
`,

joins: {
retail_calendar: {
sql: `${CUBE}.week_end_d = ${retail_calendar.calendar_d}`,
relationship: `many_to_one`
}
}
})
```

</CodeGroup>

The values arrive as bare query parameters, so they are cast before use; and the
band is parenthesised as a whole because it is an `OR`, without which appending
another condition to the `WHERE` would bind it to the second branch alone.

Every stage then scans every band, so keep the bands as narrow as the calendar
actually requires.

## `FILTER_GROUP`

If you use `FILTER_PARAMS` in your query more than once, you must wrap them
Expand Down Expand Up @@ -879,4 +958,6 @@ cube(`orders`, {
[ref-filter-boolean]: /reference/core-data-apis/rest-api/query-format#boolean-logical-operators
[ref-links]: /reference/data-modeling/dimensions#links
[ref-ref-segments]: /reference/data-modeling/segments
[ref-env-tesseract]: /reference/configuration/environment-variables#cubejs_tesseract_sql_planner
[ref-env-tesseract]: /reference/configuration/environment-variables#cubejs_tesseract_sql_planner
[ref-measures-time-shift]: /reference/data-modeling/measures#time_shift
[ref-calendar-cubes]: /docs/data-modeling/concepts/calendar-cubes
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { PostgresQuery } from '../../src/adapter/PostgresQuery';
import { prepareYamlCompiler } from './PrepareCompiler';

// A calendar shift declared with `sql` maps through a column on the calendar
// (`prior_fiscal_year` -> `next_fiscal_year_d`) rather than offsetting the date,
// so a string FILTER_PARAMS column is rejected and a callback — handed the
// query's own bounds — is not. Interval-declared shifts are offset onto the
// column instead; that shape is pinned in the planner suite.
const model = (filterParams: string) => `
cubes:
- name: fpc_calendar
calendar: true
sql: >
SELECT '2026-06-20'::date AS calendar_d,
'2025-06-21'::date AS next_fiscal_year_d,
'2024-06-22'::date AS next_two_fiscal_year_d
dimensions:
- name: calendar_d
sql: calendar_d
type: time
primary_key: true
time_shift:
- name: prior_fiscal_year
sql: "{CUBE}.next_fiscal_year_d"
- name: prior_two_fiscal_year
sql: "{CUBE}.next_two_fiscal_year_d"

- name: fpc_margin
sql: >
SELECT * FROM fpc_margin WHERE ${filterParams}
joins:
- name: fpc_calendar
sql: "{CUBE}.week_end_d = {fpc_calendar.calendar_d}"
relationship: many_to_one
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: week_end_d
sql: week_end_d
type: time
measures:
- name: net_sales
sql: net_sales_a
type: sum
- name: net_sales_ly
multi_stage: true
sql: "{net_sales}"
type: number
time_shift:
- name: prior_fiscal_year
- name: net_sales_ly2
multi_stage: true
sql: "{net_sales}"
type: number
time_shift:
- name: prior_two_fiscal_year
`;

const STRING_COLUMN = '{FILTER_PARAMS.fpc_calendar.calendar_d.filter(\'week_end_d\')}';

// The band a model writes by hand once it knows the shifted periods it has to
// cover. 371/364 days back brackets the fiscal prior year. YAML `.filter()`
// bodies are Python, so this is a lambda — the same form the reporting models
// that hit this use.
const CALLBACK_COLUMN = '{FILTER_PARAMS.fpc_calendar.calendar_d.filter('
+ 'lambda x, y: f"(week_end_d >= {x} AND week_end_d <= {y}) '
+ 'OR (week_end_d >= {x}::timestamptz - interval \'371 day\' '
+ 'AND week_end_d <= {y}::timestamptz - interval \'364 day\')")}';

async function buildSql(filterParams: string): Promise<[string, unknown[]]> {
const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler(model(filterParams));
await compiler.compile();

return new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, {
measures: ['fpc_margin.net_sales_ly', 'fpc_margin.net_sales_ly2'],
timeDimensions: [{
dimension: 'fpc_calendar.calendar_d',
dateRange: ['2026-06-20', '2026-06-20'],
}],
timezone: 'UTC',
// Named calendar time shifts are planned by Tesseract only.
useNativeSqlPlanner: true,
}).buildSqlAndParams();
}

describe('FILTER_PARAMS under a named calendar time shift', () => {
// Before this was rejected the column rendered bare and was bound to the
// unshifted reporting bounds in every stage. Each stage joins the calendar on
// its own mapping column, so the pushed-down predicate contradicted the stage
// around it and the stage came back empty — the same failure CORE-543 fixed
// for interval shifts, reached by a different route.
it('rejects a string column', async () => {
await expect(buildSql(STRING_COLUMN)).rejects.toThrow(
/fpc_calendar\.calendar_d.*prior_fiscal_year.*callback/s
);
});

// A callback column is handed the query's bounds and decides the range
// itself, so it is left alone. This is what a model widened by hand relies
// on, and it must keep working.
it('pushes a callback column into every shifted stage', async () => {
const [sql] = await buildSql(CALLBACK_COLUMN);

expect(sql.match(/week_end_d >=/g)).toHaveLength(4);
expect(sql).not.toContain('FROM fpc_margin WHERE (1 = 1)');
});

// The stage predicate is what carries the shift: each stage compares the
// calendar's own mapping column, not `calendar_d`.
it('filters each stage on its own mapping column', async () => {
const [sql] = await buildSql(CALLBACK_COLUMN);

expect(sql).toContain('next_fiscal_year_d >=');
expect(sql).toContain('next_two_fiscal_year_d >=');
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::ToSql;
use crate::physical_plan::filter::typed_filter::FilterParamsTimeShift;
use crate::physical_plan::sql_nodes::SqlNode;
use crate::physical_plan::SqlEvaluatorVisitor;
use crate::planner::filter::typed_filter::resolve_base_symbol;
Expand All @@ -25,10 +26,37 @@ impl ToSql for BaseFilter {
.filter_params_columns
.get(&symbol_to_match.full_name())
{
// Both shift kinds: `extract_time_shifts` routes calendar
// shifts to their own map, so `time_shifts` alone misses them.
let time_shift = visitor
.time_shifts()
.get_for_symbol(&symbol_to_match)
.and_then(|shift| shift.interval.as_ref());
.and_then(|shift| shift.interval.as_ref())
.map(FilterParamsTimeShift::Interval)
.or_else(|| {
// Keyed by the calendar cube's PK, so a filter on a
// non-PK dimension of that calendar misses the bare
// name and needs the PK probe. A miss renders the
// column bare, which is the silent failure this whole
// path exists to avoid.
//
// A filter on a NON-calendar cube's dimension is out of
// reach either way: `time_shift_pk_full_name` is only
// populated for calendar-cube dimensions, and a named
// shift on such a dimension is dropped by
// `extract_time_shifts` before it gets here.
let calendar_shifts = visitor.calendar_time_shifts();
calendar_shifts
.get(&symbol_to_match.full_name())
.or_else(|| {
let pk = symbol_to_match
.as_dimension()
.ok()?
.time_shift_pk_full_name()?;
calendar_shifts.get(&pk)
})
.map(FilterParamsTimeShift::Calendar)
});
Comment thread
paveltiunov marked this conversation as resolved.
return self.typed_filter().to_sql_for_filter_params(
filter_params_item,
time_shift,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::planner::filter::typed_filter::{resolve_base_symbol, FilterOp, TypedF
use crate::planner::query_tools::QueryTools;
use crate::planner::sql_call::SqlCallFilterParamsItem;
use crate::planner::sql_templates::PlanSqlTemplates;
use crate::planner::symbols::CalendarDimensionTimeShift;
use crate::planner::FiltersContext;
use crate::planner::SqlInterval;
use cubenativeutils::CubeError;
Expand Down Expand Up @@ -46,11 +47,21 @@ impl ToSql for TypedFilter {
}
}

/// The time shift active on the stage a FILTER_PARAMS column is rendered into.
/// An interval is arithmetic the column can carry. A calendar shift can be
/// either: declared as an interval it is arithmetic too, but declared with
/// `sql` it resolves through a mapping column on the calendar cube, which has
/// no expression over the fact's own column.
pub enum FilterParamsTimeShift<'a> {
Interval(&'a SqlInterval),
Calendar(&'a CalendarDimensionTimeShift),
}

impl TypedFilter {
pub fn to_sql_for_filter_params(
&self,
item: &SqlCallFilterParamsItem,
time_shift: Option<&SqlInterval>,
time_shift: Option<FilterParamsTimeShift<'_>>,
visitor: &SqlEvaluatorVisitor,
node_processor: Rc<dyn SqlNode>,
query_tools: &Rc<QueryTools>,
Expand All @@ -66,15 +77,55 @@ impl TypedFilter {
// current-period bounds contradict the shifted predicate and empty
// the CTE.
let shifted_column;
let member_sql = if let Some(interval) = time_shift {
shifted_column = format!(
"({})",
plan_templates
.add_timestamp_interval(column_sql.clone(), interval.to_sql())?
);
shifted_column.as_str()
} else {
column_sql.as_str()
let member_sql = match &time_shift {
Some(FilterParamsTimeShift::Interval(interval)) => {
shifted_column = format!(
"({})",
plan_templates
.add_timestamp_interval(column_sql.clone(), interval.to_sql())?
);
shifted_column.as_str()
}
// The inverse is not symmetric with the arm above:
// `extract_time_shifts` inverts before storing into
// `TimeShiftState`, while the calendar map keeps the
// declaration as written and the calendar node inverts at
// render. Taking `to_sql()` directly here would shift the
// pushed-down bounds the wrong way.
Some(FilterParamsTimeShift::Calendar(shift)) if shift.sql.is_none() => {
match &shift.interval {
Some(interval) => {
shifted_column = format!(
"({})",
plan_templates.add_timestamp_interval(
column_sql.clone(),
interval.inverse().to_sql()
)?
);
shifted_column.as_str()
}
// The calendar node renders the dimension unshifted
// too, so there is nothing to carry.
None => column_sql.as_str(),
}
}
Some(FilterParamsTimeShift::Calendar(shift)) => {
return Err(CubeError::user(format!(
concat!(
"FILTER_PARAMS column for `{}` is a string, so it cannot carry ",
"the `{}` calendar time shift this query applies: that shift ",
"resolves through a mapping column on the calendar cube rather ",
"than offsetting the column, and no expression over `{}` stands ",
"for it. Pass the column as a callback instead - it receives the ",
"query's own bounds and can widen the pushed-down range to cover ",
"the shifted periods."
),
item.filter_symbol_name,
shift.name.as_deref().unwrap_or("<unnamed>"),
column_sql,
)));
}
Comment thread
paveltiunov marked this conversation as resolved.
None => column_sql.as_str(),
};
let ctx = FilterSqlContext::new(
member_sql,
Expand All @@ -86,7 +137,12 @@ impl TypedFilter {
dispatch_to_sql(self.operation(), &ctx)
}
FilterParamsColumn::Compiled(compiled) => {
if time_shift.is_some() {
// An INTERVAL shift is carried by offsetting the column, which
// only a string column allows. A named calendar shift is carried
// by the callback itself: it is handed the query's own bounds and
// decides what range around them to push down, which is the only
// place that mapping can be expressed.
if matches!(time_shift, Some(FilterParamsTimeShift::Interval(_))) {
return Err(CubeError::user(format!(
"FILTER_PARAMS column for `{}` is a callback, which cannot carry the time \
shift the surrounding query applies; pass the column as a string instead",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ impl SqlNodesFactory {
&self.time_shifts
}

pub fn calendar_time_shifts(&self) -> &HashMap<String, CalendarDimensionTimeShift> {
&self.calendar_time_shifts
}

pub fn set_calendar_time_shifts(
&mut self,
calendar_time_shifts: HashMap<String, CalendarDimensionTimeShift>,
Expand Down
Loading
Loading