Skip to content

Commit 2109849

Browse files
authored
feat(schema-compiler): custom granularity support (#8537)
* remove unneeded filter for granularities * dedup month in standardGranularitiesParents * feat(schema-compiler): update cube's schema to allow granularities for time dimensions * fix custom granularity processing in time dimension * update yaml schema compiler to process custom granularities * Add custom granularities support to time dimension and base query * feat(schema-compiler): update cube's schema to allow granularities for time dimensions (simple syntax) * Add custom granularities simple syntax support to the time dimension and base query * Rename baseGranularity → rollupGranularity * fix schema validation * fix custom granularity processing in BaseTimeDimension * Moved custom granularity processing from time dimension to base query * Revert: deep evaluateSymbolSql processing * Implement dimensionTimeGroupedColumn for Snowflake * Implement dimensionTimeGroupedColumn for MySQL * Add custom granularity time intervals generation (used in rollups) * Rewrite parseSqlInterval from regex to split * Add tests for timeSeriesFromCustomInterval * Add tests for yaml schema compiler * Add tests for cube validator * Fix overTimeSeriesQuery with custom granularities * Add tests for sql query generation for queries with custom granularities * return back deleted test * Remove comment, add types * update cube validation scheme: remove sql, add origin * add tests for cube validator * add another test for yaml schema compiler * move isGranularityNaturalAligned() to utils to be reused * Add Granularity entity and move all related processing there also implement date_bin for postgres * temporary comment out dimensionTimeGroupedColumn * Fix timeSeriesFromCustomInterval generation * Fix tests timeSeriesFromCustomInterval * Add more tests for timeSeriesFromCustomInterval * Fix Granularity constructor * Add integration tests for Custom Granularities for PostgreSQL * Fix Custom Granularities tests in CI (needs order by) * Implement date_bin for Snowflake * Move BaseDbRunner from postgres to separate folder * fix customGranularity.origin init * implement dateBin in MySQL * fix interval math ops in MySQL * fix postgres custom granularities tests * Add tests for custom granularities in MySQL * move granularityFromIntervalString from BaseQuery → Granularity * fix granularityFromIntervalString * improve addInterval / subtractInterval in MS SQL Query (now supports intervals with >1 time units) * Implement dateBin for MS SQL * Add tests for custom granularities in MS SQL * fix 2 granularities test (actually revert the bad changes back) * add comment for dateBin in postgresql query * Implement dateBin for Databricks * Implement dateBin for DuckDB * implement dateBin for BigQuery * extend testing fixtures with custom granularities * fix TimeDimensionGranularity type to allow custom granularities * Add more unit tests for granularities * fix writing yaml with test cubes definitions containing origin with dashes * add tests for custom granularities in testing drivers * skip custom granularities tests for athena * skip custom granularities tests for clickhouse * add test snapshots for custom granularities tests for postgres * skip custom granularities tests for mysql (some) * add test snapshots for custom granularities tests for ms sql * skip custom granularities tests for mssql (some) * add test snapshots for custom granularities tests for snowflake * add test snapshots for custom granularities tests for bigQuery * skip custom granularities tests for databricks (some) * add test snapshots for custom granularities tests for Databricks * Implement dateBin for ClickHouse * Add custom granularities integration tests for ClickHouse * unskip custom granularities tests for clickhouse (some) * add test snapshots for custom granularities tests for ClickHouse * fix linting * fix integration tests for clickhouse v22.x * fix in dateBin for Postgresql and Bigquery * Add char limits for granularities names in queries * Add time series date range limit checking before generating * add tests for limit checking in time series generation * Improve error message for time series limit * fix tests for time series limit * Refactor: move isGranularityNaturalAligned from Basequery → Granularity class * add test snapshots for custom granularities tests for MySQL * move diffTimeUnitForInterval to BaseQuery * fix in choosing minGranularity for granularity with origin point
1 parent 0671e4a commit 2109849

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+12933
-173
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,7 @@ class ApiGateway {
12311231
if (queryGranularity.length > 1) {
12321232
throw new UserError('Data blending query granularities must match');
12331233
}
1234-
if (queryGranularity.filter(Boolean).length === 0) {
1234+
if (queryGranularity.length === 0) {
12351235
throw new UserError('Data blending query without granularity is not supported');
12361236
}
12371237
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const querySchema = Joi.object().keys({
103103
filters: Joi.array().items(oneFilter, oneCondition),
104104
timeDimensions: Joi.array().items(Joi.object().keys({
105105
dimension: id.required(),
106-
granularity: Joi.valid('quarter', 'day', 'month', 'year', 'week', 'hour', 'minute', 'second', null),
106+
granularity: Joi.string().max(128, 'utf8'), // Custom granularities may have arbitrary names
107107
dateRange: [
108108
Joi.array().items(Joi.string()).min(1).max(2),
109109
Joi.string()

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

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ const Moment = require('moment-timezone');
55

66
const moment = extendMoment(Moment);
77

8-
type QueryDateRange = [string, string];
8+
export type QueryDateRange = [string, string];
9+
type SqlInterval = string;
10+
export 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,26 +31,159 @@ 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+
export function parseSqlInterval(intervalStr: SqlInterval): ParsedInterval {
41+
const interval: ParsedInterval = {};
42+
const parts = intervalStr.split(/\s+/);
43+
44+
for (let i = 0; i < parts.length; i += 2) {
45+
const value = parseInt(parts[i], 10);
46+
const unit = parts[i + 1];
47+
48+
// Remove ending 's' (e.g., 'days' -> 'day')
49+
const singularUnit = (unit.endsWith('s') ? unit.slice(0, -1) : unit) as unitOfTime.DurationConstructor;
50+
interval[singularUnit] = value;
51+
}
52+
53+
return interval;
54+
}
55+
56+
export function addInterval(date: moment.Moment, interval: ParsedInterval): moment.Moment {
57+
const res = date.clone();
58+
59+
Object.entries(interval).forEach(([key, value]) => {
60+
res.add(value, key as unitOfTime.DurationConstructor);
61+
});
62+
63+
return res;
64+
}
65+
66+
export function subtractInterval(date: moment.Moment, interval: ParsedInterval): moment.Moment {
67+
const res = date.clone();
68+
69+
Object.entries(interval).forEach(([key, value]) => {
70+
res.subtract(value, key as unitOfTime.DurationConstructor);
71+
});
72+
73+
return res;
74+
}
75+
76+
/**
77+
* Returns the closest date prior to date parameter aligned with the origin point
78+
*/
79+
function alignToOrigin(startDate: moment.Moment, interval: ParsedInterval, origin: moment.Moment): moment.Moment {
80+
let alignedDate = startDate.clone();
81+
let intervalOp;
82+
let isIntervalNegative = false;
83+
84+
let offsetDate = addInterval(origin, interval);
85+
86+
// The easiest way to check the interval sign
87+
if (offsetDate.isBefore(origin)) {
88+
isIntervalNegative = true;
89+
}
90+
91+
offsetDate = origin.clone();
92+
93+
if (startDate.isBefore(origin)) {
94+
intervalOp = isIntervalNegative ? addInterval : subtractInterval;
95+
96+
while (offsetDate.isAfter(startDate)) {
97+
offsetDate = intervalOp(offsetDate, interval);
98+
}
99+
alignedDate = offsetDate;
100+
} else {
101+
intervalOp = isIntervalNegative ? subtractInterval : addInterval;
102+
103+
while (offsetDate.isBefore(startDate)) {
104+
alignedDate = offsetDate.clone();
105+
offsetDate = intervalOp(offsetDate, interval);
106+
}
107+
108+
if (offsetDate.isSame(startDate)) {
109+
alignedDate = offsetDate;
110+
}
111+
}
112+
113+
return alignedDate;
114+
}
115+
116+
function parsedSqlIntervalToDuration(parsedInterval: ParsedInterval): moment.Duration {
117+
const duration = moment.duration();
118+
119+
Object.entries(parsedInterval).forEach(([key, value]) => {
120+
duration.add(value, key as unitOfTime.DurationConstructor);
121+
});
122+
123+
return duration;
124+
}
125+
126+
function checkSeriesForDateRange(intervalStr: string, [startStr, endStr]: QueryDateRange): void {
127+
const intervalParsed = parseSqlInterval(intervalStr);
128+
const intervalAsSeconds = parsedSqlIntervalToDuration(intervalParsed).asSeconds();
129+
const start = moment(startStr);
130+
const end = moment(endStr);
131+
const rangeSeconds = end.diff(start, 'seconds');
132+
133+
const limit = 50000; // TODO Make this as configurable soft limit
134+
const count = rangeSeconds / intervalAsSeconds;
135+
136+
if (count > limit) {
137+
throw new Error(`The count of generated date ranges (${count}) for the request from [${startStr}] to [${endStr}] by ${intervalStr} is over limit (${limit}). Please reduce the requested date interval or use bigger granularity.`);
138+
}
139+
}
140+
141+
export const timeSeriesFromCustomInterval = (intervalStr: string, [startStr, endStr]: QueryDateRange, origin: moment.Moment, options: TimeSeriesOptions = { timestampPrecision: 3 }): QueryDateRange[] => {
142+
checkSeriesForDateRange(intervalStr, [startStr, endStr]);
143+
144+
const intervalParsed = parseSqlInterval(intervalStr);
145+
const start = moment(startStr);
146+
const end = moment(endStr);
147+
let alignedStart = alignToOrigin(start, intervalParsed, origin);
148+
149+
const dates: QueryDateRange[] = [];
150+
151+
while (alignedStart.isBefore(end)) {
152+
const s = alignedStart.clone();
153+
alignedStart = addInterval(alignedStart, intervalParsed);
154+
dates.push([
155+
s.format(`YYYY-MM-DDTHH:mm:ss.${'0'.repeat(options.timestampPrecision)}`),
156+
alignedStart.clone()
157+
.subtract(1, 'second')
158+
.format(`YYYY-MM-DDTHH:mm:ss.${'9'.repeat(options.timestampPrecision)}`)
159+
]);
160+
}
161+
162+
return dates;
31163
};
32164

165+
/**
166+
* Returns array of date ranges for a predefined granularity aligned with the start of the year as pivot point
167+
*/
33168
export const timeSeries = (granularity: string, dateRange: QueryDateRange, options: TimeSeriesOptions = { timestampPrecision: 3 }): QueryDateRange[] => {
34169
if (!TIME_SERIES[granularity]) {
35-
// TODO error
36170
throw new Error(`Unsupported time granularity: ${granularity}`);
37171
}
38172

39173
if (!options.timestampPrecision) {
40174
throw new Error(`options.timestampPrecision is required, actual: ${options.timestampPrecision}`);
41175
}
42176

177+
checkSeriesForDateRange(`1 ${granularity}`, dateRange);
178+
43179
// moment.range works with strings
44180
const range = moment.range(<any>dateRange[0], <any>dateRange[1]);
45181

46182
return TIME_SERIES[granularity](range, options.timestampPrecision);
47183
};
48184

185+
export const isPredefinedGranularity = (granularity: string): boolean => !!TIME_SERIES[granularity];
186+
49187
export const FROM_PARTITION_RANGE = '__FROM_PARTITION_RANGE';
50188

51189
export const TO_PARTITION_RANGE = '__TO_PARTITION_RANGE';

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

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { inDbTimeZone, timeSeries } from '../src';
1+
import moment from 'moment-timezone';
2+
import { inDbTimeZone, timeSeries, isPredefinedGranularity, timeSeriesFromCustomInterval } from '../src';
23

34
describe('time', () => {
45
it('time series - day', () => {
@@ -29,6 +30,154 @@ describe('time', () => {
2930
]);
3031
});
3132

33+
it('time series - reach limits', () => {
34+
expect(() => {
35+
timeSeries('second', ['1970-01-01', '2021-01-02']);
36+
}).toThrowError(/The count of generated date ranges.*for the request.*is over limit/);
37+
});
38+
39+
it('time series - custom: interval - 1 year, origin - 2021-01-01', () => {
40+
expect(timeSeriesFromCustomInterval('1 year', ['2021-01-01', '2023-12-31'], moment('2021-01-01'))).toEqual([
41+
['2021-01-01T00:00:00.000', '2021-12-31T23:59:59.999'],
42+
['2022-01-01T00:00:00.000', '2022-12-31T23:59:59.999'],
43+
['2023-01-01T00:00:00.000', '2023-12-31T23:59:59.999']
44+
]);
45+
});
46+
47+
it('time series - custom: interval - 1 year, origin - 2020-01-01', () => {
48+
expect(timeSeriesFromCustomInterval('1 year', ['2021-01-01', '2023-12-31'], moment('2020-01-01'))).toEqual([
49+
['2021-01-01T00:00:00.000', '2021-12-31T23:59:59.999'],
50+
['2022-01-01T00:00:00.000', '2022-12-31T23:59:59.999'],
51+
['2023-01-01T00:00:00.000', '2023-12-31T23:59:59.999']
52+
]);
53+
});
54+
55+
it('time series - custom: interval - 1 year, origin - 2025-01-01', () => {
56+
expect(timeSeriesFromCustomInterval('1 year', ['2021-01-01', '2023-12-31'], moment('2025-01-01'))).toEqual([
57+
['2021-01-01T00:00:00.000', '2021-12-31T23:59:59.999'],
58+
['2022-01-01T00:00:00.000', '2022-12-31T23:59:59.999'],
59+
['2023-01-01T00:00:00.000', '2023-12-31T23:59:59.999']
60+
]);
61+
});
62+
63+
it('time series - custom: interval - 1 year, origin - 2025-03-01', () => {
64+
expect(timeSeriesFromCustomInterval('1 year', ['2021-01-01', '2022-12-31'], moment('2025-03-01'))).toEqual([
65+
['2020-03-01T00:00:00.000', '2021-02-28T23:59:59.999'],
66+
['2021-03-01T00:00:00.000', '2022-02-28T23:59:59.999'],
67+
['2022-03-01T00:00:00.000', '2023-02-28T23:59:59.999']
68+
]);
69+
});
70+
71+
it('time series - custom: interval - 1 year, origin - 2015-03-01', () => {
72+
expect(timeSeriesFromCustomInterval('1 year', ['2021-01-01', '2022-12-31'], moment('2015-03-01'))).toEqual([
73+
['2020-03-01T00:00:00.000', '2021-02-28T23:59:59.999'],
74+
['2021-03-01T00:00:00.000', '2022-02-28T23:59:59.999'],
75+
['2022-03-01T00:00:00.000', '2023-02-28T23:59:59.999']
76+
]);
77+
});
78+
79+
it('time series - custom: interval - 1 year, origin - 2020-03-15', () => {
80+
expect(timeSeriesFromCustomInterval('1 year', ['2021-01-01', '2022-12-31'], moment('2020-03-15'))).toEqual([
81+
['2020-03-15T00:00:00.000', '2021-03-14T23:59:59.999'],
82+
['2021-03-15T00:00:00.000', '2022-03-14T23:59:59.999'],
83+
['2022-03-15T00:00:00.000', '2023-03-14T23:59:59.999']
84+
]);
85+
});
86+
87+
it('time series - custom: interval - 1 year, origin - 2019-03-15', () => {
88+
expect(timeSeriesFromCustomInterval('1 year', ['2021-01-01', '2022-12-31'], moment('2019-03-15'))).toEqual([
89+
['2020-03-15T00:00:00.000', '2021-03-14T23:59:59.999'],
90+
['2021-03-15T00:00:00.000', '2022-03-14T23:59:59.999'],
91+
['2022-03-15T00:00:00.000', '2023-03-14T23:59:59.999']
92+
]);
93+
});
94+
95+
it('time series - custom: interval - 2 months, origin - 2019-01-01', () => {
96+
expect(timeSeriesFromCustomInterval('2 months', ['2021-01-01', '2021-12-31'], moment('2019-01-01'))).toEqual([
97+
['2021-01-01T00:00:00.000', '2021-02-28T23:59:59.999'],
98+
['2021-03-01T00:00:00.000', '2021-04-30T23:59:59.999'],
99+
['2021-05-01T00:00:00.000', '2021-06-30T23:59:59.999'],
100+
['2021-07-01T00:00:00.000', '2021-08-31T23:59:59.999'],
101+
['2021-09-01T00:00:00.000', '2021-10-31T23:59:59.999'],
102+
['2021-11-01T00:00:00.000', '2021-12-31T23:59:59.999']
103+
]);
104+
});
105+
106+
it('time series - custom: interval - 2 months, origin - 2019-03-15', () => {
107+
expect(timeSeriesFromCustomInterval('2 months', ['2021-01-01', '2021-12-31'], moment('2019-03-15'))).toEqual([
108+
['2020-11-15T00:00:00.000', '2021-01-14T23:59:59.999'],
109+
['2021-01-15T00:00:00.000', '2021-03-14T23:59:59.999'],
110+
['2021-03-15T00:00:00.000', '2021-05-14T23:59:59.999'],
111+
['2021-05-15T00:00:00.000', '2021-07-14T23:59:59.999'],
112+
['2021-07-15T00:00:00.000', '2021-09-14T23:59:59.999'],
113+
['2021-09-15T00:00:00.000', '2021-11-14T23:59:59.999'],
114+
['2021-11-15T00:00:00.000', '2022-01-14T23:59:59.999']
115+
]);
116+
});
117+
118+
it('time series - custom: interval - 1 months 2 weeks 3 days, origin - 2021-01-25', () => {
119+
expect(timeSeriesFromCustomInterval('1 months 2 weeks 3 days', ['2021-01-01', '2021-12-31'], moment('2021-01-25'))).toEqual([
120+
['2020-12-08T00:00:00.000', '2021-01-24T23:59:59.999'],
121+
['2021-01-25T00:00:00.000', '2021-03-13T23:59:59.999'],
122+
['2021-03-14T00:00:00.000', '2021-04-30T23:59:59.999'],
123+
['2021-05-01T00:00:00.000', '2021-06-17T23:59:59.999'],
124+
['2021-06-18T00:00:00.000', '2021-08-03T23:59:59.999'],
125+
['2021-08-04T00:00:00.000', '2021-09-20T23:59:59.999'],
126+
['2021-09-21T00:00:00.000', '2021-11-06T23:59:59.999'],
127+
['2021-11-07T00:00:00.000', '2021-12-23T23:59:59.999'],
128+
['2021-12-24T00:00:00.000', '2022-02-09T23:59:59.999'],
129+
]);
130+
});
131+
132+
it('time series - custom: interval - 3 weeks, origin - 2020-12-15', () => {
133+
expect(timeSeriesFromCustomInterval('3 weeks', ['2021-01-01', '2021-03-01'], moment('2020-12-15'))).toEqual([
134+
['2020-12-15T00:00:00.000', '2021-01-04T23:59:59.999'],
135+
['2021-01-05T00:00:00.000', '2021-01-25T23:59:59.999'],
136+
['2021-01-26T00:00:00.000', '2021-02-15T23:59:59.999'],
137+
['2021-02-16T00:00:00.000', '2021-03-08T23:59:59.999']
138+
]);
139+
});
140+
141+
it('time series - custom: interval - 6 months, origin - 2021-01-01', () => {
142+
expect(timeSeriesFromCustomInterval('6 months', ['2021-01-01', '2021-12-31'], moment('2021-01-01'))).toEqual([
143+
['2021-01-01T00:00:00.000', '2021-06-30T23:59:59.999'],
144+
['2021-07-01T00:00:00.000', '2021-12-31T23:59:59.999']
145+
]);
146+
});
147+
148+
it('time series - custom: interval - 2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds, origin - 2021-01-01', () => {
149+
expect(timeSeriesFromCustomInterval('2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds', ['2021-01-01', '2021-12-31'], moment('2021-01-01'))).toEqual([
150+
['2021-01-01T00:00:00.000', '2021-03-26T05:06:06.999'],
151+
['2021-03-26T05:06:07.000', '2021-06-20T10:12:13.999'],
152+
['2021-06-20T10:12:14.000', '2021-09-14T15:18:20.999'],
153+
['2021-09-14T15:18:21.000', '2021-12-09T20:24:27.999'],
154+
['2021-12-09T20:24:28.000', '2022-03-07T01:30:34.999']
155+
]);
156+
});
157+
158+
it('time series - custom: interval - 10 minutes 15 seconds, origin - 2021-02-01 09:59:45', () => {
159+
expect(timeSeriesFromCustomInterval('10 minutes 15 seconds', ['2021-02-01 10:00:00', '2021-02-01 12:00:00'], moment('2021-02-01 09:59:45'))).toEqual([
160+
['2021-02-01T09:59:45.000', '2021-02-01T10:09:59.999'],
161+
['2021-02-01T10:10:00.000', '2021-02-01T10:20:14.999'],
162+
['2021-02-01T10:20:15.000', '2021-02-01T10:30:29.999'],
163+
['2021-02-01T10:30:30.000', '2021-02-01T10:40:44.999'],
164+
['2021-02-01T10:40:45.000', '2021-02-01T10:50:59.999'],
165+
['2021-02-01T10:51:00.000', '2021-02-01T11:01:14.999'],
166+
['2021-02-01T11:01:15.000', '2021-02-01T11:11:29.999'],
167+
['2021-02-01T11:11:30.000', '2021-02-01T11:21:44.999'],
168+
['2021-02-01T11:21:45.000', '2021-02-01T11:31:59.999'],
169+
['2021-02-01T11:32:00.000', '2021-02-01T11:42:14.999'],
170+
['2021-02-01T11:42:15.000', '2021-02-01T11:52:29.999'],
171+
['2021-02-01T11:52:30.000', '2021-02-01T12:02:44.999']
172+
]);
173+
});
174+
175+
it('time series - custom: interval - reach limits', () => {
176+
expect(() => {
177+
timeSeriesFromCustomInterval('10 minutes 15 seconds', ['1970-01-01', '2021-01-02'], moment('2021-02-01 09:59:45'));
178+
}).toThrowError(/The count of generated date ranges.*for the request.*is over limit/);
179+
});
180+
32181
it('inDbTimeZone', () => {
33182
expect(inDbTimeZone('UTC', 'YYYY-MM-DD[T]HH:mm:ss.SSSSSS[Z]', '2020-01-01T00:00:00.000000')).toEqual(
34183
'2020-01-01T00:00:00.000000Z'
@@ -38,4 +187,9 @@ describe('time', () => {
38187
'2020-01-31T23:59:59.999999Z'
39188
);
40189
});
190+
191+
it('isPredefinedGranularity', () => {
192+
expect(isPredefinedGranularity('day')).toBeTruthy();
193+
expect(isPredefinedGranularity('fiscal_year_by_1st_feb')).toBeFalsy();
194+
});
41195
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,9 @@ declare module '@cubejs-client/core' {
787787
| 'afterDate'
788788
| 'afterOrOnDate';
789789

790-
export type TimeDimensionGranularity = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
790+
export type TimeDimensionPredefinedGranularity = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
791+
792+
export type TimeDimensionGranularity = TimeDimensionPredefinedGranularity | string;
791793

792794
export type DateRange = string | [string, string];
793795

0 commit comments

Comments
 (0)