Skip to content

Commit 80e0dc9

Browse files
committed
Add custom granularity time intervals generation (used in rollups)
1 parent 05f664a commit 80e0dc9

File tree

2 files changed

+118
-6
lines changed

2 files changed

+118
-6
lines changed

packages/cubejs-backend-shared/src/time.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ const Moment = require('moment-timezone');
66
const moment = extendMoment(Moment);
77

88
type QueryDateRange = [string, string];
9+
type SqlInterval = string;
10+
type TimeSeriesOptions = {
11+
timestampPrecision: number
12+
};
13+
type ParsedInterval = Partial<Record<unitOfTime.DurationConstructor, number>>;
914

1015
export const TIME_SERIES: Record<string, (range: DateRange, timestampPrecision: number) => QueryDateRange[]> = {
1116
day: (range: DateRange, digits) => Array.from(range.snapTo('day').by('day'))
@@ -26,8 +31,103 @@ export const TIME_SERIES: Record<string, (range: DateRange, timestampPrecision:
2631
.map(d => [d.format(`YYYY-MM-DDT00:00:00.${'0'.repeat(digits)}`), d.endOf('quarter').format(`YYYY-MM-DDT23:59:59.${'9'.repeat(digits)}`)]),
2732
};
2833

29-
type TimeSeriesOptions = {
30-
timestampPrecision: number
34+
/**
35+
* Parse PostgreSQL-like interval string into object
36+
* E.g. '2 years 15 months 100 weeks 99 hours 15 seconds'
37+
* Negative units are also supported
38+
* E.g. '-2 months 5 days -10 hours'
39+
*/
40+
function parseSqlInterval(intervalStr: SqlInterval): ParsedInterval {
41+
const regex = /(-?\d+)\s*(year|quarter|month|week|day|hour|minute|second)s?/g;
42+
const interval: ParsedInterval = {};
43+
44+
for (const match of intervalStr.matchAll(regex)) {
45+
const value = parseInt(match[1], 10);
46+
const unit = match[2] as unitOfTime.DurationConstructor;
47+
interval[unit] = value;
48+
}
49+
50+
return interval;
51+
}
52+
53+
function addInterval(date: moment.Moment, interval: ParsedInterval): moment.Moment {
54+
const res = date.clone();
55+
56+
Object.entries(interval).forEach(([key, value]) => {
57+
res.add(value, key as unitOfTime.DurationConstructor);
58+
});
59+
60+
return res;
61+
}
62+
63+
function subtractInterval(date: moment.Moment, interval: ParsedInterval): moment.Moment {
64+
const res = date.clone();
65+
66+
Object.entries(interval).forEach(([key, value]) => {
67+
res.subtract(value, key as unitOfTime.DurationConstructor);
68+
});
69+
70+
return res;
71+
}
72+
73+
/**
74+
* Returns the closest date prior to date parameter aligned with the offset and interval
75+
* If no offset provided, the beginning of the year will be taken as pivot point
76+
*/
77+
function alignToOffset(date: moment.Moment, interval: ParsedInterval, offset?: ParsedInterval): moment.Moment {
78+
let alignedDate = date.clone();
79+
let intervalOp;
80+
81+
const startOfYear = moment().year(date.year()).startOf('year');
82+
let offsetDate = offset ? addInterval(startOfYear, offset) : startOfYear;
83+
84+
if (date.isBefore(offsetDate)) {
85+
intervalOp = offsetDate.isBefore(startOfYear) ? addInterval : subtractInterval;
86+
87+
while (date.isBefore(offsetDate)) {
88+
offsetDate = intervalOp(offsetDate, interval);
89+
}
90+
alignedDate = offsetDate;
91+
} else if (offsetDate.isBefore(startOfYear)) {
92+
intervalOp = offsetDate.isBefore(startOfYear) ? addInterval : subtractInterval;
93+
94+
while (date.isAfter(offsetDate)) {
95+
alignedDate = offsetDate.clone();
96+
offsetDate = intervalOp(offsetDate, interval);
97+
}
98+
} else {
99+
intervalOp = offsetDate.isBefore(startOfYear) ? subtractInterval : addInterval;
100+
101+
while (date.isAfter(offsetDate)) {
102+
alignedDate = offsetDate.clone();
103+
offsetDate = intervalOp(offsetDate, interval);
104+
}
105+
}
106+
107+
return alignedDate;
108+
}
109+
110+
export const timeSeriesFromCustomInterval = (intervalStr: string, [startStr, endStr]: QueryDateRange, offsetStr: string | undefined, options: TimeSeriesOptions = {timestampPrecision: 3}): QueryDateRange[] => {
111+
const intervalParsed = parseSqlInterval(intervalStr);
112+
const offsetParsed = offsetStr ? parseSqlInterval(offsetStr) : undefined;
113+
const start = moment(startStr);
114+
const end = moment(endStr);
115+
let alignedStart = alignToOffset(start, intervalParsed, offsetParsed);
116+
117+
const dates: QueryDateRange[] = [];
118+
119+
while (alignedStart.isBefore(end)) {
120+
const s = alignedStart.clone();
121+
alignedStart = addInterval(alignedStart, intervalParsed);
122+
dates.push([
123+
s.format(`YYYY-MM-DDTHH:mm:ss.${'0'.repeat(options.timestampPrecision)}`),
124+
alignedStart.clone()
125+
.subtract(1, 'second')
126+
.format(`YYYY-MM-DDTHH:mm:ss.${'9'.repeat(options.timestampPrecision)}`)
127+
]);
128+
}
129+
130+
return dates;
31131
};
32132

33133
export const timeSeries = (granularity: string, dateRange: QueryDateRange, options: TimeSeriesOptions = { timestampPrecision: 3 }): QueryDateRange[] => {

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import moment from 'moment-timezone';
2-
import { timeSeries, isPredefinedGranularity, FROM_PARTITION_RANGE, TO_PARTITION_RANGE, BUILD_RANGE_START_LOCAL, BUILD_RANGE_END_LOCAL } from '@cubejs-backend/shared';
2+
import {
3+
timeSeries,
4+
isPredefinedGranularity,
5+
timeSeriesFromCustomInterval,
6+
FROM_PARTITION_RANGE,
7+
TO_PARTITION_RANGE,
8+
BUILD_RANGE_START_LOCAL,
9+
BUILD_RANGE_END_LOCAL
10+
} from '@cubejs-backend/shared';
311

412
import { BaseFilter } from './BaseFilter';
513
import { UserError } from '../compiler/UserError';
@@ -278,9 +286,13 @@ export class BaseTimeDimension extends BaseFilter {
278286
];
279287
}
280288

281-
return timeSeries(this.granularity, [this.dateFromFormatted(), this.dateToFormatted()], {
282-
timestampPrecision: this.query.timestampPrecision(),
283-
});
289+
if (this.isPredefined) {
290+
return timeSeries(this.granularity, [this.dateFromFormatted(), this.dateToFormatted()], {
291+
timestampPrecision: this.query.timestampPrecision(),
292+
});
293+
}
294+
295+
return timeSeriesFromCustomInterval(this.granularityInterval, [this.dateFromFormatted(), this.dateToFormatted()], this.granularityOffset, {timestampPrecision: this.query.timestampPrecision()});
284296
}
285297

286298
public wildcardRange() {

0 commit comments

Comments
 (0)