@transitive-sdk/clickhouse 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +78 -29
- package/package.json +1 -1
- package/test/clickhouse.test.js +75 -8
package/index.js
CHANGED
|
@@ -26,14 +26,17 @@ const MULTI_TENANT_SCHEMA = {
|
|
|
26
26
|
* WHERE clause. */
|
|
27
27
|
const path2where = (path) => {
|
|
28
28
|
const where = [];
|
|
29
|
+
const wildIndices = [];
|
|
29
30
|
_.forEach(path, (value, i) => {
|
|
30
31
|
if (!['+','#'].includes(value[0])) {
|
|
31
32
|
// it's a constant, filter by it
|
|
32
33
|
where.push(`TopicParts[${i + 1}] = '${value}'`);
|
|
33
34
|
// Note that ClickHouse/SQL index starting at 1, not 0
|
|
35
|
+
} else {
|
|
36
|
+
wildIndices.push(i);
|
|
34
37
|
}
|
|
35
38
|
});
|
|
36
|
-
return where;
|
|
39
|
+
return {where, wildIndices};
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
|
|
@@ -297,7 +300,7 @@ class ClickHouse {
|
|
|
297
300
|
// Set/update TTL for this capability and sub-topic
|
|
298
301
|
|
|
299
302
|
// Derive WHERE conditions for TTL expression from non-wildcards
|
|
300
|
-
const where = path2where(path);
|
|
303
|
+
const { where } = path2where(path);
|
|
301
304
|
|
|
302
305
|
if (where.length == 0) {
|
|
303
306
|
// underspecified, don't set TTL
|
|
@@ -348,35 +351,81 @@ class ClickHouse {
|
|
|
348
351
|
topicSelector,
|
|
349
352
|
since = undefined,
|
|
350
353
|
until = undefined,
|
|
351
|
-
|
|
352
|
-
|
|
354
|
+
// if provided, extract this sub-value of the payload-json, requires type
|
|
355
|
+
path = undefined,
|
|
356
|
+
// type of element to extract using `path`: for available types, see https://clickhouse.com/docs/sql-reference/data-types
|
|
357
|
+
type = 'String',
|
|
358
|
+
orderBy = 'time DESC',
|
|
359
|
+
limit = 1000, // end result limit (i.e., after grouping)
|
|
360
|
+
|
|
361
|
+
bins = undefined, // into how many bins to aggregate (if given, requires since)
|
|
362
|
+
// Aggregation function to use (if aggSeconds or bins is given)
|
|
363
|
+
// if `bins` or `aggregateSeconds` is given, which operator to use to compute
|
|
364
|
+
// aggregate value. Default is `count` (which works for any data type).
|
|
365
|
+
// See https://clickhouse.com/docs/sql-reference/aggregate-functions/reference.
|
|
366
|
+
agg = 'count',
|
|
353
367
|
} = options;
|
|
354
368
|
|
|
355
|
-
|
|
369
|
+
let {
|
|
370
|
+
// how many seconds to group together (alternative to bins + time interval)
|
|
371
|
+
aggSeconds
|
|
372
|
+
} = options;
|
|
356
373
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
374
|
+
/* some useful queries we'd like to support:
|
|
375
|
+
|
|
376
|
+
# get avg `i` value for each minute of the last hour (limit: 60)
|
|
377
|
+
```sql
|
|
378
|
+
select toStartOfInterval(Timestamp, INTERVAL 60 SECOND) as time,
|
|
379
|
+
avg(JSONExtractInt(Payload,'i')) as agg
|
|
380
|
+
from mqtt_history_tests
|
|
381
|
+
GROUP BY (time)
|
|
382
|
+
ORDER BY time
|
|
383
|
+
LIMIT 60
|
|
384
|
+
```
|
|
385
|
+
->
|
|
386
|
+
```js
|
|
387
|
+
{ aggregateSeconds: 60, path: ['i'], type: 'Int', agg: 'avg', limit: 60 }
|
|
388
|
+
```
|
|
389
|
+
*/
|
|
390
|
+
|
|
391
|
+
const pathSelector = topicToPath(topicSelector);
|
|
367
392
|
|
|
393
|
+
// interpret wildcards
|
|
394
|
+
const { where, wildIndices } = path2where(pathSelector);
|
|
368
395
|
since && where.push(`Timestamp >= fromUnixTimestamp64Milli(${since.getTime()})`);
|
|
369
396
|
until && where.push(`Timestamp <= fromUnixTimestamp64Milli(${until.getTime()})`);
|
|
397
|
+
const whereStatement = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
370
398
|
|
|
371
|
-
const
|
|
372
|
-
? `
|
|
373
|
-
: '';
|
|
399
|
+
const extractValue = path && type
|
|
400
|
+
? `JSONExtract(Payload, ${path.map(s => `'${s}'`).join(', ')}, '${type}')`
|
|
401
|
+
: 'Payload';
|
|
374
402
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
403
|
+
let select = [`${extractValue} as value`, 'Payload', 'TopicParts',
|
|
404
|
+
'Timestamp', 'Timestamp as time'];
|
|
405
|
+
|
|
406
|
+
let group = '';
|
|
407
|
+
if (bins > 1 && since) {
|
|
408
|
+
// compute aggSeconds from desired number of bins and `since`
|
|
409
|
+
const duration = (until || Date.now()) - since.getTime();
|
|
410
|
+
aggSeconds = Math.floor((duration/1000)/(bins - 1));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// if aggregation is requested, build the GROUP BY expression and update SELECT
|
|
414
|
+
if (aggSeconds) {
|
|
415
|
+
// SQL sub-string to extract the desired value from the JSON payload
|
|
416
|
+
const wildParts = wildIndices.map(i => `TopicParts[${i + 1}]`);
|
|
417
|
+
// update SELECT statement with aggregations
|
|
418
|
+
select = [`${agg}(${extractValue}) as aggValue`,
|
|
419
|
+
...wildParts,
|
|
420
|
+
`toStartOfInterval(Timestamp, INTERVAL ${aggSeconds} SECOND) as time`
|
|
421
|
+
];
|
|
422
|
+
group = `GROUP BY (time,${wildParts.join(',')})`
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const query = `SELECT ${select.join(',')} FROM default.${this.mqttHistoryTable} ${
|
|
426
|
+
whereStatement} ${group} ORDER BY ${orderBy} ${limit ? ` LIMIT ${limit}` : ''}`;
|
|
427
|
+
// console.log(query);
|
|
428
|
+
const result = await this.client.query({ query, format: 'JSONEachRow' });
|
|
380
429
|
|
|
381
430
|
const rows = await result.json();
|
|
382
431
|
|
|
@@ -385,12 +434,12 @@ class ClickHouse {
|
|
|
385
434
|
return rows.map(row => {
|
|
386
435
|
row.Payload = row.Payload ? JSON.parse(row.Payload) : null;
|
|
387
436
|
row.Timestamp = new Date(row.Timestamp);
|
|
388
|
-
row.OrgId = row.TopicParts[0];
|
|
389
|
-
row.DeviceId = row.TopicParts[1];
|
|
390
|
-
row.Scope = row.TopicParts[2];
|
|
391
|
-
row.CapabilityName = row.TopicParts[3];
|
|
392
|
-
row.CapabilityVersion = row.TopicParts[4];
|
|
393
|
-
row.SubTopic = row.TopicParts
|
|
437
|
+
row.OrgId = row.TopicParts?.[0];
|
|
438
|
+
row.DeviceId = row.TopicParts?.[1];
|
|
439
|
+
row.Scope = row.TopicParts?.[2];
|
|
440
|
+
row.CapabilityName = row.TopicParts?.[3];
|
|
441
|
+
row.CapabilityVersion = row.TopicParts?.[4];
|
|
442
|
+
row.SubTopic = row.TopicParts?.slice(5);
|
|
394
443
|
return row;
|
|
395
444
|
});
|
|
396
445
|
}
|
package/package.json
CHANGED
package/test/clickhouse.test.js
CHANGED
|
@@ -33,6 +33,7 @@ const interceptInserts = () => {
|
|
|
33
33
|
const queryRowsByOrg = async (org, options = {}) =>
|
|
34
34
|
await clickhouse.queryMQTTHistory({
|
|
35
35
|
topicSelector: `/${org}/+/+/+/+/+`,
|
|
36
|
+
orderBy: 'Timestamp ASC',
|
|
36
37
|
...options
|
|
37
38
|
});
|
|
38
39
|
|
|
@@ -245,12 +246,14 @@ describe('ClickHouse', function() {
|
|
|
245
246
|
|
|
246
247
|
it('queries with wild cards', async () => {
|
|
247
248
|
const rows = await clickhouse.queryMQTTHistory({
|
|
249
|
+
orderBy: 'Timestamp ASC',
|
|
248
250
|
topicSelector: `/${org}/+/+/+/+/+` });
|
|
249
251
|
assert(rows.length > 0);
|
|
250
252
|
});
|
|
251
253
|
|
|
252
254
|
it('queries with multiple selectors', async () => {
|
|
253
255
|
const [row] = await clickhouse.queryMQTTHistory({
|
|
256
|
+
orderBy: 'Timestamp ASC',
|
|
254
257
|
topicSelector: `/${org}/+/+/capdata/+/+` });
|
|
255
258
|
assert.strictEqual(row.DeviceId, 'device1');
|
|
256
259
|
assert.deepEqual(row.SubTopic, ['data']);
|
|
@@ -260,6 +263,7 @@ describe('ClickHouse', function() {
|
|
|
260
263
|
|
|
261
264
|
it('queries based on sub-topic selectors', async () => {
|
|
262
265
|
const [row] = await clickhouse.queryMQTTHistory({
|
|
266
|
+
orderBy: 'Timestamp ASC',
|
|
263
267
|
topicSelector: `/${org}/+/+/+/+/data2` });
|
|
264
268
|
assert.strictEqual(row.DeviceId, 'device1');
|
|
265
269
|
assert.deepStrictEqual(row.Payload, { y: 2 });
|
|
@@ -267,12 +271,14 @@ describe('ClickHouse', function() {
|
|
|
267
271
|
|
|
268
272
|
it('queries based on sub-topic selectors with wildcards', async () => {
|
|
269
273
|
const [row] = await clickhouse.queryMQTTHistory({
|
|
274
|
+
orderBy: 'Timestamp ASC',
|
|
270
275
|
topicSelector: `/${org}/+/+/+/+/+/sub2/+` });
|
|
271
276
|
assert.deepStrictEqual(row.SubTopic[2], 'sub3.1');
|
|
272
277
|
});
|
|
273
278
|
|
|
274
279
|
it('queries based on multiple sub-topic selectors with wildcards', async () => {
|
|
275
280
|
const rows = await clickhouse.queryMQTTHistory({
|
|
281
|
+
orderBy: 'Timestamp ASC',
|
|
276
282
|
topicSelector: `/${org}/+/+/+/+/sub1/+/+` });
|
|
277
283
|
assert.strictEqual(rows[0].SubTopic.length, 3);
|
|
278
284
|
assert.strictEqual(rows[0].SubTopic[2], 'sub3.1');
|
|
@@ -281,6 +287,7 @@ describe('ClickHouse', function() {
|
|
|
281
287
|
|
|
282
288
|
it('returns the history', async () => {
|
|
283
289
|
const rows = await clickhouse.queryMQTTHistory({
|
|
290
|
+
orderBy: 'Timestamp ASC',
|
|
284
291
|
topicSelector: `/${org}/+/+/+/+/data/+/+` });
|
|
285
292
|
assert.deepStrictEqual(rows.length, 2);
|
|
286
293
|
assert.deepStrictEqual(rows[0].Payload, {x: 1});
|
|
@@ -290,9 +297,20 @@ describe('ClickHouse', function() {
|
|
|
290
297
|
|
|
291
298
|
it('handles null values', async () => {
|
|
292
299
|
const rows = await clickhouse.queryMQTTHistory({
|
|
300
|
+
orderBy: 'Timestamp ASC',
|
|
293
301
|
topicSelector: `/${org}/+/+/+/+/willBeNull` });
|
|
294
302
|
assert.strictEqual(rows.at(-1).Payload, null);
|
|
295
303
|
});
|
|
304
|
+
|
|
305
|
+
it('extracts sub-values', async () => {
|
|
306
|
+
const rows = await clickhouse.queryMQTTHistory({
|
|
307
|
+
orderBy: 'Timestamp ASC',
|
|
308
|
+
topicSelector: `/${org}/device1/@myscope/cap/+/sub1/sub2/sub3.2`,
|
|
309
|
+
path: ['data', 'aNumber'],
|
|
310
|
+
type: 'int'
|
|
311
|
+
});
|
|
312
|
+
assert.strictEqual(rows[0].value, 1234);
|
|
313
|
+
});
|
|
296
314
|
});
|
|
297
315
|
|
|
298
316
|
/** Test performance of the table (index). */
|
|
@@ -314,7 +332,8 @@ describe('ClickHouse', function() {
|
|
|
314
332
|
for (let i = 0; i < ROWS; i++) {
|
|
315
333
|
rows.push({
|
|
316
334
|
Timestamp: new Date(now + i * GAP), // use current date to avoid immediate TTL cleanup
|
|
317
|
-
TopicParts: [`org${i % 50}`, `device${i % 1000}`, '@myscope',
|
|
335
|
+
TopicParts: [`org${i % 50}`, `device${i % 1000}`, '@myscope',
|
|
336
|
+
`cap${i % 100}`, `1.${i % 100}.0`, `data_${i % 1000}`],
|
|
318
337
|
Payload: { i },
|
|
319
338
|
})
|
|
320
339
|
}
|
|
@@ -345,7 +364,7 @@ describe('ClickHouse', function() {
|
|
|
345
364
|
limit: 2 * ROWS,
|
|
346
365
|
});
|
|
347
366
|
assert.equal(rows.length, ROWS);
|
|
348
|
-
assert(rows[0].Timestamp
|
|
367
|
+
assert(rows[0].Timestamp > rows[1].Timestamp);
|
|
349
368
|
assertTimelimit(ROWS / 100);
|
|
350
369
|
});
|
|
351
370
|
|
|
@@ -360,7 +379,7 @@ describe('ClickHouse', function() {
|
|
|
360
379
|
|
|
361
380
|
it('quickly filters by DeviceId', async () => {
|
|
362
381
|
const rows = await clickhouse.queryMQTTHistory({
|
|
363
|
-
topicSelector: `/+/device123
|
|
382
|
+
topicSelector: `/+/device123/+/+/+/+`,
|
|
364
383
|
limit: 2 * ROWS,
|
|
365
384
|
});
|
|
366
385
|
assert.equal(rows.length, ROWS / 1000);
|
|
@@ -369,16 +388,25 @@ describe('ClickHouse', function() {
|
|
|
369
388
|
|
|
370
389
|
it('quickly filters by CapabilityName', async () => {
|
|
371
390
|
const rows = await clickhouse.queryMQTTHistory({
|
|
372
|
-
topicSelector: `/+/+/+/cap34
|
|
391
|
+
topicSelector: `/+/+/+/cap34/+/+`,
|
|
373
392
|
limit: 2 * ROWS,
|
|
374
393
|
});
|
|
375
394
|
assert.equal(rows.length, ROWS / 100);
|
|
376
395
|
assertTimelimit(ROWS / 1000);
|
|
377
396
|
});
|
|
378
397
|
|
|
398
|
+
it('quickly filters by SubTopic', async () => {
|
|
399
|
+
const rows = await clickhouse.queryMQTTHistory({
|
|
400
|
+
topicSelector: `/+/+/+/+/+/data_123`,
|
|
401
|
+
limit: 2 * ROWS,
|
|
402
|
+
});
|
|
403
|
+
assert.equal(rows.length, ROWS / 1000);
|
|
404
|
+
assertTimelimit(ROWS / 1000);
|
|
405
|
+
});
|
|
406
|
+
|
|
379
407
|
it('quickly filters by time: since', async () => {
|
|
380
408
|
const rows = await clickhouse.queryMQTTHistory({
|
|
381
|
-
topicSelector:
|
|
409
|
+
topicSelector: `/+/+/+/+/+/+`,
|
|
382
410
|
since: new Date(now + (ROWS - 400) * GAP),
|
|
383
411
|
limit: 2 * ROWS,
|
|
384
412
|
});
|
|
@@ -388,7 +416,7 @@ describe('ClickHouse', function() {
|
|
|
388
416
|
|
|
389
417
|
it('quickly filters by time: until', async () => {
|
|
390
418
|
const rows = await clickhouse.queryMQTTHistory({
|
|
391
|
-
topicSelector:
|
|
419
|
+
topicSelector: `/+/+/+/+/+/+`,
|
|
392
420
|
until: new Date(now + 400 * GAP),
|
|
393
421
|
limit: 2 * ROWS,
|
|
394
422
|
});
|
|
@@ -398,13 +426,52 @@ describe('ClickHouse', function() {
|
|
|
398
426
|
|
|
399
427
|
it('quickly filters by org and time: since', async () => {
|
|
400
428
|
const rows = await clickhouse.queryMQTTHistory({
|
|
401
|
-
topicSelector: `/org23
|
|
429
|
+
topicSelector: `/org23/+/+/+/+/+`,
|
|
402
430
|
since: new Date(now + (ROWS - 400) * GAP),
|
|
403
431
|
limit: 2 * ROWS,
|
|
404
432
|
});
|
|
405
433
|
assert.equal(rows.length, 8);
|
|
406
434
|
assertTimelimit(ROWS / 10000);
|
|
407
435
|
});
|
|
408
|
-
});
|
|
409
436
|
|
|
437
|
+
it('quickly filters and aggregates by time', async () => {
|
|
438
|
+
const rows = await clickhouse.queryMQTTHistory({
|
|
439
|
+
topicSelector: `/org0/device0/@myscope/cap0/1.0.0/data_0`,
|
|
440
|
+
since: new Date(now),
|
|
441
|
+
until: new Date(now + ROWS * GAP),
|
|
442
|
+
bins: 60,
|
|
443
|
+
limit: 2 * ROWS,
|
|
444
|
+
});
|
|
445
|
+
// there can be one-off errors due to rounding down to start of interval:
|
|
446
|
+
assert(Math.abs(rows.length - 60) < 2);
|
|
447
|
+
assertTimelimit(ROWS / 10000);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('quickly filters, aggregates by time, extracts value, and averages', async () => {
|
|
451
|
+
const aggSeconds = 1000;
|
|
452
|
+
const rows = await clickhouse.queryMQTTHistory({
|
|
453
|
+
topicSelector: `/org0/device0/@myscope/cap0/1.0.0/data_0`,
|
|
454
|
+
aggSeconds,
|
|
455
|
+
path: ['i'],
|
|
456
|
+
type: 'int',
|
|
457
|
+
agg: 'avg',
|
|
458
|
+
limit: 2 * ROWS,
|
|
459
|
+
});
|
|
460
|
+
assert.equal(rows.length, ROWS / aggSeconds);
|
|
461
|
+
assertTimelimit(ROWS / 1000);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('quickly filters, aggregates by time, extracts value, and averages, per device and sub-value', async () => {
|
|
465
|
+
const rows = await clickhouse.queryMQTTHistory({
|
|
466
|
+
topicSelector: `/org0/+/@myscope/cap0/1.0.0/+`,
|
|
467
|
+
aggSeconds: 1000,
|
|
468
|
+
path: ['i'],
|
|
469
|
+
type: 'int',
|
|
470
|
+
agg: 'avg',
|
|
471
|
+
limit: 2 * ROWS,
|
|
472
|
+
});
|
|
473
|
+
assert.equal(rows.length, 10000);
|
|
474
|
+
assertTimelimit(ROWS / 1000);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
410
477
|
});
|