Skip to content

Commit b7599eb

Browse files
committed
fix(clickhouse-driver): Respect timezone for origin in custom granularity
Signed-off-by: Dmitry Patsura <[email protected]>
1 parent 5a69f7b commit b7599eb

File tree

5 files changed

+279
-7
lines changed

5 files changed

+279
-7
lines changed

packages/cubejs-schema-compiler/src/adapter/ClickHouseQuery.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,27 @@ export class ClickHouseQuery extends BaseQuery {
6363
}
6464

6565
/**
66-
* Returns sql for source expression floored to timestamps aligned with
67-
* intervals relative to origin timestamp point.
66+
* Returns SQL for source expression floored to timestamps aligned with
67+
* intervals relative to the origin timestamp point.
6868
*/
6969
public dateBin(interval: string, source: string, origin: string): string {
70+
// Pass timezone to dateTimeCast to ensure origin is in the same timezone as a source, because ClickHouse aligns
71+
// both timestamps internally to the same timezone before computing the difference, causing an unintended offset.
72+
const alignedOrigin = this.dateTimeCast(`'${origin}'`, this.timezone);
7073
const intervalFormatted = this.formatInterval(interval);
7174
const timeUnit = this.diffTimeUnitForInterval(interval);
7275
const beginOfTime = 'fromUnixTimestamp(0)';
7376

74-
return `date_add(${timeUnit},
77+
const dateBinResult = `date_add(${timeUnit},
7578
FLOOR(
76-
date_diff(${timeUnit}, ${this.dateTimeCast(`'${origin}'`)}, ${source}) /
79+
date_diff(${timeUnit}, ${alignedOrigin}, ${source}) /
7780
date_diff(${timeUnit}, ${beginOfTime}, ${beginOfTime} + ${intervalFormatted})
7881
) * date_diff(${timeUnit}, ${beginOfTime}, ${beginOfTime} + ${intervalFormatted}),
79-
${this.dateTimeCast(`'${origin}'`)}
82+
${alignedOrigin}
8083
)`;
84+
85+
// Normalize the result to DateTime64(0) for consistent formatting
86+
return `toDateTime64(${dateBinResult}, 0, '${this.timezone}')`;
8187
}
8288

8389
public subtractInterval(date: string, interval: string): string {
@@ -105,11 +111,17 @@ export class ClickHouseQuery extends BaseQuery {
105111
return this.dateTimeCast(value);
106112
}
107113

108-
public dateTimeCast(value: string): string {
114+
public dateTimeCast(value: string, timezone?: string): string {
115+
// If a timezone is specified, use toDateTime64 to parse the string AS IF it's in that timezone
116+
// This is critical for custom granularity, because timezone should be aligned between origin and source column
117+
if (timezone) {
118+
// Use precision 3 for milliseconds to match the format 'YYYY-MM-DDTHH:mm:ss.SSS'
119+
return `toDateTime64(${value}, 3, '${timezone}')`;
120+
}
121+
109122
// value yields a string formatted in ISO8601, so this function returns a expression to parse a string to a DateTime
110123
// ClickHouse provides toDateTime which expects dates in UTC in format YYYY-MM-DD HH:MM:SS
111124
// However parseDateTimeBestEffort works with ISO8601
112-
//
113125
return `parseDateTimeBestEffort(${value})`;
114126
}
115127

packages/cubejs-schema-compiler/test/integration/clickhouse/custom-granularities.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ describe('Custom Granularities', () => {
5858
- name: fifteen_days_hours_minutes_seconds
5959
interval: 15 days 3 hours 25 minutes 40 seconds
6060
origin: '2024-01-01 10:15:00'
61+
- name: five_minutes_from_utc_origin
62+
interval: 5 minutes
63+
# 10:15 UTC = 11:15 Paris time (UTC+1)
64+
origin: '2024-01-01T10:15:00Z'
65+
- name: five_minutes_from_local_origin
66+
interval: 5 minutes
67+
origin: '2024-01-01 10:15:00'
6168
- name: fiscal_year_by_1st_feb
6269
interval: 1 year
6370
origin: '2024-02-01'
@@ -564,4 +571,62 @@ describe('Custom Granularities', () => {
564571
],
565572
{ joinGraph, cubeEvaluator, compiler }
566573
));
574+
575+
it('works with five_minutes_from_utc_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
576+
{
577+
measures: ['orders.count'],
578+
timeDimensions: [{
579+
dimension: 'orders.createdAt',
580+
granularity: 'five_minutes_from_utc_origin',
581+
dateRange: ['2024-01-01', '2024-01-31']
582+
}],
583+
dimensions: [],
584+
filters: [],
585+
timezone: 'Europe/Paris'
586+
},
587+
[
588+
{
589+
orders__count: '1',
590+
orders__created_at_five_minutes_from_utc_origin: '2024-01-01T01:00:00.000',
591+
},
592+
{
593+
orders__count: '1',
594+
orders__created_at_five_minutes_from_utc_origin: '2024-01-15T01:00:00.000',
595+
},
596+
{
597+
orders__count: '1',
598+
orders__created_at_five_minutes_from_utc_origin: '2024-01-29T01:00:00.000',
599+
},
600+
],
601+
{ joinGraph, cubeEvaluator, compiler }
602+
));
603+
604+
it('works with five_minutes_from_local_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
605+
{
606+
measures: ['orders.count'],
607+
timeDimensions: [{
608+
dimension: 'orders.createdAt',
609+
granularity: 'five_minutes_from_local_origin',
610+
dateRange: ['2024-01-01', '2024-01-31']
611+
}],
612+
dimensions: [],
613+
filters: [],
614+
timezone: 'Europe/Paris'
615+
},
616+
[
617+
{
618+
orders__count: '1',
619+
orders__created_at_five_minutes_from_local_origin: '2024-01-01T01:00:00.000',
620+
},
621+
{
622+
orders__count: '1',
623+
orders__created_at_five_minutes_from_local_origin: '2024-01-15T01:00:00.000',
624+
},
625+
{
626+
orders__count: '1',
627+
orders__created_at_five_minutes_from_local_origin: '2024-01-29T01:00:00.000',
628+
},
629+
],
630+
{ joinGraph, cubeEvaluator, compiler }
631+
));
567632
});

packages/cubejs-schema-compiler/test/integration/mssql/custom-granularities.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ describe('Custom Granularities', () => {
5151
- name: twenty_five_minutes
5252
interval: 25 minutes
5353
origin: '2024-01-01 10:15:00'
54+
- name: five_minutes_from_utc_origin
55+
interval: 5 minutes
56+
# 10:15 UTC = 11:15 Paris time (UTC+1)
57+
origin: '2024-01-01T10:15:00Z'
58+
- name: five_minutes_from_local_origin
59+
interval: 5 minutes
60+
origin: '2024-01-01 10:15:00'
5461
- name: fifteen_days_hours_minutes_seconds
5562
interval: 15 days 3 hours 25 minutes 40 seconds
5663
origin: '2024-01-01 10:15:00'
@@ -151,6 +158,64 @@ describe('Custom Granularities', () => {
151158
{ joinGraph, cubeEvaluator, compiler }
152159
));
153160

161+
it('works with five_minutes_from_utc_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
162+
{
163+
measures: ['orders.count'],
164+
timeDimensions: [{
165+
dimension: 'orders.createdAt',
166+
granularity: 'five_minutes_from_utc_origin',
167+
dateRange: ['2024-01-01', '2024-01-31']
168+
}],
169+
dimensions: [],
170+
filters: [],
171+
timezone: 'Europe/Paris'
172+
},
173+
[
174+
{
175+
orders__count: 1,
176+
orders__created_at_five_minutes_from_utc_origin: new Date('2024-01-01T01:00:00.000Z'),
177+
},
178+
{
179+
orders__count: 1,
180+
orders__created_at_five_minutes_from_utc_origin: new Date('2024-01-15T01:00:00.000Z'),
181+
},
182+
{
183+
orders__count: 1,
184+
orders__created_at_five_minutes_from_utc_origin: new Date('2024-01-29T01:00:00.000Z'),
185+
},
186+
],
187+
{ joinGraph, cubeEvaluator, compiler }
188+
));
189+
190+
it('works with five_minutes_from_local_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
191+
{
192+
measures: ['orders.count'],
193+
timeDimensions: [{
194+
dimension: 'orders.createdAt',
195+
granularity: 'five_minutes_from_local_origin',
196+
dateRange: ['2024-01-01', '2024-01-31']
197+
}],
198+
dimensions: [],
199+
filters: [],
200+
timezone: 'Europe/Paris'
201+
},
202+
[
203+
{
204+
orders__count: 1,
205+
orders__created_at_five_minutes_from_local_origin: new Date('2024-01-01T01:00:00.000Z'),
206+
},
207+
{
208+
orders__count: 1,
209+
orders__created_at_five_minutes_from_local_origin: new Date('2024-01-15T01:00:00.000Z'),
210+
},
211+
{
212+
orders__count: 1,
213+
orders__created_at_five_minutes_from_local_origin: new Date('2024-01-29T01:00:00.000Z'),
214+
},
215+
],
216+
{ joinGraph, cubeEvaluator, compiler }
217+
));
218+
154219
it('works with half_year custom granularity with dimension query', async () => dbRunner.runQueryTest(
155220
{
156221
measures: ['orders.count'],

packages/cubejs-schema-compiler/test/integration/mysql/custom-granularities.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ describe('Custom Granularities', () => {
5151
- name: twenty_five_minutes
5252
interval: 25 minutes
5353
origin: '2024-01-01 10:15:00'
54+
- name: five_minutes_from_utc_origin
55+
interval: 5 minutes
56+
# 10:15 UTC = 11:15 Paris time (UTC+1)
57+
origin: '2024-01-01T10:15:00Z'
58+
- name: five_minutes_from_local_origin
59+
interval: 5 minutes
60+
origin: '2024-01-01 10:15:00'
5461
- name: fifteen_days_hours_minutes_seconds
5562
interval: 15 days 3 hours 25 minutes 40 seconds
5663
origin: '2024-01-01 10:15:00'
@@ -151,6 +158,64 @@ describe('Custom Granularities', () => {
151158
{ joinGraph, cubeEvaluator, compiler }
152159
));
153160

161+
it('works with five_minutes_from_utc_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
162+
{
163+
measures: ['orders.count'],
164+
timeDimensions: [{
165+
dimension: 'orders.createdAt',
166+
granularity: 'five_minutes_from_utc_origin',
167+
dateRange: ['2024-01-01', '2024-01-31']
168+
}],
169+
dimensions: [],
170+
filters: [],
171+
timezone: 'Europe/Paris'
172+
},
173+
[
174+
{
175+
orders__count: 1,
176+
orders__created_at_five_minutes_from_utc_origin: '2024-01-01 01:00:00.000',
177+
},
178+
{
179+
orders__count: 1,
180+
orders__created_at_five_minutes_from_utc_origin: '2024-01-15 01:00:00.000',
181+
},
182+
{
183+
orders__count: 1,
184+
orders__created_at_five_minutes_from_utc_origin: '2024-01-29 01:00:00.000',
185+
},
186+
],
187+
{ joinGraph, cubeEvaluator, compiler }
188+
));
189+
190+
it('works with five_minutes_from_local_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
191+
{
192+
measures: ['orders.count'],
193+
timeDimensions: [{
194+
dimension: 'orders.createdAt',
195+
granularity: 'five_minutes_from_local_origin',
196+
dateRange: ['2024-01-01', '2024-01-31']
197+
}],
198+
dimensions: [],
199+
filters: [],
200+
timezone: 'Europe/Paris'
201+
},
202+
[
203+
{
204+
orders__count: 1,
205+
orders__created_at_five_minutes_from_local_origin: '2024-01-01 01:00:00.000',
206+
},
207+
{
208+
orders__count: 1,
209+
orders__created_at_five_minutes_from_local_origin: '2024-01-15 01:00:00.000',
210+
},
211+
{
212+
orders__count: 1,
213+
orders__created_at_five_minutes_from_local_origin: '2024-01-29 01:00:00.000',
214+
},
215+
],
216+
{ joinGraph, cubeEvaluator, compiler }
217+
));
218+
154219
it('works with half_year custom granularity with dimension query', async () => dbRunner.runQueryTest(
155220
{
156221
measures: ['orders.count'],

packages/cubejs-schema-compiler/test/integration/postgres/custom-granularities.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ describe('Custom Granularities', () => {
5656
- name: twenty_five_minutes
5757
interval: 25 minutes
5858
origin: '2024-01-01 10:15:00'
59+
- name: five_minutes_from_utc_origin
60+
interval: 5 minutes
61+
# 10:15 UTC = 11:15 Paris time (UTC+1)
62+
origin: '2024-01-01T10:15:00Z'
63+
- name: five_minutes_from_local_origin
64+
interval: 5 minutes
65+
origin: '2024-01-01 10:15:00'
5966
- name: fiscal_year_by_1st_feb
6067
interval: 1 year
6168
origin: '2024-02-01'
@@ -163,6 +170,64 @@ describe('Custom Granularities', () => {
163170
{ joinGraph, cubeEvaluator, compiler }
164171
));
165172

173+
it('works with five_minutes_from_utc_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
174+
{
175+
measures: ['orders.count'],
176+
timeDimensions: [{
177+
dimension: 'orders.createdAt',
178+
granularity: 'five_minutes_from_utc_origin',
179+
dateRange: ['2024-01-01', '2024-01-31']
180+
}],
181+
dimensions: [],
182+
filters: [],
183+
timezone: 'Europe/Paris'
184+
},
185+
[
186+
{
187+
orders__count: '1',
188+
orders__created_at_five_minutes_from_utc_origin: '2024-01-01T01:00:00.000Z',
189+
},
190+
{
191+
orders__count: '1',
192+
orders__created_at_five_minutes_from_utc_origin: '2024-01-15T01:00:00.000Z',
193+
},
194+
{
195+
orders__count: '1',
196+
orders__created_at_five_minutes_from_utc_origin: '2024-01-29T01:00:00.000Z',
197+
},
198+
],
199+
{ joinGraph, cubeEvaluator, compiler }
200+
));
201+
202+
it('works with five_minutes_from_local_origin custom granularity in Europe/Paris timezone', async () => dbRunner.runQueryTest(
203+
{
204+
measures: ['orders.count'],
205+
timeDimensions: [{
206+
dimension: 'orders.createdAt',
207+
granularity: 'five_minutes_from_local_origin',
208+
dateRange: ['2024-01-01', '2024-01-31']
209+
}],
210+
dimensions: [],
211+
filters: [],
212+
timezone: 'Europe/Paris'
213+
},
214+
[
215+
{
216+
orders__count: '1',
217+
orders__created_at_five_minutes_from_local_origin: '2024-01-01T01:00:00.000Z',
218+
},
219+
{
220+
orders__count: '1',
221+
orders__created_at_five_minutes_from_local_origin: '2024-01-15T01:00:00.000Z',
222+
},
223+
{
224+
orders__count: '1',
225+
orders__created_at_five_minutes_from_local_origin: '2024-01-29T01:00:00.000Z',
226+
},
227+
],
228+
{ joinGraph, cubeEvaluator, compiler }
229+
));
230+
166231
it('works with proxied createdAtHalfYear custom granularity as dimension query', async () => dbRunner.runQueryTest(
167232
{
168233
measures: ['orders.count'],

0 commit comments

Comments
 (0)