@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 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
- orderBy = 'Timestamp ASC',
352
- limit = 1000
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
- const path = topicToPath(topicSelector);
369
+ let {
370
+ // how many seconds to group together (alternative to bins + time interval)
371
+ aggSeconds
372
+ } = options;
356
373
 
357
- // interpret wildcards
358
- // const where = [];
359
- // _.forEach(path, (value, i) => {
360
- // if (!['+','#'].includes(value[0])) {
361
- // // it's a constant, filter by it
362
- // where.push(`TopicParts[${i + 1}] = '${value}'`);
363
- // // Note that ClickHouse/SQL index starting at 1, not 0
364
- // }
365
- // });
366
- const where = path2where(path);
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 whereStatement = where.length > 0
372
- ? `WHERE ${where.join(' AND ')}`
373
- : '';
399
+ const extractValue = path && type
400
+ ? `JSONExtract(Payload, ${path.map(s => `'${s}'`).join(', ')}, '${type}')`
401
+ : 'Payload';
374
402
 
375
- const result = await this.client.query({
376
- query: `SELECT Payload,TopicParts,Timestamp FROM default.${this.mqttHistoryTable} ${
377
- whereStatement} ORDER BY ${orderBy} ${limit ? ` LIMIT ${limit}` : ''}`,
378
- format: 'JSONEachRow'
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.slice(5);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitive-sdk/clickhouse",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "A tiny ClickHouse utility class for use in the Transitive framework.",
5
5
  "homepage": "https://transitiverobotics.com",
6
6
  "repository": {
@@ -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', `cap${i % 100}`, `1.${i % 100}.0`, 'data', i],
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 < rows[1].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/+/+/+/data`,
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/+/data`,
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: `/+/+/+/+/+/data`,
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: `/+/+/+/+/+/data`,
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/+/+/+/+/data`,
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
  });