From 77528ec4027f15fbf19e994881614447c5532ccc Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Tue, 28 Oct 2025 10:21:28 -0700 Subject: [PATCH 1/7] Fix drilldown() for custom granularities --- packages/cubejs-client-core/src/ResultSet.ts | 39 +++++- .../test/drill-down.test.ts | 117 ++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/cubejs-client-core/src/ResultSet.ts b/packages/cubejs-client-core/src/ResultSet.ts index 4ce990b933d93..d8828362b3eb4 100644 --- a/packages/cubejs-client-core/src/ResultSet.ts +++ b/packages/cubejs-client-core/src/ResultSet.ts @@ -6,11 +6,13 @@ import { import { aliasSeries } from './utils'; import { + addInterval, DateRegex, dayRange, internalDayjs, isPredefinedGranularity, LocalDateRegex, + parseSqlInterval, TIME_SERIES, timeSeriesFromCustomInterval } from './time'; @@ -215,7 +217,7 @@ export default class ResultSet = any> { normalizedPivotConfig?.y.forEach((member, currentIndex) => values.push([member, yValues[currentIndex]])); const { filters: parentFilters = [], segments = [] } = this.query(); - const { measures } = this.loadResponses[0].annotation; + const { measures, timeDimensions: timeDimensionsAnnotation } = this.loadResponses[0].annotation; let [, measureName] = values.find(([member]) => member === 'measures') || []; if (measureName === undefined) { @@ -240,7 +242,40 @@ export default class ResultSet = any> { const [cubeName, dimension, granularity] = member.split('.'); if (granularity !== undefined) { - const range = dayRange(value, value).snapTo(granularity); + let range: { start: dayjs.Dayjs; end: dayjs.Dayjs }; + + // Check if this is a custom granularity + if (!isPredefinedGranularity(granularity)) { + // Get custom granularity metadata from annotations + const customGranularity = timeDimensionsAnnotation?.[member]?.granularity; + + if (customGranularity && customGranularity.interval) { + // Parse the interval (e.g., "5 minutes") + const intervalParsed = parseSqlInterval(customGranularity.interval); + + // The value is the start of the interval bucket + const intervalStart = internalDayjs(value); + + // Calculate the end of the interval bucket + // End is start + interval - 1 millisecond + const intervalEnd = addInterval(intervalStart, intervalParsed).subtract(1, 'millisecond'); + + range = { + start: intervalStart, + end: intervalEnd + }; + } else { + // Fallback to point-in-time if no custom granularity metadata found + range = { + start: internalDayjs(value), + end: internalDayjs(value) + }; + } + } else { + // Use existing logic for predefined granularities + range = dayRange(value, value).snapTo(granularity); + } + const originalTimeDimension = query.timeDimensions?.find((td) => td.dimension); let dateRange = [ diff --git a/packages/cubejs-client-core/test/drill-down.test.ts b/packages/cubejs-client-core/test/drill-down.test.ts index 672512c0a7003..10dc553bcf431 100644 --- a/packages/cubejs-client-core/test/drill-down.test.ts +++ b/packages/cubejs-client-core/test/drill-down.test.ts @@ -401,4 +401,121 @@ describe('drill down query', () => { timezone: 'UTC', }); }); + + it('handles custom granularity with interval and origin', () => { + const EVALUATION_PERIOD = 5; + const LAST_EVALUATED_AT = '2020-08-01T00:00:00.000'; + + const customGranularityResponse = { + queryType: 'regularQuery', + results: [ + { + query: { + measures: ['Transactions.count'], + timeDimensions: [ + { + dimension: 'Transactions.createdAt', + granularity: 'alerting_monitor', + dateRange: ['2020-08-01T00:00:00.000', '2020-08-01T01:00:00.000'], + }, + ], + filters: [], + timezone: 'UTC', + order: [], + dimensions: [], + }, + data: [ + { + 'Transactions.createdAt.alerting_monitor': '2020-08-01T00:00:00.000', + 'Transactions.createdAt': '2020-08-01T00:00:00.000', + 'Transactions.count': 10, + }, + { + 'Transactions.createdAt.alerting_monitor': '2020-08-01T00:05:00.000', + 'Transactions.createdAt': '2020-08-01T00:05:00.000', + 'Transactions.count': 15, + }, + { + 'Transactions.createdAt.alerting_monitor': '2020-08-01T00:10:00.000', + 'Transactions.createdAt': '2020-08-01T00:10:00.000', + 'Transactions.count': 8, + }, + ], + annotation: { + measures: { + 'Transactions.count': { + title: 'Transactions Count', + shortTitle: 'Count', + type: 'number', + drillMembers: ['Transactions.id', 'Transactions.createdAt'], + drillMembersGrouped: { + measures: [], + dimensions: ['Transactions.id', 'Transactions.createdAt'], + }, + }, + }, + dimensions: {}, + segments: {}, + timeDimensions: { + 'Transactions.createdAt.alerting_monitor': { + title: 'Transaction created at', + shortTitle: 'Created at', + type: 'time', + granularity: { + name: 'alerting_monitor', + title: 'Alerting Monitor', + interval: `${EVALUATION_PERIOD} minutes`, + origin: LAST_EVALUATED_AT, + }, + }, + 'Transactions.createdAt': { + title: 'Transaction created at', + shortTitle: 'Created at', + type: 'time', + }, + }, + }, + }, + ], + pivotQuery: { + measures: ['Transactions.count'], + timeDimensions: [ + { + dimension: 'Transactions.createdAt', + granularity: 'alerting_monitor', + dateRange: ['2020-08-01T00:00:00.000', '2020-08-01T01:00:00.000'], + }, + ], + filters: [], + timezone: 'UTC', + order: [], + dimensions: [], + }, + }; + + const resultSet = new ResultSet(customGranularityResponse as any); + + // Test drilling down on the second data point (00:05:00) + expect( + resultSet.drillDown({ xValues: ['2020-08-01T00:05:00.000'] }) + ).toEqual({ + measures: [], + segments: [], + dimensions: ['Transactions.id', 'Transactions.createdAt'], + filters: [ + { + member: 'Transactions.count', + operator: 'measureFilter', + }, + ], + timeDimensions: [ + { + dimension: 'Transactions.createdAt', + // Should create a date range for the 5-minute interval starting at 00:05:00 + dateRange: ['2020-08-01T00:05:00.000', '2020-08-01T00:09:59.999'], + }, + ], + timezone: 'UTC', + }); + }); }); From ab04e00ffdb8874ba5f7769e5e4f6bad18e7e9d0 Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Tue, 28 Oct 2025 12:15:53 -0700 Subject: [PATCH 2/7] fix dayRange func --- packages/cubejs-client-core/src/ResultSet.ts | 45 +++-------- packages/cubejs-client-core/src/time.ts | 82 +++++++++++++++++++- 2 files changed, 89 insertions(+), 38 deletions(-) diff --git a/packages/cubejs-client-core/src/ResultSet.ts b/packages/cubejs-client-core/src/ResultSet.ts index d8828362b3eb4..0c010f39c7be6 100644 --- a/packages/cubejs-client-core/src/ResultSet.ts +++ b/packages/cubejs-client-core/src/ResultSet.ts @@ -6,13 +6,12 @@ import { import { aliasSeries } from './utils'; import { - addInterval, DateRegex, dayRange, + getGranularityDateRange, internalDayjs, isPredefinedGranularity, LocalDateRegex, - parseSqlInterval, TIME_SERIES, timeSeriesFromCustomInterval } from './time'; @@ -242,39 +241,13 @@ export default class ResultSet = any> { const [cubeName, dimension, granularity] = member.split('.'); if (granularity !== undefined) { - let range: { start: dayjs.Dayjs; end: dayjs.Dayjs }; - - // Check if this is a custom granularity - if (!isPredefinedGranularity(granularity)) { - // Get custom granularity metadata from annotations - const customGranularity = timeDimensionsAnnotation?.[member]?.granularity; - - if (customGranularity && customGranularity.interval) { - // Parse the interval (e.g., "5 minutes") - const intervalParsed = parseSqlInterval(customGranularity.interval); - - // The value is the start of the interval bucket - const intervalStart = internalDayjs(value); - - // Calculate the end of the interval bucket - // End is start + interval - 1 millisecond - const intervalEnd = addInterval(intervalStart, intervalParsed).subtract(1, 'millisecond'); - - range = { - start: intervalStart, - end: intervalEnd - }; - } else { - // Fallback to point-in-time if no custom granularity metadata found - range = { - start: internalDayjs(value), - end: internalDayjs(value) - }; - } - } else { - // Use existing logic for predefined granularities - range = dayRange(value, value).snapTo(granularity); - } + // Use the new helper function that handles both predefined and custom granularities + const range = getGranularityDateRange( + value, + granularity, + timeDimensionsAnnotation?.[member], + timeDimensionsAnnotation + ); const originalTimeDimension = query.timeDimensions?.find((td) => td.dimension); @@ -504,7 +477,7 @@ export default class ResultSet = any> { !['hour', 'minute', 'second'].includes(timeDimension.granularity); const [start, end] = dateRange; - const range = dayRange(start, end); + const range = dayRange(start, end, annotations); if (isPredefinedGranularity(timeDimension.granularity)) { return TIME_SERIES[timeDimension.granularity]( diff --git a/packages/cubejs-client-core/src/time.ts b/packages/cubejs-client-core/src/time.ts index 0273a1f3c09ca..a4109852b3750 100644 --- a/packages/cubejs-client-core/src/time.ts +++ b/packages/cubejs-client-core/src/time.ts @@ -79,7 +79,7 @@ export const isPredefinedGranularity = (granularity: TimeDimensionGranularity): export const DateRegex = /^\d\d\d\d-\d\d-\d\d$/; export const LocalDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z?$/; -export const dayRange = (from: any, to: any): DayRange => ({ +export const dayRange = (from: any, to: any, annotations?: Record): DayRange => ({ by: (value: any) => { const results = []; @@ -93,7 +93,34 @@ export const dayRange = (from: any, to: any): DayRange => ({ return results; }, - snapTo: (value: any): DayRange => dayRange(internalDayjs(from).startOf(value), internalDayjs(to).endOf(value)), + snapTo: (value: any): DayRange => { + // Check if this is a custom granularity + if (!isPredefinedGranularity(value) && annotations) { + // Try to find the custom granularity metadata + // The annotation key might be in format "Cube.dimension.granularity" + // So we need to search through all annotations + let customGranularity: Granularity | undefined; + + for (const key of Object.keys(annotations)) { + if (key.endsWith(`.${value}`) && annotations[key].granularity) { + customGranularity = annotations[key].granularity; + break; + } + } + + if (customGranularity && customGranularity.interval) { + // For custom granularities, calculate the range based on interval + const intervalParsed = parseSqlInterval(customGranularity.interval); + const intervalStart = internalDayjs(from); + const intervalEnd = addInterval(internalDayjs(to), intervalParsed); + + return dayRange(intervalStart, intervalEnd, annotations); + } + } + + // Default behavior for predefined granularities + return dayRange(internalDayjs(from).startOf(value), internalDayjs(to).endOf(value), annotations); + }, start: internalDayjs(from), end: internalDayjs(to), }); @@ -227,6 +254,57 @@ export const timeSeriesFromCustomInterval = (from: string, to: string, granulari return dates; }; +/** + * Returns the date range for a specific granularity bucket containing the given value. + * Handles both predefined granularities (day, week, month, etc.) and custom granularities. + * + * @param value - The date/time value to find the bucket for + * @param granularity - The granularity name (predefined or custom) + * @param granularityAnnotation - Optional annotation containing custom granularity metadata (interval, origin, offset) + * @returns Object with start and end dayjs instances representing the granularity bucket + */ +export const getGranularityDateRange = ( + value: any, + granularity: string, + granularityAnnotation?: { granularity?: Granularity }, + allAnnotations?: Record +): { start: dayjs.Dayjs; end: dayjs.Dayjs } => { + if (isPredefinedGranularity(granularity)) { + // Use existing dayRange.snapTo logic for predefined granularities + const range = dayRange(value, value, allAnnotations).snapTo(granularity); + return { + start: range.start, + end: range.end + }; + } + + // Handle custom granularities + const customGranularity = granularityAnnotation?.granularity; + + if (customGranularity && customGranularity.interval) { + // Parse the interval (e.g., "5 minutes") + const intervalParsed = parseSqlInterval(customGranularity.interval); + + // The value is the start of the interval bucket + const intervalStart = internalDayjs(value); + + // Calculate the end of the interval bucket + // End is start + interval - 1 millisecond + const intervalEnd = addInterval(intervalStart, intervalParsed).subtract(1, 'millisecond'); + + return { + start: intervalStart, + end: intervalEnd + }; + } + + // Fallback to point-in-time if no custom granularity metadata found + return { + start: internalDayjs(value), + end: internalDayjs(value) + }; +}; + /** * Returns the lowest time unit for the interval */ From 2f0d2da35b6154ba9c13f303f15cd2813117d796 Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Tue, 28 Oct 2025 12:23:17 -0700 Subject: [PATCH 3/7] use dayRange instead of new func --- packages/cubejs-client-core/src/ResultSet.ts | 10 ++-------- packages/cubejs-client-core/src/time.ts | 5 +++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/cubejs-client-core/src/ResultSet.ts b/packages/cubejs-client-core/src/ResultSet.ts index 0c010f39c7be6..4056fb8e65691 100644 --- a/packages/cubejs-client-core/src/ResultSet.ts +++ b/packages/cubejs-client-core/src/ResultSet.ts @@ -8,7 +8,6 @@ import { aliasSeries } from './utils'; import { DateRegex, dayRange, - getGranularityDateRange, internalDayjs, isPredefinedGranularity, LocalDateRegex, @@ -241,13 +240,8 @@ export default class ResultSet = any> { const [cubeName, dimension, granularity] = member.split('.'); if (granularity !== undefined) { - // Use the new helper function that handles both predefined and custom granularities - const range = getGranularityDateRange( - value, - granularity, - timeDimensionsAnnotation?.[member], - timeDimensionsAnnotation - ); + // dayRange.snapTo now handles both predefined and custom granularities + const range = dayRange(value, value, timeDimensionsAnnotation).snapTo(granularity); const originalTimeDimension = query.timeDimensions?.find((td) => td.dimension); diff --git a/packages/cubejs-client-core/src/time.ts b/packages/cubejs-client-core/src/time.ts index a4109852b3750..e3227d9416697 100644 --- a/packages/cubejs-client-core/src/time.ts +++ b/packages/cubejs-client-core/src/time.ts @@ -109,10 +109,11 @@ export const dayRange = (from: any, to: any, annotations?: Record Date: Tue, 28 Oct 2025 12:29:20 -0700 Subject: [PATCH 4/7] remove new func --- packages/cubejs-client-core/src/time.ts | 51 ------------------------- 1 file changed, 51 deletions(-) diff --git a/packages/cubejs-client-core/src/time.ts b/packages/cubejs-client-core/src/time.ts index e3227d9416697..1cdd2b2ccec57 100644 --- a/packages/cubejs-client-core/src/time.ts +++ b/packages/cubejs-client-core/src/time.ts @@ -255,57 +255,6 @@ export const timeSeriesFromCustomInterval = (from: string, to: string, granulari return dates; }; -/** - * Returns the date range for a specific granularity bucket containing the given value. - * Handles both predefined granularities (day, week, month, etc.) and custom granularities. - * - * @param value - The date/time value to find the bucket for - * @param granularity - The granularity name (predefined or custom) - * @param granularityAnnotation - Optional annotation containing custom granularity metadata (interval, origin, offset) - * @returns Object with start and end dayjs instances representing the granularity bucket - */ -export const getGranularityDateRange = ( - value: any, - granularity: string, - granularityAnnotation?: { granularity?: Granularity }, - allAnnotations?: Record -): { start: dayjs.Dayjs; end: dayjs.Dayjs } => { - if (isPredefinedGranularity(granularity)) { - // Use existing dayRange.snapTo logic for predefined granularities - const range = dayRange(value, value, allAnnotations).snapTo(granularity); - return { - start: range.start, - end: range.end - }; - } - - // Handle custom granularities - const customGranularity = granularityAnnotation?.granularity; - - if (customGranularity && customGranularity.interval) { - // Parse the interval (e.g., "5 minutes") - const intervalParsed = parseSqlInterval(customGranularity.interval); - - // The value is the start of the interval bucket - const intervalStart = internalDayjs(value); - - // Calculate the end of the interval bucket - // End is start + interval - 1 millisecond - const intervalEnd = addInterval(intervalStart, intervalParsed).subtract(1, 'millisecond'); - - return { - start: intervalStart, - end: intervalEnd - }; - } - - // Fallback to point-in-time if no custom granularity metadata found - return { - start: internalDayjs(value), - end: internalDayjs(value) - }; -}; - /** * Returns the lowest time unit for the interval */ From a5f672958aa2a50a5b778dd10db51bbf1b8817a0 Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Tue, 28 Oct 2025 12:47:03 -0700 Subject: [PATCH 5/7] fix lint --- packages/cubejs-client-core/src/time.ts | 88 ++++++++++++------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/cubejs-client-core/src/time.ts b/packages/cubejs-client-core/src/time.ts index 1cdd2b2ccec57..9621b9ab25e0e 100644 --- a/packages/cubejs-client-core/src/time.ts +++ b/packages/cubejs-client-core/src/time.ts @@ -79,6 +79,50 @@ export const isPredefinedGranularity = (granularity: TimeDimensionGranularity): export const DateRegex = /^\d\d\d\d-\d\d-\d\d$/; export const LocalDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z?$/; +/** + * Parse PostgreSQL-like interval string into object + * E.g. '2 years 15 months 100 weeks 99 hours 15 seconds' + * Negative units are also supported + * E.g. '-2 months 5 days -10 hours' + * + * TODO: It's copy/paste of parseSqlInterval from @cubejs-backend/shared [time.ts] + * It's not referenced to omit imports of moment.js staff. + * Probably one day we should choose one implementation and reuse it in other places. + */ +export function parseSqlInterval(intervalStr: SqlInterval): ParsedInterval { + const interval: ParsedInterval = {}; + const parts = intervalStr.split(/\s+/); + + for (let i = 0; i < parts.length; i += 2) { + const value = parseInt(parts[i], 10); + const unit = parts[i + 1]; + + // Remove ending 's' (e.g., 'days' -> 'day') + const singularUnit = unit.endsWith('s') ? unit.slice(0, -1) : unit; + interval[singularUnit] = value; + } + + return interval; +} + +/** + * Adds interval to provided date. + * TODO: It's copy/paste of addInterval from @cubejs-backend/shared [time.ts] + * but operates with dayjs instead of moment.js + * @param {dayjs} date + * @param interval + * @returns {dayjs} + */ +export function addInterval(date: dayjs.Dayjs, interval: ParsedInterval): dayjs.Dayjs { + let res = date.clone(); + + Object.entries(interval).forEach(([key, value]) => { + res = res.add(value, key); + }); + + return res; +} + export const dayRange = (from: any, to: any, annotations?: Record): DayRange => ({ by: (value: any) => { const results = []; @@ -126,50 +170,6 @@ export const dayRange = (from: any, to: any, annotations?: Record 'day') - const singularUnit = unit.endsWith('s') ? unit.slice(0, -1) : unit; - interval[singularUnit] = value; - } - - return interval; -} - -/** - * Adds interval to provided date. - * TODO: It's copy/paste of addInterval from @cubejs-backend/shared [time.ts] - * but operates with dayjs instead of moment.js - * @param {dayjs} date - * @param interval - * @returns {dayjs} - */ -export function addInterval(date: dayjs.Dayjs, interval: ParsedInterval): dayjs.Dayjs { - let res = date.clone(); - - Object.entries(interval).forEach(([key, value]) => { - res = res.add(value, key); - }); - - return res; -} - /** * Adds interval to provided date. * TODO: It's copy/paste of subtractInterval from @cubejs-backend/shared [time.ts] From 2c1bfb190032172a58b882a714f4a03e13f6ecaf Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Wed, 29 Oct 2025 15:57:15 -0700 Subject: [PATCH 6/7] 'Add case for non-aligned origin' --- packages/cubejs-client-core/src/time.ts | 106 ++++++++------- .../test/drill-down.test.ts | 121 ++++++++++++++++++ 2 files changed, 180 insertions(+), 47 deletions(-) diff --git a/packages/cubejs-client-core/src/time.ts b/packages/cubejs-client-core/src/time.ts index 9621b9ab25e0e..cd7a0c5a25293 100644 --- a/packages/cubejs-client-core/src/time.ts +++ b/packages/cubejs-client-core/src/time.ts @@ -123,53 +123,6 @@ export function addInterval(date: dayjs.Dayjs, interval: ParsedInterval): dayjs. return res; } -export const dayRange = (from: any, to: any, annotations?: Record): DayRange => ({ - by: (value: any) => { - const results = []; - - let start = internalDayjs(from); - const end = internalDayjs(to); - - while (start.startOf(value).isBefore(end) || start.isSame(end)) { - results.push(start); - start = start.add(1, value); - } - - return results; - }, - snapTo: (value: any): DayRange => { - // Check if this is a custom granularity - if (!isPredefinedGranularity(value) && annotations) { - // Try to find the custom granularity metadata - // The annotation key might be in format "Cube.dimension.granularity" - // So we need to search through all annotations - let customGranularity: Granularity | undefined; - - for (const key of Object.keys(annotations)) { - if (key.endsWith(`.${value}`) && annotations[key].granularity) { - customGranularity = annotations[key].granularity; - break; - } - } - - if (customGranularity && customGranularity.interval) { - // For custom granularities, calculate the range for the bucket - const intervalParsed = parseSqlInterval(customGranularity.interval); - const intervalStart = internalDayjs(from); - // End is start + interval - 1 millisecond (to stay within the bucket) - const intervalEnd = addInterval(intervalStart, intervalParsed).subtract(1, 'millisecond'); - - return dayRange(intervalStart, intervalEnd, annotations); - } - } - - // Default behavior for predefined granularities - return dayRange(internalDayjs(from).startOf(value), internalDayjs(to).endOf(value), annotations); - }, - start: internalDayjs(from), - end: internalDayjs(to), -}); - /** * Adds interval to provided date. * TODO: It's copy/paste of subtractInterval from @cubejs-backend/shared [time.ts] @@ -230,6 +183,65 @@ function alignToOrigin(startDate: dayjs.Dayjs, interval: ParsedInterval, origin: return alignedDate; } +export const dayRange = (from: any, to: any, annotations?: Record): DayRange => ({ + by: (value: any) => { + const results = []; + + let start = internalDayjs(from); + const end = internalDayjs(to); + + while (start.startOf(value).isBefore(end) || start.isSame(end)) { + results.push(start); + start = start.add(1, value); + } + + return results; + }, + snapTo: (value: any): DayRange => { + // Check if this is a custom granularity + if (!isPredefinedGranularity(value) && annotations) { + // Try to find the custom granularity metadata + // The annotation key might be in format "Cube.dimension.granularity" + // So we need to search through all annotations + let customGranularity: Granularity | undefined; + + for (const key of Object.keys(annotations)) { + if (key.endsWith(`.${value}`) && annotations[key].granularity) { + customGranularity = annotations[key].granularity; + break; + } + } + + if (customGranularity?.interval) { + // For custom granularities, calculate the range for the bucket + const intervalParsed = parseSqlInterval(customGranularity.interval); + let intervalStart = internalDayjs(from); + + // If custom granularity has an origin, align to it + if (customGranularity.origin) { + let origin = internalDayjs(customGranularity.origin); + if (customGranularity.offset) { + origin = addInterval(origin, parseSqlInterval(customGranularity.offset)); + } + + // Align the value to the origin to find the actual bucket start + intervalStart = alignToOrigin(intervalStart, intervalParsed, origin); + } + + // End is start + interval - 1 millisecond (to stay within the bucket) + const intervalEnd = addInterval(intervalStart, intervalParsed).subtract(1, 'millisecond'); + + return dayRange(intervalStart, intervalEnd, annotations); + } + } + + // Default behavior for predefined granularities + return dayRange(internalDayjs(from).startOf(value), internalDayjs(to).endOf(value), annotations); + }, + start: internalDayjs(from), + end: internalDayjs(to), +}); + /** * Returns the time series points for the custom interval * TODO: It's almost a copy/paste of timeSeriesFromCustomInterval from diff --git a/packages/cubejs-client-core/test/drill-down.test.ts b/packages/cubejs-client-core/test/drill-down.test.ts index 10dc553bcf431..ab192d7f4760f 100644 --- a/packages/cubejs-client-core/test/drill-down.test.ts +++ b/packages/cubejs-client-core/test/drill-down.test.ts @@ -518,4 +518,125 @@ describe('drill down query', () => { timezone: 'UTC', }); }); + + it('handles custom granularity with non-aligned origin', () => { + const EVALUATION_PERIOD = 5; + const NON_ALIGNED_ORIGIN = '2020-08-01T00:02:00.000'; // Origin at 00:02 instead of 00:00 + + const customGranularityResponse = { + queryType: 'regularQuery', + results: [ + { + query: { + measures: ['Transactions.count'], + timeDimensions: [ + { + dimension: 'Transactions.createdAt', + granularity: 'alerting_monitor', + dateRange: ['2020-08-01T00:00:00.000', '2020-08-01T01:00:00.000'], + }, + ], + filters: [], + timezone: 'UTC', + order: [], + dimensions: [], + }, + data: [ + { + // First bucket starts at 00:02:00 (origin) + 'Transactions.createdAt.alerting_monitor': '2020-08-01T00:02:00.000', + 'Transactions.createdAt': '2020-08-01T00:02:00.000', + 'Transactions.count': 10, + }, + { + // Second bucket starts at 00:07:00 (origin + 5 minutes) + 'Transactions.createdAt.alerting_monitor': '2020-08-01T00:07:00.000', + 'Transactions.createdAt': '2020-08-01T00:07:00.000', + 'Transactions.count': 15, + }, + { + // Third bucket starts at 00:12:00 (origin + 10 minutes) + 'Transactions.createdAt.alerting_monitor': '2020-08-01T00:12:00.000', + 'Transactions.createdAt': '2020-08-01T00:12:00.000', + 'Transactions.count': 8, + }, + ], + annotation: { + measures: { + 'Transactions.count': { + title: 'Transactions Count', + shortTitle: 'Count', + type: 'number', + drillMembers: ['Transactions.id', 'Transactions.createdAt'], + drillMembersGrouped: { + measures: [], + dimensions: ['Transactions.id', 'Transactions.createdAt'], + }, + }, + }, + dimensions: {}, + segments: {}, + timeDimensions: { + 'Transactions.createdAt.alerting_monitor': { + title: 'Transaction created at', + shortTitle: 'Created at', + type: 'time', + granularity: { + name: 'alerting_monitor', + title: 'Alerting Monitor', + interval: `${EVALUATION_PERIOD} minutes`, + origin: NON_ALIGNED_ORIGIN, + }, + }, + 'Transactions.createdAt': { + title: 'Transaction created at', + shortTitle: 'Created at', + type: 'time', + }, + }, + }, + }, + ], + pivotQuery: { + measures: ['Transactions.count'], + timeDimensions: [ + { + dimension: 'Transactions.createdAt', + granularity: 'alerting_monitor', + dateRange: ['2020-08-01T00:00:00.000', '2020-08-01T01:00:00.000'], + }, + ], + filters: [], + timezone: 'UTC', + order: [], + dimensions: [], + }, + }; + + const resultSet = new ResultSet(customGranularityResponse as any); + + // Test drilling down on the second data point (00:07:00) + // Since origin is 00:02:00, the bucket is 00:07:00 - 00:11:59 + expect( + resultSet.drillDown({ xValues: ['2020-08-01T00:07:00.000'] }) + ).toEqual({ + measures: [], + segments: [], + dimensions: ['Transactions.id', 'Transactions.createdAt'], + filters: [ + { + member: 'Transactions.count', + operator: 'measureFilter', + }, + ], + timeDimensions: [ + { + dimension: 'Transactions.createdAt', + // Should align to origin: bucket starts at 00:07:00 (origin + 5min) + dateRange: ['2020-08-01T00:07:00.000', '2020-08-01T00:11:59.999'], + }, + ], + timezone: 'UTC', + }); + }); }); From d3733aebc5ca4ac13880e0e7c74954c480c3dfb7 Mon Sep 17 00:00:00 2001 From: Paco Valdez Date: Wed, 5 Nov 2025 16:59:29 -0800 Subject: [PATCH 7/7] fix origin calc --- packages/cubejs-client-core/src/time.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/cubejs-client-core/src/time.ts b/packages/cubejs-client-core/src/time.ts index cd7a0c5a25293..dcba68a6d70dd 100644 --- a/packages/cubejs-client-core/src/time.ts +++ b/packages/cubejs-client-core/src/time.ts @@ -217,11 +217,16 @@ export const dayRange = (from: any, to: any, annotations?: Record