Skip to content
Merged
8 changes: 5 additions & 3 deletions packages/cubejs-client-core/src/ResultSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export default class ResultSet<T extends Record<string, any> = 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) {
Expand All @@ -240,7 +240,9 @@ export default class ResultSet<T extends Record<string, any> = any> {
const [cubeName, dimension, granularity] = member.split('.');

if (granularity !== undefined) {
const range = dayRange(value, value).snapTo(granularity);
// 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);

let dateRange = [
Expand Down Expand Up @@ -469,7 +471,7 @@ export default class ResultSet<T extends Record<string, any> = 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](
Expand Down
66 changes: 47 additions & 19 deletions packages/cubejs-client-core/src/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,6 @@ 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 => ({
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 => dayRange(internalDayjs(from).startOf(value), internalDayjs(to).endOf(value)),
start: internalDayjs(from),
end: internalDayjs(to),
});

/**
* Parse PostgreSQL-like interval string into object
* E.g. '2 years 15 months 100 weeks 99 hours 15 seconds'
Expand Down Expand Up @@ -142,6 +123,53 @@ export function addInterval(date: dayjs.Dayjs, interval: ParsedInterval): dayjs.
return res;
}

export const dayRange = (from: any, to: any, annotations?: Record<string, { granularity?: Granularity }>): 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]
Expand Down
117 changes: 117 additions & 0 deletions packages/cubejs-client-core/test/drill-down.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
Loading