@graffy/clickhouse 0.17.9-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 +27 -0
- package/index.cjs +724 -0
- package/index.mjs +724 -0
- package/package.json +24 -0
- package/types/Db.d.ts +13 -0
- package/types/filter/getAst.d.ts +1 -0
- package/types/filter/getSql.d.ts +1 -0
- package/types/index.d.ts +24 -0
- package/types/sql/escape.d.ts +5 -0
- package/types/sql/getArgSql.d.ts +35 -0
- package/types/sql/lookup.d.ts +11 -0
- package/types/sql/select.d.ts +25 -0
package/index.cjs
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const common = require("@graffy/common");
|
|
4
|
+
const client = require("@clickhouse/client");
|
|
5
|
+
function quoteIdent(name) {
|
|
6
|
+
const ident = String(name).replace(/`/g, "``");
|
|
7
|
+
return `\`${ident}\``;
|
|
8
|
+
}
|
|
9
|
+
function literal(value) {
|
|
10
|
+
if (value === null || value === void 0) return "NULL";
|
|
11
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
12
|
+
if (typeof value === "number") {
|
|
13
|
+
if (!Number.isFinite(value)) throw Error("clickhouse.invalid_number");
|
|
14
|
+
return String(value);
|
|
15
|
+
}
|
|
16
|
+
if (typeof value === "bigint") return value.toString();
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
return `(${value.map((item) => literal(item)).join(", ")})`;
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === "object") {
|
|
21
|
+
return literal(JSON.stringify(value));
|
|
22
|
+
}
|
|
23
|
+
const escaped = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
24
|
+
return `'${escaped}'`;
|
|
25
|
+
}
|
|
26
|
+
function isPlainObject(value) {
|
|
27
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && value.constructor === Object;
|
|
28
|
+
}
|
|
29
|
+
function isStringishType(type) {
|
|
30
|
+
return type === "String" || type === "Nullable(String)";
|
|
31
|
+
}
|
|
32
|
+
function getLookup(prop, options) {
|
|
33
|
+
const [root, ...suffix] = prop.split(".");
|
|
34
|
+
const types = options?.schema?.types || {};
|
|
35
|
+
const type = types[root];
|
|
36
|
+
if (!type) {
|
|
37
|
+
throw Error(`clickhouse.no_column ${root}`);
|
|
38
|
+
}
|
|
39
|
+
const rootExpr = quoteIdent(root);
|
|
40
|
+
if (!suffix.length) {
|
|
41
|
+
return {
|
|
42
|
+
root,
|
|
43
|
+
suffix,
|
|
44
|
+
type,
|
|
45
|
+
isJsonPath: false,
|
|
46
|
+
rootExpr,
|
|
47
|
+
orderExpr: rootExpr,
|
|
48
|
+
textExpr: type === "String" || type === "Nullable(String)" ? `ifNull(${rootExpr}, '')` : `toString(${rootExpr})`,
|
|
49
|
+
rawExpr: rootExpr,
|
|
50
|
+
numericExpr: `toFloat64OrZero(${rootExpr})`
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const pathArgs = suffix.map((seg) => literal(seg)).join(", ");
|
|
54
|
+
const jsonExpr = `ifNull(${rootExpr}, '{}')`;
|
|
55
|
+
const textExpr = `JSONExtractString(${jsonExpr}, ${pathArgs})`;
|
|
56
|
+
return {
|
|
57
|
+
root,
|
|
58
|
+
suffix,
|
|
59
|
+
type,
|
|
60
|
+
isJsonPath: true,
|
|
61
|
+
rootExpr,
|
|
62
|
+
orderExpr: textExpr,
|
|
63
|
+
textExpr,
|
|
64
|
+
rawExpr: `JSONExtractRaw(${jsonExpr}, ${pathArgs})`,
|
|
65
|
+
numericExpr: `toFloat64OrZero(${textExpr})`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const valid = {
|
|
69
|
+
$eq: true,
|
|
70
|
+
$lt: true,
|
|
71
|
+
$gt: true,
|
|
72
|
+
$lte: true,
|
|
73
|
+
$gte: true,
|
|
74
|
+
$re: true,
|
|
75
|
+
$ire: true,
|
|
76
|
+
$text: true,
|
|
77
|
+
$and: true,
|
|
78
|
+
$or: true,
|
|
79
|
+
$any: true,
|
|
80
|
+
$all: true,
|
|
81
|
+
$has: true,
|
|
82
|
+
$cts: true,
|
|
83
|
+
$ctd: true,
|
|
84
|
+
$keycts: true,
|
|
85
|
+
$keyctd: true
|
|
86
|
+
};
|
|
87
|
+
const inverse = {
|
|
88
|
+
$eq: "$neq",
|
|
89
|
+
$neq: "$eq",
|
|
90
|
+
$in: "$nin",
|
|
91
|
+
$nin: "$in",
|
|
92
|
+
$lt: "$gte",
|
|
93
|
+
$gte: "$lt",
|
|
94
|
+
$gt: "$lte",
|
|
95
|
+
$lte: "$gt"
|
|
96
|
+
};
|
|
97
|
+
function getAst(filter) {
|
|
98
|
+
return simplify(construct(filter));
|
|
99
|
+
}
|
|
100
|
+
function isValidSubQuery(node) {
|
|
101
|
+
if (!node || typeof node !== "object") return false;
|
|
102
|
+
const keys = Object.keys(node);
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
if (key[0] === "$" && !["$and", "$or", "$not"].includes(key)) return false;
|
|
105
|
+
if (key[0] !== "$") return true;
|
|
106
|
+
}
|
|
107
|
+
for (const key in node) {
|
|
108
|
+
if (!isValidSubQuery(node[key])) return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
function construct(node, prop, op) {
|
|
113
|
+
if (!node || typeof node !== "object" || prop && op) {
|
|
114
|
+
if (op && prop) return [op, prop, node];
|
|
115
|
+
if (prop) return ["$eq", prop, node];
|
|
116
|
+
throw Error(`clickhouse_ast.expected_prop_before:${JSON.stringify(node)}`);
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(node)) {
|
|
119
|
+
return ["$or", node.map((item) => construct(item, prop, op))];
|
|
120
|
+
}
|
|
121
|
+
if (prop && isValidSubQuery(node)) {
|
|
122
|
+
return ["$sub", prop, construct(node)];
|
|
123
|
+
}
|
|
124
|
+
return [
|
|
125
|
+
"$and",
|
|
126
|
+
Object.entries(node).map(([key, val]) => {
|
|
127
|
+
if (key === "$or" || key === "$and") {
|
|
128
|
+
return [key, construct(val, prop, op)[1]];
|
|
129
|
+
}
|
|
130
|
+
if (key === "$not") {
|
|
131
|
+
return [key, construct(val, prop, op)];
|
|
132
|
+
}
|
|
133
|
+
if (key[0] === "$") {
|
|
134
|
+
if (!valid[key]) throw Error(`clickhouse_ast.invalid_op:${key}`);
|
|
135
|
+
if (op) throw Error(`clickhouse_ast.unexpected_op:${op} before:${key}`);
|
|
136
|
+
if (!prop) throw Error(`clickhouse_ast.expected_prop_before:${key}`);
|
|
137
|
+
return construct(val, prop, key);
|
|
138
|
+
}
|
|
139
|
+
return construct(val, key);
|
|
140
|
+
})
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
function simplify(node) {
|
|
144
|
+
const op = node[0];
|
|
145
|
+
if (op === "$and" || op === "$or") {
|
|
146
|
+
node[1] = node[1].map((subnode) => simplify(subnode));
|
|
147
|
+
} else if (op === "$not") {
|
|
148
|
+
node[1] = simplify(node[1]);
|
|
149
|
+
} else if (op === "$sub") {
|
|
150
|
+
node[2] = simplify(node[2]);
|
|
151
|
+
}
|
|
152
|
+
if (op === "$and") {
|
|
153
|
+
if (!node[1].length) return true;
|
|
154
|
+
if (node[1].includes(false)) return false;
|
|
155
|
+
node[1] = node[1].filter((item) => item !== true);
|
|
156
|
+
} else if (op === "$or") {
|
|
157
|
+
if (!node[1].length) return false;
|
|
158
|
+
if (node[1].includes(true)) return true;
|
|
159
|
+
node[1] = node[1].filter((item) => item !== false);
|
|
160
|
+
} else if (op === "$not" && typeof node[1] === "boolean") {
|
|
161
|
+
return !node[1];
|
|
162
|
+
}
|
|
163
|
+
if (op === "$or") {
|
|
164
|
+
const { eqmap, noneq, change } = node[1].reduce(
|
|
165
|
+
(acc, item) => {
|
|
166
|
+
if (item[0] !== "$eq" || item[2] === null) {
|
|
167
|
+
acc.noneq.push(item);
|
|
168
|
+
} else if (acc.eqmap[item[1]]) {
|
|
169
|
+
acc.change = true;
|
|
170
|
+
acc.eqmap[item[1]].push(item[2]);
|
|
171
|
+
return acc;
|
|
172
|
+
} else {
|
|
173
|
+
acc.eqmap[item[1]] = [item[2]];
|
|
174
|
+
}
|
|
175
|
+
return acc;
|
|
176
|
+
},
|
|
177
|
+
{ eqmap: {}, noneq: [], change: false }
|
|
178
|
+
);
|
|
179
|
+
if (change) {
|
|
180
|
+
node[1] = [
|
|
181
|
+
...noneq,
|
|
182
|
+
...Object.entries(eqmap).map(
|
|
183
|
+
([prop, val]) => val.length > 1 ? ["$in", prop, val] : ["$eq", prop, val[0]]
|
|
184
|
+
)
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if ((op === "$and" || op === "$or") && node[1].length === 1) {
|
|
189
|
+
return node[1][0];
|
|
190
|
+
}
|
|
191
|
+
if (op === "$not") {
|
|
192
|
+
const [subop, ...subargs] = node[1];
|
|
193
|
+
const invop = inverse[subop];
|
|
194
|
+
return invop ? [invop, ...subargs] : node;
|
|
195
|
+
}
|
|
196
|
+
return node;
|
|
197
|
+
}
|
|
198
|
+
function getTableSql$1({ database = "default", table, final = true }) {
|
|
199
|
+
const tableSql = `${quoteIdent(database)}.${quoteIdent(table)}`;
|
|
200
|
+
return final ? `${tableSql} FINAL` : tableSql;
|
|
201
|
+
}
|
|
202
|
+
function getNullCheckSql(lookup, op) {
|
|
203
|
+
if (lookup.isJsonPath) {
|
|
204
|
+
const missingExpr = `isNull(nullIf(${lookup.rawExpr}, ''))`;
|
|
205
|
+
return op === "$eq" ? missingExpr : `NOT (${missingExpr})`;
|
|
206
|
+
}
|
|
207
|
+
return op === "$eq" ? `isNull(${lookup.rawExpr})` : `NOT isNull(${lookup.rawExpr})`;
|
|
208
|
+
}
|
|
209
|
+
function getRegexSql(lookup, op, value) {
|
|
210
|
+
const pattern = literal(String(value));
|
|
211
|
+
if (op === "$ire") {
|
|
212
|
+
return `match(${lookup.textExpr}, concat('(?i)', ${pattern}))`;
|
|
213
|
+
}
|
|
214
|
+
return `match(${lookup.textExpr}, ${pattern})`;
|
|
215
|
+
}
|
|
216
|
+
function getInSql(lookup, op, value, type) {
|
|
217
|
+
const values = value.filter((item) => item !== null);
|
|
218
|
+
const hasNull = values.length !== value.length;
|
|
219
|
+
const lhs = lookup.isJsonPath || isStringishType(type) ? lookup.textExpr : lookup.rawExpr;
|
|
220
|
+
const inExpr = values.length ? `${lhs} IN (${values.map((item) => literal(item)).join(", ")})` : null;
|
|
221
|
+
const nullExpr = hasNull ? getNullCheckSql(lookup, "$eq") : null;
|
|
222
|
+
if (op === "$in") {
|
|
223
|
+
if (inExpr && nullExpr) return `(${inExpr} OR ${nullExpr})`;
|
|
224
|
+
if (inExpr) return inExpr;
|
|
225
|
+
return nullExpr || "0";
|
|
226
|
+
}
|
|
227
|
+
if (inExpr && nullExpr) return `NOT (${inExpr} OR ${nullExpr})`;
|
|
228
|
+
if (inExpr) return `NOT (${inExpr})`;
|
|
229
|
+
return nullExpr ? `NOT (${nullExpr})` : "1";
|
|
230
|
+
}
|
|
231
|
+
function getCtsSql(lookup, value) {
|
|
232
|
+
if (Array.isArray(value) && value.every((item) => isPlainObject(item))) {
|
|
233
|
+
return value.map((needle) => {
|
|
234
|
+
const conditions = Object.entries(needle).map(
|
|
235
|
+
([k, v]) => `JSONExtractRaw(item, ${literal(k)}) = ${literal(JSON.stringify(v))}`
|
|
236
|
+
);
|
|
237
|
+
return `arrayExists(item -> (${conditions.join(" AND ")}), JSONExtractArrayRaw(ifNull(${lookup.rootExpr}, '[]')))`;
|
|
238
|
+
}).join(" AND ");
|
|
239
|
+
}
|
|
240
|
+
if (isPlainObject(value)) {
|
|
241
|
+
const checks = Object.entries(value).map(
|
|
242
|
+
([k, v]) => `JSONExtractRaw(ifNull(${lookup.rootExpr}, '{}'), ${literal(k)}) = ${literal(JSON.stringify(v))}`
|
|
243
|
+
);
|
|
244
|
+
return checks.join(" AND ");
|
|
245
|
+
}
|
|
246
|
+
return `positionUTF8(ifNull(${lookup.rootExpr}, ''), ${literal(JSON.stringify(value))}) > 0`;
|
|
247
|
+
}
|
|
248
|
+
function getSimpleBinarySql(lookup, op, value) {
|
|
249
|
+
const opSql = {
|
|
250
|
+
$eq: "=",
|
|
251
|
+
$neq: "!=",
|
|
252
|
+
$lt: "<",
|
|
253
|
+
$lte: "<=",
|
|
254
|
+
$gt: ">",
|
|
255
|
+
$gte: ">="
|
|
256
|
+
}[op];
|
|
257
|
+
if (!opSql) throw Error(`clickhouse.getSql_unknown_operator ${op}`);
|
|
258
|
+
const lhs = lookup.isJsonPath ? typeof value === "string" ? lookup.textExpr : lookup.numericExpr : lookup.rawExpr;
|
|
259
|
+
return `${lhs} ${opSql} ${literal(value)}`;
|
|
260
|
+
}
|
|
261
|
+
function getBinarySql(node, options) {
|
|
262
|
+
const [op, prop, value] = node;
|
|
263
|
+
const lookup = getLookup(prop, options);
|
|
264
|
+
const type = lookup.type;
|
|
265
|
+
if (value === null && (op === "$eq" || op === "$neq")) {
|
|
266
|
+
return getNullCheckSql(lookup, op);
|
|
267
|
+
}
|
|
268
|
+
if (op === "$in" || op === "$nin") {
|
|
269
|
+
return getInSql(lookup, op, value, type);
|
|
270
|
+
}
|
|
271
|
+
if (op === "$re" || op === "$ire") {
|
|
272
|
+
return getRegexSql(lookup, op, value);
|
|
273
|
+
}
|
|
274
|
+
if (op === "$cts") {
|
|
275
|
+
return getCtsSql(lookup, value);
|
|
276
|
+
}
|
|
277
|
+
if (op === "$ctd" || op === "$keycts" || op === "$keyctd") {
|
|
278
|
+
throw Error(`clickhouse.unsupported_operator ${op}`);
|
|
279
|
+
}
|
|
280
|
+
return getSimpleBinarySql(lookup, op, value);
|
|
281
|
+
}
|
|
282
|
+
function getNodeSql(ast, options) {
|
|
283
|
+
if (typeof ast === "boolean") return ast ? "1" : "0";
|
|
284
|
+
const [op] = ast;
|
|
285
|
+
if (op === "$and" || op === "$or") {
|
|
286
|
+
const delim = op === "$and" ? " AND " : " OR ";
|
|
287
|
+
return `(${ast[1].map((node) => getNodeSql(node, options)).join(delim)})`;
|
|
288
|
+
}
|
|
289
|
+
if (op === "$not") {
|
|
290
|
+
return `NOT (${getNodeSql(ast[1], options)})`;
|
|
291
|
+
}
|
|
292
|
+
if (op === "$sub") {
|
|
293
|
+
const joinName = ast[1];
|
|
294
|
+
const joinOptions = options.joins?.[joinName];
|
|
295
|
+
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));
|
|
301
|
+
const rootIdCol = quoteIdent(options.idCol);
|
|
302
|
+
const joinRefCol = quoteIdent(joinOptions.refCol);
|
|
303
|
+
return `${rootIdCol} IN (SELECT ${joinRefCol} FROM ${getTableSql$1(joinOptions)} WHERE ${where.join(" AND ")})`;
|
|
304
|
+
}
|
|
305
|
+
return getBinarySql(ast, options);
|
|
306
|
+
}
|
|
307
|
+
function getFilterSql(filter, options) {
|
|
308
|
+
const ast = getAst(filter);
|
|
309
|
+
return getNodeSql(ast, options);
|
|
310
|
+
}
|
|
311
|
+
const MAX_LIMIT = 4096;
|
|
312
|
+
function getBoundCond(boundCols, bound, kind) {
|
|
313
|
+
if (!Array.isArray(bound) || !bound.length || boundCols.length !== bound.length) {
|
|
314
|
+
throw Error(`clickhouse_arg.bad_query bound:${JSON.stringify(bound)}`);
|
|
315
|
+
}
|
|
316
|
+
const lhs = `(${boundCols.join(", ")})`;
|
|
317
|
+
const rhs = `(${bound.map((item) => literal(item)).join(", ")})`;
|
|
318
|
+
switch (kind) {
|
|
319
|
+
case "$after":
|
|
320
|
+
return `${lhs} > ${rhs}`;
|
|
321
|
+
case "$since":
|
|
322
|
+
return `${lhs} >= ${rhs}`;
|
|
323
|
+
case "$before":
|
|
324
|
+
return `${lhs} < ${rhs}`;
|
|
325
|
+
case "$until":
|
|
326
|
+
return `${lhs} <= ${rhs}`;
|
|
327
|
+
default:
|
|
328
|
+
throw Error(`clickhouse_arg.bad_bound_kind ${kind}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function getArgSql({ $first, $last, $after, $before, $since, $until, $all, $cursor: _, ...rest }, options) {
|
|
332
|
+
const { $order, $group, ...filter } = rest;
|
|
333
|
+
const groupSpec = $group === true ? true : Array.isArray($group) && $group.length ? $group : null;
|
|
334
|
+
const hasRangeArg = !!($before || $after || $since || $until || $first || $last || $all);
|
|
335
|
+
if ($order && $group) {
|
|
336
|
+
throw Error("clickhouse_arg.order_and_group_unsupported");
|
|
337
|
+
}
|
|
338
|
+
if (($order || $group && $group !== true) && !hasRangeArg) {
|
|
339
|
+
throw Error("clickhouse_arg.range_arg_expected");
|
|
340
|
+
}
|
|
341
|
+
const where = [];
|
|
342
|
+
if (options.final !== false && options.schema?.types?._sign) {
|
|
343
|
+
where.push("`_sign` = 1");
|
|
344
|
+
}
|
|
345
|
+
if (!common.isEmpty(filter)) where.push(getFilterSql(filter, options));
|
|
346
|
+
if (!hasRangeArg) {
|
|
347
|
+
return {
|
|
348
|
+
where,
|
|
349
|
+
limit: groupSpec ? 1 : 2,
|
|
350
|
+
orderSpec: groupSpec && groupSpec !== true ? groupSpec : [options.idCol],
|
|
351
|
+
order: null,
|
|
352
|
+
groupSpec,
|
|
353
|
+
ensureSingleRow: !groupSpec,
|
|
354
|
+
hasRangeArg: false,
|
|
355
|
+
hasCursor: false,
|
|
356
|
+
keyBase: rest
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (groupSpec === true) {
|
|
360
|
+
return {
|
|
361
|
+
where,
|
|
362
|
+
orderSpec: [],
|
|
363
|
+
order: null,
|
|
364
|
+
groupSpec,
|
|
365
|
+
limit: 1,
|
|
366
|
+
ensureSingleRow: false,
|
|
367
|
+
hasRangeArg: true,
|
|
368
|
+
hasCursor: false,
|
|
369
|
+
keyBase: rest
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const orderSpec = groupSpec || $order || [options.idCol];
|
|
373
|
+
const boundCols = orderSpec.map((orderItem) => {
|
|
374
|
+
if (orderItem[0] === "!") {
|
|
375
|
+
return `-(${getLookup(orderItem.slice(1), options).numericExpr})`;
|
|
376
|
+
}
|
|
377
|
+
return getLookup(orderItem, options).orderExpr;
|
|
378
|
+
});
|
|
379
|
+
Object.entries({ $after, $before, $since, $until }).forEach(
|
|
380
|
+
([kind, value]) => {
|
|
381
|
+
if (value) where.push(getBoundCond(boundCols, value, kind));
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
const order = orderSpec.map((orderItem) => {
|
|
385
|
+
const desc = orderItem[0] === "!";
|
|
386
|
+
const prop = desc ? orderItem.slice(1) : orderItem;
|
|
387
|
+
const lookup = getLookup(prop, options);
|
|
388
|
+
const direction = desc ? $last ? "ASC" : "DESC" : $last ? "DESC" : "ASC";
|
|
389
|
+
return `${lookup.orderExpr} ${direction}`;
|
|
390
|
+
}).join(", ");
|
|
391
|
+
return {
|
|
392
|
+
where,
|
|
393
|
+
orderSpec,
|
|
394
|
+
order,
|
|
395
|
+
groupSpec,
|
|
396
|
+
limit: Math.min(MAX_LIMIT, $first || $last || MAX_LIMIT),
|
|
397
|
+
ensureSingleRow: false,
|
|
398
|
+
hasRangeArg: true,
|
|
399
|
+
hasCursor: true,
|
|
400
|
+
keyBase: rest
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const aggOps = {
|
|
404
|
+
$sum: (lookup) => `sum(${lookup.numericExpr})`,
|
|
405
|
+
$avg: (lookup) => `avg(${lookup.numericExpr})`,
|
|
406
|
+
$max: (lookup) => `max(${lookup.numericExpr})`,
|
|
407
|
+
$min: (lookup) => `min(${lookup.numericExpr})`,
|
|
408
|
+
$card: (lookup) => `uniqExact(${lookup.rawExpr})`
|
|
409
|
+
};
|
|
410
|
+
const aggOpOrder = ["$sum", "$avg", "$max", "$min", "$card"];
|
|
411
|
+
function getTableSql({ database = "default", table, final = true }) {
|
|
412
|
+
const tableSql = `${quoteIdent(database)}.${quoteIdent(table)}`;
|
|
413
|
+
return final ? `${tableSql} FINAL` : tableSql;
|
|
414
|
+
}
|
|
415
|
+
function getAggregateSelectSql(projection, options, groupSpec) {
|
|
416
|
+
const selectCols = [];
|
|
417
|
+
const groupExprs = [];
|
|
418
|
+
const groupAliases = [];
|
|
419
|
+
const aggregateAliases = {};
|
|
420
|
+
let aggIx = 0;
|
|
421
|
+
if (Array.isArray(groupSpec)) {
|
|
422
|
+
groupSpec.forEach((prop, ix) => {
|
|
423
|
+
const lookup = getLookup(prop, options);
|
|
424
|
+
const alias = `__group_${ix}`;
|
|
425
|
+
selectCols.push(`${lookup.orderExpr} AS ${quoteIdent(alias)}`);
|
|
426
|
+
groupExprs.push(lookup.orderExpr);
|
|
427
|
+
groupAliases.push(alias);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (projection?.$count) {
|
|
431
|
+
selectCols.push(`count() AS ${quoteIdent("$count")}`);
|
|
432
|
+
}
|
|
433
|
+
aggOpOrder.forEach((op) => {
|
|
434
|
+
const values = projection?.[op];
|
|
435
|
+
if (!values || typeof values !== "object") return;
|
|
436
|
+
Object.keys(values).forEach((prop) => {
|
|
437
|
+
const alias = `__agg_${aggIx++}`;
|
|
438
|
+
const lookup = getLookup(prop, options);
|
|
439
|
+
selectCols.push(`${aggOps[op](lookup)} AS ${quoteIdent(alias)}`);
|
|
440
|
+
aggregateAliases[alias] = { op, prop };
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
if (!selectCols.length) selectCols.push("count() AS `__count`");
|
|
444
|
+
return {
|
|
445
|
+
selectSql: selectCols.join(", "),
|
|
446
|
+
aggregateAliases,
|
|
447
|
+
groupAliases,
|
|
448
|
+
groupExprs
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function selectByArgs(args, projection, options) {
|
|
452
|
+
const { where, order, limit, groupSpec, ...rest } = getArgSql(args, options);
|
|
453
|
+
const whereClause = where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
454
|
+
const orderClause = order ? ` ORDER BY ${order}` : "";
|
|
455
|
+
const wantsAggregate = !!groupSpec || !!projection?.$count || aggOpOrder.some(
|
|
456
|
+
(op) => projection?.[op] && typeof projection[op] === "object"
|
|
457
|
+
);
|
|
458
|
+
if (!wantsAggregate) {
|
|
459
|
+
return {
|
|
460
|
+
...rest,
|
|
461
|
+
where,
|
|
462
|
+
order,
|
|
463
|
+
limit,
|
|
464
|
+
groupSpec,
|
|
465
|
+
isAggregate: false,
|
|
466
|
+
aggregateAliases: {},
|
|
467
|
+
groupAliases: [],
|
|
468
|
+
sql: `SELECT * FROM ${getTableSql(options)}${whereClause}${orderClause} LIMIT ${limit}`
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const { selectSql, aggregateAliases, groupAliases, groupExprs } = getAggregateSelectSql(projection, options, groupSpec);
|
|
472
|
+
const groupClause = groupExprs.length ? ` GROUP BY ${groupExprs.join(", ")}` : "";
|
|
473
|
+
return {
|
|
474
|
+
...rest,
|
|
475
|
+
where,
|
|
476
|
+
order,
|
|
477
|
+
limit,
|
|
478
|
+
groupSpec,
|
|
479
|
+
isAggregate: true,
|
|
480
|
+
aggregateAliases,
|
|
481
|
+
groupAliases,
|
|
482
|
+
sql: `SELECT ${selectSql} FROM ${getTableSql(options)}${whereClause}${groupClause}${orderClause} LIMIT ${limit}`
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
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(
|
|
491
|
+
`${quoteIdent(options.idCol)} IN (${ids.map((id) => literal(id)).join(", ")})`
|
|
492
|
+
);
|
|
493
|
+
return {
|
|
494
|
+
sql: `SELECT * FROM ${getTableSql(options)} WHERE ${where.join(" AND ")}`
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function maybeParseJson(value) {
|
|
498
|
+
if (typeof value !== "string") return value;
|
|
499
|
+
const trimmed = value.trim();
|
|
500
|
+
if (!trimmed) return value;
|
|
501
|
+
if (trimmed === "null") return null;
|
|
502
|
+
if (trimmed[0] === "{" && trimmed[trimmed.length - 1] === "}" || trimmed[0] === "[" && trimmed[trimmed.length - 1] === "]") {
|
|
503
|
+
try {
|
|
504
|
+
return JSON.parse(trimmed);
|
|
505
|
+
} catch {
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return value;
|
|
510
|
+
}
|
|
511
|
+
function deepCloneJson(value) {
|
|
512
|
+
return JSON.parse(JSON.stringify(value));
|
|
513
|
+
}
|
|
514
|
+
function applyAggregateAliases(object, aggregateAliases) {
|
|
515
|
+
Object.entries(aggregateAliases).forEach(([alias, { op, prop }]) => {
|
|
516
|
+
if (!(alias in object)) return;
|
|
517
|
+
if (!object[op] || typeof object[op] !== "object") object[op] = {};
|
|
518
|
+
object[op][prop] = object[alias];
|
|
519
|
+
delete object[alias];
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
class Db {
|
|
523
|
+
constructor(connection) {
|
|
524
|
+
if (connection?.query && typeof connection.query === "function") {
|
|
525
|
+
this.client = connection;
|
|
526
|
+
} else {
|
|
527
|
+
this.client = client.createClient(connection || {});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async query(sql) {
|
|
531
|
+
try {
|
|
532
|
+
const resultSet = await this.client.query({
|
|
533
|
+
query: sql,
|
|
534
|
+
format: "JSONEachRow"
|
|
535
|
+
});
|
|
536
|
+
if (Array.isArray(resultSet)) return resultSet;
|
|
537
|
+
if (resultSet?.data && Array.isArray(resultSet.data))
|
|
538
|
+
return resultSet.data;
|
|
539
|
+
if (typeof resultSet?.json === "function") {
|
|
540
|
+
const rows = await resultSet.json();
|
|
541
|
+
return Array.isArray(rows) ? rows : [];
|
|
542
|
+
}
|
|
543
|
+
return [];
|
|
544
|
+
} catch (e) {
|
|
545
|
+
const message = [e?.message, sql].filter(Boolean).join("; ");
|
|
546
|
+
throw Error(`clickhouse.sql_error ${message}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async ensureSchema(tableOptions) {
|
|
550
|
+
if (!tableOptions.schema?.types) {
|
|
551
|
+
const rows = await this.query(`
|
|
552
|
+
SELECT name, type
|
|
553
|
+
FROM system.columns
|
|
554
|
+
WHERE database = ${literal(tableOptions.database || "default")}
|
|
555
|
+
AND table = ${literal(tableOptions.table)}
|
|
556
|
+
ORDER BY position
|
|
557
|
+
`);
|
|
558
|
+
if (!rows.length) {
|
|
559
|
+
throw Error(`clickhouse.missing_table ${tableOptions.table}`);
|
|
560
|
+
}
|
|
561
|
+
tableOptions.schema = {
|
|
562
|
+
types: Object.fromEntries(rows.map(({ name, type }) => [name, type]))
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
await Promise.all(
|
|
566
|
+
Object.values(tableOptions.joins || {}).map(
|
|
567
|
+
(joinOptions) => this.ensureSchema(joinOptions)
|
|
568
|
+
)
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
normalizeRow(row, schema) {
|
|
572
|
+
const out = {};
|
|
573
|
+
for (const [key, value] of Object.entries(row)) {
|
|
574
|
+
const type = schema?.types?.[key];
|
|
575
|
+
if (value === null || value === void 0) {
|
|
576
|
+
out[key] = null;
|
|
577
|
+
} else if (type === "UInt8" || type === "Nullable(UInt8)") {
|
|
578
|
+
out[key] = Boolean(value);
|
|
579
|
+
} else if (type === "String" || type === "Nullable(String)") {
|
|
580
|
+
out[key] = maybeParseJson(value);
|
|
581
|
+
} else {
|
|
582
|
+
out[key] = value;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
getCursorValue(row, orderItem) {
|
|
588
|
+
const isDesc = orderItem[0] === "!";
|
|
589
|
+
const prop = isDesc ? orderItem.slice(1) : orderItem;
|
|
590
|
+
const path = prop.split(".");
|
|
591
|
+
let value = row[path[0]];
|
|
592
|
+
for (let i = 1; i < path.length; i += 1) {
|
|
593
|
+
value = value?.[path[i]];
|
|
594
|
+
}
|
|
595
|
+
if (value === void 0) value = null;
|
|
596
|
+
if (!isDesc || value === null) return value;
|
|
597
|
+
const numeric = Number(value);
|
|
598
|
+
if (!Number.isFinite(numeric)) {
|
|
599
|
+
throw Error(`clickhouse.cursor_desc_non_numeric ${prop}`);
|
|
600
|
+
}
|
|
601
|
+
return -numeric;
|
|
602
|
+
}
|
|
603
|
+
async read(rootQuery, tableOptions) {
|
|
604
|
+
const idQueries = {};
|
|
605
|
+
const promises = [];
|
|
606
|
+
const results = [];
|
|
607
|
+
const { prefix: rawPrefix } = tableOptions;
|
|
608
|
+
const prefix = common.encodePath(rawPrefix);
|
|
609
|
+
await this.ensureSchema(tableOptions);
|
|
610
|
+
const getByArgs = async (args, projection) => {
|
|
611
|
+
const selection = selectByArgs(args, projection, tableOptions);
|
|
612
|
+
const rows = await this.query(selection.sql);
|
|
613
|
+
if (selection.ensureSingleRow && rows.length > 1) {
|
|
614
|
+
throw Error(`clickhouse.more_than_one_row ${tableOptions.table}`);
|
|
615
|
+
}
|
|
616
|
+
const wrappedRows = rows.map((row) => {
|
|
617
|
+
const object = this.normalizeRow(row, tableOptions.schema);
|
|
618
|
+
applyAggregateAliases(object, selection.aggregateAliases || {});
|
|
619
|
+
const key = deepCloneJson(selection.keyBase);
|
|
620
|
+
if (selection.isAggregate && selection.groupAliases?.length) {
|
|
621
|
+
key.$cursor = selection.groupAliases.map((alias) => object[alias]);
|
|
622
|
+
} else if (selection.isAggregate && selection.groupSpec === true && selection.hasRangeArg) {
|
|
623
|
+
key.$cursor = "";
|
|
624
|
+
} else if (selection.hasRangeArg && selection.hasCursor) {
|
|
625
|
+
key.$cursor = selection.orderSpec.map(
|
|
626
|
+
(orderItem) => this.getCursorValue(object, orderItem)
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
(selection.groupAliases || []).forEach((alias) => {
|
|
630
|
+
delete object[alias];
|
|
631
|
+
});
|
|
632
|
+
object.$key = key;
|
|
633
|
+
object.$ver = object[tableOptions.verCol] ?? object._version ?? null;
|
|
634
|
+
if (!selection.isAggregate) {
|
|
635
|
+
object.$ref = [...rawPrefix, object[tableOptions.idCol]];
|
|
636
|
+
}
|
|
637
|
+
return object;
|
|
638
|
+
});
|
|
639
|
+
common.merge(results, common.encodeGraph(common.wrapObject(wrappedRows, rawPrefix)));
|
|
640
|
+
};
|
|
641
|
+
const getByIds = async () => {
|
|
642
|
+
const selection = selectByIds(Object.keys(idQueries), tableOptions);
|
|
643
|
+
const rows = await this.query(selection.sql);
|
|
644
|
+
for (const row of rows) {
|
|
645
|
+
const object = this.normalizeRow(row, tableOptions.schema);
|
|
646
|
+
object.$key = object[tableOptions.idCol];
|
|
647
|
+
object.$ver = object[tableOptions.verCol] ?? object._version ?? null;
|
|
648
|
+
common.merge(results, common.encodeGraph(common.wrapObject(object, rawPrefix)));
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
const query = common.unwrap(rootQuery, prefix);
|
|
652
|
+
for (const node of query) {
|
|
653
|
+
const args = common.decodeArgs(node);
|
|
654
|
+
if (common.isPlainObject(args)) {
|
|
655
|
+
if (node.prefix) {
|
|
656
|
+
for (const childNode of node.children) {
|
|
657
|
+
const childArgs = common.decodeArgs(childNode);
|
|
658
|
+
const projection = childNode.children ? common.decodeQuery(childNode.children) : null;
|
|
659
|
+
promises.push(getByArgs({ ...args, ...childArgs }, projection));
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
const projection = node.children ? common.decodeQuery(node.children) : null;
|
|
663
|
+
promises.push(getByArgs(args, projection));
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
idQueries[args] = node.children;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (!common.isEmpty(idQueries)) promises.push(getByIds());
|
|
670
|
+
await Promise.all(promises);
|
|
671
|
+
return common.finalize(results, common.wrap(query, prefix));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function getTableOpts(name, options = {}, parentName = null, parentDefaults = {}) {
|
|
675
|
+
const { table, idCol, verCol, schema, database, final } = options;
|
|
676
|
+
const tableName = table || name;
|
|
677
|
+
const tableDatabase = database || parentDefaults.database || "default";
|
|
678
|
+
const tableFinal = final ?? parentDefaults.final ?? true;
|
|
679
|
+
const joins = Object.fromEntries(
|
|
680
|
+
Object.entries(options.joins || {}).map(([joinName, joinRaw = {}]) => {
|
|
681
|
+
const { refCol = parentName, ...joinOptions } = joinRaw;
|
|
682
|
+
return [
|
|
683
|
+
joinName,
|
|
684
|
+
{
|
|
685
|
+
refCol: refCol || parentName || tableName,
|
|
686
|
+
...getTableOpts(joinName, joinOptions, tableName, {
|
|
687
|
+
database: tableDatabase,
|
|
688
|
+
final: tableFinal
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
];
|
|
692
|
+
})
|
|
693
|
+
);
|
|
694
|
+
return {
|
|
695
|
+
table: tableName,
|
|
696
|
+
idCol: idCol || "id",
|
|
697
|
+
verCol: verCol || "updatedAt",
|
|
698
|
+
database: tableDatabase,
|
|
699
|
+
final: tableFinal,
|
|
700
|
+
schema,
|
|
701
|
+
joins
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
const clickhouse = (options = {}) => (store) => {
|
|
705
|
+
const { connection, ...rawOptions } = options;
|
|
706
|
+
store.on("read", read);
|
|
707
|
+
const prefix = store.path;
|
|
708
|
+
const tableOpts = getTableOpts(prefix[prefix.length - 1], rawOptions);
|
|
709
|
+
tableOpts.prefix = prefix;
|
|
710
|
+
const defaultDb = new Db(connection);
|
|
711
|
+
function read(query, _options, next) {
|
|
712
|
+
const readPromise = defaultDb.read(query, tableOpts);
|
|
713
|
+
const remainingQuery = common.remove(query, common.encodePath(prefix));
|
|
714
|
+
const nextPromise = next(remainingQuery);
|
|
715
|
+
return Promise.all([readPromise, nextPromise]).then(
|
|
716
|
+
([readRes, nextRes]) => {
|
|
717
|
+
return common.merge(readRes, nextRes);
|
|
718
|
+
}
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
const ch = clickhouse;
|
|
723
|
+
exports.ch = ch;
|
|
724
|
+
exports.clickhouse = clickhouse;
|