Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs-mintlify/reference/data-modeling/measures.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,13 @@ for the series of time windows.
With Tesseract, the [next-generation data modeling engine][link-tesseract],
rolling window calculations don't require the date range for the time dimension. In versions before v1.7.0, Tesseract was not enabled by default.

Omitting the date range requires the data source to generate the time series in
SQL. It is supported for Amazon Athena, Databricks, Google BigQuery, Microsoft SQL
Server, Postgres, Presto, Snowflake, and Trino, as well as for MySQL with the
`CUBEJS_DB_MYSQL_USE_GENERATED_TIME_SERIES` environment variable set. With other
data sources, and with [custom granularities][ref-custom-granularities] on Google
BigQuery, Microsoft SQL Server, and Snowflake, the date range is still required.

</Warning>

#### `offset`
Expand Down Expand Up @@ -1504,6 +1511,7 @@ cube(`orders`, {
[ref-nested-aggregate]: /docs/data-modeling/measures#nested-aggregates
[ref-calendar-cubes]: /docs/data-modeling/concepts/calendar-cubes
[ref-switch-dimensions]: /reference/data-modeling/dimensions#type
[ref-custom-granularities]: /reference/data-modeling/dimensions#granularities
[ref-filters-query]: /reference/core-data-apis/rest-api/query-format#filters-format
[ref-data-masking]: /docs/data-modeling/data-access-policies#data-masking
[link-d3-format]: https://d3js.org/d3-format
Expand Down
36 changes: 36 additions & 0 deletions packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const GRANULARITY_TO_INTERVAL = {
year: 'YEAR'
};

// Ordered from the smallest, so the first match is the interval's own unit.
const INTERVAL_UNITS = ['second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year'];

class SnowflakeFilter extends BaseFilter {
public likeIgnoreCase(column: string, not: boolean, param: any, type: string) {
const p = (!type || type === 'contains' || type === 'ends') ? '\'%\' || ' : '';
Expand Down Expand Up @@ -84,6 +87,19 @@ export class SnowflakeQuery extends BaseQuery {
return `${value}::timestamp_tz`;
}

/**
* The generated time series steps with DATEADD, which takes a time unit and a
* row number rather than an interval, so it needs the unit the interval is
* actually expressed in. `diffTimeUnitForInterval` answers a different
* question - it degrades WEEK to DAY and QUARTER to MONTH, which DATEADD would
* then step by, producing seven or three times as many periods as asked for.
*/
public override intervalAndMinimalTimeUnit(interval: string): [string, string] {
const unit = INTERVAL_UNITS.find(u => new RegExp(u, 'i').test(interval));

return [interval, unit || 'year'];
}
Comment on lines +97 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things about this override worth tightening:

  1. new RegExp(u, 'i') is an unanchored substring match, so '1 millisecond' matches second (milli**second**) and returns SECOND — a silently wrong unit rather than an error. A word-boundary pattern (new RegExp(\b${u}s?\b, 'i')) or interval.toLowerCase().includes(...) with millisecond handled explicitly avoids it.
  2. The || 'year' fallback turns an unrecognised interval into a plausible-looking series instead of failing. The inherited diffTimeUnitForInterval throws in that case; matching it would be safer here, since a wrong unit produces silently wrong data rather than an error.
  3. The count is dropped: '2 week'['2 week', 'week'], and both templates step by 1 {{ minimal_time_unit }} and ignore {{ granularity }} entirely, so the series would come out at the wrong period. Unreachable today because supportGeneratedSeriesForCustomTd is false — but nothing here records that dependency, so flipping that flag on later would silently produce a wrong series. Throwing unless the interval is exactly 1 <unit> would make the coupling enforced rather than documented.


public defaultRefreshKeyRenewalThreshold() {
return 120;
}
Expand Down Expand Up @@ -146,6 +162,26 @@ export class SnowflakeQuery extends BaseQuery {
// the same reason described there.
templates.tesseract.ilike = '{{ expr }} {% if negated %}NOT {% endif %}ILIKE {{ pattern }} ESCAPE \'\\\\\'';
templates.tesseract.join_types_full = 'FULL';
// Snowflake has no generate_series, and GENERATOR only takes a literal row
// count. ARRAY_GENERATE_RANGE does accept arbitrary expressions for its
// bounds, so the series covers whatever range the query turns out to need;
// the only ceiling is the maximum size of a single ARRAY value.
//
// Its row number is a count of whole time units, which is all DATEADD can
// step by. A custom granularity's interval carries no such count, so
// `supportGeneratedSeriesForCustomTd` stays off and those queries keep
// asking for an explicit date range.
//
// Snowflake folds unquoted identifiers to upper case while the planner reads
// the series back by lower-case name, so the two output columns are quoted.
Comment on lines +165 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a 12-line comment over two template strings, and most of it re-states the PR description rather than protecting the code below. The load-bearing sentences are the two that a later edit could otherwise undo: why the columns are quoted (identifier folding) and why timestamp_ntz rather than timeStampCast — the latter isn't stated here at all, which is the one a future reader is most likely to "fix" by swapping in {{ date_from }}/{{ date_to }}. The dialect survey (no generate_series, GENERATOR takes a literal) and the custom-granularity paragraph belong in the commit message, which already carries them. Suggest trimming to ~3 lines and moving the timestamp_ntz rationale in.

templates.statements.generated_time_series_select = 'SELECT series_date AS "date_from",\n' +
'DATEADD(MILLISECOND, -1, DATEADD({{ minimal_time_unit }}, 1, series_date)) AS "date_to"\n' +
'FROM (SELECT DATEADD({{ minimal_time_unit }}, series_index.value::int, {{ start }}::timestamp_ntz) AS series_date\n' +
'FROM TABLE(FLATTEN(input => ARRAY_GENERATE_RANGE(0, DATEDIFF({{ minimal_time_unit }}, {{ start }}::timestamp_ntz, {{ end }}::timestamp_ntz) + 1))) AS series_index) AS series';
templates.statements.generated_time_series_with_cte_range_source = 'SELECT series_date AS "date_from",\n' +
'DATEADD(MILLISECOND, -1, DATEADD({{ minimal_time_unit }}, 1, series_date)) AS "date_to"\n' +
'FROM (SELECT DATEADD({{ minimal_time_unit }}, series_index.value::int, {{ range_source }}."{{ min_name }}") AS series_date\n' +
'FROM {{ range_source }}, LATERAL FLATTEN(input => ARRAY_GENERATE_RANGE(0, DATEDIFF({{ minimal_time_unit }}, {{ range_source }}."{{ min_name }}", {{ range_source }}."{{ max_name }}") + 1)) AS series_index) AS series';
Comment on lines +177 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Row count can overshoot the range end. Snowflake's DATEDIFF counts unit boundaries crossed, not whole elapsed units, so when start / min_date is not aligned to the unit boundary DATEDIFF(...) + 1 yields one row more than Postgres' generate_series(start, end, interval) does, and the extra row starts after end.

Concretely, month granularity with min_date = 2024-01-15, max_date = 2024-02-05:

  • DATEDIFF(month, '2024-01-15', '2024-02-05') = 1ARRAY_GENERATE_RANGE(0, 2) → series 2024-01-15, 2024-02-15
  • Postgres: generate_series('2024-01-15','2024-02-05','1 month')2024-01-15 only

The CTE-range variant is the more exposed of the two, since min_date/max_date come straight from MIN()/MAX() over the data and are almost never boundary-aligned — which is exactly the path the three un-skipped driver tests take. That is also a plausible way for the predicted snapshots to come back with one extra trailing bucket.

Cheap guard — bound the series explicitly instead of relying on DATEDIFF being exact:

  • generated_time_series_select: append \nWHERE series_date <= {{ end }}::timestamp_ntz after AS series.
  • generated_time_series_with_cte_range_source: the outer scope can't see range_source, so the filter has to go on the inner select, repeating the expression:
    ... AS series_index WHERE DATEADD({{ minimal_time_unit }}, series_index.value::int, {{ range_source }}."{{ min_name }}") <= {{ range_source }}."{{ max_name }}"

With that in place + 1 stays a safe over-allocation rather than an exact count.

delete templates.types.interval;
return templates;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/* eslint-disable no-restricted-syntax */
import { PostgresQuery } from '../../src/adapter/PostgresQuery';
import { SnowflakeQuery } from '../../src/adapter/SnowflakeQuery';
import { prepareYamlCompiler } from './PrepareCompiler';

/**
* A rolling window asked for at some granularity but with no date range has to
* derive the bounds of its time series in SQL, from the data itself. The planner
* can only do that where the dialect defines `generated_time_series_select`;
* without it there is nowhere to take the bounds from and the query is rejected
* with "Date range is required for time series".
*/
describe('generated time series', () => {
const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler(`
cubes:
- name: events
sql: "SELECT 1 AS user_id, '2024-01-01' AS invited_at"
dimensions:
- name: invited_at
sql: invited_at
type: time
granularities:
- name: two_weeks
interval: 2 weeks
origin: "2024-01-01"
measures:
- name: cumulative_users
sql: user_id
type: count_distinct
rolling_window:
trailing: unbounded
- name: rolling_30d_users
sql: user_id
type: count_distinct
rolling_window:
trailing: "30 day"
`);

const buildSql = async (
QueryClass: any,
{ granularity = 'month', dateRange, measure = 'events.cumulative_users' }: {
granularity?: string, dateRange?: [string, string], measure?: string
} = {}
) => {
await compiler.compile();

const query = new QueryClass({ joinGraph, cubeEvaluator, compiler }, {
measures: [measure],
timeDimensions: [{
dimension: 'events.invited_at',
granularity,
...(dateRange ? { dateRange } : {}),
}],
timezone: 'UTC',
useNativeSqlPlanner: true,
});

return query.buildSqlAndParams()[0];
};

// Every dialect that generates the series in SQL has to accept the same query,
// so the guard is stated once over all of them rather than per dialect.
const GENERATING_DIALECTS: [string, any][] = [
['Postgres', PostgresQuery],
['Snowflake', SnowflakeQuery],
];

const PREDEFINED_GRANULARITIES = ['second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year'];

describe.each(GENERATING_DIALECTS)('%s', (_name, QueryClass) => {
it.each(PREDEFINED_GRANULARITIES)('plans a rolling window at %s granularity with no date range', async (granularity) => {
const sql = await buildSql(QueryClass, { granularity });

expect(sql).toContain('time_series');
});

it('keeps planning a rolling window with an explicit date range', async () => {
const sql = await buildSql(QueryClass, { dateRange: ['2024-01-01', '2024-12-31'] });

expect(sql).toContain('time_series');
});

it('plans a bounded rolling window with no date range', async () => {
const sql = await buildSql(QueryClass, { measure: 'events.rolling_30d_users' });

expect(sql).toContain('time_series');
});
Comment on lines +71 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toContain('time_series') is satisfied by the non-generated path too, so these cases really only assert "did not throw". Since the whole point is that the series is now built in SQL, asserting the generator is present would pin the behaviour: e.g. ARRAY_GENERATE_RANGE for Snowflake and generate_series for Postgres, keyed off the dialect in GENERATING_DIALECTS. Worth it especially for the rolling_30d_users case, where a regression back to the legacy series would still pass as written.

});

describe('Snowflake', () => {
it('steps the series by the granularity itself, not by its smallest time unit', async () => {
const weekly = await buildSql(SnowflakeQuery, { granularity: 'week' });
const quarterly = await buildSql(SnowflakeQuery, { granularity: 'quarter' });

expect(weekly).toContain('DATEDIFF(week');
expect(quarterly).toContain('DATEDIFF(quarter');
});

it('names the series columns so that they survive identifier folding', async () => {
const sql = await buildSql(SnowflakeQuery);

expect(sql).toContain('"date_from"');
expect(sql).toContain('"date_to"');
});

// Snowflake cannot multiply an arbitrary interval by a row number, so a
// granularity that is not one whole time unit still needs the range spelled
// out and the series built outside the database.
it('still requires a date range for a custom granularity', async () => {
await expect(buildSql(SnowflakeQuery, { granularity: 'two_weeks' }))
.rejects.toThrow('Date range is required for time series');

const sql = await buildSql(SnowflakeQuery, {
granularity: 'two_weeks',
dateRange: ['2024-01-01', '2024-12-31'],
});

expect(sql).toContain('time_series');
});
});
});
5 changes: 0 additions & 5 deletions packages/cubejs-testing-drivers/fixtures/snowflake.json
Original file line number Diff line number Diff line change
Expand Up @@ -321,11 +321,6 @@
"SQL API: Extended nested Rollup over asterisk",
"SQL API: SQL push down push to cube quoted alias",



"querying BigECommerce: rolling window by 2 day without date range",
"querying BigECommerce: rolling window by 2 month without date range",
"querying BigECommerce: rolling window YTD without date range",
"querying custom granularities (with preaggregation) ECommerce: totalQuantity by half_year + no dimension",

"---- Different results comparing to baseQuery version. Need to investigate ----",
Expand Down
Loading
Loading