@graffy/clickhouse 0.17.9-alpha.1 → 0.18.1-alpha.1

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/Readme.md CHANGED
@@ -1,22 +1,36 @@
1
1
  # ClickHouse Provider
2
2
 
3
- Read-only Graffy provider for ClickHouse.
3
+ Graffy provider for ClickHouse.
4
4
 
5
- Current scope is intentionally minimal and focused on read patterns used by
6
- tracker-like workloads:
5
+ Current scope is intentionally minimal and focused on tracker-like workloads:
7
6
 
8
7
  - ID reads and `$key` reads
9
8
  - Range args: `$all`, `$first`, `$last`, `$order`, `$after`, `$before`
10
9
  - Filter operators: `$eq`, `$not`, `$lt`, `$lte`, `$gt`, `$gte`, `$re`, `$ire`,
11
10
  `$cts`, plus list shorthand (`prop: [a, b]`)
12
- - Dot-path filters on JSON-encoded string columns (for example
13
- `sources.messageId`)
11
+ - Dot-path filters on JSON-encoded string columns and native `Map(...)`
12
+ columns (for example `sources.messageId` or `recordIds.gmailMessageId`)
14
13
  - Nested projection from JSON-encoded string columns
15
14
  - Join filters via subqueries (for example `$key: { syncJob: { ... } }`)
16
15
  - Aggregates: `$count`, `$sum`, `$avg`, `$max`, `$min`, `$card` with
17
16
  `$group: true` and `$group: [..]`
17
+ - Writes: single-row id writes with `$put`
18
18
 
19
- Writes are out of scope.
19
+ The default table model is append-only `MergeTree`. Reads do not add `FINAL`
20
+ unless you explicitly opt into `final: true` for a legacy
21
+ `ReplacingMergeTree` table.
22
+
23
+ Write support is append-only. `$put` is mandatory, writes must target a scalar
24
+ id path, and the adapter always does a blind insert. Filter writes, patch-style
25
+ updates, and deletes are not supported.
26
+
27
+ The default `idCol` is `id`, and the default `verCol` is `time`.
28
+
29
+ `idCol` and `verCol` must be Graffy-compatible: when loaded through the
30
+ ClickHouse client they should be strings or numbers. `verCol` should also be
31
+ backed by a table `DEFAULT` expression. If a write provides a value for
32
+ `verCol`, it is inserted as-is. If not, the column is omitted from the insert
33
+ and ClickHouse supplies the default value.
20
34
 
21
35
  ## E2E tests
22
36
 
package/index.cjs CHANGED
@@ -6,6 +6,21 @@ function quoteIdent(name) {
6
6
  const ident = String(name).replace(/`/g, "``");
7
7
  return `\`${ident}\``;
8
8
  }
9
+ function unwrapType(type) {
10
+ let current = type;
11
+ while (typeof current === "string") {
12
+ if (current.startsWith("Nullable(") && current.endsWith(")")) {
13
+ current = current.slice("Nullable(".length, -1);
14
+ continue;
15
+ }
16
+ if (current.startsWith("LowCardinality(") && current.endsWith(")")) {
17
+ current = current.slice("LowCardinality(".length, -1);
18
+ continue;
19
+ }
20
+ break;
21
+ }
22
+ return current;
23
+ }
9
24
  function literal(value) {
10
25
  if (value === null || value === void 0) return "NULL";
11
26
  if (typeof value === "boolean") return value ? "1" : "0";
@@ -26,8 +41,35 @@ function literal(value) {
26
41
  function isPlainObject(value) {
27
42
  return !!value && typeof value === "object" && !Array.isArray(value) && value.constructor === Object;
28
43
  }
44
+ function isUInt8Type(type) {
45
+ return unwrapType(type) === "UInt8";
46
+ }
29
47
  function isStringishType(type) {
30
- return type === "String" || type === "Nullable(String)";
48
+ const unwrapped = unwrapType(type);
49
+ return unwrapped === "String" || /^FixedString\(\d+\)$/.test(unwrapped || "") || /^Enum(?:8|16)\(/.test(unwrapped || "");
50
+ }
51
+ function splitTopLevelArgs(value) {
52
+ const parts = [];
53
+ let depth = 0;
54
+ let start = 0;
55
+ for (let i = 0; i < value.length; i += 1) {
56
+ const ch2 = value[i];
57
+ if (ch2 === "(") depth += 1;
58
+ else if (ch2 === ")") depth -= 1;
59
+ else if (ch2 === "," && depth === 0) {
60
+ parts.push(value.slice(start, i).trim());
61
+ start = i + 1;
62
+ }
63
+ }
64
+ parts.push(value.slice(start).trim());
65
+ return parts;
66
+ }
67
+ function getMapTypes(type) {
68
+ const unwrapped = unwrapType(type);
69
+ if (!unwrapped?.startsWith("Map(") || !unwrapped.endsWith(")")) return null;
70
+ const inner = unwrapped.slice("Map(".length, -1);
71
+ const [keyType, valueType] = splitTopLevelArgs(inner);
72
+ return keyType && valueType ? { keyType, valueType } : null;
31
73
  }
32
74
  function getLookup(prop, options) {
33
75
  const [root, ...suffix] = prop.split(".");
@@ -37,6 +79,7 @@ function getLookup(prop, options) {
37
79
  throw Error(`clickhouse.no_column ${root}`);
38
80
  }
39
81
  const rootExpr = quoteIdent(root);
82
+ const mapTypes = getMapTypes(type);
40
83
  if (!suffix.length) {
41
84
  return {
42
85
  root,
@@ -50,6 +93,25 @@ function getLookup(prop, options) {
50
93
  numericExpr: `toFloat64OrZero(${rootExpr})`
51
94
  };
52
95
  }
96
+ if (mapTypes) {
97
+ if (suffix.length !== 1) {
98
+ throw Error(`clickhouse.map_deep_lookup_unsupported ${prop}`);
99
+ }
100
+ const keyExpr = literal(suffix[0]);
101
+ const rawExpr = `if(mapContains(${rootExpr}, ${keyExpr}), ${rootExpr}[${keyExpr}], NULL)`;
102
+ return {
103
+ root,
104
+ suffix,
105
+ type: mapTypes.valueType,
106
+ isJsonPath: false,
107
+ isMapPath: true,
108
+ rootExpr,
109
+ orderExpr: rawExpr,
110
+ textExpr: isStringishType(mapTypes.valueType) ? `ifNull(${rawExpr}, '')` : `toString(${rawExpr})`,
111
+ rawExpr,
112
+ numericExpr: `toFloat64OrZero(${rawExpr})`
113
+ };
114
+ }
53
115
  const pathArgs = suffix.map((seg) => literal(seg)).join(", ");
54
116
  const jsonExpr = `ifNull(${rootExpr}, '{}')`;
55
117
  const textExpr = `JSONExtractString(${jsonExpr}, ${pathArgs})`;
@@ -195,7 +257,7 @@ function simplify(node) {
195
257
  }
196
258
  return node;
197
259
  }
198
- function getTableSql$1({ database = "default", table, final = true }) {
260
+ function getTableSql$1({ database = "default", table, final = false }) {
199
261
  const tableSql = `${quoteIdent(database)}.${quoteIdent(table)}`;
200
262
  return final ? `${tableSql} FINAL` : tableSql;
201
263
  }
@@ -293,11 +355,7 @@ function getNodeSql(ast, options) {
293
355
  const joinName = ast[1];
294
356
  const joinOptions = options.joins?.[joinName];
295
357
  if (!joinOptions) throw Error(`clickhouse.no_join ${joinName}`);
296
- const where = [];
297
- if (joinOptions.final !== false && joinOptions.schema?.types?._sign) {
298
- where.push("`_sign` = 1");
299
- }
300
- where.push(getNodeSql(ast[2], joinOptions));
358
+ const where = [getNodeSql(ast[2], joinOptions)];
301
359
  const rootIdCol = quoteIdent(options.idCol);
302
360
  const joinRefCol = quoteIdent(joinOptions.refCol);
303
361
  return `${rootIdCol} IN (SELECT ${joinRefCol} FROM ${getTableSql$1(joinOptions)} WHERE ${where.join(" AND ")})`;
@@ -339,9 +397,6 @@ function getArgSql({ $first, $last, $after, $before, $since, $until, $all, $curs
339
397
  throw Error("clickhouse_arg.range_arg_expected");
340
398
  }
341
399
  const where = [];
342
- if (options.final !== false && options.schema?.types?._sign) {
343
- where.push("`_sign` = 1");
344
- }
345
400
  if (!common.isEmpty(filter)) where.push(getFilterSql(filter, options));
346
401
  if (!hasRangeArg) {
347
402
  return {
@@ -408,7 +463,7 @@ const aggOps = {
408
463
  $card: (lookup) => `uniqExact(${lookup.rawExpr})`
409
464
  };
410
465
  const aggOpOrder = ["$sum", "$avg", "$max", "$min", "$card"];
411
- function getTableSql({ database = "default", table, final = true }) {
466
+ function getTableSql({ database = "default", table, final = false }) {
412
467
  const tableSql = `${quoteIdent(database)}.${quoteIdent(table)}`;
413
468
  return final ? `${tableSql} FINAL` : tableSql;
414
469
  }
@@ -483,13 +538,9 @@ function selectByArgs(args, projection, options) {
483
538
  };
484
539
  }
485
540
  function selectByIds(ids, options) {
486
- const where = [];
487
- if (options.final !== false && options.schema?.types?._sign) {
488
- where.push("`_sign` = 1");
489
- }
490
- where.push(
541
+ const where = [
491
542
  `${quoteIdent(options.idCol)} IN (${ids.map((id) => literal(id)).join(", ")})`
492
- );
543
+ ];
493
544
  return {
494
545
  sql: `SELECT * FROM ${getTableSql(options)} WHERE ${where.join(" AND ")}`
495
546
  };
@@ -511,6 +562,20 @@ function maybeParseJson(value) {
511
562
  function deepCloneJson(value) {
512
563
  return JSON.parse(JSON.stringify(value));
513
564
  }
565
+ function stripJsonValue(value) {
566
+ if (value === void 0 || value === null) return value ?? null;
567
+ if (Array.isArray(value)) return value.map((item) => stripJsonValue(item));
568
+ if (!common.isPlainObject(value)) return value;
569
+ if ("$val" in value) return stripJsonValue(value.$val);
570
+ const out = {};
571
+ for (const [key, item] of Object.entries(value)) {
572
+ if (key[0] === "$") continue;
573
+ const next = stripJsonValue(item);
574
+ if (next === void 0 || next === null) continue;
575
+ out[key] = next;
576
+ }
577
+ return common.isEmpty(out) ? null : out;
578
+ }
514
579
  function applyAggregateAliases(object, aggregateAliases) {
515
580
  Object.entries(aggregateAliases).forEach(([alias, { op, prop }]) => {
516
581
  if (!(alias in object)) return;
@@ -546,6 +611,19 @@ class Db {
546
611
  throw Error(`clickhouse.sql_error ${message}`);
547
612
  }
548
613
  }
614
+ async insert(tableOptions, rows) {
615
+ if (!rows.length) return;
616
+ try {
617
+ await this.client.insert({
618
+ table: `${tableOptions.database || "default"}.${tableOptions.table}`,
619
+ values: rows,
620
+ format: "JSONEachRow"
621
+ });
622
+ } catch (e) {
623
+ const message = [e?.message, JSON.stringify(rows)].filter(Boolean).join("; ");
624
+ throw Error(`clickhouse.sql_error ${message}`);
625
+ }
626
+ }
549
627
  async ensureSchema(tableOptions) {
550
628
  if (!tableOptions.schema?.types) {
551
629
  const rows = await this.query(`
@@ -574,9 +652,9 @@ class Db {
574
652
  const type = schema?.types?.[key];
575
653
  if (value === null || value === void 0) {
576
654
  out[key] = null;
577
- } else if (type === "UInt8" || type === "Nullable(UInt8)") {
655
+ } else if (isUInt8Type(type)) {
578
656
  out[key] = Boolean(value);
579
- } else if (type === "String" || type === "Nullable(String)") {
657
+ } else if (isStringishType(type)) {
580
658
  out[key] = maybeParseJson(value);
581
659
  } else {
582
660
  out[key] = value;
@@ -600,6 +678,42 @@ class Db {
600
678
  }
601
679
  return -numeric;
602
680
  }
681
+ applyRowChange(row, change, tableOptions) {
682
+ for (const [col, value] of Object.entries(change)) {
683
+ if (col[0] === "$") continue;
684
+ const type = tableOptions.schema?.types?.[col];
685
+ if (isStringishType(type) && (value === null || Array.isArray(value) || common.isPlainObject(value))) {
686
+ row[col] = stripJsonValue(value);
687
+ } else {
688
+ row[col] = value;
689
+ }
690
+ }
691
+ }
692
+ getWriteRow(change, tableOptions) {
693
+ const row = {};
694
+ this.applyRowChange(row, change, tableOptions);
695
+ return row;
696
+ }
697
+ getInsertRow(row, tableOptions) {
698
+ const out = {};
699
+ for (const [col, type] of Object.entries(
700
+ tableOptions.schema?.types || {}
701
+ )) {
702
+ if (!(col in row)) continue;
703
+ const value = row[col];
704
+ if (value === void 0) continue;
705
+ if (value === null) {
706
+ out[col] = null;
707
+ } else if (isUInt8Type(type)) {
708
+ out[col] = value ? 1 : 0;
709
+ } else if (isStringishType(type) && typeof value === "object") {
710
+ out[col] = JSON.stringify(value);
711
+ } else {
712
+ out[col] = value;
713
+ }
714
+ }
715
+ return out;
716
+ }
603
717
  async read(rootQuery, tableOptions) {
604
718
  const idQueries = {};
605
719
  const promises = [];
@@ -630,7 +744,7 @@ class Db {
630
744
  delete object[alias];
631
745
  });
632
746
  object.$key = key;
633
- object.$ver = object[tableOptions.verCol] ?? object._version ?? null;
747
+ object.$ver = object[tableOptions.verCol];
634
748
  if (!selection.isAggregate) {
635
749
  object.$ref = [...rawPrefix, object[tableOptions.idCol]];
636
750
  }
@@ -644,7 +758,7 @@ class Db {
644
758
  for (const row of rows) {
645
759
  const object = this.normalizeRow(row, tableOptions.schema);
646
760
  object.$key = object[tableOptions.idCol];
647
- object.$ver = object[tableOptions.verCol] ?? object._version ?? null;
761
+ object.$ver = object[tableOptions.verCol];
648
762
  common.merge(results, common.encodeGraph(common.wrapObject(object, rawPrefix)));
649
763
  }
650
764
  };
@@ -670,12 +784,51 @@ class Db {
670
784
  await Promise.all(promises);
671
785
  return common.finalize(results, common.wrap(query, prefix));
672
786
  }
787
+ async write(rootChange, tableOptions) {
788
+ const { prefix: rawPrefix } = tableOptions;
789
+ const prefix = common.encodePath(rawPrefix);
790
+ await this.ensureSchema(tableOptions);
791
+ const change = common.unwrap(rootChange, prefix);
792
+ const result = [];
793
+ for (const node of change) {
794
+ if (common.isRange(node)) {
795
+ throw Error("clickhouse_write.delete_unsupported");
796
+ }
797
+ const arg = common.decodeArgs(node);
798
+ const object = common.decodeGraph(node.children) || {};
799
+ if (common.isPlainObject(arg)) {
800
+ throw Error("clickhouse_write.object_arg_unsupported");
801
+ }
802
+ if (!object.$put || object.$put !== true) {
803
+ throw Error("clickhouse_write.put_required");
804
+ }
805
+ object[tableOptions.idCol] = arg;
806
+ const writtenRow = this.getWriteRow(object, tableOptions);
807
+ await this.insert(tableOptions, [
808
+ this.getInsertRow(writtenRow, tableOptions)
809
+ ]);
810
+ common.merge(
811
+ result,
812
+ common.encodeGraph(
813
+ common.wrapObject(
814
+ {
815
+ ...writtenRow,
816
+ $key: writtenRow[tableOptions.idCol],
817
+ $ver: writtenRow[tableOptions.verCol]
818
+ },
819
+ rawPrefix
820
+ )
821
+ )
822
+ );
823
+ }
824
+ return result;
825
+ }
673
826
  }
674
827
  function getTableOpts(name, options = {}, parentName = null, parentDefaults = {}) {
675
828
  const { table, idCol, verCol, schema, database, final } = options;
676
829
  const tableName = table || name;
677
830
  const tableDatabase = database || parentDefaults.database || "default";
678
- const tableFinal = final ?? parentDefaults.final ?? true;
831
+ const tableFinal = final ?? parentDefaults.final ?? false;
679
832
  const joins = Object.fromEntries(
680
833
  Object.entries(options.joins || {}).map(([joinName, joinRaw = {}]) => {
681
834
  const { refCol = parentName, ...joinOptions } = joinRaw;
@@ -694,7 +847,7 @@ function getTableOpts(name, options = {}, parentName = null, parentDefaults = {}
694
847
  return {
695
848
  table: tableName,
696
849
  idCol: idCol || "id",
697
- verCol: verCol || "updatedAt",
850
+ verCol: verCol || "time",
698
851
  database: tableDatabase,
699
852
  final: tableFinal,
700
853
  schema,
@@ -704,6 +857,7 @@ function getTableOpts(name, options = {}, parentName = null, parentDefaults = {}
704
857
  const clickhouse = (options = {}) => (store) => {
705
858
  const { connection, ...rawOptions } = options;
706
859
  store.on("read", read);
860
+ store.on("write", write);
707
861
  const prefix = store.path;
708
862
  const tableOpts = getTableOpts(prefix[prefix.length - 1], rawOptions);
709
863
  tableOpts.prefix = prefix;
@@ -718,6 +872,18 @@ const clickhouse = (options = {}) => (store) => {
718
872
  }
719
873
  );
720
874
  }
875
+ function write(change, writeOptions, next) {
876
+ const { chClient, clickhouseClient } = writeOptions || {};
877
+ const db = chClient || clickhouseClient ? new Db(chClient || clickhouseClient) : defaultDb;
878
+ const writePromise = db.write(change, tableOpts);
879
+ const remainingChange = common.remove(change, common.encodePath(prefix));
880
+ const nextPromise = next(remainingChange);
881
+ return Promise.all([writePromise, nextPromise]).then(
882
+ ([writeRes, nextRes]) => {
883
+ return common.merge(writeRes, nextRes);
884
+ }
885
+ );
886
+ }
721
887
  };
722
888
  const ch = clickhouse;
723
889
  exports.ch = ch;
package/index.mjs CHANGED
@@ -1,9 +1,24 @@
1
- import { isEmpty, encodePath, unwrap, decodeArgs, isPlainObject as isPlainObject$1, decodeQuery, finalize, wrap, merge, encodeGraph, wrapObject, remove } from "@graffy/common";
1
+ import { isEmpty, isPlainObject as isPlainObject$1, encodePath, unwrap, decodeArgs, decodeQuery, finalize, wrap, isRange, decodeGraph, merge, encodeGraph, wrapObject, remove } from "@graffy/common";
2
2
  import { createClient } from "@clickhouse/client";
3
3
  function quoteIdent(name) {
4
4
  const ident = String(name).replace(/`/g, "``");
5
5
  return `\`${ident}\``;
6
6
  }
7
+ function unwrapType(type) {
8
+ let current = type;
9
+ while (typeof current === "string") {
10
+ if (current.startsWith("Nullable(") && current.endsWith(")")) {
11
+ current = current.slice("Nullable(".length, -1);
12
+ continue;
13
+ }
14
+ if (current.startsWith("LowCardinality(") && current.endsWith(")")) {
15
+ current = current.slice("LowCardinality(".length, -1);
16
+ continue;
17
+ }
18
+ break;
19
+ }
20
+ return current;
21
+ }
7
22
  function literal(value) {
8
23
  if (value === null || value === void 0) return "NULL";
9
24
  if (typeof value === "boolean") return value ? "1" : "0";
@@ -24,8 +39,35 @@ function literal(value) {
24
39
  function isPlainObject(value) {
25
40
  return !!value && typeof value === "object" && !Array.isArray(value) && value.constructor === Object;
26
41
  }
42
+ function isUInt8Type(type) {
43
+ return unwrapType(type) === "UInt8";
44
+ }
27
45
  function isStringishType(type) {
28
- return type === "String" || type === "Nullable(String)";
46
+ const unwrapped = unwrapType(type);
47
+ return unwrapped === "String" || /^FixedString\(\d+\)$/.test(unwrapped || "") || /^Enum(?:8|16)\(/.test(unwrapped || "");
48
+ }
49
+ function splitTopLevelArgs(value) {
50
+ const parts = [];
51
+ let depth = 0;
52
+ let start = 0;
53
+ for (let i = 0; i < value.length; i += 1) {
54
+ const ch2 = value[i];
55
+ if (ch2 === "(") depth += 1;
56
+ else if (ch2 === ")") depth -= 1;
57
+ else if (ch2 === "," && depth === 0) {
58
+ parts.push(value.slice(start, i).trim());
59
+ start = i + 1;
60
+ }
61
+ }
62
+ parts.push(value.slice(start).trim());
63
+ return parts;
64
+ }
65
+ function getMapTypes(type) {
66
+ const unwrapped = unwrapType(type);
67
+ if (!unwrapped?.startsWith("Map(") || !unwrapped.endsWith(")")) return null;
68
+ const inner = unwrapped.slice("Map(".length, -1);
69
+ const [keyType, valueType] = splitTopLevelArgs(inner);
70
+ return keyType && valueType ? { keyType, valueType } : null;
29
71
  }
30
72
  function getLookup(prop, options) {
31
73
  const [root, ...suffix] = prop.split(".");
@@ -35,6 +77,7 @@ function getLookup(prop, options) {
35
77
  throw Error(`clickhouse.no_column ${root}`);
36
78
  }
37
79
  const rootExpr = quoteIdent(root);
80
+ const mapTypes = getMapTypes(type);
38
81
  if (!suffix.length) {
39
82
  return {
40
83
  root,
@@ -48,6 +91,25 @@ function getLookup(prop, options) {
48
91
  numericExpr: `toFloat64OrZero(${rootExpr})`
49
92
  };
50
93
  }
94
+ if (mapTypes) {
95
+ if (suffix.length !== 1) {
96
+ throw Error(`clickhouse.map_deep_lookup_unsupported ${prop}`);
97
+ }
98
+ const keyExpr = literal(suffix[0]);
99
+ const rawExpr = `if(mapContains(${rootExpr}, ${keyExpr}), ${rootExpr}[${keyExpr}], NULL)`;
100
+ return {
101
+ root,
102
+ suffix,
103
+ type: mapTypes.valueType,
104
+ isJsonPath: false,
105
+ isMapPath: true,
106
+ rootExpr,
107
+ orderExpr: rawExpr,
108
+ textExpr: isStringishType(mapTypes.valueType) ? `ifNull(${rawExpr}, '')` : `toString(${rawExpr})`,
109
+ rawExpr,
110
+ numericExpr: `toFloat64OrZero(${rawExpr})`
111
+ };
112
+ }
51
113
  const pathArgs = suffix.map((seg) => literal(seg)).join(", ");
52
114
  const jsonExpr = `ifNull(${rootExpr}, '{}')`;
53
115
  const textExpr = `JSONExtractString(${jsonExpr}, ${pathArgs})`;
@@ -193,7 +255,7 @@ function simplify(node) {
193
255
  }
194
256
  return node;
195
257
  }
196
- function getTableSql$1({ database = "default", table, final = true }) {
258
+ function getTableSql$1({ database = "default", table, final = false }) {
197
259
  const tableSql = `${quoteIdent(database)}.${quoteIdent(table)}`;
198
260
  return final ? `${tableSql} FINAL` : tableSql;
199
261
  }
@@ -291,11 +353,7 @@ function getNodeSql(ast, options) {
291
353
  const joinName = ast[1];
292
354
  const joinOptions = options.joins?.[joinName];
293
355
  if (!joinOptions) throw Error(`clickhouse.no_join ${joinName}`);
294
- const where = [];
295
- if (joinOptions.final !== false && joinOptions.schema?.types?._sign) {
296
- where.push("`_sign` = 1");
297
- }
298
- where.push(getNodeSql(ast[2], joinOptions));
356
+ const where = [getNodeSql(ast[2], joinOptions)];
299
357
  const rootIdCol = quoteIdent(options.idCol);
300
358
  const joinRefCol = quoteIdent(joinOptions.refCol);
301
359
  return `${rootIdCol} IN (SELECT ${joinRefCol} FROM ${getTableSql$1(joinOptions)} WHERE ${where.join(" AND ")})`;
@@ -337,9 +395,6 @@ function getArgSql({ $first, $last, $after, $before, $since, $until, $all, $curs
337
395
  throw Error("clickhouse_arg.range_arg_expected");
338
396
  }
339
397
  const where = [];
340
- if (options.final !== false && options.schema?.types?._sign) {
341
- where.push("`_sign` = 1");
342
- }
343
398
  if (!isEmpty(filter)) where.push(getFilterSql(filter, options));
344
399
  if (!hasRangeArg) {
345
400
  return {
@@ -406,7 +461,7 @@ const aggOps = {
406
461
  $card: (lookup) => `uniqExact(${lookup.rawExpr})`
407
462
  };
408
463
  const aggOpOrder = ["$sum", "$avg", "$max", "$min", "$card"];
409
- function getTableSql({ database = "default", table, final = true }) {
464
+ function getTableSql({ database = "default", table, final = false }) {
410
465
  const tableSql = `${quoteIdent(database)}.${quoteIdent(table)}`;
411
466
  return final ? `${tableSql} FINAL` : tableSql;
412
467
  }
@@ -481,13 +536,9 @@ function selectByArgs(args, projection, options) {
481
536
  };
482
537
  }
483
538
  function selectByIds(ids, options) {
484
- const where = [];
485
- if (options.final !== false && options.schema?.types?._sign) {
486
- where.push("`_sign` = 1");
487
- }
488
- where.push(
539
+ const where = [
489
540
  `${quoteIdent(options.idCol)} IN (${ids.map((id) => literal(id)).join(", ")})`
490
- );
541
+ ];
491
542
  return {
492
543
  sql: `SELECT * FROM ${getTableSql(options)} WHERE ${where.join(" AND ")}`
493
544
  };
@@ -509,6 +560,20 @@ function maybeParseJson(value) {
509
560
  function deepCloneJson(value) {
510
561
  return JSON.parse(JSON.stringify(value));
511
562
  }
563
+ function stripJsonValue(value) {
564
+ if (value === void 0 || value === null) return value ?? null;
565
+ if (Array.isArray(value)) return value.map((item) => stripJsonValue(item));
566
+ if (!isPlainObject$1(value)) return value;
567
+ if ("$val" in value) return stripJsonValue(value.$val);
568
+ const out = {};
569
+ for (const [key, item] of Object.entries(value)) {
570
+ if (key[0] === "$") continue;
571
+ const next = stripJsonValue(item);
572
+ if (next === void 0 || next === null) continue;
573
+ out[key] = next;
574
+ }
575
+ return isEmpty(out) ? null : out;
576
+ }
512
577
  function applyAggregateAliases(object, aggregateAliases) {
513
578
  Object.entries(aggregateAliases).forEach(([alias, { op, prop }]) => {
514
579
  if (!(alias in object)) return;
@@ -544,6 +609,19 @@ class Db {
544
609
  throw Error(`clickhouse.sql_error ${message}`);
545
610
  }
546
611
  }
612
+ async insert(tableOptions, rows) {
613
+ if (!rows.length) return;
614
+ try {
615
+ await this.client.insert({
616
+ table: `${tableOptions.database || "default"}.${tableOptions.table}`,
617
+ values: rows,
618
+ format: "JSONEachRow"
619
+ });
620
+ } catch (e) {
621
+ const message = [e?.message, JSON.stringify(rows)].filter(Boolean).join("; ");
622
+ throw Error(`clickhouse.sql_error ${message}`);
623
+ }
624
+ }
547
625
  async ensureSchema(tableOptions) {
548
626
  if (!tableOptions.schema?.types) {
549
627
  const rows = await this.query(`
@@ -572,9 +650,9 @@ class Db {
572
650
  const type = schema?.types?.[key];
573
651
  if (value === null || value === void 0) {
574
652
  out[key] = null;
575
- } else if (type === "UInt8" || type === "Nullable(UInt8)") {
653
+ } else if (isUInt8Type(type)) {
576
654
  out[key] = Boolean(value);
577
- } else if (type === "String" || type === "Nullable(String)") {
655
+ } else if (isStringishType(type)) {
578
656
  out[key] = maybeParseJson(value);
579
657
  } else {
580
658
  out[key] = value;
@@ -598,6 +676,42 @@ class Db {
598
676
  }
599
677
  return -numeric;
600
678
  }
679
+ applyRowChange(row, change, tableOptions) {
680
+ for (const [col, value] of Object.entries(change)) {
681
+ if (col[0] === "$") continue;
682
+ const type = tableOptions.schema?.types?.[col];
683
+ if (isStringishType(type) && (value === null || Array.isArray(value) || isPlainObject$1(value))) {
684
+ row[col] = stripJsonValue(value);
685
+ } else {
686
+ row[col] = value;
687
+ }
688
+ }
689
+ }
690
+ getWriteRow(change, tableOptions) {
691
+ const row = {};
692
+ this.applyRowChange(row, change, tableOptions);
693
+ return row;
694
+ }
695
+ getInsertRow(row, tableOptions) {
696
+ const out = {};
697
+ for (const [col, type] of Object.entries(
698
+ tableOptions.schema?.types || {}
699
+ )) {
700
+ if (!(col in row)) continue;
701
+ const value = row[col];
702
+ if (value === void 0) continue;
703
+ if (value === null) {
704
+ out[col] = null;
705
+ } else if (isUInt8Type(type)) {
706
+ out[col] = value ? 1 : 0;
707
+ } else if (isStringishType(type) && typeof value === "object") {
708
+ out[col] = JSON.stringify(value);
709
+ } else {
710
+ out[col] = value;
711
+ }
712
+ }
713
+ return out;
714
+ }
601
715
  async read(rootQuery, tableOptions) {
602
716
  const idQueries = {};
603
717
  const promises = [];
@@ -628,7 +742,7 @@ class Db {
628
742
  delete object[alias];
629
743
  });
630
744
  object.$key = key;
631
- object.$ver = object[tableOptions.verCol] ?? object._version ?? null;
745
+ object.$ver = object[tableOptions.verCol];
632
746
  if (!selection.isAggregate) {
633
747
  object.$ref = [...rawPrefix, object[tableOptions.idCol]];
634
748
  }
@@ -642,7 +756,7 @@ class Db {
642
756
  for (const row of rows) {
643
757
  const object = this.normalizeRow(row, tableOptions.schema);
644
758
  object.$key = object[tableOptions.idCol];
645
- object.$ver = object[tableOptions.verCol] ?? object._version ?? null;
759
+ object.$ver = object[tableOptions.verCol];
646
760
  merge(results, encodeGraph(wrapObject(object, rawPrefix)));
647
761
  }
648
762
  };
@@ -668,12 +782,51 @@ class Db {
668
782
  await Promise.all(promises);
669
783
  return finalize(results, wrap(query, prefix));
670
784
  }
785
+ async write(rootChange, tableOptions) {
786
+ const { prefix: rawPrefix } = tableOptions;
787
+ const prefix = encodePath(rawPrefix);
788
+ await this.ensureSchema(tableOptions);
789
+ const change = unwrap(rootChange, prefix);
790
+ const result = [];
791
+ for (const node of change) {
792
+ if (isRange(node)) {
793
+ throw Error("clickhouse_write.delete_unsupported");
794
+ }
795
+ const arg = decodeArgs(node);
796
+ const object = decodeGraph(node.children) || {};
797
+ if (isPlainObject$1(arg)) {
798
+ throw Error("clickhouse_write.object_arg_unsupported");
799
+ }
800
+ if (!object.$put || object.$put !== true) {
801
+ throw Error("clickhouse_write.put_required");
802
+ }
803
+ object[tableOptions.idCol] = arg;
804
+ const writtenRow = this.getWriteRow(object, tableOptions);
805
+ await this.insert(tableOptions, [
806
+ this.getInsertRow(writtenRow, tableOptions)
807
+ ]);
808
+ merge(
809
+ result,
810
+ encodeGraph(
811
+ wrapObject(
812
+ {
813
+ ...writtenRow,
814
+ $key: writtenRow[tableOptions.idCol],
815
+ $ver: writtenRow[tableOptions.verCol]
816
+ },
817
+ rawPrefix
818
+ )
819
+ )
820
+ );
821
+ }
822
+ return result;
823
+ }
671
824
  }
672
825
  function getTableOpts(name, options = {}, parentName = null, parentDefaults = {}) {
673
826
  const { table, idCol, verCol, schema, database, final } = options;
674
827
  const tableName = table || name;
675
828
  const tableDatabase = database || parentDefaults.database || "default";
676
- const tableFinal = final ?? parentDefaults.final ?? true;
829
+ const tableFinal = final ?? parentDefaults.final ?? false;
677
830
  const joins = Object.fromEntries(
678
831
  Object.entries(options.joins || {}).map(([joinName, joinRaw = {}]) => {
679
832
  const { refCol = parentName, ...joinOptions } = joinRaw;
@@ -692,7 +845,7 @@ function getTableOpts(name, options = {}, parentName = null, parentDefaults = {}
692
845
  return {
693
846
  table: tableName,
694
847
  idCol: idCol || "id",
695
- verCol: verCol || "updatedAt",
848
+ verCol: verCol || "time",
696
849
  database: tableDatabase,
697
850
  final: tableFinal,
698
851
  schema,
@@ -702,6 +855,7 @@ function getTableOpts(name, options = {}, parentName = null, parentDefaults = {}
702
855
  const clickhouse = (options = {}) => (store) => {
703
856
  const { connection, ...rawOptions } = options;
704
857
  store.on("read", read);
858
+ store.on("write", write);
705
859
  const prefix = store.path;
706
860
  const tableOpts = getTableOpts(prefix[prefix.length - 1], rawOptions);
707
861
  tableOpts.prefix = prefix;
@@ -716,6 +870,18 @@ const clickhouse = (options = {}) => (store) => {
716
870
  }
717
871
  );
718
872
  }
873
+ function write(change, writeOptions, next) {
874
+ const { chClient, clickhouseClient } = writeOptions || {};
875
+ const db = chClient || clickhouseClient ? new Db(chClient || clickhouseClient) : defaultDb;
876
+ const writePromise = db.write(change, tableOpts);
877
+ const remainingChange = remove(change, encodePath(prefix));
878
+ const nextPromise = next(remainingChange);
879
+ return Promise.all([writePromise, nextPromise]).then(
880
+ ([writeRes, nextRes]) => {
881
+ return merge(writeRes, nextRes);
882
+ }
883
+ );
884
+ }
719
885
  };
720
886
  const ch = clickhouse;
721
887
  export {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@graffy/clickhouse",
3
- "description": "Read-only Graffy provider for ClickHouse.",
3
+ "description": "Graffy provider for ClickHouse.",
4
4
  "author": "aravind (https://github.com/aravindet)",
5
- "version": "0.17.9-alpha.1",
5
+ "version": "0.18.1-alpha.1",
6
6
  "main": "./index.cjs",
7
7
  "exports": {
8
8
  "import": "./index.mjs",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "dependencies": {
19
- "@graffy/common": "0.17.9-alpha.1"
19
+ "@graffy/common": "0.18.1-alpha.1"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@clickhouse/client": "^1.17.0"
package/types/Db.d.ts CHANGED
@@ -2,12 +2,17 @@ export default class Db {
2
2
  constructor(connection: any);
3
3
  client: any;
4
4
  query(sql: any): Promise<any>;
5
+ insert(tableOptions: any, rows: any): Promise<void>;
5
6
  ensureSchema(tableOptions: any): Promise<void>;
6
7
  normalizeRow(row: any, schema: any): {};
7
8
  getCursorValue(row: any, orderItem: any): any;
9
+ applyRowChange(row: any, change: any, tableOptions: any): void;
10
+ getWriteRow(change: any, tableOptions: any): {};
11
+ getInsertRow(row: any, tableOptions: any): {};
8
12
  read(rootQuery: any, tableOptions: any): Promise<{
9
13
  key: Uint8Array<ArrayBuffer>;
10
14
  end: Uint8Array<ArrayBuffer>;
11
15
  version: number;
12
16
  }[]>;
17
+ write(rootChange: any, tableOptions: any): Promise<any[]>;
13
18
  }
@@ -1,4 +1,5 @@
1
1
  export function quoteIdent(name: any): string;
2
+ export function unwrapType(type: any): any;
2
3
  export function literal(value: any): any;
3
4
  export function isPlainObject(value: any): boolean;
4
5
  export function isUInt8Type(type: any): boolean;
@@ -8,4 +8,16 @@ export function getLookup(prop: any, options: any): {
8
8
  textExpr: string;
9
9
  rawExpr: string;
10
10
  numericExpr: string;
11
+ isMapPath?: undefined;
12
+ } | {
13
+ root: any;
14
+ suffix: any;
15
+ type: any;
16
+ isJsonPath: boolean;
17
+ isMapPath: boolean;
18
+ rootExpr: string;
19
+ orderExpr: string;
20
+ textExpr: string;
21
+ rawExpr: string;
22
+ numericExpr: string;
11
23
  };