@quark-fw/clisma 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/dist/client.d.ts +18 -0
- package/dist/client.js +63 -0
- package/dist/column.d.ts +17 -0
- package/dist/column.js +31 -0
- package/dist/ddl.d.ts +2 -0
- package/dist/ddl.js +17 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.js +42 -0
- package/dist/rows.d.ts +2 -0
- package/dist/rows.js +31 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +4 -0
- package/dist/sql.d.ts +25 -0
- package/dist/sql.js +255 -0
- package/dist/table-client.d.ts +55 -0
- package/dist/table-client.js +186 -0
- package/dist/table.d.ts +13 -0
- package/dist/table.js +9 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/dist/where.d.ts +24 -0
- package/dist/where.js +2 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# clisma
|
|
2
|
+
|
|
3
|
+
**Prisma-подобный клиент и мигратор для ClickHouse.**
|
|
4
|
+
|
|
5
|
+
Зачем: в проектах, где Postgres (данные кампаний, Prisma) живёт рядом с ClickHouse
|
|
6
|
+
(клики/события), запросы приходится писать двумя разными способами и вручную
|
|
7
|
+
синхронизировать схемы. clisma даёт ClickHouse тот же синтаксис запросов, что у
|
|
8
|
+
Prisma — `prisma.user.findMany(...)` переносится как `db.user.findMany(...)` почти
|
|
9
|
+
без изменений — плюс миграции схемы.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const users = await db.users.findMany({
|
|
13
|
+
where: {
|
|
14
|
+
email: { endsWith: "@gmail.com" },
|
|
15
|
+
age: { gte: 18 },
|
|
16
|
+
OR: [{ name: null }, { name: { startsWith: "A" } }]
|
|
17
|
+
},
|
|
18
|
+
select: { id: true, email: true },
|
|
19
|
+
orderBy: { createdAt: "desc" },
|
|
20
|
+
take: 100
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Установка
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm i @quark-fw/clisma
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Требования: Node.js >= 20. Пакет ESM-only (`import`), CommonJS-сборки нет.
|
|
33
|
+
|
|
34
|
+
## Быстрый старт
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// db.clisma.ts — схема
|
|
38
|
+
import { createClisma, table, t } from "@quark-fw/clisma"
|
|
39
|
+
|
|
40
|
+
export const schema = createClisma({
|
|
41
|
+
database: "analytics",
|
|
42
|
+
tables: {
|
|
43
|
+
users: table({
|
|
44
|
+
engine: "MergeTree",
|
|
45
|
+
orderBy: ["id"],
|
|
46
|
+
columns: {
|
|
47
|
+
id: t.uint64(),
|
|
48
|
+
email: t.string(),
|
|
49
|
+
name: t.nullable(t.string()),
|
|
50
|
+
age: t.nullable(t.uint8()),
|
|
51
|
+
createdAt: t.datetime()
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// приложение
|
|
60
|
+
import { createClient } from "@quark-fw/clisma"
|
|
61
|
+
import { schema } from "./db.clisma.js"
|
|
62
|
+
|
|
63
|
+
const db = createClient(schema, {
|
|
64
|
+
url: "http://localhost:8123",
|
|
65
|
+
username: "default",
|
|
66
|
+
password: "",
|
|
67
|
+
autoCreateDatabase: true
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
await db.$migrate() // привести БД к схеме
|
|
71
|
+
|
|
72
|
+
const user = await db.users.create({
|
|
73
|
+
data: { email: "a@b.c", createdAt: new Date() } // id сгенерируется
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Все типы выводятся из схемы: `db.users.findMany()` возвращает
|
|
78
|
+
`{ id: bigint; email: string; name: string | null; age: number | null; createdAt: Date }[]`,
|
|
79
|
+
`select` сужает тип результата, `where` принимает только существующие колонки.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Перенос кода с Prisma
|
|
84
|
+
|
|
85
|
+
| Prisma (Postgres) | clisma (ClickHouse) | Отличия |
|
|
86
|
+
|--------------------------------------|--------------------------------------|---------|
|
|
87
|
+
| `prisma.user.findMany(args)` | `db.user.findMany(args)` | тот же синтаксис `where/select/orderBy/take/skip` |
|
|
88
|
+
| `prisma.user.findFirst(args)` | `db.user.findFirst(args)` | — |
|
|
89
|
+
| `prisma.user.findUnique({where})` | `db.user.findFirst({where})` | в ClickHouse нет уникальных ключей — «уникальность» не гарантируется БД |
|
|
90
|
+
| `prisma.user.create({data})` | `db.user.create({data})` | возвращаемая строка собирается на клиенте, см. ниже |
|
|
91
|
+
| `prisma.user.createMany({data})` | `db.user.createMany({data})` | — |
|
|
92
|
+
| `prisma.user.update({where, data})` | `db.user.updateMany({where, data})` | без unique-ключей одиночного `update` нет |
|
|
93
|
+
| `prisma.user.updateMany(...)` | `db.user.updateMany(...)` | механика зависит от стратегии таблицы, см. «Мутации» |
|
|
94
|
+
| `prisma.user.delete({where})` | `db.user.deleteMany({where})` | аналогично |
|
|
95
|
+
| `prisma.user.deleteMany(...)` | `db.user.deleteMany(...)` | — |
|
|
96
|
+
| `prisma.user.count({where})` | `db.user.count({where})` | — |
|
|
97
|
+
| `aggregate` / `groupBy` / `distinct` | пока нет | в планах; `distinct` будет через `LIMIT 1 BY` |
|
|
98
|
+
| `$transaction` | нет | ClickHouse не транзакционен |
|
|
99
|
+
|
|
100
|
+
### Операторы `where`
|
|
101
|
+
|
|
102
|
+
Поддержан Prisma-синтаксис:
|
|
103
|
+
|
|
104
|
+
- shorthand: `{ email: "a@b.c" }`, `{ name: null }` → `IS NULL`
|
|
105
|
+
- сравнения: `equals`, `not` (значение или `null`), `in`, `notIn`, `lt`, `lte`, `gt`, `gte`
|
|
106
|
+
- строки: `contains`, `startsWith`, `endsWith` (без LIKE — через `position()/startsWith()/endsWith()`, спецсимволы экранировать не нужно)
|
|
107
|
+
- логика: `AND` (объект или массив), `OR` (массив), `NOT` — вкладываются рекурсивно
|
|
108
|
+
- `in: []` не матчит ничего, `notIn: []` матчит всё (как в Prisma)
|
|
109
|
+
|
|
110
|
+
Не поддержано (пока): вложенный фильтр в `not`, `mode: "insensitive"`,
|
|
111
|
+
`has/hasSome/hasEvery` для Array-колонок (Array-колонки в `where` запрещены на уровне типов).
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Схема
|
|
116
|
+
|
|
117
|
+
### Типы колонок
|
|
118
|
+
|
|
119
|
+
| Фабрика | ClickHouse | TypeScript |
|
|
120
|
+
|--------------------|-----------------------|------------|
|
|
121
|
+
| `t.string()` | `String` | `string` |
|
|
122
|
+
| `t.uint8()` | `UInt8` | `number` |
|
|
123
|
+
| `t.int32()` | `Int32` | `number` |
|
|
124
|
+
| `t.uint64()` | `UInt64` | `bigint` |
|
|
125
|
+
| `t.boolean()` | `Bool` | `boolean` |
|
|
126
|
+
| `t.datetime()` | `DateTime` | `Date` |
|
|
127
|
+
| `t.nullable(col)` | `Nullable(...)` | `T \| null`|
|
|
128
|
+
| `t.array(col)` | `Array(...)` | `T[]` |
|
|
129
|
+
|
|
130
|
+
Пока нет: `DateTime64`, `Decimal`, `Enum`, `LowCardinality`, `DEFAULT`,
|
|
131
|
+
`PARTITION BY`, `TTL`.
|
|
132
|
+
|
|
133
|
+
### Колонка `id`
|
|
134
|
+
|
|
135
|
+
Если в таблице есть колонка `id` и при `create`/`insert` значение не передано,
|
|
136
|
+
clisma генерирует уникальный `bigint` (timestamp в старших битах + счётчик —
|
|
137
|
+
уникален в пределах процесса, монотонно растёт).
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Клиент
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const db = createClient(schema, {
|
|
145
|
+
url, // http://host:8123
|
|
146
|
+
username, password,
|
|
147
|
+
database, // приоритетнее schema.database
|
|
148
|
+
autoCreateDatabase: true // $init/$migrate создадут БД
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- `db.<table>` — клиент таблицы (см. API ниже)
|
|
153
|
+
- `db.$init()` — создать БД и таблицы (`CREATE ... IF NOT EXISTS`), без диффа
|
|
154
|
+
- `db.$migrate()` — привести БД к схеме (см. «Миграции»)
|
|
155
|
+
|
|
156
|
+
## Миграции: `db.$migrate()`
|
|
157
|
+
|
|
158
|
+
Сравнивает живую схему (`system.columns`) с кодом и применяет разницу:
|
|
159
|
+
|
|
160
|
+
- таблицы нет → `CREATE TABLE`
|
|
161
|
+
- колонки нет → `ALTER TABLE ... ADD COLUMN`
|
|
162
|
+
- тип отличается → `ALTER TABLE ... MODIFY COLUMN`
|
|
163
|
+
|
|
164
|
+
Возвращает `{ statements, warnings }` — применённые SQL и предупреждения.
|
|
165
|
+
Повторный запуск на совпадающей схеме — пустой план (идемпотентно).
|
|
166
|
+
|
|
167
|
+
**Ограничения (осознанные):**
|
|
168
|
+
|
|
169
|
+
- лишние колонки в БД **никогда не удаляются** — только warning;
|
|
170
|
+
- `ENGINE`, `ORDER BY`, `PARTITION BY` не диффятся — их изменение требует
|
|
171
|
+
пересоздания таблицы вручную;
|
|
172
|
+
- `MODIFY COLUMN` ключевой колонки (из `ORDER BY`) отклонит сам ClickHouse;
|
|
173
|
+
- нет файлов/истории миграций как у `prisma migrate` — сравнение всегда
|
|
174
|
+
с живой схемой.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## API таблицы
|
|
179
|
+
|
|
180
|
+
### Чтение
|
|
181
|
+
|
|
182
|
+
- `findMany({ where?, select?, orderBy?, take?, skip? })` → `Row[]`
|
|
183
|
+
- `findFirst({ where?, select?, orderBy?, skip? })` → `Row | null`
|
|
184
|
+
- `count({ where? })` → `number`
|
|
185
|
+
|
|
186
|
+
### Запись
|
|
187
|
+
|
|
188
|
+
- `create({ data })` → созданная строка. **Строка собирается на клиенте**
|
|
189
|
+
(data + сгенерированный id; пропущенные nullable-поля становятся `null`,
|
|
190
|
+
`Date` обрезается до секунд) — без чтения с сервера. Если в таблице появятся
|
|
191
|
+
серверные `DEFAULT`/`MATERIALIZED` колонки, в возвращённой строке их не будет.
|
|
192
|
+
- `createMany({ data })` → `{ count }`; пустой массив не ходит в БД.
|
|
193
|
+
- `insert(row | rows)` — низкоуровневая вставка без возврата строк.
|
|
194
|
+
|
|
195
|
+
### Мутации
|
|
196
|
+
|
|
197
|
+
- `updateMany({ where?, data })` → `{ count }`
|
|
198
|
+
- `deleteMany({ where? })` → `{ count }`
|
|
199
|
+
|
|
200
|
+
Без `where` операция затрагивает **все строки** таблицы.
|
|
201
|
+
Обновлять колонки из `ORDER BY` нельзя (ошибка до запроса к БД).
|
|
202
|
+
|
|
203
|
+
**`count` в ответе** считается отдельным запросом до мутации — при
|
|
204
|
+
конкурентной записи между `count` и мутацией число может отличаться от
|
|
205
|
+
фактически затронутых строк (ClickHouse не возвращает affected rows).
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Мутации: стратегия на уровне таблицы
|
|
210
|
+
|
|
211
|
+
ClickHouse — не OLTP: `UPDATE`/`DELETE` в нём принципиально дороже, чем в
|
|
212
|
+
Postgres. clisma поддерживает три механизма; выбор — поле `mutations` таблицы:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
table({
|
|
216
|
+
engine: "ReplacingMergeTree",
|
|
217
|
+
orderBy: ["id"],
|
|
218
|
+
mutations: "replacing", // "alter" (по умолчанию) | "lightweight" | "replacing"
|
|
219
|
+
columns: { ... }
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
| Стратегия | updateMany | deleteMany | Чтения |
|
|
224
|
+
|----------------|-----------------------------------|-----------------------------|--------------|
|
|
225
|
+
| `"alter"` (default) | `ALTER TABLE ... UPDATE` (тяжёлая мутация, перезаписывает парты; ждём завершения — `mutations_sync=1`) | `ALTER TABLE ... DELETE` (аналогично) | обычные |
|
|
226
|
+
| `"lightweight"`| **фолбэк на `ALTER UPDATE`** — стабильного lightweight update в ClickHouse нет | `DELETE FROM` (быстрые «надгробия», `lightweight_deletes_sync=2`) | обычные |
|
|
227
|
+
| `"replacing"` | SELECT подходящих строк (FINAL) → вставка новых версий строк | `DELETE FROM` (lightweight) | автоматически с `FINAL` |
|
|
228
|
+
|
|
229
|
+
Как выбирать:
|
|
230
|
+
|
|
231
|
+
- **append-only таблицы** (клики, события) — не трогайте мутации вообще;
|
|
232
|
+
стратегия не важна, оставьте `"alter"` по умолчанию.
|
|
233
|
+
- **редкие массовые правки** (GDPR-удаление, backfill) — `"alter"`:
|
|
234
|
+
честная мутация, дорого, зато таблица остаётся обычным MergeTree.
|
|
235
|
+
- **частые удаления, редкие обновления** — `"lightweight"`: удаление дешёвое,
|
|
236
|
+
но помните, что update молча превращается в тяжёлый ALTER.
|
|
237
|
+
- **мутабельные сущности** (сессии, статусы, профили) — `"replacing"` +
|
|
238
|
+
`ReplacingMergeTree`: «обновление» — это дешёвая вставка новой версии.
|
|
239
|
+
|
|
240
|
+
Особенности `"replacing"`:
|
|
241
|
+
|
|
242
|
+
- требуется engine семейства `ReplacingMergeTree` (включая
|
|
243
|
+
`ReplicatedReplacingMergeTree`) — проверяется при объявлении таблицы;
|
|
244
|
+
- все чтения (`findMany`/`findFirst`/`count`) идут с `FINAL` — корректно,
|
|
245
|
+
но дороже обычного SELECT на больших таблицах;
|
|
246
|
+
- без колонки-версии `ReplacingMergeTree()` оставляет последнюю *вставленную*
|
|
247
|
+
строку; для детерминизма добавьте колонку версии
|
|
248
|
+
(`ReplacingMergeTree(ver)`) и включайте её в `data` при обновлении;
|
|
249
|
+
- `updateMany` пере-вставляет строки с теми же значениями `ORDER BY` —
|
|
250
|
+
поэтому менять ключевые колонки запрещено.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Особенности работы с ClickHouse (важно)
|
|
255
|
+
|
|
256
|
+
Эти решения зашиты в clisma, потому что дефолты ClickHouse/клиента ломают
|
|
257
|
+
корректность:
|
|
258
|
+
|
|
259
|
+
1. **Вставки ждут флаша** (`wait_for_async_insert=1` на каждом insert).
|
|
260
|
+
Если на сервере включён `async_insert=1, wait_for_async_insert=0`,
|
|
261
|
+
вставки подтверждаются *до* парсинга: ошибки молча теряются вместе с
|
|
262
|
+
данными, а свежие строки не видны следующему SELECT.
|
|
263
|
+
2. **Мутации синхронные** (`mutations_sync=1`, `lightweight_deletes_sync=2`) —
|
|
264
|
+
после `updateMany`/`deleteMany` чтение сразу видит результат.
|
|
265
|
+
3. **`Date` ⇄ `DateTime`**: в параметрах и вставках даты передаются
|
|
266
|
+
unix-секундами (тип `DateTime` секундной точности не принимает ISO-строки
|
|
267
|
+
с миллисекундами); в ответах включён `date_time_output_format=iso`, и
|
|
268
|
+
строки парсятся в `Date` без таймзонной неоднозначности.
|
|
269
|
+
4. **`bigint` без потери точности**: включён
|
|
270
|
+
`output_format_json_quote_64bit_integers=1` — `UInt64/Int64` приходят
|
|
271
|
+
строками и превращаются в `bigint` (иначе `JSON.parse` терял бы точность).
|
|
272
|
+
5. Все идентификаторы (БД, таблицы, колонки) экранируются бэктиками; все
|
|
273
|
+
значения передаются серверными query-параметрами `{p:Тип}` с типом из
|
|
274
|
+
схемы — SQL-инъекции через значения невозможны.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Тесты
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
npm run test:unit # юнит-тесты (node:test): SQL-генерация, стратегии, миграции
|
|
282
|
+
npm test # smoke на живом ClickHouse (src/test/.env), убирает за собой
|
|
283
|
+
npx tsc --noEmit # проверка типов — обязательный гейт (tsx типы не проверяет)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Ограничения / roadmap
|
|
287
|
+
|
|
288
|
+
- нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert`;
|
|
289
|
+
- нет вложенного `not`, `mode: "insensitive"`, операторов для Array-колонок;
|
|
290
|
+
- нет файлов миграций/истории; дифф только add/modify колонок;
|
|
291
|
+
- нет `DateTime64`/`Decimal`/`Enum`/`LowCardinality`, `DEFAULT`, `PARTITION BY`, `TTL`;
|
|
292
|
+
- `create` без серверного round-trip (см. выше);
|
|
293
|
+
- нет relations/include — ClickHouse-философия: денормализуйте.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TableClient } from "./table-client.js";
|
|
2
|
+
import { InferRow } from "./table.js";
|
|
3
|
+
import { MigrationPlan } from "./migrate.js";
|
|
4
|
+
type ClientOptions = {
|
|
5
|
+
url: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
password?: string;
|
|
8
|
+
database?: string;
|
|
9
|
+
autoCreateDatabase?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function createClient<TSchema extends {
|
|
12
|
+
database?: string;
|
|
13
|
+
tables: Record<string, any>;
|
|
14
|
+
}>(schema: TSchema, options: ClientOptions): { [K in keyof TSchema["tables"]]: TableClient<InferRow<TSchema["tables"][K]>>; } & {
|
|
15
|
+
$init(): Promise<void>;
|
|
16
|
+
$migrate(): Promise<MigrationPlan>;
|
|
17
|
+
};
|
|
18
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createClient as createCHClient } from "@clickhouse/client";
|
|
2
|
+
import { TableClient } from "./table-client.js";
|
|
3
|
+
import { buildCreateTableSQL } from "./ddl.js";
|
|
4
|
+
import { escapeId } from "./sql.js";
|
|
5
|
+
import { buildMigrationPlan } from "./migrate.js";
|
|
6
|
+
export function createClient(schema, options) {
|
|
7
|
+
const database = options.database ?? schema.database ?? "default";
|
|
8
|
+
const ch = createCHClient({
|
|
9
|
+
url: options.url,
|
|
10
|
+
username: options.username,
|
|
11
|
+
password: options.password,
|
|
12
|
+
database
|
|
13
|
+
});
|
|
14
|
+
const db = {};
|
|
15
|
+
for (const tableName in schema.tables) {
|
|
16
|
+
db[tableName] = new TableClient(database, tableName, schema.tables[tableName], ch);
|
|
17
|
+
}
|
|
18
|
+
const ensureDatabase = async () => {
|
|
19
|
+
if (options.autoCreateDatabase) {
|
|
20
|
+
const rootClient = createCHClient({
|
|
21
|
+
url: options.url,
|
|
22
|
+
username: options.username,
|
|
23
|
+
password: options.password
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
await rootClient.command({
|
|
27
|
+
query: `CREATE DATABASE IF NOT EXISTS ${escapeId(database)}`
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await rootClient.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
db.$init = async () => {
|
|
36
|
+
await ensureDatabase();
|
|
37
|
+
for (const tableName in schema.tables) {
|
|
38
|
+
const table = schema.tables[tableName];
|
|
39
|
+
const createSQL = buildCreateTableSQL(database, tableName, table);
|
|
40
|
+
await ch.command({ query: createSQL });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
db.$migrate = async () => {
|
|
44
|
+
await ensureDatabase();
|
|
45
|
+
const result = await ch.query({
|
|
46
|
+
query: `
|
|
47
|
+
SELECT table, name, type
|
|
48
|
+
FROM system.columns
|
|
49
|
+
WHERE database = {db:String}
|
|
50
|
+
ORDER BY table, position
|
|
51
|
+
`,
|
|
52
|
+
query_params: { db: database },
|
|
53
|
+
format: "JSONEachRow"
|
|
54
|
+
});
|
|
55
|
+
const existing = await result.json();
|
|
56
|
+
const plan = buildMigrationPlan(database, schema.tables, existing);
|
|
57
|
+
for (const statement of plan.statements) {
|
|
58
|
+
await ch.command({ query: statement });
|
|
59
|
+
}
|
|
60
|
+
return plan;
|
|
61
|
+
};
|
|
62
|
+
return db;
|
|
63
|
+
}
|
package/dist/column.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Column<T> = {
|
|
2
|
+
clickhouseType: string;
|
|
3
|
+
nullable: boolean;
|
|
4
|
+
__type?: T;
|
|
5
|
+
};
|
|
6
|
+
type NullableColumn<C extends Column<any>> = C extends Column<infer T> ? Column<T | null> : never;
|
|
7
|
+
export declare const t: {
|
|
8
|
+
string: () => Column<string>;
|
|
9
|
+
uint8: () => Column<number>;
|
|
10
|
+
uint64: () => Column<bigint>;
|
|
11
|
+
int32: () => Column<number>;
|
|
12
|
+
boolean: () => Column<boolean>;
|
|
13
|
+
datetime: () => Column<Date>;
|
|
14
|
+
nullable<C extends Column<any>>(col: C): NullableColumn<C>;
|
|
15
|
+
array<C extends Column<any>>(col: C): Column<C extends Column<infer T> ? T[] : never>;
|
|
16
|
+
};
|
|
17
|
+
export {};
|
package/dist/column.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/column.ts
|
|
2
|
+
function makeColumn(clickhouseType, nullable = false) {
|
|
3
|
+
return {
|
|
4
|
+
clickhouseType,
|
|
5
|
+
nullable,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function uint64Id() {
|
|
9
|
+
return {
|
|
10
|
+
clickhouseType: "UInt64",
|
|
11
|
+
nullable: false,
|
|
12
|
+
autoId: true // phantom field для TS
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export const t = {
|
|
16
|
+
string: () => makeColumn("String"),
|
|
17
|
+
uint8: () => makeColumn("UInt8"),
|
|
18
|
+
uint64: () => makeColumn("UInt64"),
|
|
19
|
+
int32: () => makeColumn("Int32"),
|
|
20
|
+
boolean: () => makeColumn("Bool"),
|
|
21
|
+
datetime: () => makeColumn("DateTime"),
|
|
22
|
+
nullable(col) {
|
|
23
|
+
return {
|
|
24
|
+
clickhouseType: `Nullable(${col.clickhouseType})`,
|
|
25
|
+
nullable: true,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
array(col) {
|
|
29
|
+
return makeColumn(`Array(${col.clickhouseType})`);
|
|
30
|
+
}
|
|
31
|
+
};
|
package/dist/ddl.d.ts
ADDED
package/dist/ddl.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// src/ddl.ts
|
|
2
|
+
import { escapeId } from "./sql.js";
|
|
3
|
+
export function buildCreateTableSQL(database, tableName, table) {
|
|
4
|
+
const columnsSQL = Object.entries(table.columns)
|
|
5
|
+
.map(([name, col]) => {
|
|
6
|
+
return `${escapeId(name)} ${col.clickhouseType}`;
|
|
7
|
+
})
|
|
8
|
+
.join(",\n");
|
|
9
|
+
return `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS ${escapeId(database)}.${escapeId(tableName)}
|
|
11
|
+
(
|
|
12
|
+
${columnsSQL}
|
|
13
|
+
)
|
|
14
|
+
ENGINE = ${table.engine}
|
|
15
|
+
ORDER BY (${table.orderBy.map(escapeId).join(", ")})
|
|
16
|
+
`.trim();
|
|
17
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./column.js";
|
|
2
|
+
export * from "./table.js";
|
|
3
|
+
export * from "./schema.js";
|
|
4
|
+
export * from "./client.js";
|
|
5
|
+
export * from "./where.js";
|
|
6
|
+
export * from "./sql.js";
|
|
7
|
+
export * from "./table-client.js";
|
|
8
|
+
export * from "./rows.js";
|
|
9
|
+
export * from "./migrate.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./column.js";
|
|
2
|
+
export * from "./table.js";
|
|
3
|
+
export * from "./schema.js";
|
|
4
|
+
export * from "./client.js";
|
|
5
|
+
export * from "./where.js";
|
|
6
|
+
export * from "./sql.js";
|
|
7
|
+
export * from "./table-client.js";
|
|
8
|
+
export * from "./rows.js";
|
|
9
|
+
export * from "./migrate.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { TableDefinition } from "./table.js";
|
|
2
|
+
export type DbColumn = {
|
|
3
|
+
table: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: string;
|
|
6
|
+
};
|
|
7
|
+
export type MigrationPlan = {
|
|
8
|
+
statements: string[];
|
|
9
|
+
warnings: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare function buildMigrationPlan(database: string, tables: Record<string, TableDefinition<any>>, existing: DbColumn[]): MigrationPlan;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/migrate.ts
|
|
2
|
+
import { buildCreateTableSQL } from "./ddl.js";
|
|
3
|
+
import { escapeId } from "./sql.js";
|
|
4
|
+
export function buildMigrationPlan(database, tables, existing) {
|
|
5
|
+
const statements = [];
|
|
6
|
+
const warnings = [];
|
|
7
|
+
const dbTables = new Map();
|
|
8
|
+
for (const col of existing) {
|
|
9
|
+
let cols = dbTables.get(col.table);
|
|
10
|
+
if (!cols) {
|
|
11
|
+
cols = new Map();
|
|
12
|
+
dbTables.set(col.table, cols);
|
|
13
|
+
}
|
|
14
|
+
cols.set(col.name, col.type);
|
|
15
|
+
}
|
|
16
|
+
for (const tableName in tables) {
|
|
17
|
+
const def = tables[tableName];
|
|
18
|
+
const dbCols = dbTables.get(tableName);
|
|
19
|
+
if (!dbCols) {
|
|
20
|
+
statements.push(buildCreateTableSQL(database, tableName, def));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const tableRef = `${escapeId(database)}.${escapeId(tableName)}`;
|
|
24
|
+
for (const colName in def.columns) {
|
|
25
|
+
const wanted = def.columns[colName].clickhouseType;
|
|
26
|
+
const actual = dbCols.get(colName);
|
|
27
|
+
if (actual === undefined) {
|
|
28
|
+
statements.push(`ALTER TABLE ${tableRef} ADD COLUMN ${escapeId(colName)} ${wanted}`);
|
|
29
|
+
}
|
|
30
|
+
else if (actual !== wanted) {
|
|
31
|
+
statements.push(`ALTER TABLE ${tableRef} MODIFY COLUMN ${escapeId(colName)} ${wanted}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const dbColName of dbCols.keys()) {
|
|
35
|
+
if (!(dbColName in def.columns)) {
|
|
36
|
+
warnings.push(`Column "${dbColName}" exists in ${database}.${tableName} ` +
|
|
37
|
+
`but not in the schema — not dropped, remove it manually if unneeded`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { statements, warnings };
|
|
42
|
+
}
|
package/dist/rows.d.ts
ADDED
package/dist/rows.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/rows.ts
|
|
2
|
+
const BIGINT_TYPES = new Set([
|
|
3
|
+
"UInt64", "Int64", "UInt128", "Int128", "UInt256", "Int256"
|
|
4
|
+
]);
|
|
5
|
+
function convertValue(value, chType) {
|
|
6
|
+
if (value === null || value === undefined)
|
|
7
|
+
return value;
|
|
8
|
+
const nullable = /^Nullable\((.+)\)$/.exec(chType);
|
|
9
|
+
if (nullable)
|
|
10
|
+
return convertValue(value, nullable[1]);
|
|
11
|
+
const array = /^Array\((.+)\)$/.exec(chType);
|
|
12
|
+
if (array) {
|
|
13
|
+
return value.map(v => convertValue(v, array[1]));
|
|
14
|
+
}
|
|
15
|
+
if (BIGINT_TYPES.has(chType))
|
|
16
|
+
return BigInt(value);
|
|
17
|
+
if (chType.startsWith("DateTime") || chType.startsWith("Date")) {
|
|
18
|
+
return new Date(value);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function deserializeRows(rows, columns) {
|
|
23
|
+
return rows.map(row => {
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const k in row) {
|
|
26
|
+
const col = columns[k];
|
|
27
|
+
out[k] = col ? convertValue(row[k], col.clickhouseType) : row[k];
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
});
|
|
31
|
+
}
|
package/dist/schema.d.ts
ADDED
package/dist/schema.js
ADDED
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TableDefinition } from "./table.js";
|
|
2
|
+
import { WhereInput } from "./where.js";
|
|
3
|
+
export type SQLBuildResult = {
|
|
4
|
+
sql: string;
|
|
5
|
+
params: Record<string, any>;
|
|
6
|
+
};
|
|
7
|
+
export type OrderByInput<T> = {
|
|
8
|
+
[K in keyof T]?: "asc" | "desc";
|
|
9
|
+
};
|
|
10
|
+
export type FindManyArgs<T> = {
|
|
11
|
+
where?: WhereInput<T>;
|
|
12
|
+
select?: {
|
|
13
|
+
[K in keyof T]?: boolean;
|
|
14
|
+
};
|
|
15
|
+
orderBy?: OrderByInput<T> | OrderByInput<T>[];
|
|
16
|
+
take?: number;
|
|
17
|
+
skip?: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function escapeId(name: string): string;
|
|
20
|
+
export declare function buildFindManyQuery(database: string, tableName: string, table: TableDefinition<any>, args?: FindManyArgs<any>): SQLBuildResult;
|
|
21
|
+
export declare function buildCountQuery(database: string, tableName: string, table: TableDefinition<any>, where?: WhereInput<any>): SQLBuildResult;
|
|
22
|
+
export declare function validateUpdateData(table: TableDefinition<any>, tableName: string, data: Record<string, any>): string[];
|
|
23
|
+
export declare function buildUpdateQuery(database: string, tableName: string, table: TableDefinition<any>, where: WhereInput<any> | undefined, data: Record<string, any>): SQLBuildResult;
|
|
24
|
+
export declare function buildDeleteQuery(database: string, tableName: string, table: TableDefinition<any>, where?: WhereInput<any>): SQLBuildResult;
|
|
25
|
+
export declare function buildLightweightDeleteQuery(database: string, tableName: string, table: TableDefinition<any>, where?: WhereInput<any>): SQLBuildResult;
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// src/sql.ts
|
|
2
|
+
export function escapeId(name) {
|
|
3
|
+
return "`" + name.replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
|
|
4
|
+
}
|
|
5
|
+
// UInt64 max — used to emulate a bare OFFSET (skip without take)
|
|
6
|
+
const NO_LIMIT = "18446744073709551615";
|
|
7
|
+
// DateTime is second-precision; the client would send Date with
|
|
8
|
+
// a fractional part, which ClickHouse rejects — send unix seconds.
|
|
9
|
+
function serialize(value) {
|
|
10
|
+
if (value instanceof Date) {
|
|
11
|
+
return Math.floor(value.getTime() / 1000);
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return value.map(serialize);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function createBuildContext(tableName, columns) {
|
|
19
|
+
const params = {};
|
|
20
|
+
let paramIndex = 0;
|
|
21
|
+
function colRef(key) {
|
|
22
|
+
if (!columns[key]) {
|
|
23
|
+
throw new Error(`Unknown column "${key}" in table "${tableName}"`);
|
|
24
|
+
}
|
|
25
|
+
return escapeId(key);
|
|
26
|
+
}
|
|
27
|
+
function baseType(key) {
|
|
28
|
+
const chType = columns[key].clickhouseType;
|
|
29
|
+
const m = /^Nullable\((.+)\)$/.exec(chType);
|
|
30
|
+
return m ? m[1] : chType;
|
|
31
|
+
}
|
|
32
|
+
function addParam(value, type) {
|
|
33
|
+
const name = `p${paramIndex++}`;
|
|
34
|
+
params[name] = serialize(value);
|
|
35
|
+
return `{${name}:${type}}`;
|
|
36
|
+
}
|
|
37
|
+
function buildFieldFilter(key, filter) {
|
|
38
|
+
const col = colRef(key);
|
|
39
|
+
const conditions = [];
|
|
40
|
+
for (const op in filter) {
|
|
41
|
+
const value = filter[op];
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
switch (op) {
|
|
45
|
+
case "equals":
|
|
46
|
+
conditions.push(value === null
|
|
47
|
+
? `${col} IS NULL`
|
|
48
|
+
: `${col} = ${addParam(value, baseType(key))}`);
|
|
49
|
+
break;
|
|
50
|
+
case "not":
|
|
51
|
+
conditions.push(value === null
|
|
52
|
+
? `${col} IS NOT NULL`
|
|
53
|
+
: `${col} != ${addParam(value, baseType(key))}`);
|
|
54
|
+
break;
|
|
55
|
+
case "in":
|
|
56
|
+
conditions.push(value.length === 0
|
|
57
|
+
? "0"
|
|
58
|
+
: `${col} IN ${addParam(value, `Array(${baseType(key)})`)}`);
|
|
59
|
+
break;
|
|
60
|
+
case "notIn":
|
|
61
|
+
if (value.length > 0) {
|
|
62
|
+
conditions.push(`${col} NOT IN ${addParam(value, `Array(${baseType(key)})`)}`);
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
case "lt":
|
|
66
|
+
case "lte":
|
|
67
|
+
case "gt":
|
|
68
|
+
case "gte": {
|
|
69
|
+
const cmp = { lt: "<", lte: "<=", gt: ">", gte: ">=" }[op];
|
|
70
|
+
conditions.push(`${col} ${cmp} ${addParam(value, baseType(key))}`);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "contains":
|
|
74
|
+
conditions.push(`position(${col}, ${addParam(value, "String")}) > 0`);
|
|
75
|
+
break;
|
|
76
|
+
case "startsWith":
|
|
77
|
+
conditions.push(`startsWith(${col}, ${addParam(value, "String")})`);
|
|
78
|
+
break;
|
|
79
|
+
case "endsWith":
|
|
80
|
+
conditions.push(`endsWith(${col}, ${addParam(value, "String")})`);
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
throw new Error(`Unknown operator "${op}" for column "${key}"`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return conditions;
|
|
87
|
+
}
|
|
88
|
+
function isFilterObject(value) {
|
|
89
|
+
return (value !== null &&
|
|
90
|
+
typeof value === "object" &&
|
|
91
|
+
!(value instanceof Date) &&
|
|
92
|
+
!Array.isArray(value));
|
|
93
|
+
}
|
|
94
|
+
function whereSQL(where) {
|
|
95
|
+
const parts = [];
|
|
96
|
+
for (const key in where) {
|
|
97
|
+
const value = where[key];
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
if (key === "AND") {
|
|
101
|
+
const list = Array.isArray(value) ? value : [value];
|
|
102
|
+
for (const w of list) {
|
|
103
|
+
const c = whereSQL(w);
|
|
104
|
+
if (c)
|
|
105
|
+
parts.push(`(${c})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (key === "OR") {
|
|
109
|
+
const conds = value
|
|
110
|
+
.map((w) => whereSQL(w))
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.map((c) => `(${c})`);
|
|
113
|
+
if (conds.length)
|
|
114
|
+
parts.push(`(${conds.join(" OR ")})`);
|
|
115
|
+
}
|
|
116
|
+
else if (key === "NOT") {
|
|
117
|
+
const list = Array.isArray(value) ? value : [value];
|
|
118
|
+
for (const w of list) {
|
|
119
|
+
const c = whereSQL(w);
|
|
120
|
+
if (c)
|
|
121
|
+
parts.push(`NOT (${c})`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (value === null) {
|
|
125
|
+
parts.push(`${colRef(key)} IS NULL`);
|
|
126
|
+
}
|
|
127
|
+
else if (isFilterObject(value)) {
|
|
128
|
+
parts.push(...buildFieldFilter(key, value));
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
parts.push(`${colRef(key)} = ${addParam(value, baseType(key))}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return parts.join(" AND ");
|
|
135
|
+
}
|
|
136
|
+
return { params, colRef, baseType, addParam, whereSQL };
|
|
137
|
+
}
|
|
138
|
+
function tableRef(database, tableName, table) {
|
|
139
|
+
const ref = `${escapeId(database)}.${escapeId(tableName)}`;
|
|
140
|
+
// replacing tables must read the latest row versions
|
|
141
|
+
return table.mutations === "replacing" ? `${ref} FINAL` : ref;
|
|
142
|
+
}
|
|
143
|
+
function checkPagination(name, value) {
|
|
144
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
145
|
+
throw new Error(`"${name}" must be a non-negative integer, got: ${value}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function buildFindManyQuery(database, tableName, table, args = {}) {
|
|
149
|
+
const ctx = createBuildContext(tableName, table.columns);
|
|
150
|
+
// SELECT
|
|
151
|
+
let selectSQL = "*";
|
|
152
|
+
if (args.select) {
|
|
153
|
+
const keys = Object.keys(args.select).filter(k => args.select[k] === true);
|
|
154
|
+
if (keys.length) {
|
|
155
|
+
selectSQL = keys.map(ctx.colRef).join(", ");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const clauses = [
|
|
159
|
+
`SELECT ${selectSQL} FROM ${tableRef(database, tableName, table)}`
|
|
160
|
+
];
|
|
161
|
+
// WHERE
|
|
162
|
+
if (args.where) {
|
|
163
|
+
const where = ctx.whereSQL(args.where);
|
|
164
|
+
if (where)
|
|
165
|
+
clauses.push(`WHERE ${where}`);
|
|
166
|
+
}
|
|
167
|
+
// ORDER BY
|
|
168
|
+
if (args.orderBy) {
|
|
169
|
+
const list = Array.isArray(args.orderBy)
|
|
170
|
+
? args.orderBy
|
|
171
|
+
: [args.orderBy];
|
|
172
|
+
const orders = [];
|
|
173
|
+
for (const entry of list) {
|
|
174
|
+
for (const key in entry) {
|
|
175
|
+
const dir = entry[key];
|
|
176
|
+
if (dir === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
if (dir !== "asc" && dir !== "desc") {
|
|
179
|
+
throw new Error(`Invalid sort direction "${dir}" for column "${key}"`);
|
|
180
|
+
}
|
|
181
|
+
orders.push(`${ctx.colRef(key)} ${dir.toUpperCase()}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (orders.length)
|
|
185
|
+
clauses.push(`ORDER BY ${orders.join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
// LIMIT / OFFSET
|
|
188
|
+
if (args.take !== undefined)
|
|
189
|
+
checkPagination("take", args.take);
|
|
190
|
+
if (args.skip !== undefined)
|
|
191
|
+
checkPagination("skip", args.skip);
|
|
192
|
+
if (args.take !== undefined && args.skip !== undefined) {
|
|
193
|
+
clauses.push(`LIMIT ${args.take} OFFSET ${args.skip}`);
|
|
194
|
+
}
|
|
195
|
+
else if (args.take !== undefined) {
|
|
196
|
+
clauses.push(`LIMIT ${args.take}`);
|
|
197
|
+
}
|
|
198
|
+
else if (args.skip !== undefined) {
|
|
199
|
+
clauses.push(`LIMIT ${NO_LIMIT} OFFSET ${args.skip}`);
|
|
200
|
+
}
|
|
201
|
+
return { sql: clauses.join(" "), params: ctx.params };
|
|
202
|
+
}
|
|
203
|
+
export function buildCountQuery(database, tableName, table, where) {
|
|
204
|
+
const inner = buildFindManyQuery(database, tableName, table, { where });
|
|
205
|
+
// with no select arg the query starts with "SELECT * FROM ..." —
|
|
206
|
+
// swap the projection for count()
|
|
207
|
+
const from = inner.sql.indexOf(" FROM ");
|
|
208
|
+
return {
|
|
209
|
+
sql: "SELECT count() AS count" + inner.sql.slice(from),
|
|
210
|
+
params: inner.params
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// shared by buildUpdateQuery and the replacing-strategy update path
|
|
214
|
+
export function validateUpdateData(table, tableName, data) {
|
|
215
|
+
const keys = Object.keys(data).filter(k => data[k] !== undefined);
|
|
216
|
+
if (keys.length === 0) {
|
|
217
|
+
throw new Error(`updateMany requires non-empty data`);
|
|
218
|
+
}
|
|
219
|
+
for (const key of keys) {
|
|
220
|
+
if (table.orderBy.includes(key)) {
|
|
221
|
+
throw new Error(`Cannot update ORDER BY key column "${key}" ` +
|
|
222
|
+
`of table "${tableName}"`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return keys;
|
|
226
|
+
}
|
|
227
|
+
export function buildUpdateQuery(database, tableName, table, where, data) {
|
|
228
|
+
const keys = validateUpdateData(table, tableName, data);
|
|
229
|
+
const ctx = createBuildContext(tableName, table.columns);
|
|
230
|
+
const sets = keys.map(k => `${ctx.colRef(k)} = ${ctx.addParam(data[k], ctx.baseType(k))}`);
|
|
231
|
+
const whereSQL = where ? ctx.whereSQL(where) : "";
|
|
232
|
+
return {
|
|
233
|
+
sql: `ALTER TABLE ${escapeId(database)}.${escapeId(tableName)} ` +
|
|
234
|
+
`UPDATE ${sets.join(", ")} WHERE ${whereSQL || "1"}`,
|
|
235
|
+
params: ctx.params
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
export function buildDeleteQuery(database, tableName, table, where) {
|
|
239
|
+
const ctx = createBuildContext(tableName, table.columns);
|
|
240
|
+
const whereSQL = where ? ctx.whereSQL(where) : "";
|
|
241
|
+
return {
|
|
242
|
+
sql: `ALTER TABLE ${escapeId(database)}.${escapeId(tableName)} ` +
|
|
243
|
+
`DELETE WHERE ${whereSQL || "1"}`,
|
|
244
|
+
params: ctx.params
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function buildLightweightDeleteQuery(database, tableName, table, where) {
|
|
248
|
+
const ctx = createBuildContext(tableName, table.columns);
|
|
249
|
+
const whereSQL = where ? ctx.whereSQL(where) : "";
|
|
250
|
+
return {
|
|
251
|
+
sql: `DELETE FROM ${escapeId(database)}.${escapeId(tableName)} ` +
|
|
252
|
+
`WHERE ${whereSQL || "1"}`,
|
|
253
|
+
params: ctx.params
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ClickHouseClient } from "@clickhouse/client";
|
|
2
|
+
import { TableDefinition } from "./table.js";
|
|
3
|
+
import { Simplify } from "./types.js";
|
|
4
|
+
import { WhereInput } from "./where.js";
|
|
5
|
+
import { FindManyArgs } from "./sql.js";
|
|
6
|
+
export type FindManyResult<T, A> = A extends {
|
|
7
|
+
select: infer S;
|
|
8
|
+
} ? Simplify<{
|
|
9
|
+
[K in keyof S & keyof T as S[K] extends true ? K : never]: T[K];
|
|
10
|
+
}> : T;
|
|
11
|
+
type NullableKeys<T> = {
|
|
12
|
+
[K in keyof T]-?: null extends T[K] ? K : never;
|
|
13
|
+
}[keyof T];
|
|
14
|
+
type OptionalCreateKeys<T> = NullableKeys<T> | ("id" & keyof T);
|
|
15
|
+
export type CreateData<T> = Simplify<Omit<T, OptionalCreateKeys<T>> & {
|
|
16
|
+
[K in OptionalCreateKeys<T>]?: T[K];
|
|
17
|
+
}>;
|
|
18
|
+
export declare class TableClient<T> {
|
|
19
|
+
private database;
|
|
20
|
+
private tableName;
|
|
21
|
+
private table;
|
|
22
|
+
private client;
|
|
23
|
+
constructor(database: string, tableName: string, table: TableDefinition<any>, client: ClickHouseClient);
|
|
24
|
+
private get tableRef();
|
|
25
|
+
private runQuery;
|
|
26
|
+
findMany<A extends FindManyArgs<T>>(args?: A): Promise<FindManyResult<T, A>[]>;
|
|
27
|
+
findFirst<A extends Omit<FindManyArgs<T>, "take">>(args?: A): Promise<FindManyResult<T, A> | null>;
|
|
28
|
+
count(args?: {
|
|
29
|
+
where?: WhereInput<T>;
|
|
30
|
+
}): Promise<number>;
|
|
31
|
+
private prepareRows;
|
|
32
|
+
private insertWire;
|
|
33
|
+
insert(rows: Partial<T> | Partial<T>[]): Promise<void>;
|
|
34
|
+
create(args: {
|
|
35
|
+
data: CreateData<T>;
|
|
36
|
+
}): Promise<T>;
|
|
37
|
+
private get strategy();
|
|
38
|
+
updateMany(args: {
|
|
39
|
+
where?: WhereInput<T>;
|
|
40
|
+
data: Partial<T>;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
count: number;
|
|
43
|
+
}>;
|
|
44
|
+
deleteMany(args?: {
|
|
45
|
+
where?: WhereInput<T>;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
count: number;
|
|
48
|
+
}>;
|
|
49
|
+
createMany(args: {
|
|
50
|
+
data: CreateData<T>[];
|
|
51
|
+
}): Promise<{
|
|
52
|
+
count: number;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// src/table-client.ts
|
|
2
|
+
import { buildFindManyQuery, buildCountQuery, buildUpdateQuery, buildDeleteQuery, buildLightweightDeleteQuery, validateUpdateData, escapeId } from "./sql.js";
|
|
3
|
+
import { deserializeRows } from "./rows.js";
|
|
4
|
+
// process-unique id: ms timestamp in the high bits + 20-bit counter
|
|
5
|
+
let idCounter = 0n;
|
|
6
|
+
function generateId() {
|
|
7
|
+
return (BigInt(Date.now()) << 20n) | (idCounter++ & 0xfffffn);
|
|
8
|
+
}
|
|
9
|
+
export class TableClient {
|
|
10
|
+
database;
|
|
11
|
+
tableName;
|
|
12
|
+
table;
|
|
13
|
+
client;
|
|
14
|
+
constructor(database, tableName, table, client) {
|
|
15
|
+
this.database = database;
|
|
16
|
+
this.tableName = tableName;
|
|
17
|
+
this.table = table;
|
|
18
|
+
this.client = client;
|
|
19
|
+
}
|
|
20
|
+
get tableRef() {
|
|
21
|
+
return `${escapeId(this.database)}.${escapeId(this.tableName)}`;
|
|
22
|
+
}
|
|
23
|
+
async runQuery(sql, params) {
|
|
24
|
+
const result = await this.client.query({
|
|
25
|
+
query: sql,
|
|
26
|
+
query_params: params,
|
|
27
|
+
format: "JSONEachRow",
|
|
28
|
+
clickhouse_settings: {
|
|
29
|
+
// ISO with Z: the default DateTime rendering is in server
|
|
30
|
+
// timezone with no offset, which is unparseable correctly
|
|
31
|
+
date_time_output_format: "iso",
|
|
32
|
+
// force 64-bit ints to arrive as strings so BigInt() is
|
|
33
|
+
// lossless even if the server default was changed
|
|
34
|
+
output_format_json_quote_64bit_integers: 1
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return result.json();
|
|
38
|
+
}
|
|
39
|
+
async findMany(args) {
|
|
40
|
+
const { sql, params } = buildFindManyQuery(this.database, this.tableName, this.table, args);
|
|
41
|
+
const rows = await this.runQuery(sql, params);
|
|
42
|
+
return deserializeRows(rows, this.table.columns);
|
|
43
|
+
}
|
|
44
|
+
async findFirst(args) {
|
|
45
|
+
const rows = await this.findMany({ ...args, take: 1 });
|
|
46
|
+
return rows[0] ?? null;
|
|
47
|
+
}
|
|
48
|
+
async count(args) {
|
|
49
|
+
const { sql, params } = buildCountQuery(this.database, this.tableName, this.table, args?.where);
|
|
50
|
+
const rows = await this.runQuery(sql, params);
|
|
51
|
+
return Number(rows[0].count);
|
|
52
|
+
}
|
|
53
|
+
// wire: values serialized for JSONEachRow; completed: JS values
|
|
54
|
+
// including the generated id (what create() returns)
|
|
55
|
+
prepareRows(arr) {
|
|
56
|
+
const hasIdColumn = "id" in this.table.columns;
|
|
57
|
+
const wire = [];
|
|
58
|
+
const completed = [];
|
|
59
|
+
for (const r of arr) {
|
|
60
|
+
const full = { ...r };
|
|
61
|
+
if (hasIdColumn && full.id === undefined) {
|
|
62
|
+
full.id = generateId();
|
|
63
|
+
}
|
|
64
|
+
// the returned row must match both its type and what a
|
|
65
|
+
// subsequent read returns: omitted nullable columns are null,
|
|
66
|
+
// DateTime values are stored with second precision
|
|
67
|
+
for (const colName in this.table.columns) {
|
|
68
|
+
if (full[colName] === undefined && this.table.columns[colName].nullable) {
|
|
69
|
+
full[colName] = null;
|
|
70
|
+
}
|
|
71
|
+
else if (full[colName] instanceof Date) {
|
|
72
|
+
full[colName] = new Date(Math.floor(full[colName].getTime() / 1000) * 1000);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const obj = {};
|
|
76
|
+
for (const k in full) {
|
|
77
|
+
const v = full[k];
|
|
78
|
+
if (v === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
if (typeof v === "bigint") {
|
|
81
|
+
obj[k] = v.toString();
|
|
82
|
+
}
|
|
83
|
+
else if (v instanceof Date) {
|
|
84
|
+
// unix seconds: ISO strings are rejected by DateTime
|
|
85
|
+
// columns in the default (basic) input format
|
|
86
|
+
obj[k] = Math.floor(v.getTime() / 1000);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
obj[k] = v;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
wire.push(obj);
|
|
93
|
+
completed.push(full);
|
|
94
|
+
}
|
|
95
|
+
return { wire, completed };
|
|
96
|
+
}
|
|
97
|
+
async insertWire(wire) {
|
|
98
|
+
await this.client.insert({
|
|
99
|
+
table: this.tableRef,
|
|
100
|
+
values: wire,
|
|
101
|
+
format: "JSONEachRow",
|
|
102
|
+
clickhouse_settings: {
|
|
103
|
+
// with wait_for_async_insert=0 on the server, inserts are
|
|
104
|
+
// acked before flush: errors are swallowed and rows are not
|
|
105
|
+
// immediately readable — force read-your-writes semantics
|
|
106
|
+
wait_for_async_insert: 1
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async insert(rows) {
|
|
111
|
+
const arr = Array.isArray(rows) ? rows : [rows];
|
|
112
|
+
if (arr.length === 0)
|
|
113
|
+
return;
|
|
114
|
+
const { wire } = this.prepareRows(arr);
|
|
115
|
+
await this.insertWire(wire);
|
|
116
|
+
}
|
|
117
|
+
async create(args) {
|
|
118
|
+
const { wire, completed } = this.prepareRows([args.data]);
|
|
119
|
+
await this.insertWire(wire);
|
|
120
|
+
return completed[0];
|
|
121
|
+
}
|
|
122
|
+
get strategy() {
|
|
123
|
+
return this.table.mutations ?? "alter";
|
|
124
|
+
}
|
|
125
|
+
async updateMany(args) {
|
|
126
|
+
if (this.strategy === "replacing") {
|
|
127
|
+
const keys = validateUpdateData(this.table, this.tableName, args.data);
|
|
128
|
+
// re-insert new versions of the matching rows;
|
|
129
|
+
// FINAL on the read keeps this idempotent
|
|
130
|
+
const rows = await this.findMany({ where: args.where });
|
|
131
|
+
if (rows.length === 0)
|
|
132
|
+
return { count: 0 };
|
|
133
|
+
const patch = {};
|
|
134
|
+
for (const k of keys)
|
|
135
|
+
patch[k] = args.data[k];
|
|
136
|
+
const { wire } = this.prepareRows(rows.map(r => ({ ...r, ...patch })));
|
|
137
|
+
await this.insertWire(wire);
|
|
138
|
+
return { count: rows.length };
|
|
139
|
+
}
|
|
140
|
+
// "alter" and "lightweight" (ClickHouse has no stable lightweight
|
|
141
|
+
// UPDATE — lightweight tables fall back to a heavy ALTER mutation)
|
|
142
|
+
const { sql, params } = buildUpdateQuery(this.database, this.tableName, this.table, args.where, args.data);
|
|
143
|
+
const count = await this.count({ where: args.where });
|
|
144
|
+
if (count === 0)
|
|
145
|
+
return { count: 0 };
|
|
146
|
+
await this.client.command({
|
|
147
|
+
query: sql,
|
|
148
|
+
query_params: params,
|
|
149
|
+
clickhouse_settings: {
|
|
150
|
+
// wait for the mutation so subsequent reads see the change
|
|
151
|
+
mutations_sync: "1"
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return { count };
|
|
155
|
+
}
|
|
156
|
+
async deleteMany(args) {
|
|
157
|
+
const count = await this.count({ where: args?.where });
|
|
158
|
+
if (count === 0)
|
|
159
|
+
return { count: 0 };
|
|
160
|
+
if (this.strategy === "alter") {
|
|
161
|
+
const { sql, params } = buildDeleteQuery(this.database, this.tableName, this.table, args?.where);
|
|
162
|
+
await this.client.command({
|
|
163
|
+
query: sql,
|
|
164
|
+
query_params: params,
|
|
165
|
+
clickhouse_settings: { mutations_sync: "1" }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// "lightweight" and "replacing": fast tombstone-based delete
|
|
170
|
+
const { sql, params } = buildLightweightDeleteQuery(this.database, this.tableName, this.table, args?.where);
|
|
171
|
+
await this.client.command({
|
|
172
|
+
query: sql,
|
|
173
|
+
query_params: params,
|
|
174
|
+
clickhouse_settings: { lightweight_deletes_sync: "2" }
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return { count };
|
|
178
|
+
}
|
|
179
|
+
async createMany(args) {
|
|
180
|
+
if (args.data.length === 0)
|
|
181
|
+
return { count: 0 };
|
|
182
|
+
const { wire } = this.prepareRows(args.data);
|
|
183
|
+
await this.insertWire(wire);
|
|
184
|
+
return { count: args.data.length };
|
|
185
|
+
}
|
|
186
|
+
}
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Column } from "./column.js";
|
|
2
|
+
import { Simplify } from "./types.js";
|
|
3
|
+
export type MutationStrategy = "alter" | "lightweight" | "replacing";
|
|
4
|
+
export type TableDefinition<TColumns extends Record<string, Column<any>>> = {
|
|
5
|
+
engine: string;
|
|
6
|
+
orderBy: string[];
|
|
7
|
+
mutations?: MutationStrategy;
|
|
8
|
+
columns: TColumns;
|
|
9
|
+
};
|
|
10
|
+
export declare function table<TColumns extends Record<string, Column<any>>>(def: TableDefinition<TColumns>): TableDefinition<TColumns>;
|
|
11
|
+
export type InferRow<TTable> = TTable extends TableDefinition<infer TColumns> ? Simplify<{
|
|
12
|
+
[K in keyof TColumns]: TColumns[K] extends Column<infer T> ? T : never;
|
|
13
|
+
}> : never;
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// src/table.ts
|
|
2
|
+
export function table(def) {
|
|
3
|
+
if (def.mutations === "replacing" &&
|
|
4
|
+
!def.engine.includes("ReplacingMergeTree")) {
|
|
5
|
+
throw new Error(`mutations: "replacing" requires a ReplacingMergeTree engine ` +
|
|
6
|
+
`(got "${def.engine}")`);
|
|
7
|
+
}
|
|
8
|
+
return def;
|
|
9
|
+
}
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
package/dist/where.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type ComparableFilter<T> = {
|
|
2
|
+
lt?: T;
|
|
3
|
+
lte?: T;
|
|
4
|
+
gt?: T;
|
|
5
|
+
gte?: T;
|
|
6
|
+
};
|
|
7
|
+
export type StringFilter = {
|
|
8
|
+
contains?: string;
|
|
9
|
+
startsWith?: string;
|
|
10
|
+
endsWith?: string;
|
|
11
|
+
};
|
|
12
|
+
export type ScalarFilter<T> = {
|
|
13
|
+
equals?: T | null;
|
|
14
|
+
not?: T | null;
|
|
15
|
+
in?: T[];
|
|
16
|
+
notIn?: T[];
|
|
17
|
+
} & (T extends number | bigint | Date | string ? ComparableFilter<T> : {}) & (T extends string ? StringFilter : {});
|
|
18
|
+
export type WhereInput<T> = {
|
|
19
|
+
AND?: WhereInput<T> | WhereInput<T>[];
|
|
20
|
+
OR?: WhereInput<T>[];
|
|
21
|
+
NOT?: WhereInput<T> | WhereInput<T>[];
|
|
22
|
+
} & {
|
|
23
|
+
[K in keyof T]?: T[K] extends readonly any[] ? never : T[K] | ScalarFilter<NonNullable<T[K]>> | null;
|
|
24
|
+
};
|
package/dist/where.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quark-fw/clisma",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Prisma-like typed client and schema migrator for ClickHouse",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"clickhouse",
|
|
7
|
+
"orm",
|
|
8
|
+
"prisma",
|
|
9
|
+
"query-builder",
|
|
10
|
+
"migrations",
|
|
11
|
+
"typescript"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Drus",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "tsx --env-file=./src/test/.env ./src/test/index.ts",
|
|
35
|
+
"test:unit": "tsx --test src/test/unit.test.ts",
|
|
36
|
+
"prepublishOnly": "npm run typecheck && npm run test:unit && npm run build"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@clickhouse/client": "^1.17.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^25.3.3",
|
|
43
|
+
"tsx": "^4.21.0",
|
|
44
|
+
"typescript": "^5.5.3"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|