@quark-fw/clisma 0.1.12 → 0.1.14
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 +126 -19
- package/dist/assert-replicated-schema.d.ts +2 -0
- package/dist/assert-replicated-schema.js +16 -0
- package/dist/build-count-query.js +2 -1
- package/dist/build-create-dictionary-query.d.ts +2 -0
- package/dist/build-create-dictionary-query.js +51 -0
- package/dist/build-find-many-query.js +1 -1
- package/dist/build-index-sql.d.ts +2 -0
- package/dist/build-index-sql.js +11 -0
- package/dist/client.d.ts +17 -3
- package/dist/client.js +45 -18
- package/dist/column.d.ts +30 -1
- package/dist/column.js +55 -0
- package/dist/ddl-shared.d.ts +13 -0
- package/dist/ddl-shared.js +54 -0
- package/dist/ddl.d.ts +2 -0
- package/dist/ddl.js +41 -7
- package/dist/dictionary.d.ts +26 -0
- package/dist/dictionary.js +7 -0
- package/dist/engine.d.ts +16 -0
- package/dist/engine.js +26 -0
- package/dist/ensure-dictionaries-on-nodes.d.ts +3 -0
- package/dist/ensure-dictionaries-on-nodes.js +15 -0
- package/dist/ensure-tables-on-nodes.d.ts +11 -0
- package/dist/ensure-tables-on-nodes.js +39 -0
- package/dist/escape-id.d.ts +1 -0
- package/dist/escape-id.js +4 -0
- package/dist/expr.d.ts +5 -0
- package/dist/expr.js +4 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/migrate.js +15 -5
- package/dist/query.d.ts +26 -0
- package/dist/query.js +60 -0
- package/dist/rows.js +9 -0
- package/dist/schema.d.ts +4 -2
- package/dist/sql-shared.d.ts +5 -2
- package/dist/sql-shared.js +16 -7
- package/dist/table-client.js +9 -2
- package/dist/table.d.ts +26 -4
- package/dist/table.js +24 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -119,16 +119,84 @@ const user = await db.users.create({
|
|
|
119
119
|
| Фабрика | ClickHouse | TypeScript |
|
|
120
120
|
|--------------------|-----------------------|------------|
|
|
121
121
|
| `t.string()` | `String` | `string` |
|
|
122
|
-
| `t.uint8()`
|
|
123
|
-
| `t.int32()`
|
|
124
|
-
| `t.uint64()`
|
|
122
|
+
| `t.uint8()` / `t.uint16()` / `t.uint32()` | `UInt8/16/32` | `number` |
|
|
123
|
+
| `t.int8()` / `t.int16()` / `t.int32()` | `Int8/16/32` | `number` |
|
|
124
|
+
| `t.uint64()` / `t.int64()` | `UInt64` / `Int64` | `bigint` |
|
|
125
|
+
| `t.float32()` / `t.float64()` | `Float32/64` | `number` |
|
|
126
|
+
| `t.decimal(p, s)` | `Decimal(p, s)` | `string` (без потери точности) |
|
|
125
127
|
| `t.boolean()` | `Bool` | `boolean` |
|
|
128
|
+
| `t.date()` / `t.date32()` | `Date` / `Date32` | `Date` |
|
|
126
129
|
| `t.datetime()` | `DateTime` | `Date` |
|
|
130
|
+
| `t.datetime64(p, tz?)` | `DateTime64(p[, 'tz'])` | `Date` |
|
|
131
|
+
| `t.enum8({ a: 1, b: 2 })` | `Enum8('a' = 1, 'b' = 2)` | `"a" \| "b"` |
|
|
132
|
+
| `t.lowCardinality(col)` | `LowCardinality(...)` | тот же `T` |
|
|
133
|
+
| `t.ipv4()` / `t.ipv6()` | `IPv4` / `IPv6` | `string` |
|
|
134
|
+
| `t.map(k, v)` | `Map(K, V)` | `Record<string, V>` |
|
|
127
135
|
| `t.nullable(col)` | `Nullable(...)` | `T \| null`|
|
|
128
136
|
| `t.array(col)` | `Array(...)` | `T[]` |
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
`
|
|
138
|
+
Модификаторы (возвращают копию колонки): `t.default(col, expr)`,
|
|
139
|
+
`t.materialized(col, expr)`, `t.alias(col, expr)`, `t.codec(col, "Delta, ZSTD(1)")`,
|
|
140
|
+
`t.comment(col, "…")`.
|
|
141
|
+
|
|
142
|
+
### Движок и ключи таблицы
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { engine, expr, table, t } from "@quark-fw/clisma"
|
|
146
|
+
|
|
147
|
+
const clicks = table({
|
|
148
|
+
engine: engine.versionedCollapsingMergeTree("sign", "version"),
|
|
149
|
+
// путь в Keeper и макрос реплики → ReplicatedVersionedCollapsingMergeTree(...)
|
|
150
|
+
replication: { path: "/clickhouse/tables/{shard}/analytics/clicks", replica: "{replica}" },
|
|
151
|
+
partitionBy: expr("toYYYYMM(datetime)"),
|
|
152
|
+
primaryKey: [expr("toStartOfHour(datetime)"), "sub_id_15"],
|
|
153
|
+
orderBy: [expr("toStartOfHour(datetime)"), "sub_id_15", "click_id"],
|
|
154
|
+
indexes: [{ name: "idx_sub", expr: "sub_id_16", type: "bloom_filter", args: [0.01], granularity: 4 }],
|
|
155
|
+
settings: { index_granularity: 8192 },
|
|
156
|
+
columns: { /* … */ sign: t.int8(), version: t.uint64() }
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- `engine` — строка как раньше **или** спека из `engine.*`: `mergeTree()`,
|
|
161
|
+
`replacingMergeTree(ver?)`, `collapsingMergeTree(sign)`,
|
|
162
|
+
`versionedCollapsingMergeTree(sign, version)`, `summingMergeTree(cols?)`,
|
|
163
|
+
`aggregatingMergeTree()`. Со спекой clisma знает семейство: проверяет колонки
|
|
164
|
+
знака/версии при объявлении, считает `count()` как `sum(sign)` и требует
|
|
165
|
+
`final: true` в `findMany` у коллапсирующих таблиц.
|
|
166
|
+
- `expr("…")` — сырое выражение там, где иначе ждут имя колонки (`orderBy`,
|
|
167
|
+
`primaryKey`, `partitionBy`, `sampleBy`, `indexes[].expr`); имена колонок
|
|
168
|
+
по-прежнему экранируются.
|
|
169
|
+
- Опции: `primaryKey`, `partitionBy`, `sampleBy`, `indexes`, `ttl`, `settings`,
|
|
170
|
+
`comment`.
|
|
171
|
+
|
|
172
|
+
### Словари
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { dictionary } from "@quark-fw/clisma"
|
|
176
|
+
|
|
177
|
+
createClisma({
|
|
178
|
+
database: "analytics",
|
|
179
|
+
tables: { /* … */ },
|
|
180
|
+
dictionaries: {
|
|
181
|
+
user_subids_dict: dictionary({
|
|
182
|
+
key: { subid_value: t.uint32() },
|
|
183
|
+
attributes: { user_id: t.uint32(), team_ids: t.array(t.uint32()) },
|
|
184
|
+
source: { query: "SELECT subid_value, user_id, team_ids FROM analytics.user_subids FINAL" },
|
|
185
|
+
// или { table: "user_subids", where: "kind = 'BUYER'" }
|
|
186
|
+
layout: "hashed",
|
|
187
|
+
lifetime: { min: 60, max: 300 }
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Словарь — замена JOIN на измерение: `dictGet` ищет по хешу за O(1) на строку.
|
|
194
|
+
Создаётся на каждой DDL-ноде; `$init` — `CREATE DICTIONARY IF NOT EXISTS`,
|
|
195
|
+
`$migrate` — `CREATE OR REPLACE DICTIONARY` (иначе изменение источника или
|
|
196
|
+
атрибутов никогда бы не доехало). Учётка источника по умолчанию — учётка
|
|
197
|
+
клиента (`USER … PASSWORD …`): без неё ClickHouse читает локальные таблицы
|
|
198
|
+
как `default`, у которого обычно стоит пароль; переопределяется полями
|
|
199
|
+
`user`/`password` в `source`.
|
|
132
200
|
|
|
133
201
|
### Колонка `id`
|
|
134
202
|
|
|
@@ -142,16 +210,43 @@ clisma генерирует уникальный `bigint` (timestamp в стар
|
|
|
142
210
|
|
|
143
211
|
```ts
|
|
144
212
|
const db = createClient(schema, {
|
|
145
|
-
url, // http://host:8123
|
|
213
|
+
url, // http://host:8123 — сюда идут все запросы
|
|
146
214
|
username, password,
|
|
147
215
|
database, // приоритетнее schema.database
|
|
148
|
-
autoCreateDatabase: true
|
|
216
|
+
autoCreateDatabase: true, // $init/$migrate создадут БД
|
|
217
|
+
ddlNodes: [url, replicaUrl] // необязательно: ноды для DDL, первая — источник истины
|
|
218
|
+
requestTimeoutMs: 300_000, // необязательно: таймаут HTTP-запроса, по умолчанию 30 с драйвера
|
|
149
219
|
})
|
|
150
220
|
```
|
|
151
221
|
|
|
152
222
|
- `db.<table>` — клиент таблицы (см. API ниже)
|
|
153
|
-
- `db.$init()` — создать
|
|
223
|
+
- `db.$init()` — создать БД, таблицы и словари (`CREATE ... IF NOT EXISTS`), без диффа
|
|
154
224
|
- `db.$migrate()` — привести БД к схеме (см. «Миграции»)
|
|
225
|
+
- `db.$query<Row>(sql, { params, settings, columns })` — сырой SELECT
|
|
226
|
+
(`JSONEachRow`); `columns` — карта колонок результата, чтобы `UInt64`/`DateTime`
|
|
227
|
+
вернулись как `bigint`/`Date`
|
|
228
|
+
- `db.$queryJson<Row, Totals>(sql, opts)` — тот же запрос в `FORMAT JSON`:
|
|
229
|
+
`{ data, totals?, rows, rows_before_limit_at_least?, statistics }` — для
|
|
230
|
+
`WITH TOTALS`, точного числа строк до `LIMIT` (`exact_rows_before_limit = 1`)
|
|
231
|
+
и статистики чтения
|
|
232
|
+
- `db.$command(sql, { params, settings })` — `INSERT … SELECT`, `ALTER`, `DROP PARTITION`
|
|
233
|
+
- `db.$close()` — закрыть все соединения
|
|
234
|
+
|
|
235
|
+
Параметры — серверные `{name:Type}` (`Date` уходит unix-секундами), настройки
|
|
236
|
+
запроса сливаются с проводными (`date_time_output_format = iso`,
|
|
237
|
+
`output_format_json_quote_64bit_integers = 1`).
|
|
238
|
+
|
|
239
|
+
### DDL на несколько нод (`ddlNodes`)
|
|
240
|
+
|
|
241
|
+
Когда БД `Atomic` и в `remote_servers` нет кластера, `CREATE TABLE` не
|
|
242
|
+
разъезжается по репликам сам: без таблицы на реплике чтение через неё падает с
|
|
243
|
+
`UNKNOWN_TABLE`. С `ddlNodes` clisma создаёт таблицы на первой ноде из схемы,
|
|
244
|
+
а на остальные копирует её `SHOW CREATE TABLE` (Keeper сверяет метаданные реплик
|
|
245
|
+
побайтово, повторный рендер мог бы разойтись); `ALTER` идёт только на первую —
|
|
246
|
+
реплицируемая мутация разъезжается сама; словари создаются на каждой ноде.
|
|
247
|
+
При двух и более нодах принимаются только таблицы с `replication` (или строкой
|
|
248
|
+
движка `Replicated*`) — обычный `MergeTree` на двух нодах был бы двумя
|
|
249
|
+
независимыми таблицами.
|
|
155
250
|
|
|
156
251
|
## Миграции: `db.$migrate()`
|
|
157
252
|
|
|
@@ -161,15 +256,21 @@ const db = createClient(schema, {
|
|
|
161
256
|
- колонки нет → `ALTER TABLE ... ADD COLUMN`
|
|
162
257
|
- тип отличается → `ALTER TABLE ... MODIFY COLUMN`
|
|
163
258
|
|
|
164
|
-
Возвращает `{ statements, warnings }` —
|
|
165
|
-
|
|
259
|
+
Возвращает `{ statements, warnings, executed }` — план, предупреждения и
|
|
260
|
+
фактически выполненные SQL с нодой (`executed[].node`). Повторный запуск на
|
|
261
|
+
совпадающей схеме — пустой план (идемпотентно); словари при этом всё равно
|
|
262
|
+
пересоздаются (`CREATE OR REPLACE`, дёшево).
|
|
166
263
|
|
|
167
264
|
**Ограничения (осознанные):**
|
|
168
265
|
|
|
169
266
|
- лишние колонки в БД **никогда не удаляются** — только warning;
|
|
170
|
-
-
|
|
171
|
-
|
|
172
|
-
- `
|
|
267
|
+
- сравнивается только тип колонки: кодеки, `DEFAULT`, комментарии в диффе не
|
|
268
|
+
участвуют (в `ADD COLUMN` они рендерятся);
|
|
269
|
+
- `ENGINE`, `ORDER BY`, `PARTITION BY`, индексы, TTL не диффятся — их
|
|
270
|
+
изменение требует пересоздания таблицы вручную;
|
|
271
|
+
- смена типа ключевой колонки (из `ORDER BY`/`PRIMARY KEY`) — ошибка до
|
|
272
|
+
выполнения плана: сервер её всё равно отклонит, лучше упасть целиком, чем
|
|
273
|
+
посреди миграции;
|
|
173
274
|
- нет файлов/истории миграций как у `prisma migrate` — сравнение всегда
|
|
174
275
|
с живой схемой.
|
|
175
276
|
|
|
@@ -179,9 +280,13 @@ const db = createClient(schema, {
|
|
|
179
280
|
|
|
180
281
|
### Чтение
|
|
181
282
|
|
|
182
|
-
- `findMany({ where?, select?, orderBy?, take?, skip? })` → `Row[]`
|
|
283
|
+
- `findMany({ where?, select?, orderBy?, take?, skip?, final?, settings? })` → `Row[]`
|
|
183
284
|
- `findFirst({ where?, select?, orderBy?, skip? })` → `Row | null`
|
|
184
|
-
- `count({ where? })` → `number`
|
|
285
|
+
- `count({ where? })` → `number`; у коллапсирующих таблиц это `sum(sign)`
|
|
286
|
+
|
|
287
|
+
`final: true` добавляет `FINAL` таблице без стратегии `replacing`;
|
|
288
|
+
у коллапсирующих таблиц он обязателен для `findMany` (сырые строки содержат
|
|
289
|
+
погашенные версии). `settings` — настройки ClickHouse на этот запрос.
|
|
185
290
|
|
|
186
291
|
### Запись
|
|
187
292
|
|
|
@@ -285,9 +390,11 @@ npx tsc --noEmit # проверка типов — обязательный
|
|
|
285
390
|
|
|
286
391
|
## Ограничения / roadmap
|
|
287
392
|
|
|
288
|
-
- нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert
|
|
393
|
+
- нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert` —
|
|
394
|
+
агрегаты пишутся сырым SQL через `$query`/`$queryJson`;
|
|
289
395
|
- нет вложенного `not`, `mode: "insensitive"`, операторов для Array-колонок;
|
|
290
|
-
- нет файлов миграций/истории; дифф только add/modify
|
|
291
|
-
- нет `
|
|
396
|
+
- нет файлов миграций/истории; дифф только add/modify колонок по типу;
|
|
397
|
+
- нет `Nested`-сахара (объявляйте `"players.deposits": t.array(t.float64())`),
|
|
398
|
+
`Tuple`, `UUID`, материализованных представлений и `Distributed`;
|
|
292
399
|
- `create` без серверного round-trip (см. выше);
|
|
293
|
-
- нет relations/include — ClickHouse-философия:
|
|
400
|
+
- нет relations/include — ClickHouse-философия: денормализуйте (или словари).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/assert-replicated-schema.ts
|
|
2
|
+
import { isReplicatedEngine } from "./ddl-shared.js";
|
|
3
|
+
// Без кластера в remote_servers DDL не разъезжается сам: таблицу создают на
|
|
4
|
+
// каждой ноде руками. Обычный MergeTree на нескольких нодах — это две
|
|
5
|
+
// независимые таблицы, которые тихо разойдутся по данным, поэтому при
|
|
6
|
+
// нескольких DDL-нодах принимаем только Replicated-движки.
|
|
7
|
+
export function assertReplicatedSchema(nodeCount, tables) {
|
|
8
|
+
if (nodeCount < 2)
|
|
9
|
+
return;
|
|
10
|
+
for (const name in tables) {
|
|
11
|
+
if (!isReplicatedEngine(tables[name])) {
|
|
12
|
+
throw new Error(`Table "${name}" must use a Replicated* engine (set \`replication\`) ` +
|
|
13
|
+
`when DDL targets ${nodeCount} nodes`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/build-count-query.ts
|
|
2
|
+
import { countExpr } from "./sql-shared.js";
|
|
2
3
|
import { buildFindManyQuery } from "./build-find-many-query.js";
|
|
3
4
|
export function buildCountQuery(database, tableName, table, where) {
|
|
4
5
|
const inner = buildFindManyQuery(database, tableName, table, { where });
|
|
@@ -6,7 +7,7 @@ export function buildCountQuery(database, tableName, table, where) {
|
|
|
6
7
|
// swap the projection for count()
|
|
7
8
|
const from = inner.sql.indexOf(" FROM ");
|
|
8
9
|
return {
|
|
9
|
-
sql:
|
|
10
|
+
sql: `SELECT ${countExpr(table)} AS count` + inner.sql.slice(from),
|
|
10
11
|
params: inner.params,
|
|
11
12
|
};
|
|
12
13
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/build-create-dictionary-query.ts
|
|
2
|
+
import { escapeId } from "./escape-id.js";
|
|
3
|
+
import { quoteString } from "./ddl-shared.js";
|
|
4
|
+
export function buildCreateDictionarySQL(database, name, dict, credentials,
|
|
5
|
+
// $migrate пересоздаёт словарь: IF NOT EXISTS оставил бы старое
|
|
6
|
+
// определение (источник, учётку, атрибуты) навсегда
|
|
7
|
+
mode = "create") {
|
|
8
|
+
const columns = [
|
|
9
|
+
...Object.entries(dict.key),
|
|
10
|
+
...Object.entries(dict.attributes),
|
|
11
|
+
]
|
|
12
|
+
.map(([colName, col]) => `${escapeId(colName)} ${col.clickhouseType}`)
|
|
13
|
+
.join(",\n");
|
|
14
|
+
const keys = Object.keys(dict.key).map(escapeId).join(", ");
|
|
15
|
+
const auth = dict.source.user
|
|
16
|
+
? { user: dict.source.user, password: dict.source.password }
|
|
17
|
+
: credentials;
|
|
18
|
+
const authSQL = auth
|
|
19
|
+
? `USER ${quoteString(auth.user)} PASSWORD ${quoteString(auth.password ?? "")} `
|
|
20
|
+
: "";
|
|
21
|
+
// источник — тот же ClickHouse: запрос целиком, либо таблица с фильтром
|
|
22
|
+
const source = "query" in dict.source
|
|
23
|
+
? `CLICKHOUSE(${authSQL}QUERY ${quoteString(dict.source.query)})`
|
|
24
|
+
: `CLICKHOUSE(${authSQL}DB ${quoteString(dict.source.database ?? database)} ` +
|
|
25
|
+
`TABLE ${quoteString(dict.source.table)}` +
|
|
26
|
+
(dict.source.where
|
|
27
|
+
? ` WHERE ${quoteString(dict.source.where)}`
|
|
28
|
+
: "") +
|
|
29
|
+
")";
|
|
30
|
+
const lifetime = typeof dict.lifetime === "number"
|
|
31
|
+
? `LIFETIME(${dict.lifetime})`
|
|
32
|
+
: `LIFETIME(MIN ${dict.lifetime.min} MAX ${dict.lifetime.max})`;
|
|
33
|
+
const clauses = [
|
|
34
|
+
`PRIMARY KEY ${keys}`,
|
|
35
|
+
`SOURCE(${source})`,
|
|
36
|
+
`LAYOUT(${dict.layout.toUpperCase()}())`,
|
|
37
|
+
lifetime,
|
|
38
|
+
];
|
|
39
|
+
if (dict.comment)
|
|
40
|
+
clauses.push(`COMMENT ${quoteString(dict.comment)}`);
|
|
41
|
+
const verb = mode === "replace"
|
|
42
|
+
? "CREATE OR REPLACE DICTIONARY"
|
|
43
|
+
: "CREATE DICTIONARY IF NOT EXISTS";
|
|
44
|
+
return `
|
|
45
|
+
${verb} ${escapeId(database)}.${escapeId(name)}
|
|
46
|
+
(
|
|
47
|
+
${columns}
|
|
48
|
+
)
|
|
49
|
+
${clauses.join("\n ")}
|
|
50
|
+
`.trim();
|
|
51
|
+
}
|
|
@@ -12,7 +12,7 @@ export function buildFindManyQuery(database, tableName, table, args = {}) {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
const clauses = [
|
|
15
|
-
`SELECT ${selectSQL} FROM ${tableRef(database, tableName, table)}`,
|
|
15
|
+
`SELECT ${selectSQL} FROM ${tableRef(database, tableName, table, args.final)}`,
|
|
16
16
|
];
|
|
17
17
|
// WHERE
|
|
18
18
|
if (args.where) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/build-index-sql.ts
|
|
2
|
+
import { escapeId } from "./escape-id.js";
|
|
3
|
+
import { renderKeyPart } from "./ddl-shared.js";
|
|
4
|
+
export function buildIndexSQL(index) {
|
|
5
|
+
const args = index.args?.length ? `(${index.args.join(", ")})` : "";
|
|
6
|
+
const granularity = index.granularity !== undefined
|
|
7
|
+
? ` GRANULARITY ${index.granularity}`
|
|
8
|
+
: "";
|
|
9
|
+
return (`INDEX ${escapeId(index.name)} ${renderKeyPart(index.expr)} ` +
|
|
10
|
+
`TYPE ${index.type}${args}${granularity}`);
|
|
11
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
|
+
import { type ClickHouseClient } from "@clickhouse/client";
|
|
1
2
|
import { TableClient } from "./table-client.js";
|
|
2
3
|
import { AnyTable, InferRow } from "./table.js";
|
|
4
|
+
import type { AnyDictionary } from "./dictionary.js";
|
|
3
5
|
import { MigrationPlan } from "./migrate.js";
|
|
6
|
+
import { type DdlStatement } from "./ensure-tables-on-nodes.js";
|
|
7
|
+
import { type QueryJsonResult, type QueryOptions } from "./query.js";
|
|
4
8
|
type ClientOptions = {
|
|
5
9
|
url: string;
|
|
6
10
|
username?: string;
|
|
7
11
|
password?: string;
|
|
8
12
|
database?: string;
|
|
9
13
|
autoCreateDatabase?: boolean;
|
|
14
|
+
ddlNodes?: string[];
|
|
15
|
+
requestTimeoutMs?: number;
|
|
16
|
+
};
|
|
17
|
+
export type DdlPlan = MigrationPlan & {
|
|
18
|
+
executed: DdlStatement[];
|
|
10
19
|
};
|
|
11
20
|
export declare function createClient<TSchema extends {
|
|
12
21
|
database?: string;
|
|
13
22
|
tables: Record<string, AnyTable>;
|
|
23
|
+
dictionaries?: Record<string, AnyDictionary>;
|
|
14
24
|
}>(schema: TSchema, options: ClientOptions): { [K in keyof TSchema["tables"]]: TableClient<InferRow<TSchema["tables"][K]>>; } & {
|
|
15
|
-
$init(): Promise<
|
|
16
|
-
$migrate(): Promise<
|
|
25
|
+
$init(): Promise<DdlStatement[]>;
|
|
26
|
+
$migrate(): Promise<DdlPlan>;
|
|
27
|
+
$query<TRow>(sql: string, opts?: QueryOptions): Promise<TRow[]>;
|
|
28
|
+
$queryJson<TRow, TTotals = TRow>(sql: string, opts?: QueryOptions): Promise<QueryJsonResult<TRow, TTotals>>;
|
|
29
|
+
$command(sql: string, opts?: Omit<QueryOptions, "columns">): Promise<void>;
|
|
30
|
+
$close(): Promise<void>;
|
|
17
31
|
};
|
|
18
|
-
export {};
|
|
32
|
+
export type { ClickHouseClient };
|
package/dist/client.js
CHANGED
|
@@ -1,27 +1,37 @@
|
|
|
1
|
-
import { createClient as createCHClient } from "@clickhouse/client";
|
|
1
|
+
import { createClient as createCHClient, } from "@clickhouse/client";
|
|
2
2
|
import { TableClient } from "./table-client.js";
|
|
3
|
-
import {
|
|
4
|
-
import { escapeId } from "./sql.js";
|
|
3
|
+
import { escapeId } from "./escape-id.js";
|
|
5
4
|
import { buildMigrationPlan } from "./migrate.js";
|
|
5
|
+
import { assertReplicatedSchema } from "./assert-replicated-schema.js";
|
|
6
|
+
import { ensureTablesOnNodes, } from "./ensure-tables-on-nodes.js";
|
|
7
|
+
import { ensureDictionariesOnNodes } from "./ensure-dictionaries-on-nodes.js";
|
|
8
|
+
import { runCommand, runQuery, runQueryJson, } from "./query.js";
|
|
6
9
|
export function createClient(schema, options) {
|
|
7
10
|
const database = options.database ?? schema.database ?? "default";
|
|
8
|
-
const
|
|
9
|
-
|
|
11
|
+
const dictionaries = schema.dictionaries ?? {};
|
|
12
|
+
const connect = (url, withDatabase) => createCHClient({
|
|
13
|
+
url,
|
|
10
14
|
username: options.username,
|
|
11
15
|
password: options.password,
|
|
12
|
-
database,
|
|
16
|
+
...(withDatabase ? { database } : {}),
|
|
17
|
+
...(options.requestTimeoutMs ? { request_timeout: options.requestTimeoutMs } : {}),
|
|
13
18
|
});
|
|
19
|
+
const ch = connect(options.url, true);
|
|
20
|
+
// клиенты DDL-нод создаются один раз; основная нода переиспользует `ch`
|
|
21
|
+
const ddlUrls = options.ddlNodes?.length ? options.ddlNodes : [options.url];
|
|
22
|
+
const nodes = ddlUrls.map((url) => ({
|
|
23
|
+
url,
|
|
24
|
+
client: url === options.url ? ch : connect(url, true),
|
|
25
|
+
}));
|
|
14
26
|
const db = {};
|
|
15
27
|
for (const tableName in schema.tables) {
|
|
16
28
|
db[tableName] = new TableClient(database, tableName, schema.tables[tableName], ch);
|
|
17
29
|
}
|
|
18
30
|
const ensureDatabase = async () => {
|
|
19
|
-
if (options.autoCreateDatabase)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
password: options.password,
|
|
24
|
-
});
|
|
31
|
+
if (!options.autoCreateDatabase)
|
|
32
|
+
return;
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
const rootClient = connect(node.url, false);
|
|
25
35
|
try {
|
|
26
36
|
await rootClient.command({
|
|
27
37
|
query: `CREATE DATABASE IF NOT EXISTS ${escapeId(database)}`,
|
|
@@ -32,15 +42,19 @@ export function createClient(schema, options) {
|
|
|
32
42
|
}
|
|
33
43
|
}
|
|
34
44
|
};
|
|
45
|
+
const ensureEverywhere = async (dictionaryMode) => [
|
|
46
|
+
...(await ensureTablesOnNodes(nodes, database, schema.tables)),
|
|
47
|
+
...(await ensureDictionariesOnNodes(nodes, database, dictionaries, options.username
|
|
48
|
+
? { user: options.username, password: options.password }
|
|
49
|
+
: undefined, dictionaryMode)),
|
|
50
|
+
];
|
|
35
51
|
db.$init = async () => {
|
|
52
|
+
assertReplicatedSchema(nodes.length, schema.tables);
|
|
36
53
|
await ensureDatabase();
|
|
37
|
-
|
|
38
|
-
const table = schema.tables[tableName];
|
|
39
|
-
const createSQL = buildCreateTableSQL(database, tableName, table);
|
|
40
|
-
await ch.command({ query: createSQL });
|
|
41
|
-
}
|
|
54
|
+
return ensureEverywhere("create");
|
|
42
55
|
};
|
|
43
56
|
db.$migrate = async () => {
|
|
57
|
+
assertReplicatedSchema(nodes.length, schema.tables);
|
|
44
58
|
await ensureDatabase();
|
|
45
59
|
const result = await ch.query({
|
|
46
60
|
query: `
|
|
@@ -54,10 +68,23 @@ export function createClient(schema, options) {
|
|
|
54
68
|
});
|
|
55
69
|
const existing = await result.json();
|
|
56
70
|
const plan = buildMigrationPlan(database, schema.tables, existing);
|
|
71
|
+
// план (CREATE недостающих, ALTER ADD/MODIFY) выполняется на первой
|
|
72
|
+
// ноде: реплицируемый ALTER сам разъезжается по репликам, а недостающие
|
|
73
|
+
// на репликах таблицы докладывает ensureTablesOnNodes копией SHOW CREATE
|
|
74
|
+
const executed = [];
|
|
57
75
|
for (const statement of plan.statements) {
|
|
58
76
|
await ch.command({ query: statement });
|
|
77
|
+
executed.push({ node: options.url, sql: statement });
|
|
59
78
|
}
|
|
60
|
-
|
|
79
|
+
executed.push(...(await ensureEverywhere("replace")));
|
|
80
|
+
return { ...plan, executed };
|
|
81
|
+
};
|
|
82
|
+
db.$query = (sql, opts) => runQuery(ch, sql, opts);
|
|
83
|
+
db.$queryJson = (sql, opts) => runQueryJson(ch, sql, opts);
|
|
84
|
+
db.$command = (sql, opts) => runCommand(ch, sql, opts);
|
|
85
|
+
db.$close = async () => {
|
|
86
|
+
for (const node of nodes)
|
|
87
|
+
await node.client.close();
|
|
61
88
|
};
|
|
62
89
|
return db;
|
|
63
90
|
}
|
package/dist/column.d.ts
CHANGED
|
@@ -1,17 +1,46 @@
|
|
|
1
|
+
export type ColumnDefault = {
|
|
2
|
+
kind: "DEFAULT" | "MATERIALIZED" | "ALIAS";
|
|
3
|
+
expr: string;
|
|
4
|
+
};
|
|
1
5
|
export type Column<T> = {
|
|
2
6
|
clickhouseType: string;
|
|
3
7
|
nullable: boolean;
|
|
8
|
+
default?: ColumnDefault;
|
|
9
|
+
codec?: string;
|
|
10
|
+
comment?: string;
|
|
4
11
|
__type?: T;
|
|
5
12
|
};
|
|
6
13
|
type NullableColumn<C extends Column<unknown>> = C extends Column<infer T> ? Column<T | null> : never;
|
|
14
|
+
type ColumnType<C extends Column<unknown>> = C extends Column<infer T> ? T : never;
|
|
7
15
|
export declare const t: {
|
|
8
16
|
string: () => Column<string>;
|
|
9
17
|
uint8: () => Column<number>;
|
|
18
|
+
uint16: () => Column<number>;
|
|
19
|
+
uint32: () => Column<number>;
|
|
10
20
|
uint64: () => Column<bigint>;
|
|
21
|
+
int8: () => Column<number>;
|
|
22
|
+
int16: () => Column<number>;
|
|
11
23
|
int32: () => Column<number>;
|
|
24
|
+
int64: () => Column<bigint>;
|
|
25
|
+
float32: () => Column<number>;
|
|
26
|
+
float64: () => Column<number>;
|
|
27
|
+
decimal: (precision: number, scale: number) => Column<string>;
|
|
12
28
|
boolean: () => Column<boolean>;
|
|
29
|
+
date: () => Column<Date>;
|
|
30
|
+
date32: () => Column<Date>;
|
|
13
31
|
datetime: () => Column<Date>;
|
|
32
|
+
datetime64: (precision?: number, timezone?: string) => Column<Date>;
|
|
33
|
+
ipv4: () => Column<string>;
|
|
34
|
+
ipv6: () => Column<string>;
|
|
35
|
+
enum8<TValues extends Record<string, number>>(values: TValues): Column<keyof TValues & string>;
|
|
36
|
+
lowCardinality<C extends Column<unknown>>(col: C): C;
|
|
37
|
+
map<K extends Column<unknown>, V extends Column<unknown>>(key: K, value: V): Column<Record<string, ColumnType<V>>>;
|
|
14
38
|
nullable<C extends Column<unknown>>(col: C): NullableColumn<C>;
|
|
15
|
-
array<C extends Column<unknown>>(col: C): Column<C
|
|
39
|
+
array<C extends Column<unknown>>(col: C): Column<ColumnType<C>[]>;
|
|
40
|
+
default<C extends Column<unknown>>(col: C, expr: string): C;
|
|
41
|
+
materialized<C extends Column<unknown>>(col: C, expr: string): C;
|
|
42
|
+
alias<C extends Column<unknown>>(col: C, expr: string): C;
|
|
43
|
+
codec<C extends Column<unknown>>(col: C, codec: string): C;
|
|
44
|
+
comment<C extends Column<unknown>>(col: C, comment: string): C;
|
|
16
45
|
};
|
|
17
46
|
export {};
|
package/dist/column.js
CHANGED
|
@@ -8,12 +8,50 @@ function makeColumn(clickhouseType, nullable = false) {
|
|
|
8
8
|
export const t = {
|
|
9
9
|
string: () => makeColumn("String"),
|
|
10
10
|
uint8: () => makeColumn("UInt8"),
|
|
11
|
+
uint16: () => makeColumn("UInt16"),
|
|
12
|
+
uint32: () => makeColumn("UInt32"),
|
|
13
|
+
// 64-битные целые не помещаются в number без потерь — только bigint
|
|
11
14
|
uint64: () => makeColumn("UInt64"),
|
|
15
|
+
int8: () => makeColumn("Int8"),
|
|
16
|
+
int16: () => makeColumn("Int16"),
|
|
12
17
|
int32: () => makeColumn("Int32"),
|
|
18
|
+
int64: () => makeColumn("Int64"),
|
|
19
|
+
float32: () => makeColumn("Float32"),
|
|
20
|
+
float64: () => makeColumn("Float64"),
|
|
21
|
+
// Decimal отдаётся строкой: number потерял бы точность, а bigint не знает
|
|
22
|
+
// про дробную часть — арифметику ведёт вызывающий код
|
|
23
|
+
decimal: (precision, scale) => makeColumn(`Decimal(${precision}, ${scale})`),
|
|
13
24
|
boolean: () => makeColumn("Bool"),
|
|
25
|
+
date: () => makeColumn("Date"),
|
|
26
|
+
date32: () => makeColumn("Date32"),
|
|
14
27
|
datetime: () => makeColumn("DateTime"),
|
|
28
|
+
datetime64: (precision = 3, timezone) => makeColumn(timezone
|
|
29
|
+
? `DateTime64(${precision}, '${timezone}')`
|
|
30
|
+
: `DateTime64(${precision})`),
|
|
31
|
+
ipv4: () => makeColumn("IPv4"),
|
|
32
|
+
ipv6: () => makeColumn("IPv6"),
|
|
33
|
+
// Enum8: TS-тип — объединение имён значений, на проводе те же строки
|
|
34
|
+
enum8(values) {
|
|
35
|
+
const items = Object.entries(values)
|
|
36
|
+
.map(([name, code]) => `'${name.replace(/'/g, "''")}' = ${code}`)
|
|
37
|
+
.join(", ");
|
|
38
|
+
return makeColumn(`Enum8(${items})`);
|
|
39
|
+
},
|
|
40
|
+
// LowCardinality не меняет TS-тип и признак nullable: это только способ
|
|
41
|
+
// хранения словарём, который для строк с малым числом значений даёт
|
|
42
|
+
// и сжатие, и быстрый GROUP BY
|
|
43
|
+
lowCardinality(col) {
|
|
44
|
+
return {
|
|
45
|
+
...col,
|
|
46
|
+
clickhouseType: `LowCardinality(${col.clickhouseType})`,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
map(key, value) {
|
|
50
|
+
return makeColumn(`Map(${key.clickhouseType}, ${value.clickhouseType})`);
|
|
51
|
+
},
|
|
15
52
|
nullable(col) {
|
|
16
53
|
return {
|
|
54
|
+
...col,
|
|
17
55
|
clickhouseType: `Nullable(${col.clickhouseType})`,
|
|
18
56
|
nullable: true,
|
|
19
57
|
};
|
|
@@ -21,4 +59,21 @@ export const t = {
|
|
|
21
59
|
array(col) {
|
|
22
60
|
return makeColumn(`Array(${col.clickhouseType})`);
|
|
23
61
|
},
|
|
62
|
+
// модификаторы возвращают копию: одна фабрика может описывать несколько
|
|
63
|
+
// колонок разных таблиц, общий объект нельзя мутировать
|
|
64
|
+
default(col, expr) {
|
|
65
|
+
return { ...col, default: { kind: "DEFAULT", expr } };
|
|
66
|
+
},
|
|
67
|
+
materialized(col, expr) {
|
|
68
|
+
return { ...col, default: { kind: "MATERIALIZED", expr } };
|
|
69
|
+
},
|
|
70
|
+
alias(col, expr) {
|
|
71
|
+
return { ...col, default: { kind: "ALIAS", expr } };
|
|
72
|
+
},
|
|
73
|
+
codec(col, codec) {
|
|
74
|
+
return { ...col, codec };
|
|
75
|
+
},
|
|
76
|
+
comment(col, comment) {
|
|
77
|
+
return { ...col, comment };
|
|
78
|
+
},
|
|
24
79
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EngineSpec } from "./engine.js";
|
|
2
|
+
import type { Expr, KeyPart } from "./expr.js";
|
|
3
|
+
import type { AnyTable } from "./table.js";
|
|
4
|
+
export declare function isExpr(part: KeyPart): part is Expr;
|
|
5
|
+
export declare function renderKeyPart(part: KeyPart): string;
|
|
6
|
+
export declare function renderKeyList(parts: KeyPart[]): string;
|
|
7
|
+
export declare function keyColumns(parts: KeyPart[]): string[];
|
|
8
|
+
export declare function quoteString(value: string): string;
|
|
9
|
+
export declare function engineFamily(engine: string | EngineSpec): string;
|
|
10
|
+
export declare function isReplicatedEngine(table: AnyTable): boolean;
|
|
11
|
+
export declare function isReplacingEngine(engine: string | EngineSpec): boolean;
|
|
12
|
+
export declare function collapsingOf(engine: string | EngineSpec): EngineSpec["collapsing"] | undefined;
|
|
13
|
+
export declare function renderEngine(table: AnyTable): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/ddl-shared.ts
|
|
2
|
+
// Общие хелперы рендера DDL: разбор движка и ключей, экранирование.
|
|
3
|
+
import { escapeId } from "./escape-id.js";
|
|
4
|
+
export function isExpr(part) {
|
|
5
|
+
return typeof part === "object" && part !== null && "__expr" in part;
|
|
6
|
+
}
|
|
7
|
+
export function renderKeyPart(part) {
|
|
8
|
+
return isExpr(part) ? part.__expr : escapeId(part);
|
|
9
|
+
}
|
|
10
|
+
export function renderKeyList(parts) {
|
|
11
|
+
return `(${parts.map(renderKeyPart).join(", ")})`;
|
|
12
|
+
}
|
|
13
|
+
// имена колонок ключа без выражений — для проверок «ключ нельзя менять»
|
|
14
|
+
export function keyColumns(parts) {
|
|
15
|
+
return parts.filter((p) => typeof p === "string");
|
|
16
|
+
}
|
|
17
|
+
export function quoteString(value) {
|
|
18
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
19
|
+
}
|
|
20
|
+
export function engineFamily(engine) {
|
|
21
|
+
if (typeof engine !== "string")
|
|
22
|
+
return engine.family;
|
|
23
|
+
// у сырой строки семейство — имя до скобки, без префикса Replicated
|
|
24
|
+
return engine
|
|
25
|
+
.replace(/^Replicated/, "")
|
|
26
|
+
.replace(/\(.*$/s, "")
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
export function isReplicatedEngine(table) {
|
|
30
|
+
if (typeof table.engine === "string") {
|
|
31
|
+
return table.engine.startsWith("Replicated");
|
|
32
|
+
}
|
|
33
|
+
return table.replication !== undefined;
|
|
34
|
+
}
|
|
35
|
+
export function isReplacingEngine(engine) {
|
|
36
|
+
return engineFamily(engine).includes("ReplacingMergeTree");
|
|
37
|
+
}
|
|
38
|
+
export function collapsingOf(engine) {
|
|
39
|
+
return typeof engine === "string" ? undefined : engine.collapsing;
|
|
40
|
+
}
|
|
41
|
+
export function renderEngine(table) {
|
|
42
|
+
if (typeof table.engine === "string")
|
|
43
|
+
return table.engine;
|
|
44
|
+
const { family, args } = table.engine;
|
|
45
|
+
if (!table.replication) {
|
|
46
|
+
return args.length ? `${family}(${args.join(", ")})` : family;
|
|
47
|
+
}
|
|
48
|
+
const replicated = [
|
|
49
|
+
quoteString(table.replication.path),
|
|
50
|
+
quoteString(table.replication.replica),
|
|
51
|
+
...args,
|
|
52
|
+
];
|
|
53
|
+
return `Replicated${family}(${replicated.join(", ")})`;
|
|
54
|
+
}
|
package/dist/ddl.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import { AnyTable } from "./table.js";
|
|
2
|
+
import type { Column } from "./column.js";
|
|
3
|
+
export declare function buildColumnSQL(name: string, col: Column<unknown>): string;
|
|
2
4
|
export declare function buildCreateTableSQL(database: string, tableName: string, table: AnyTable): string;
|