Skip to content

Commit 3e55f5c

Browse files
feat: refreshKey every support for CRON format interval (#1048)
* cron format interval * cron interval * cron string & and tests * fixed bug with start time offset calculation * docs for every and timezone * Allow only timezone offset instead of timezone * new docs and tests * fix tests * fix tests and docs * Update cube.md * Update cube.md * code style * code style
1 parent b586631 commit 3e55f5c

File tree

8 files changed

+245
-15
lines changed

8 files changed

+245
-15
lines changed

docs/Schema/cube.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,24 @@ cube(`OrderFacts`, {
255255
});
256256
```
257257

258-
Available interval granularities are: `second`, `minute`, `hour`, `day` and `week`.
258+
259+
`every` - can be set as an interval with granularities `second`, `minute`, `hour`, `day`, and `week` or accept CRON string with some limitations.
260+
If you set `every` as CRON string, you can use the `timezone` property.
261+
262+
For example:
263+
264+
```javascript
265+
cube(`OrderFacts`, {
266+
sql: `SELECT * FROM orders`,
267+
refreshKey: {
268+
every: '30 5 * * 5',
269+
timezone: 'America/Los_Angeles'
270+
}
271+
});
272+
```
273+
274+
`every` can accept only equal time intervals - so "Day of month" and "month" intervals in CRON expressions are not supported.
275+
259276
Such `refreshKey` is just a syntactic sugar over `refreshKey` SQL.
260277
It's guaranteed that `refreshKey` change it's value at least once during `every` interval.
261278
It will be converted to appropriate SQL select which value will change over time based on interval value.

packages/cubejs-api-gateway/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@hapi/joi": "^15.1.1",
2222
"body-parser": "^1.19.0",
2323
"chrono-node": "1.4.4",
24+
"cron-parser": "^2.16.3",
2425
"jsonwebtoken": "^8.3.0",
2526
"moment": "^2.24.0",
2627
"moment-timezone": "^0.5.27",

packages/cubejs-api-gateway/yarn.lock

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,14 @@ core-util-is@1.0.2, core-util-is@~1.0.0:
10181018
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
10191019
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
10201020

1021+
cron-parser@^2.16.3:
1022+
version "2.16.3"
1023+
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-2.16.3.tgz#acb8e405eed1733aac542fdf604cb7c1daf0204a"
1024+
integrity sha512-XNJBD1QLFeAMUkZtZQuncAAOgJFWNhBdIbwgD22hZxrcWOImBFMKgPC66GzaXpyoJs7UvYLLgPH/8BRk/7gbZg==
1025+
dependencies:
1026+
is-nan "^1.3.0"
1027+
moment-timezone "^0.5.31"
1028+
10211029
cross-spawn@^6.0.0, cross-spawn@^6.0.5:
10221030
version "6.0.5"
10231031
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@@ -2253,6 +2261,13 @@ is-map@^2.0.1:
22532261
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1"
22542262
integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==
22552263

2264+
is-nan@^1.3.0:
2265+
version "1.3.0"
2266+
resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.0.tgz#85d1f5482f7051c2019f5673ccebdb06f3b0db03"
2267+
integrity sha512-z7bbREymOqt2CCaZVly8aC4ML3Xhfi0ekuOnjO2L8vKdl+CttdVoGZQhd4adMFAsxQ5VeRVwORs4tU8RH+HFtQ==
2268+
dependencies:
2269+
define-properties "^1.1.3"
2270+
22562271
is-number@^3.0.0:
22572272
version "3.0.0"
22582273
resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
@@ -3206,6 +3221,13 @@ moment-timezone@^0.5.27:
32063221
dependencies:
32073222
moment ">= 2.9.0"
32083223

3224+
moment-timezone@^0.5.31:
3225+
version "0.5.31"
3226+
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.31.tgz#9c40d8c5026f0c7ab46eda3d63e49c155148de05"
3227+
integrity sha512-+GgHNg8xRhMXfEbv81iDtrVeTcWt0kWmTEY1XQK14dICTXnWJnT0dxdlPspwqF3keKMVPXwayEsk1DI0AA/jdA==
3228+
dependencies:
3229+
moment ">= 2.9.0"
3230+
32093231
"moment@>= 2.9.0", moment@^2.24.0:
32103232
version "2.24.0"
32113233
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"

packages/cubejs-schema-compiler/adapter/BaseQuery.js

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/* eslint-disable no-unused-vars,prefer-template */
22
const R = require('ramda');
3+
const cronParser = require('cron-parser');
4+
35
const moment = require('moment-timezone');
46
const inflection = require('inflection');
57

@@ -1653,7 +1655,7 @@ class BaseQuery {
16531655
return this.evaluateSql(cube, cubeFromPath.refreshKey.sql);
16541656
}
16551657
if (cubeFromPath.refreshKey.every) {
1656-
return `SELECT ${this.everyRefreshKeySql(cubeFromPath.refreshKey.every)}`;
1658+
return `SELECT ${this.everyRefreshKeySql(cubeFromPath.refreshKey)}`;
16571659
}
16581660
}
16591661
refreshKeyAllSetManually = false;
@@ -1687,7 +1689,7 @@ class BaseQuery {
16871689
refreshKeyRenewalThresholds: cubes.map(c => {
16881690
const cubeFromPath = this.cubeEvaluator.cubeFromPath(c);
16891691
if (cubeFromPath.refreshKey && cubeFromPath.refreshKey.every) {
1690-
return this.refreshKeyRenewalThresholdForInterval(cubeFromPath.refreshKey.every);
1692+
return this.refreshKeyRenewalThresholdForInterval(cubeFromPath.refreshKey);
16911693
}
16921694
return this.defaultRefreshKeyRenewalThreshold();
16931695
})
@@ -1817,9 +1819,59 @@ class BaseQuery {
18171819
}
18181820
}
18191821

1820-
everyRefreshKeySql(interval) {
1821-
// cron-parser
1822-
return this.floorSql(`${this.unixTimestampSql()} / ${this.parseSecondDuration(interval)}`);
1822+
calcIntervalForCronString(refreshKey) {
1823+
const every = refreshKey.every || '1 hour';
1824+
// One of the years that start from monday (first day of week)
1825+
// Mon, 01 Jan 2018 00:00:00 GMT
1826+
const startDate = 1514764800000;
1827+
const opt = {
1828+
currentDate: new Date(startDate)
1829+
};
1830+
let utcOffset = 0;
1831+
if (refreshKey.timezone) {
1832+
utcOffset = moment.tz(refreshKey.timezone).utcOffset() * 60;
1833+
}
1834+
1835+
let start;
1836+
let end;
1837+
let dayOffset;
1838+
let dayOffsetPrev;
1839+
try {
1840+
const interval = cronParser.parseExpression(every, opt);
1841+
dayOffset = interval.next().getTime();
1842+
dayOffsetPrev = interval.prev().getTime();
1843+
if (dayOffsetPrev === startDate) {
1844+
dayOffset = startDate;
1845+
}
1846+
1847+
start = interval.next().getTime();
1848+
end = interval.next().getTime();
1849+
} catch (err) {
1850+
throw new UserError(`Invalid cron string '${every}' in refreshKey (${err})`);
1851+
}
1852+
const delta = (end - start) / 1000;
1853+
1854+
if (
1855+
!/^(\*|\d+)? ?(\*|\d+) (\*|\d+) \* \* (\*|\d+)$/g.test(every.replace(/ +/g, ' ').replace(/^ | $/g, ''))
1856+
) {
1857+
throw new UserError(`Your cron string ('${every}') is correct, but we support only equal time intervals.`);
1858+
}
1859+
return {
1860+
utcOffset,
1861+
interval: delta,
1862+
dayOffset: (dayOffset - startDate) / 1000
1863+
};
1864+
}
1865+
1866+
everyRefreshKeySql(refreshKey) {
1867+
const every = refreshKey.every || '1 hour';
1868+
1869+
if (/^(\d+) (second|minute|hour|day|week)s?$/.test(every)) {
1870+
return this.floorSql(`(${this.unixTimestampSql()}) / ${this.parseSecondDuration(every)}`);
1871+
}
1872+
1873+
const { dayOffset, utcOffset, interval } = this.calcIntervalForCronString(refreshKey);
1874+
return this.floorSql(`(${utcOffset} + ${dayOffset} + ${this.unixTimestampSql()}) / ${interval}`);
18231875
}
18241876

18251877
granularityFor(momentDate) {
@@ -1877,7 +1929,6 @@ class BaseQuery {
18771929
}
18781930

18791931
parseSecondDuration(interval) {
1880-
// cron-parser
18811932
const intervalMatch = interval.match(/^(\d+) (second|minute|hour|day|week)s?$/);
18821933
if (!intervalMatch) {
18831934
throw new UserError(`Invalid interval: ${interval}`);
@@ -1922,8 +1973,8 @@ class BaseQuery {
19221973
refreshKeyRenewalThresholds: [this.defaultRefreshKeyRenewalThreshold()]
19231974
};
19241975
}
1925-
const interval = preAggregation.refreshKey.every || '1 hour';
1926-
let refreshKey = this.everyRefreshKeySql(interval);
1976+
1977+
let refreshKey = this.everyRefreshKeySql(preAggregation.refreshKey);
19271978
if (preAggregation.refreshKey.incremental) {
19281979
if (!preAggregation.partitionGranularity) {
19291980
throw new UserError('Incremental refresh key can only be used for partitioned pre-aggregations');
@@ -1944,7 +1995,7 @@ class BaseQuery {
19441995
if (preAggregation.refreshKey.every || preAggregation.refreshKey.incremental) {
19451996
return {
19461997
queries: [this.paramAllocator.buildSqlAndParams(`SELECT ${refreshKey}`)],
1947-
refreshKeyRenewalThresholds: [this.refreshKeyRenewalThresholdForInterval(interval)]
1998+
refreshKeyRenewalThresholds: [this.refreshKeyRenewalThresholdForInterval(preAggregation.refreshKey)]
19481999
};
19492000
}
19502001
}
@@ -1988,9 +2039,15 @@ class BaseQuery {
19882039
);
19892040
}
19902041

1991-
refreshKeyRenewalThresholdForInterval(interval) {
1992-
// cron-parser
1993-
return Math.max(Math.min(Math.round(this.parseSecondDuration(interval) / 10), 300), 1);
2042+
refreshKeyRenewalThresholdForInterval(refreshKey) {
2043+
const { every } = refreshKey;
2044+
2045+
if (/^(\d+) (second|minute|hour|day|week)s?$/.test(every)) {
2046+
return Math.max(Math.min(Math.round(this.parseSecondDuration(every) / 10), 300), 1);
2047+
}
2048+
2049+
const { interval } = this.calcIntervalForCronString(refreshKey);
2050+
return Math.max(Math.min(Math.round(interval / 10), 300), 1);
19942051
}
19952052

19962053
preAggregationStartEndQueries(cube, preAggregation) {

packages/cubejs-schema-compiler/compiler/CubeValidator.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const timeInterval =
88
Joi.any().valid('unbounded')
99
]);
1010
const everyInterval = Joi.string().regex(/^(\d+) (second|minute|hour|day|week)s?$/, 'refresh time interval');
11+
const everyCronInterval = Joi.string();
12+
const everyCronTimeZone = Joi.string();
1113

1214
const BaseDimensionWithoutSubQuery = {
1315
aliases: Joi.array().items(Joi.string()),
@@ -69,7 +71,8 @@ const BasePreAggregationWithoutPartitionGranularity = {
6971
sql: Joi.func().required()
7072
}),
7173
Joi.object().keys({
72-
every: everyInterval,
74+
every: Joi.alternatives().try(everyInterval, everyCronInterval),
75+
timezone: everyCronTimeZone,
7376
incremental: Joi.boolean(),
7477
updateWindow: timeInterval
7578
})
@@ -109,7 +112,8 @@ const cubeSchema = Joi.object().keys({
109112
immutable: Joi.boolean().required()
110113
}),
111114
Joi.object().keys({
112-
every: everyInterval
115+
every: Joi.alternatives().try(everyInterval, everyCronInterval),
116+
timezone: everyCronTimeZone,
113117
})
114118
),
115119
fileName: Joi.string().required(),

packages/cubejs-schema-compiler/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@babel/types": "^7.4.0",
2626
"@hapi/joi": "^15.1.1",
2727
"antlr4": "^4.8.0",
28+
"cron-parser": "^2.16.3",
2829
"humps": "^2.0.1",
2930
"inflection": "^1.12.0",
3031
"lru-cache": "^5.1.1",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* globals it, describe, after */
2+
/* eslint-disable quote-props */
3+
const moment = require('moment-timezone');
4+
const UserError = require('../compiler/UserError');
5+
const PostgresQuery = require('../adapter/PostgresQuery');
6+
const PrepareCompiler = require('./PrepareCompiler');
7+
require('should');
8+
9+
const { prepareCompiler } = PrepareCompiler;
10+
const dbRunner = require('./DbRunner');
11+
12+
describe('SQL Generation', function test() {
13+
this.timeout(90000);
14+
15+
after(async () => {
16+
await dbRunner.tearDown();
17+
});
18+
19+
const { compiler, joinGraph, cubeEvaluator, transformer } = prepareCompiler(`
20+
cube('cards', {
21+
sql: \`
22+
select * from cards
23+
\`,
24+
25+
measures: {
26+
count: {
27+
type: 'count'
28+
}
29+
},
30+
31+
dimensions: {
32+
id: {
33+
type: 'number',
34+
sql: 'id',
35+
primaryKey: true
36+
}
37+
}
38+
})
39+
`);
40+
41+
42+
it('Test for everyRefreshKeySql', () => {
43+
const result = compiler.compile().then(() => {
44+
const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, {
45+
measures: [
46+
'cards.count'
47+
],
48+
timeDimensions: [],
49+
filters: [],
50+
timezone: 'America/Los_Angeles'
51+
});
52+
53+
const utcOffset = moment.tz('America/Los_Angeles').utcOffset() * 60;
54+
let r;
55+
r = query.everyRefreshKeySql({
56+
every: '1 hour'
57+
});
58+
r.should.be.equal('FLOOR((EXTRACT(EPOCH FROM NOW())) / 3600)');
59+
60+
r = query.everyRefreshKeySql({
61+
every: '0 * * * * *',
62+
timezone: 'America/Los_Angeles'
63+
});
64+
r.should.be.equal(`FLOOR((${utcOffset} + 0 + EXTRACT(EPOCH FROM NOW())) / 60)`);
65+
66+
r = query.everyRefreshKeySql({
67+
every: '0 * * * *',
68+
timezone: 'America/Los_Angeles'
69+
});
70+
r.should.be.equal(`FLOOR((${utcOffset} + 0 + EXTRACT(EPOCH FROM NOW())) / 3600)`);
71+
72+
r = query.everyRefreshKeySql({
73+
every: '30 * * * *',
74+
timezone: 'America/Los_Angeles'
75+
});
76+
r.should.be.equal(`FLOOR((${utcOffset} + 1800 + EXTRACT(EPOCH FROM NOW())) / 3600)`);
77+
78+
r = query.everyRefreshKeySql({
79+
every: '30 5 * * 5',
80+
timezone: 'America/Los_Angeles'
81+
});
82+
r.should.be.equal(`FLOOR((${utcOffset} + 365400 + EXTRACT(EPOCH FROM NOW())) / 604800)`);
83+
84+
for (let i = 1; i < 59; i++) {
85+
r = query.everyRefreshKeySql({
86+
every: `${i} * * * *`,
87+
timezone: 'America/Los_Angeles'
88+
});
89+
r.should.be.equal(`FLOOR((${utcOffset} + ${i * 60} + EXTRACT(EPOCH FROM NOW())) / ${1 * 60 * 60})`);
90+
}
91+
92+
try {
93+
r = query.everyRefreshKeySql({
94+
every: '*/9 */7 * * *',
95+
timezone: 'America/Los_Angeles'
96+
});
97+
98+
throw new Error();
99+
} catch (error) {
100+
error.should.be.instanceof(UserError);
101+
}
102+
});
103+
104+
return result;
105+
});
106+
});

packages/cubejs-schema-compiler/yarn.lock

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,14 @@ core-util-is@1.0.2, core-util-is@~1.0.0:
15821582
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
15831583
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
15841584

1585+
cron-parser@^2.16.3:
1586+
version "2.16.3"
1587+
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-2.16.3.tgz#acb8e405eed1733aac542fdf604cb7c1daf0204a"
1588+
integrity sha512-XNJBD1QLFeAMUkZtZQuncAAOgJFWNhBdIbwgD22hZxrcWOImBFMKgPC66GzaXpyoJs7UvYLLgPH/8BRk/7gbZg==
1589+
dependencies:
1590+
is-nan "^1.3.0"
1591+
moment-timezone "^0.5.31"
1592+
15851593
cross-spawn@^6.0.5:
15861594
version "6.0.5"
15871595
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@@ -2635,6 +2643,13 @@ is-map@^2.0.1:
26352643
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1"
26362644
integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==
26372645

2646+
is-nan@^1.3.0:
2647+
version "1.3.0"
2648+
resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.0.tgz#85d1f5482f7051c2019f5673ccebdb06f3b0db03"
2649+
integrity sha512-z7bbREymOqt2CCaZVly8aC4ML3Xhfi0ekuOnjO2L8vKdl+CttdVoGZQhd4adMFAsxQ5VeRVwORs4tU8RH+HFtQ==
2650+
dependencies:
2651+
define-properties "^1.1.3"
2652+
26382653
is-number@^7.0.0:
26392654
version "7.0.0"
26402655
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
@@ -3103,6 +3118,13 @@ moment-timezone@^0.5.28:
31033118
dependencies:
31043119
moment ">= 2.9.0"
31053120

3121+
moment-timezone@^0.5.31:
3122+
version "0.5.31"
3123+
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.31.tgz#9c40d8c5026f0c7ab46eda3d63e49c155148de05"
3124+
integrity sha512-+GgHNg8xRhMXfEbv81iDtrVeTcWt0kWmTEY1XQK14dICTXnWJnT0dxdlPspwqF3keKMVPXwayEsk1DI0AA/jdA==
3125+
dependencies:
3126+
moment ">= 2.9.0"
3127+
31063128
"moment@>= 2.9.0":
31073129
version "2.23.0"
31083130
resolved "https://registry.yarnpkg.com/moment/-/moment-2.23.0.tgz#759ea491ac97d54bac5ad776996e2a58cc1bc225"

0 commit comments

Comments
 (0)