@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 +20 -6
- package/index.cjs +189 -23
- package/index.mjs +190 -24
- package/package.json +3 -3
- package/types/Db.d.ts +5 -0
- package/types/sql/escape.d.ts +1 -0
- package/types/sql/lookup.d.ts +12 -0
package/Readme.md
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
# ClickHouse Provider
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Graffy provider for ClickHouse.
|
|
4
4
|
|
|
5
|
-
Current scope is intentionally minimal and focused on
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
655
|
+
} else if (isUInt8Type(type)) {
|
|
578
656
|
out[key] = Boolean(value);
|
|
579
|
-
} else if (type
|
|
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]
|
|
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]
|
|
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 ??
|
|
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 || "
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
653
|
+
} else if (isUInt8Type(type)) {
|
|
576
654
|
out[key] = Boolean(value);
|
|
577
|
-
} else if (type
|
|
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]
|
|
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]
|
|
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 ??
|
|
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 || "
|
|
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": "
|
|
3
|
+
"description": "Graffy provider for ClickHouse.",
|
|
4
4
|
"author": "aravind (https://github.com/aravindet)",
|
|
5
|
-
"version": "0.
|
|
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.
|
|
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
|
}
|
package/types/sql/escape.d.ts
CHANGED
package/types/sql/lookup.d.ts
CHANGED
|
@@ -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
|
};
|