-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(tesseract): generate the time series in SQL on Snowflake #11785
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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') ? '\'%\' || ' : ''; | ||
|
|
@@ -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']; | ||
| } | ||
|
|
||
| public defaultRefreshKeyRenewalThreshold() { | ||
| return 120; | ||
| } | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Row count can overshoot the range end. Snowflake's Concretely, month granularity with
The CTE-range variant is the more exposed of the two, since Cheap guard — bound the series explicitly instead of relying on
With that in place |
||
| delete templates.types.interval; | ||
| return templates; | ||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }); | ||
|
|
||
| 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'); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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:
new RegExp(u, 'i')is an unanchored substring match, so'1 millisecond'matchessecond(milli**second**) and returnsSECOND— a silently wrong unit rather than an error. A word-boundary pattern (new RegExp(\b${u}s?\b, 'i')) orinterval.toLowerCase().includes(...)withmillisecondhandled explicitly avoids it.|| 'year'fallback turns an unrecognised interval into a plausible-looking series instead of failing. The inheriteddiffTimeUnitForIntervalthrows in that case; matching it would be safer here, since a wrong unit produces silently wrong data rather than an error.'2 week'→['2 week', 'week'], and both templates step by1 {{ minimal_time_unit }}and ignore{{ granularity }}entirely, so the series would come out at the wrong period. Unreachable today becausesupportGeneratedSeriesForCustomTdis false — but nothing here records that dependency, so flipping that flag on later would silently produce a wrong series. Throwing unless the interval is exactly1 <unit>would make the coupling enforced rather than documented.