diff --git a/packages/cubejs-api-gateway/src/graphql.ts b/packages/cubejs-api-gateway/src/graphql.ts index b00a9d4738b4e..a0864b09379e6 100644 --- a/packages/cubejs-api-gateway/src/graphql.ts +++ b/packages/cubejs-api-gateway/src/graphql.ts @@ -426,9 +426,11 @@ export function getJsonQuery(metaConfig: any, args: Record, infos: if (granularityName === 'value') { dimensions.push(key); } else { + const offsetArg = getArgumentValue(granularityNode, 'offset', infos.variableValues); timeDimensions.push({ dimension: key, granularity: granularityName, + ...(offsetArg ? { offset: offsetArg } : null), ...(dateRangeFilters[key] ? { dateRange: dateRangeFilters[key], } : null) diff --git a/packages/cubejs-api-gateway/src/query.js b/packages/cubejs-api-gateway/src/query.js index b9320d128168f..cb6fbbb2bb7a7 100644 --- a/packages/cubejs-api-gateway/src/query.js +++ b/packages/cubejs-api-gateway/src/query.js @@ -172,12 +172,18 @@ const querySchema = Joi.object().keys({ timeDimensions: Joi.array().items(Joi.object().keys({ dimension: id.required(), granularity: Joi.string().max(128, 'utf8'), // Custom granularities may have arbitrary names + offset: Joi.string().max(128, 'utf8'), // Query-time granularity offset dateRange: [ Joi.array().items(Joi.string()).min(1).max(2), Joi.string() ], compareDateRange: Joi.array() - }).oxor('dateRange', 'compareDateRange')), + }).oxor('dateRange', 'compareDateRange').custom((value, helpers) => { + if (value.offset && !value.granularity) { + return helpers.error('any.invalid', { message: 'offset can only be specified when granularity is also specified' }); + } + return value; + })), order: Joi.alternatives( Joi.object().pattern(idOrMemberExpressionName, Joi.valid('asc', 'desc')), Joi.array().items(Joi.array().min(2).ordered(idOrMemberExpressionName, Joi.valid('asc', 'desc'))) diff --git a/packages/cubejs-api-gateway/src/types/query.ts b/packages/cubejs-api-gateway/src/types/query.ts index 7c470a6aaa27c..f7383aa9b70fe 100644 --- a/packages/cubejs-api-gateway/src/types/query.ts +++ b/packages/cubejs-api-gateway/src/types/query.ts @@ -111,6 +111,7 @@ interface QueryTimeDimension { dateRange?: string[] | string; compareDateRange?: string[]; granularity?: QueryTimeDimensionGranularity; + offset?: string; } type SubqueryJoins = { diff --git a/packages/cubejs-client-core/src/types.ts b/packages/cubejs-client-core/src/types.ts index 58f5adba4c24a..3450a2c482591 100644 --- a/packages/cubejs-client-core/src/types.ts +++ b/packages/cubejs-client-core/src/types.ts @@ -41,6 +41,7 @@ export interface TimeDimensionBase { dimension: string; granularity?: TimeDimensionGranularity; dateRange?: DateRange; + offset?: string; } export interface TimeDimensionComparison extends TimeDimensionBase { diff --git a/packages/cubejs-client-dx/index.d.ts b/packages/cubejs-client-dx/index.d.ts index 9e61ed1fe0de6..8e77eced6229d 100644 --- a/packages/cubejs-client-dx/index.d.ts +++ b/packages/cubejs-client-dx/index.d.ts @@ -25,6 +25,9 @@ declare module "@cubejs-client/core" { export interface TimeDimensionBase { dimension: IntrospectedTimeDimensionName; + granularity?: TimeDimensionGranularity; + dateRange?: DateRange; + offset?: string; } export interface BinaryFilter { diff --git a/packages/cubejs-client-ngx/src/query-builder/query-members.ts b/packages/cubejs-client-ngx/src/query-builder/query-members.ts index 69db485db9369..c639b474663dc 100644 --- a/packages/cubejs-client-ngx/src/query-builder/query-members.ts +++ b/packages/cubejs-client-ngx/src/query-builder/query-members.ts @@ -130,6 +130,10 @@ export class TimeDimensionMember { this.updateTimeDimension(by, { granularity }); } + setOffset(by: string | number, offset: string) { + this.updateTimeDimension(by, { offset }); + } + asArray(): any[] { return (this.query.asCubeQuery().timeDimensions || []).map((td) => { return { diff --git a/packages/cubejs-schema-compiler/src/adapter/Granularity.ts b/packages/cubejs-schema-compiler/src/adapter/Granularity.ts index bf3260f040cb8..eaca16db9eb4e 100644 --- a/packages/cubejs-schema-compiler/src/adapter/Granularity.ts +++ b/packages/cubejs-schema-compiler/src/adapter/Granularity.ts @@ -34,8 +34,23 @@ export class Granularity { this.queryTimezone = query.timezone || 'UTC'; this.origin = moment.tz(query.timezone).startOf('year'); // Defaults to current year start + // Query-time offset takes precedence over data model offset + const queryTimeOffset = timeDimension.offset; + if (this.predefinedGranularity) { this.granularityInterval = `1 ${this.granularity}`; + // Support query-time offset for predefined granularities + if (queryTimeOffset) { + this.fixOriginForWeeksIfNeeded(); + // Validate offset format + try { + const parsedOffset = parseSqlInterval(queryTimeOffset); + this.granularityOffset = queryTimeOffset; + this.origin = addInterval(this.origin, parsedOffset); + } catch (e) { + throw new Error(`Invalid offset format "${queryTimeOffset}". Expected SQL interval format like "2 hours", "-30 minutes", or "1 day 2 hours"`); + } + } } else { const customGranularity = this.query.cacheValue( ['customGranularity', timeDimension.dimension, this.granularity], @@ -51,6 +66,8 @@ export class Granularity { if (customGranularity.origin) { this.origin = moment.tz(customGranularity.origin, query.timezone); + } else if (queryTimeOffset) { + 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.)`); } else if (customGranularity.offset) { // Needed because if interval is week-based, offset is expected to be relative to the start of a week this.fixOriginForWeeksIfNeeded(); diff --git a/packages/cubejs-schema-compiler/test/integration/clickhouse/custom-granularities.test.ts b/packages/cubejs-schema-compiler/test/integration/clickhouse/custom-granularities.test.ts index 2d8a1665d66b6..1e5247cc2d365 100644 --- a/packages/cubejs-schema-compiler/test/integration/clickhouse/custom-granularities.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/clickhouse/custom-granularities.test.ts @@ -49,6 +49,9 @@ describe('Custom Granularities', () => { - name: two_weeks_by_friday interval: 2 weeks origin: '2024-08-23' + - name: one_week_by_friday_by_offset + interval: 1 week + offset: 4 days - name: one_hour_by_5min_offset interval: 1 hour offset: 5 minutes @@ -564,4 +567,147 @@ describe('Custom Granularities', () => { ], { joinGraph, cubeEvaluator, compiler } )); + + // Query-time offset tests + + // Test demonstrating day boundaries shifted to 2am-2am + it('works with query-time offset shifting day boundary (2am-2am)', async () => { + const { compiler: offsetCompiler, joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: events + sql: > + SELECT * + FROM ( + SELECT 1 as event_id, toDateTime('2024-01-01 00:00:00') as event_time UNION ALL + SELECT 2, toDateTime('2024-01-01 01:00:00') UNION ALL + SELECT 3, toDateTime('2024-01-01 01:30:00') UNION ALL + SELECT 4, toDateTime('2024-01-02 00:00:00') UNION ALL + SELECT 5, toDateTime('2024-01-02 01:00:00') UNION ALL + SELECT 6, toDateTime('2024-01-02 01:30:00') + ) + + dimensions: + - name: event_id + sql: event_id + type: number + primary_key: true + + - name: eventTime + sql: event_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['events.count'], + timeDimensions: [{ + dimension: 'events.eventTime', + granularity: 'day', + offset: '2 hours', + dateRange: ['2024-01-01', '2024-01-03'] + }], + dimensions: [], + timezone: 'UTC', + order: [['events.eventTime', 'asc']], + }, + [ + { + events__event_time_day: '2023-12-31T02:00:00.000', + events__count: '3', + }, + { + events__event_time_day: '2024-01-01T02:00:00.000', + events__count: '3', + }, + ], + { joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator, compiler: offsetCompiler } + ); + }); + + // Test demonstrating week boundaries shifted to Wednesday-Wednesday + it('works with query-time offset shifting week to start on Wednesday', async () => { + const { compiler: weekCompiler, joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: activities + sql: > + SELECT * + FROM ( + SELECT 1 as activity_id, toDateTime('2024-01-01 10:00:00') as activity_time UNION ALL + SELECT 2, toDateTime('2024-01-02 10:00:00') UNION ALL + SELECT 3, toDateTime('2024-01-03 10:00:00') UNION ALL + SELECT 4, toDateTime('2024-01-04 10:00:00') UNION ALL + SELECT 5, toDateTime('2024-01-08 10:00:00') UNION ALL + SELECT 6, toDateTime('2024-01-10 10:00:00') + ) + + dimensions: + - name: activity_id + sql: activity_id + type: number + primary_key: true + + - name: activityTime + sql: activity_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['activities.count'], + timeDimensions: [{ + dimension: 'activities.activityTime', + granularity: 'week', + offset: '2 days', + dateRange: ['2024-01-01', '2024-01-15'] + }], + dimensions: [], + timezone: 'UTC', + order: [['activities.activityTime', 'asc']], + }, + [ + { + activities__activity_time_week: '2024-01-03T00:00:00.000', + activities__count: '3', + }, + { + activities__activity_time_week: '2024-01-10T00:00:00.000', + activities__count: '1', + }, + { + activities__activity_time_week: '2023-12-27T00:00:00.000', + activities__count: '2', + }, + ], + { joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator, compiler: weekCompiler } + ); + }); + + it('rejects query-time offset with custom granularity', async () => { + await expect( + dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'one_week_by_friday_by_offset', + offset: '2 days', + dateRange: ['2024-01-01', '2024-02-28'] + }], + dimensions: [], + timezone: 'UTC', + order: [['orders.createdAt', 'asc']], + }, + [], + { joinGraph, cubeEvaluator, compiler } + ) + ).rejects.toThrow('Query-time offset parameter cannot be used with custom granularity'); + }); }); diff --git a/packages/cubejs-schema-compiler/test/integration/mssql/custom-granularities.test.ts b/packages/cubejs-schema-compiler/test/integration/mssql/custom-granularities.test.ts index 922b37e48b80a..bf53c6d595f98 100644 --- a/packages/cubejs-schema-compiler/test/integration/mssql/custom-granularities.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/mssql/custom-granularities.test.ts @@ -45,6 +45,9 @@ describe('Custom Granularities', () => { - name: two_weeks_by_friday interval: 2 weeks origin: '2024-08-23' + - name: one_week_by_friday_by_offset + interval: 1 week + offset: 4 days - name: one_hour_by_5min_offset interval: 1 hour offset: 5 minutes @@ -858,4 +861,147 @@ describe('Custom Granularities', () => { ], { joinGraph, cubeEvaluator, compiler } )); + + // Query-time offset tests + + // Test demonstrating day boundaries shifted to 2am-2am + it('works with query-time offset shifting day boundary (2am-2am)', async () => { + const { compiler: offsetCompiler, joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: events + sql: > + SELECT * + FROM ( + SELECT 1 as event_id, CAST('2024-01-01 00:00:00' AS DATETIME) as event_time UNION ALL + SELECT 2, CAST('2024-01-01 01:00:00' AS DATETIME) UNION ALL + SELECT 3, CAST('2024-01-01 01:30:00' AS DATETIME) UNION ALL + SELECT 4, CAST('2024-01-02 00:00:00' AS DATETIME) UNION ALL + SELECT 5, CAST('2024-01-02 01:00:00' AS DATETIME) UNION ALL + SELECT 6, CAST('2024-01-02 01:30:00' AS DATETIME) + ) AS t(event_id, event_time) + + dimensions: + - name: event_id + sql: event_id + type: number + primary_key: true + + - name: eventTime + sql: event_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['events.count'], + timeDimensions: [{ + dimension: 'events.eventTime', + granularity: 'day', + offset: '2 hours', + dateRange: ['2024-01-01', '2024-01-03'] + }], + dimensions: [], + timezone: 'UTC', + order: [['events.eventTime', 'asc']], + }, + [ + { + events__event_time_day: new Date('2023-12-31T02:00:00.000Z'), + events__count: 3, + }, + { + events__event_time_day: new Date('2024-01-01T02:00:00.000Z'), + events__count: 3, + }, + ], + { joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator, compiler: offsetCompiler } + ); + }); + + // Test demonstrating week boundaries shifted to Wednesday-Wednesday + it('works with query-time offset shifting week to start on Wednesday', async () => { + const { compiler: weekCompiler, joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: activities + sql: > + SELECT * + FROM ( + SELECT 1 as activity_id, CAST('2024-01-01 10:00:00' AS DATETIME) as activity_time UNION ALL + SELECT 2, CAST('2024-01-02 10:00:00' AS DATETIME) UNION ALL + SELECT 3, CAST('2024-01-03 10:00:00' AS DATETIME) UNION ALL + SELECT 4, CAST('2024-01-04 10:00:00' AS DATETIME) UNION ALL + SELECT 5, CAST('2024-01-08 10:00:00' AS DATETIME) UNION ALL + SELECT 6, CAST('2024-01-10 10:00:00' AS DATETIME) + ) AS t(activity_id, activity_time) + + dimensions: + - name: activity_id + sql: activity_id + type: number + primary_key: true + + - name: activityTime + sql: activity_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['activities.count'], + timeDimensions: [{ + dimension: 'activities.activityTime', + granularity: 'week', + offset: '2 days', + dateRange: ['2024-01-01', '2024-01-15'] + }], + dimensions: [], + timezone: 'UTC', + order: [['activities.activityTime', 'asc']], + }, + [ + { + activities__activity_time_week: new Date('2024-01-03T00:00:00.000Z'), + activities__count: 3, + }, + { + activities__activity_time_week: new Date('2024-01-10T00:00:00.000Z'), + activities__count: 1, + }, + { + activities__activity_time_week: new Date('2023-12-27T00:00:00.000Z'), + activities__count: 2, + }, + ], + { joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator, compiler: weekCompiler } + ); + }); + + it('rejects query-time offset with custom granularity', async () => { + await expect( + dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'one_week_by_friday_by_offset', + offset: '2 days', + dateRange: ['2024-01-01', '2024-02-28'] + }], + dimensions: [], + timezone: 'UTC', + order: [['orders.createdAt', 'asc']], + }, + [], + { joinGraph, cubeEvaluator, compiler } + ) + ).rejects.toThrow('Query-time offset parameter cannot be used with custom granularity'); + }); }); diff --git a/packages/cubejs-schema-compiler/test/integration/mysql/custom-granularities.test.ts b/packages/cubejs-schema-compiler/test/integration/mysql/custom-granularities.test.ts index 1505442c453b0..89f28bb9c1a8b 100644 --- a/packages/cubejs-schema-compiler/test/integration/mysql/custom-granularities.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/mysql/custom-granularities.test.ts @@ -45,6 +45,9 @@ describe('Custom Granularities', () => { - name: two_weeks_by_friday interval: 2 weeks origin: '2024-08-23' + - name: one_week_by_friday_by_offset + interval: 1 week + offset: 4 days - name: one_hour_by_5min_offset interval: 1 hour offset: 5 minutes @@ -858,4 +861,147 @@ describe('Custom Granularities', () => { ], { joinGraph, cubeEvaluator, compiler } )); + + // Query-time offset tests + + // Test demonstrating day boundaries shifted to 2am-2am + it('works with query-time offset shifting day boundary (2am-2am)', async () => { + const { compiler: offsetCompiler, joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: events + sql: > + SELECT * + FROM ( + SELECT 1 as event_id, '2024-01-01 00:00:00' as event_time UNION ALL + SELECT 2, '2024-01-01 01:00:00' UNION ALL + SELECT 3, '2024-01-01 01:30:00' UNION ALL + SELECT 4, '2024-01-02 00:00:00' UNION ALL + SELECT 5, '2024-01-02 01:00:00' UNION ALL + SELECT 6, '2024-01-02 01:30:00' + ) AS t + + dimensions: + - name: event_id + sql: event_id + type: number + primary_key: true + + - name: eventTime + sql: event_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['events.count'], + timeDimensions: [{ + dimension: 'events.eventTime', + granularity: 'day', + offset: '2 hours', + dateRange: ['2024-01-01', '2024-01-03'] + }], + dimensions: [], + timezone: 'UTC', + order: [['events.eventTime', 'asc']], + }, + [ + { + events__event_time_day: '2023-12-31 02:00:00.000', + events__count: 3, + }, + { + events__event_time_day: '2024-01-01 02:00:00.000', + events__count: 3, + }, + ], + { joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator, compiler: offsetCompiler } + ); + }); + + // Test demonstrating week boundaries shifted to Wednesday-Wednesday + it('works with query-time offset shifting week to start on Wednesday', async () => { + const { compiler: weekCompiler, joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: activities + sql: > + SELECT * + FROM ( + SELECT 1 as activity_id, '2024-01-01 10:00:00' as activity_time UNION ALL + SELECT 2, '2024-01-02 10:00:00' UNION ALL + SELECT 3, '2024-01-03 10:00:00' UNION ALL + SELECT 4, '2024-01-04 10:00:00' UNION ALL + SELECT 5, '2024-01-08 10:00:00' UNION ALL + SELECT 6, '2024-01-10 10:00:00' + ) AS t + + dimensions: + - name: activity_id + sql: activity_id + type: number + primary_key: true + + - name: activityTime + sql: activity_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['activities.count'], + timeDimensions: [{ + dimension: 'activities.activityTime', + granularity: 'week', + offset: '2 days', + dateRange: ['2024-01-01', '2024-01-15'] + }], + dimensions: [], + timezone: 'UTC', + order: [['activities.activityTime', 'asc']], + }, + [ + { + activities__activity_time_week: '2024-01-03 00:00:00.000', + activities__count: 3, + }, + { + activities__activity_time_week: '2024-01-10 00:00:00.000', + activities__count: 1, + }, + { + activities__activity_time_week: '2023-12-27 00:00:00.000', + activities__count: 2, + }, + ], + { joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator, compiler: weekCompiler } + ); + }); + + it('rejects query-time offset with custom granularity', async () => { + await expect( + dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'one_week_by_friday_by_offset', + offset: '2 days', + dateRange: ['2024-01-01', '2024-02-28'] + }], + dimensions: [], + timezone: 'UTC', + order: [['orders.createdAt', 'asc']], + }, + [], + { joinGraph, cubeEvaluator, compiler } + ) + ).rejects.toThrow('Query-time offset parameter cannot be used with custom granularity'); + }); }); diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/custom-granularities.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/custom-granularities.test.ts index 000d8482df0d2..d1de918d67584 100644 --- a/packages/cubejs-schema-compiler/test/integration/postgres/custom-granularities.test.ts +++ b/packages/cubejs-schema-compiler/test/integration/postgres/custom-granularities.test.ts @@ -984,4 +984,233 @@ describe('Custom Granularities', () => { ], { joinGraph, cubeEvaluator, compiler } )); + + // Query-time offset tests + + // Test demonstrating day boundaries shifted to 2am-2am + // With offset "2 hours", midnight and 1am data get grouped into the previous day's bucket + it('works with query-time offset shifting day boundary (2am-2am)', async () => { + const { compiler: offsetCompiler, joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: events + sql: > + SELECT * + FROM (VALUES + (1, '2024-01-01 00:00:00'::timestamp), + (2, '2024-01-01 01:00:00'::timestamp), + (3, '2024-01-01 01:30:00'::timestamp), + (4, '2024-01-02 00:00:00'::timestamp), + (5, '2024-01-02 01:00:00'::timestamp), + (6, '2024-01-02 01:30:00'::timestamp) + ) AS t(event_id, event_time) + + dimensions: + - name: event_id + sql: event_id + type: number + primary_key: true + + - name: eventTime + sql: event_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['events.count'], + timeDimensions: [{ + dimension: 'events.eventTime', + granularity: 'day', + offset: '2 hours', // Days run 2am-2am instead of midnight-midnight + dateRange: ['2024-01-01', '2024-01-03'] + }], + dimensions: [], + timezone: 'UTC', + order: [['events.eventTime', 'asc']], + }, + [ + { + // Dec 31 at 2am = bucket for data from Dec 31 2am to Jan 1 2am + // Contains: Jan 1 00:00, Jan 1 01:00, Jan 1 01:30 (3 records before Jan 1 2am) + events__event_time_day: '2023-12-31T02:00:00.000Z', + events__count: '3', + }, + { + // Jan 1 at 2am = bucket for data from Jan 1 2am to Jan 2 2am + // Contains: Jan 2 00:00, Jan 2 01:00, Jan 2 01:30 (3 records before Jan 2 2am) + events__event_time_day: '2024-01-01T02:00:00.000Z', + events__count: '3', + }, + ], + { joinGraph: offsetJoinGraph, cubeEvaluator: offsetCubeEvaluator, compiler: offsetCompiler } + ); + }); + + // Test demonstrating week boundaries shifted to Wednesday-Wednesday + // Default week is Monday-based (ISO), offset "2 days" shifts to Wednesday + it('works with query-time offset shifting week to start on Wednesday', async () => { + const { compiler: weekCompiler, joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator } = prepareYamlCompiler(` + cubes: + - name: activities + sql: > + SELECT * + FROM (VALUES + (1, '2024-01-01 10:00:00'::timestamp), + (2, '2024-01-02 10:00:00'::timestamp), + (3, '2024-01-03 10:00:00'::timestamp), + (4, '2024-01-04 10:00:00'::timestamp), + (5, '2024-01-08 10:00:00'::timestamp), + (6, '2024-01-10 10:00:00'::timestamp) + ) AS t(activity_id, activity_time) + + dimensions: + - name: activity_id + sql: activity_id + type: number + primary_key: true + + - name: activityTime + sql: activity_time + type: time + + measures: + - name: count + type: count + `); + + await dbRunner.runQueryTest( + { + measures: ['activities.count'], + timeDimensions: [{ + dimension: 'activities.activityTime', + granularity: 'week', + offset: '2 days', // Shift from Monday start to Wednesday start + dateRange: ['2024-01-01', '2024-01-15'] + }], + dimensions: [], + timezone: 'UTC', + order: [['activities.activityTime', 'asc']], + }, + [ + { + // Week of Jan 3 (Wednesday) = Wed Jan 3 to Tue Jan 9 + // Contains: Jan 3 (Wed), Jan 4 (Thu), Jan 8 (Mon) + activities__activity_time_week: '2024-01-03T00:00:00.000Z', + activities__count: '3', + }, + { + // Week of Jan 10 (Wednesday) = Wed Jan 10 to Tue Jan 16 + // Contains: Jan 10 (Wed) + activities__activity_time_week: '2024-01-10T00:00:00.000Z', + activities__count: '1', + }, + { + // Week of Dec 27 (Wednesday) = Wed Dec 27 to Tue Jan 2 + // Contains: Jan 1 (Mon), Jan 2 (Tue) - both before the Wednesday cutoff + activities__activity_time_week: '2023-12-27T00:00:00.000Z', + activities__count: '2', + }, + ], + { joinGraph: weekJoinGraph, cubeEvaluator: weekCubeEvaluator, compiler: weekCompiler } + ); + }); + + it('works with query-time offset on predefined hour granularity', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'hour', + offset: '15 minutes', + dateRange: ['2024-01-01T00:00:00.000', '2024-01-01T02:00:00.000'] + }], + dimensions: [], + timezone: 'UTC', + order: [['orders.createdAt', 'asc']], + }, + [ + { + orders__created_at_hour: '2023-12-31T23:15:00.000Z', + orders__count: '1', + }, + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('rejects query-time offset with custom granularity', async () => { + await expect( + dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'one_week_by_friday_by_offset', // Custom granularity + offset: '2 days', // Should be rejected + dateRange: ['2024-01-01', '2024-03-31'] + }], + dimensions: [], + timezone: 'UTC', + order: [['orders.createdAt', 'asc']], + }, + [], + { joinGraph, cubeEvaluator, compiler } + ) + ).rejects.toThrow('Query-time offset parameter cannot be used with custom granularity'); + }); + + it('works with negative query-time offset', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'day', + offset: '-6 hours', + dateRange: ['2024-01-01', '2024-01-31'] + }], + dimensions: [], + timezone: 'UTC', + order: [['orders.createdAt', 'asc']], + }, + [ + { + orders__created_at_day: '2023-12-31T18:00:00.000Z', + orders__count: '1', + }, + { + orders__created_at_day: '2024-01-14T18:00:00.000Z', + orders__count: '1', + }, + { + orders__created_at_day: '2024-01-28T18:00:00.000Z', + orders__count: '1', + }, + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('works with query-time offset with minute precision', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'hour', + offset: '-45 minutes', + dateRange: ['2024-01-01T00:00:00.000', '2024-01-01T02:00:00.000'] + }], + dimensions: [], + timezone: 'UTC', + order: [['orders.createdAt', 'asc']], + }, + [ + { + orders__created_at_hour: '2023-12-31T23:15:00.000Z', + orders__count: '1', + }, + ], + { joinGraph, cubeEvaluator, compiler } + )); }); diff --git a/packages/cubejs-schema-compiler/test/unit/query-time-offset.test.ts b/packages/cubejs-schema-compiler/test/unit/query-time-offset.test.ts new file mode 100644 index 0000000000000..3a3c3a73c0399 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/query-time-offset.test.ts @@ -0,0 +1,119 @@ +import { PostgresQuery } from '../../src'; +import { prepareYamlCompiler } from './PrepareCompiler'; + +describe('Query-time Granularity Offset', () => { + const compilers = prepareYamlCompiler(` + cubes: + - name: orders + sql: "SELECT * FROM orders" + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: createdAt + sql: created_at + type: time + granularities: + - name: custom_week + interval: 1 week + offset: 4 days + + measures: + - name: count + type: count + `); + + it('should accept offset parameter with day granularity', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'day', + offset: '-2 hours 30 minutes', + dateRange: ['2024-01-01', '2024-01-31'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + expect(queryAndParams[0]).toBeDefined(); + expect(queryAndParams[0]).toContain('orders'); + }); + + it('should apply offset to predefined hour granularity', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'hour', + offset: '15 minutes', + dateRange: ['2024-01-01', '2024-01-02'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + expect(queryAndParams[0]).toBeDefined(); + }); + + it('should reject offset with custom granularity', async () => { + await compilers.compiler.compile(); + + expect(() => { + new PostgresQuery(compilers, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'custom_week', // Custom granularity + offset: '2 days', // Should be rejected + dateRange: ['2024-01-01', '2024-01-31'] + }], + timezone: 'UTC' + }); + }).toThrow('Query-time offset parameter cannot be used with custom granularity'); + }); + + it('should support negative offsets', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'day', + offset: '-6 hours', + dateRange: ['2024-01-01', '2024-01-31'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + expect(queryAndParams[0]).toBeDefined(); + }); + + it('should accept complex offset formats', async () => { + await compilers.compiler.compile(); + + const query = new PostgresQuery(compilers, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + granularity: 'day', + offset: '2 hours 30 minutes 15 seconds', + dateRange: ['2024-01-01', '2024-01-31'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + expect(queryAndParams[0]).toBeDefined(); + }); +}); +