Skip to content

Commit a4a07f0

Browse files
committed
feat: Add query-time offset parameter for predefined granularities
Adds support for a query-time `offset` parameter on timeDimensions that allows shifting time boundaries for predefined granularities (day, week, month, etc.). This enables use cases like: - Days running 2am-2am instead of midnight-midnight - Weeks starting on Wednesday instead of Monday The offset parameter: - Only works with predefined granularities (day, week, month, etc.) - Throws a validation error if used with custom granularities - Uses SQL interval syntax (e.g., "2 hours", "-30 minutes", "2 days") - Is supported in REST API and GraphQL API Includes comprehensive unit and integration tests demonstrating the day-shifting and week-shifting use cases.
1 parent 23d69f4 commit a4a07f0

File tree

12 files changed

+821
-1
lines changed

12 files changed

+821
-1
lines changed

packages/cubejs-api-gateway/src/graphql.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,11 @@ export function getJsonQuery(metaConfig: any, args: Record<string, any>, infos:
426426
if (granularityName === 'value') {
427427
dimensions.push(key);
428428
} else {
429+
const offsetArg = getArgumentValue(granularityNode, 'offset', infos.variableValues);
429430
timeDimensions.push({
430431
dimension: key,
431432
granularity: granularityName,
433+
...(offsetArg ? { offset: offsetArg } : null),
432434
...(dateRangeFilters[key] ? {
433435
dateRange: dateRangeFilters[key],
434436
} : null)

packages/cubejs-api-gateway/src/query.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,18 @@ const querySchema = Joi.object().keys({
172172
timeDimensions: Joi.array().items(Joi.object().keys({
173173
dimension: id.required(),
174174
granularity: Joi.string().max(128, 'utf8'), // Custom granularities may have arbitrary names
175+
offset: Joi.string().max(128, 'utf8'), // Query-time granularity offset
175176
dateRange: [
176177
Joi.array().items(Joi.string()).min(1).max(2),
177178
Joi.string()
178179
],
179180
compareDateRange: Joi.array()
180-
}).oxor('dateRange', 'compareDateRange')),
181+
}).oxor('dateRange', 'compareDateRange').custom((value, helpers) => {
182+
if (value.offset && !value.granularity) {
183+
return helpers.error('any.invalid', { message: 'offset can only be specified when granularity is also specified' });
184+
}
185+
return value;
186+
})),
181187
order: Joi.alternatives(
182188
Joi.object().pattern(idOrMemberExpressionName, Joi.valid('asc', 'desc')),
183189
Joi.array().items(Joi.array().min(2).ordered(idOrMemberExpressionName, Joi.valid('asc', 'desc')))

packages/cubejs-api-gateway/src/types/query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ interface QueryTimeDimension {
111111
dateRange?: string[] | string;
112112
compareDateRange?: string[];
113113
granularity?: QueryTimeDimensionGranularity;
114+
offset?: string;
114115
}
115116

116117
type SubqueryJoins = {

packages/cubejs-client-core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface TimeDimensionBase {
4141
dimension: string;
4242
granularity?: TimeDimensionGranularity;
4343
dateRange?: DateRange;
44+
offset?: string;
4445
}
4546

4647
export interface TimeDimensionComparison extends TimeDimensionBase {

packages/cubejs-client-dx/index.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ declare module "@cubejs-client/core" {
2525

2626
export interface TimeDimensionBase {
2727
dimension: IntrospectedTimeDimensionName;
28+
granularity?: TimeDimensionGranularity;
29+
dateRange?: DateRange;
30+
offset?: string;
2831
}
2932

3033
export interface BinaryFilter {

packages/cubejs-client-ngx/src/query-builder/query-members.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ export class TimeDimensionMember {
130130
this.updateTimeDimension(by, { granularity });
131131
}
132132

133+
setOffset(by: string | number, offset: string) {
134+
this.updateTimeDimension(by, { offset });
135+
}
136+
133137
asArray(): any[] {
134138
return (this.query.asCubeQuery().timeDimensions || []).map((td) => {
135139
return {

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,23 @@ export class Granularity {
3434
this.queryTimezone = query.timezone || 'UTC';
3535
this.origin = moment.tz(query.timezone).startOf('year'); // Defaults to current year start
3636

37+
// Query-time offset takes precedence over data model offset
38+
const queryTimeOffset = timeDimension.offset;
39+
3740
if (this.predefinedGranularity) {
3841
this.granularityInterval = `1 ${this.granularity}`;
42+
// Support query-time offset for predefined granularities
43+
if (queryTimeOffset) {
44+
this.fixOriginForWeeksIfNeeded();
45+
// Validate offset format
46+
try {
47+
const parsedOffset = parseSqlInterval(queryTimeOffset);
48+
this.granularityOffset = queryTimeOffset;
49+
this.origin = addInterval(this.origin, parsedOffset);
50+
} catch (e) {
51+
throw new Error(`Invalid offset format "${queryTimeOffset}". Expected SQL interval format like "2 hours", "-30 minutes", or "1 day 2 hours"`);
52+
}
53+
}
3954
} else {
4055
const customGranularity = this.query.cacheValue(
4156
['customGranularity', timeDimension.dimension, this.granularity],
@@ -51,6 +66,8 @@ export class Granularity {
5166

5267
if (customGranularity.origin) {
5368
this.origin = moment.tz(customGranularity.origin, query.timezone);
69+
} else if (queryTimeOffset) {
70+
throw new Error(`Query-time offset parameter cannot be used with custom granularity "${this.granularity}". Offset is only supported with predefined granularities (day, week, month, etc.)`);
5471
} else if (customGranularity.offset) {
5572
// Needed because if interval is week-based, offset is expected to be relative to the start of a week
5673
this.fixOriginForWeeksIfNeeded();

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

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ describe('Custom Granularities', () => {
4949
- name: two_weeks_by_friday
5050
interval: 2 weeks
5151
origin: '2024-08-23'
52+
- name: one_week_by_friday_by_offset
53+
interval: 1 week
54+
offset: 4 days
5255
- name: one_hour_by_5min_offset
5356
interval: 1 hour
5457
offset: 5 minutes
@@ -564,4 +567,147 @@ describe('Custom Granularities', () => {
564567
],
565568
{ joinGraph, cubeEvaluator, compiler }
566569
));
570+
571+
// Query-time offset tests
572+
573+
// Test demonstrating day boundaries shifted to 2am-2am
574+
it('works with query-time offset shifting day boundary (2am-2am)', async () => {
575+
const { compiler: offsetCompiler, joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator } = prepareYamlCompiler(`
576+
cubes:
577+
- name: events
578+
sql: >
579+
SELECT *
580+
FROM (
581+
SELECT 1 as event_id, toDateTime('2024-01-01 00:00:00') as event_time UNION ALL
582+
SELECT 2, toDateTime('2024-01-01 01:00:00') UNION ALL
583+
SELECT 3, toDateTime('2024-01-01 01:30:00') UNION ALL
584+
SELECT 4, toDateTime('2024-01-02 00:00:00') UNION ALL
585+
SELECT 5, toDateTime('2024-01-02 01:00:00') UNION ALL
586+
SELECT 6, toDateTime('2024-01-02 01:30:00')
587+
)
588+
589+
dimensions:
590+
- name: event_id
591+
sql: event_id
592+
type: number
593+
primary_key: true
594+
595+
- name: eventTime
596+
sql: event_time
597+
type: time
598+
599+
measures:
600+
- name: count
601+
type: count
602+
`);
603+
604+
await dbRunner.runQueryTest(
605+
{
606+
measures: ['events.count'],
607+
timeDimensions: [{
608+
dimension: 'events.eventTime',
609+
granularity: 'day',
610+
offset: '2 hours',
611+
dateRange: ['2024-01-01', '2024-01-03']
612+
}],
613+
dimensions: [],
614+
timezone: 'UTC',
615+
order: [['events.eventTime', 'asc']],
616+
},
617+
[
618+
{
619+
events__event_time_day: '2023-12-31T02:00:00.000',
620+
events__count: '3',
621+
},
622+
{
623+
events__event_time_day: '2024-01-01T02:00:00.000',
624+
events__count: '3',
625+
},
626+
],
627+
{ joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator, compiler: offsetCompiler }
628+
);
629+
});
630+
631+
// Test demonstrating week boundaries shifted to Wednesday-Wednesday
632+
it('works with query-time offset shifting week to start on Wednesday', async () => {
633+
const { compiler: weekCompiler, joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator } = prepareYamlCompiler(`
634+
cubes:
635+
- name: activities
636+
sql: >
637+
SELECT *
638+
FROM (
639+
SELECT 1 as activity_id, toDateTime('2024-01-01 10:00:00') as activity_time UNION ALL
640+
SELECT 2, toDateTime('2024-01-02 10:00:00') UNION ALL
641+
SELECT 3, toDateTime('2024-01-03 10:00:00') UNION ALL
642+
SELECT 4, toDateTime('2024-01-04 10:00:00') UNION ALL
643+
SELECT 5, toDateTime('2024-01-08 10:00:00') UNION ALL
644+
SELECT 6, toDateTime('2024-01-10 10:00:00')
645+
)
646+
647+
dimensions:
648+
- name: activity_id
649+
sql: activity_id
650+
type: number
651+
primary_key: true
652+
653+
- name: activityTime
654+
sql: activity_time
655+
type: time
656+
657+
measures:
658+
- name: count
659+
type: count
660+
`);
661+
662+
await dbRunner.runQueryTest(
663+
{
664+
measures: ['activities.count'],
665+
timeDimensions: [{
666+
dimension: 'activities.activityTime',
667+
granularity: 'week',
668+
offset: '2 days',
669+
dateRange: ['2024-01-01', '2024-01-15']
670+
}],
671+
dimensions: [],
672+
timezone: 'UTC',
673+
order: [['activities.activityTime', 'asc']],
674+
},
675+
[
676+
{
677+
activities__activity_time_week: '2024-01-03T00:00:00.000',
678+
activities__count: '3',
679+
},
680+
{
681+
activities__activity_time_week: '2024-01-10T00:00:00.000',
682+
activities__count: '1',
683+
},
684+
{
685+
activities__activity_time_week: '2023-12-27T00:00:00.000',
686+
activities__count: '2',
687+
},
688+
],
689+
{ joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator, compiler: weekCompiler }
690+
);
691+
});
692+
693+
it('rejects query-time offset with custom granularity', async () => {
694+
await expect(
695+
dbRunner.runQueryTest(
696+
{
697+
measures: ['orders.count'],
698+
timeDimensions: [{
699+
dimension: 'orders.createdAt',
700+
granularity: 'one_week_by_friday_by_offset',
701+
offset: '2 days',
702+
dateRange: ['2024-01-01', '2024-02-28']
703+
}],
704+
dimensions: [],
705+
timezone: 'UTC',
706+
order: [['orders.createdAt', 'asc']],
707+
},
708+
[],
709+
{ joinGraph, cubeEvaluator, compiler }
710+
)
711+
).rejects.toThrow('Query-time offset parameter cannot be used with custom granularity');
712+
});
567713
});

0 commit comments

Comments
 (0)