@quark-fw/clisma 0.1.13 → 0.1.15

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.
Files changed (43) hide show
  1. package/README.md +135 -19
  2. package/dist/assert-replicated-schema.d.ts +2 -0
  3. package/dist/assert-replicated-schema.js +16 -0
  4. package/dist/build-count-query.js +2 -1
  5. package/dist/build-create-dictionary-query.d.ts +2 -0
  6. package/dist/build-create-dictionary-query.js +51 -0
  7. package/dist/build-find-many-query.js +1 -1
  8. package/dist/build-index-sql.d.ts +2 -0
  9. package/dist/build-index-sql.js +11 -0
  10. package/dist/client.d.ts +21 -3
  11. package/dist/client.js +75 -21
  12. package/dist/column.d.ts +30 -1
  13. package/dist/column.js +55 -0
  14. package/dist/ddl-shared.d.ts +13 -0
  15. package/dist/ddl-shared.js +54 -0
  16. package/dist/ddl.d.ts +3 -0
  17. package/dist/ddl.js +42 -7
  18. package/dist/dictionary.d.ts +26 -0
  19. package/dist/dictionary.js +7 -0
  20. package/dist/engine.d.ts +16 -0
  21. package/dist/engine.js +26 -0
  22. package/dist/ensure-dictionaries-on-nodes.d.ts +3 -0
  23. package/dist/ensure-dictionaries-on-nodes.js +15 -0
  24. package/dist/ensure-tables-on-nodes.d.ts +11 -0
  25. package/dist/ensure-tables-on-nodes.js +39 -0
  26. package/dist/escape-id.d.ts +1 -0
  27. package/dist/escape-id.js +4 -0
  28. package/dist/expr.d.ts +5 -0
  29. package/dist/expr.js +4 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.js +5 -0
  32. package/dist/migrate.d.ts +3 -0
  33. package/dist/migrate.js +52 -5
  34. package/dist/query.d.ts +26 -0
  35. package/dist/query.js +60 -0
  36. package/dist/rows.js +9 -0
  37. package/dist/schema.d.ts +4 -2
  38. package/dist/sql-shared.d.ts +5 -2
  39. package/dist/sql-shared.js +16 -7
  40. package/dist/table-client.js +9 -2
  41. package/dist/table.d.ts +26 -4
  42. package/dist/table.js +24 -3
  43. package/package.json +2 -2
@@ -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,5 @@
1
1
  import { AnyTable } from "./table.js";
2
+ import type { Column } from "./column.js";
3
+ export declare function renderSettings(settings: Record<string, string | number>): string;
4
+ export declare function buildColumnSQL(name: string, col: Column<unknown>): string;
2
5
  export declare function buildCreateTableSQL(database: string, tableName: string, table: AnyTable): string;
package/dist/ddl.js CHANGED
@@ -1,15 +1,50 @@
1
1
  // src/ddl.ts
2
- import { escapeId } from "./sql.js";
2
+ import { escapeId } from "./escape-id.js";
3
+ import { buildIndexSQL } from "./build-index-sql.js";
4
+ import { quoteString, renderEngine, renderKeyList, renderKeyPart, } from "./ddl-shared.js";
5
+ // список для `SETTINGS` / `MODIFY SETTING`: числа как есть, строки в кавычках
6
+ export function renderSettings(settings) {
7
+ return Object.entries(settings)
8
+ .map(([key, value]) => typeof value === "number" ? `${key} = ${value}` : `${key} = ${quoteString(value)}`)
9
+ .join(", ");
10
+ }
11
+ export function buildColumnSQL(name, col) {
12
+ const parts = [escapeId(name), col.clickhouseType];
13
+ if (col.default)
14
+ parts.push(`${col.default.kind} ${col.default.expr}`);
15
+ if (col.codec)
16
+ parts.push(`CODEC(${col.codec})`);
17
+ if (col.comment)
18
+ parts.push(`COMMENT ${quoteString(col.comment)}`);
19
+ return parts.join(" ");
20
+ }
3
21
  export function buildCreateTableSQL(database, tableName, table) {
4
- const columnsSQL = Object.entries(table.columns)
5
- .map(([name, col]) => `${escapeId(name)} ${col.clickhouseType}`)
6
- .join(",\n");
22
+ const body = [
23
+ ...Object.entries(table.columns).map(([name, col]) => buildColumnSQL(name, col)),
24
+ ...(table.indexes ?? []).map(buildIndexSQL),
25
+ ].join(",\n");
26
+ const clauses = [`ENGINE = ${renderEngine(table)}`];
27
+ if (table.partitionBy) {
28
+ clauses.push(`PARTITION BY ${renderKeyPart(table.partitionBy)}`);
29
+ }
30
+ if (table.primaryKey) {
31
+ clauses.push(`PRIMARY KEY ${renderKeyList(table.primaryKey)}`);
32
+ }
33
+ clauses.push(`ORDER BY ${renderKeyList(table.orderBy)}`);
34
+ if (table.sampleBy)
35
+ clauses.push(`SAMPLE BY ${renderKeyPart(table.sampleBy)}`);
36
+ if (table.ttl)
37
+ clauses.push(`TTL ${table.ttl}`);
38
+ if (table.settings && Object.keys(table.settings).length) {
39
+ clauses.push(`SETTINGS ${renderSettings(table.settings)}`);
40
+ }
41
+ if (table.comment)
42
+ clauses.push(`COMMENT ${quoteString(table.comment)}`);
7
43
  return `
8
44
  CREATE TABLE IF NOT EXISTS ${escapeId(database)}.${escapeId(tableName)}
9
45
  (
10
- ${columnsSQL}
46
+ ${body}
11
47
  )
12
- ENGINE = ${table.engine}
13
- ORDER BY (${table.orderBy.map(escapeId).join(", ")})
48
+ ${clauses.join("\n ")}
14
49
  `.trim();
15
50
  }
@@ -0,0 +1,26 @@
1
+ import type { Column } from "./column.js";
2
+ export type DictionaryCredentials = {
3
+ user: string;
4
+ password?: string;
5
+ };
6
+ export type DictionarySource = ({
7
+ query: string;
8
+ } | {
9
+ table: string;
10
+ database?: string;
11
+ where?: string;
12
+ }) & Partial<DictionaryCredentials>;
13
+ export type DictionaryLayout = "flat" | "hashed" | "sparse_hashed" | "hashed_array" | "complex_key_hashed";
14
+ export type DictionaryDefinition<TKey extends Record<string, Column<unknown>>, TAttributes extends Record<string, Column<unknown>>> = {
15
+ key: TKey;
16
+ attributes: TAttributes;
17
+ source: DictionarySource;
18
+ layout: DictionaryLayout;
19
+ lifetime: number | {
20
+ min: number;
21
+ max: number;
22
+ };
23
+ comment?: string;
24
+ };
25
+ export type AnyDictionary = DictionaryDefinition<Record<string, Column<unknown>>, Record<string, Column<unknown>>>;
26
+ export declare function dictionary<TKey extends Record<string, Column<unknown>>, TAttributes extends Record<string, Column<unknown>>>(def: DictionaryDefinition<TKey, TAttributes>): DictionaryDefinition<TKey, TAttributes>;
@@ -0,0 +1,7 @@
1
+ // src/dictionary.ts
2
+ export function dictionary(def) {
3
+ if (Object.keys(def.key).length === 0) {
4
+ throw new Error("Dictionary requires at least one key column");
5
+ }
6
+ return def;
7
+ }
@@ -0,0 +1,16 @@
1
+ export type EngineSpec = {
2
+ family: "MergeTree" | "ReplacingMergeTree" | "CollapsingMergeTree" | "VersionedCollapsingMergeTree" | "SummingMergeTree" | "AggregatingMergeTree";
3
+ args: string[];
4
+ collapsing?: {
5
+ sign: string;
6
+ version?: string;
7
+ };
8
+ };
9
+ export declare const engine: {
10
+ mergeTree: () => EngineSpec;
11
+ replacingMergeTree: (version?: string) => EngineSpec;
12
+ collapsingMergeTree: (sign: string) => EngineSpec;
13
+ versionedCollapsingMergeTree: (sign: string, version: string) => EngineSpec;
14
+ summingMergeTree: (columns?: string[]) => EngineSpec;
15
+ aggregatingMergeTree: () => EngineSpec;
16
+ };
package/dist/engine.js ADDED
@@ -0,0 +1,26 @@
1
+ // src/engine.ts
2
+ export const engine = {
3
+ mergeTree: () => ({ family: "MergeTree", args: [] }),
4
+ replacingMergeTree: (version) => ({
5
+ family: "ReplacingMergeTree",
6
+ args: version ? [version] : [],
7
+ }),
8
+ collapsingMergeTree: (sign) => ({
9
+ family: "CollapsingMergeTree",
10
+ args: [sign],
11
+ collapsing: { sign },
12
+ }),
13
+ versionedCollapsingMergeTree: (sign, version) => ({
14
+ family: "VersionedCollapsingMergeTree",
15
+ args: [sign, version],
16
+ collapsing: { sign, version },
17
+ }),
18
+ summingMergeTree: (columns = []) => ({
19
+ family: "SummingMergeTree",
20
+ args: columns.length ? [`(${columns.join(", ")})`] : [],
21
+ }),
22
+ aggregatingMergeTree: () => ({
23
+ family: "AggregatingMergeTree",
24
+ args: [],
25
+ }),
26
+ };
@@ -0,0 +1,3 @@
1
+ import type { AnyDictionary, DictionaryCredentials } from "./dictionary.js";
2
+ import type { DdlNode, DdlStatement } from "./ensure-tables-on-nodes.js";
3
+ export declare function ensureDictionariesOnNodes(nodes: DdlNode[], database: string, dictionaries: Record<string, AnyDictionary>, credentials?: DictionaryCredentials, mode?: "create" | "replace"): Promise<DdlStatement[]>;
@@ -0,0 +1,15 @@
1
+ // src/ensure-dictionaries-on-nodes.ts
2
+ import { buildCreateDictionarySQL } from "./build-create-dictionary-query.js";
3
+ // Словарь не реплицируется — это локальный кэш каждой ноды над её же
4
+ // данными, поэтому он создаётся из схемы на каждой ноде отдельно.
5
+ export async function ensureDictionariesOnNodes(nodes, database, dictionaries, credentials, mode = "create") {
6
+ const executed = [];
7
+ for (const node of nodes) {
8
+ for (const name in dictionaries) {
9
+ const sql = buildCreateDictionarySQL(database, name, dictionaries[name], credentials, mode);
10
+ await node.client.command({ query: sql });
11
+ executed.push({ node: node.url, sql });
12
+ }
13
+ }
14
+ return executed;
15
+ }
@@ -0,0 +1,11 @@
1
+ import type { ClickHouseClient } from "@clickhouse/client";
2
+ import type { AnyTable } from "./table.js";
3
+ export type DdlNode = {
4
+ url: string;
5
+ client: ClickHouseClient;
6
+ };
7
+ export type DdlStatement = {
8
+ node: string;
9
+ sql: string;
10
+ };
11
+ export declare function ensureTablesOnNodes(nodes: DdlNode[], database: string, tables: Record<string, AnyTable>): Promise<DdlStatement[]>;
@@ -0,0 +1,39 @@
1
+ // src/ensure-tables-on-nodes.ts
2
+ import { buildCreateTableSQL } from "./ddl.js";
3
+ import { escapeId } from "./escape-id.js";
4
+ async function tableExists(client, database, tableName) {
5
+ const result = await client.query({
6
+ query: `EXISTS TABLE ${escapeId(database)}.${escapeId(tableName)}`,
7
+ format: "JSONEachRow",
8
+ });
9
+ const rows = await result.json();
10
+ return Number(rows[0]?.result) === 1;
11
+ }
12
+ // Первая нода — источник истины: на ней таблица создаётся из схемы, а на
13
+ // остальные копируется её SHOW CREATE TABLE. Копия, а не повторный рендер —
14
+ // потому что Keeper сверяет метаданные реплик побайтово (порядок колонок,
15
+ // дефолты), и любое расхождение рендера оставило бы реплику вне набора.
16
+ export async function ensureTablesOnNodes(nodes, database, tables) {
17
+ const [primary, ...secondaries] = nodes;
18
+ const executed = [];
19
+ for (const tableName in tables) {
20
+ const createSQL = buildCreateTableSQL(database, tableName, tables[tableName]);
21
+ await primary.client.command({ query: createSQL });
22
+ executed.push({ node: primary.url, sql: createSQL });
23
+ }
24
+ for (const node of secondaries) {
25
+ for (const tableName in tables) {
26
+ if (await tableExists(node.client, database, tableName))
27
+ continue;
28
+ const shown = await primary.client.query({
29
+ query: `SHOW CREATE TABLE ${escapeId(database)}.${escapeId(tableName)}`,
30
+ format: "JSONEachRow",
31
+ });
32
+ const [{ statement }] = await shown.json();
33
+ const copySQL = statement.replace(/^CREATE TABLE /, "CREATE TABLE IF NOT EXISTS ");
34
+ await node.client.command({ query: copySQL });
35
+ executed.push({ node: node.url, sql: copySQL });
36
+ }
37
+ }
38
+ return executed;
39
+ }
@@ -0,0 +1 @@
1
+ export declare function escapeId(name: string): string;
@@ -0,0 +1,4 @@
1
+ // src/escape-id.ts
2
+ export function escapeId(name) {
3
+ return "`" + name.replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
4
+ }
package/dist/expr.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type Expr = {
2
+ readonly __expr: string;
3
+ };
4
+ export type KeyPart = string | Expr;
5
+ export declare function expr(sql: string): Expr;
package/dist/expr.js ADDED
@@ -0,0 +1,4 @@
1
+ // src/expr.ts
2
+ export function expr(sql) {
3
+ return { __expr: sql };
4
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from "./column.js";
2
+ export * from "./expr.js";
3
+ export * from "./engine.js";
2
4
  export * from "./table.js";
5
+ export * from "./dictionary.js";
3
6
  export * from "./schema.js";
4
7
  export * from "./client.js";
5
8
  export * from "./where.js";
@@ -7,3 +10,7 @@ export * from "./sql.js";
7
10
  export * from "./table-client.js";
8
11
  export * from "./rows.js";
9
12
  export * from "./migrate.js";
13
+ export * from "./ddl.js";
14
+ export * from "./build-create-dictionary-query.js";
15
+ export type { QueryOptions, QueryJsonResult, QuerySettings } from "./query.js";
16
+ export type { DdlNode, DdlStatement } from "./ensure-tables-on-nodes.js";
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from "./column.js";
2
+ export * from "./expr.js";
3
+ export * from "./engine.js";
2
4
  export * from "./table.js";
5
+ export * from "./dictionary.js";
3
6
  export * from "./schema.js";
4
7
  export * from "./client.js";
5
8
  export * from "./where.js";
@@ -7,3 +10,5 @@ export * from "./sql.js";
7
10
  export * from "./table-client.js";
8
11
  export * from "./rows.js";
9
12
  export * from "./migrate.js";
13
+ export * from "./ddl.js";
14
+ export * from "./build-create-dictionary-query.js";
package/dist/migrate.d.ts CHANGED
@@ -4,8 +4,11 @@ export type DbColumn = {
4
4
  name: string;
5
5
  type: string;
6
6
  };
7
+ export type DbTableSettings = Record<string, Record<string, string>>;
7
8
  export type MigrationPlan = {
8
9
  statements: string[];
9
10
  warnings: string[];
10
11
  };
12
+ export declare function parseEngineSettings(engineFull: string): Record<string, string>;
11
13
  export declare function buildMigrationPlan(database: string, tables: Record<string, AnyTable>, existing: DbColumn[]): MigrationPlan;
14
+ export declare function buildSettingsPlan(database: string, tables: Record<string, AnyTable>, existingSettings: DbTableSettings): string[];
package/dist/migrate.js CHANGED
@@ -1,6 +1,20 @@
1
1
  // src/migrate.ts
2
- import { buildCreateTableSQL } from "./ddl.js";
3
- import { escapeId } from "./sql.js";
2
+ import { buildColumnSQL, buildCreateTableSQL, renderSettings } from "./ddl.js";
3
+ import { escapeId } from "./escape-id.js";
4
+ import { keyColumns } from "./ddl-shared.js";
5
+ // `SETTINGS a = 1, b = 'x'` из хвоста `system.tables.engine_full`
6
+ export function parseEngineSettings(engineFull) {
7
+ const out = {};
8
+ const at = engineFull.lastIndexOf(" SETTINGS ");
9
+ if (at === -1)
10
+ return out;
11
+ const re = /(\w+)\s*=\s*('(?:[^'\\]|\\.)*'|[^,]+)/g;
12
+ for (const m of engineFull.slice(at + " SETTINGS ".length).matchAll(re)) {
13
+ const raw = m[2].trim();
14
+ out[m[1]] = raw.startsWith("'") ? raw.slice(1, -1).replace(/\\(.)/g, "$1") : raw;
15
+ }
16
+ return out;
17
+ }
4
18
  export function buildMigrationPlan(database, tables, existing) {
5
19
  const statements = [];
6
20
  const warnings = [];
@@ -21,14 +35,23 @@ export function buildMigrationPlan(database, tables, existing) {
21
35
  continue;
22
36
  }
23
37
  const tableRef = `${escapeId(database)}.${escapeId(tableName)}`;
38
+ const keys = keyColumns([...def.orderBy, ...(def.primaryKey ?? [])]);
24
39
  for (const colName in def.columns) {
25
- const wanted = def.columns[colName].clickhouseType;
40
+ const col = def.columns[colName];
41
+ const wanted = col.clickhouseType;
26
42
  const actual = dbCols.get(colName);
27
43
  if (actual === undefined) {
28
- statements.push(`ALTER TABLE ${tableRef} ADD COLUMN ${escapeId(colName)} ${wanted}`);
44
+ statements.push(`ALTER TABLE ${tableRef} ADD COLUMN ${buildColumnSQL(colName, col)}`);
29
45
  }
30
46
  else if (actual !== wanted) {
31
- statements.push(`ALTER TABLE ${tableRef} MODIFY COLUMN ${escapeId(colName)} ${wanted}`);
47
+ // ключевую колонку сервер менять не даст; лучше внятная ошибка
48
+ // до выполнения плана, чем упавшая посередине миграция
49
+ if (keys.includes(colName)) {
50
+ throw new Error(`Column "${colName}" of ${database}.${tableName} is part of the ` +
51
+ `sorting key and cannot change type (${actual} → ${wanted}); ` +
52
+ `recreate the table instead`);
53
+ }
54
+ statements.push(`ALTER TABLE ${tableRef} MODIFY COLUMN ${buildColumnSQL(colName, col)}`);
32
55
  }
33
56
  }
34
57
  for (const dbColName of dbCols.keys()) {
@@ -40,3 +63,27 @@ export function buildMigrationPlan(database, tables, existing) {
40
63
  }
41
64
  return { statements, warnings };
42
65
  }
66
+ // Настройки таблицы — локальные метаданные каждой ноды: MODIFY SETTING не
67
+ // реплицируется (в отличие от ADD/MODIFY COLUMN), поэтому план настроек
68
+ // строится и выполняется на каждой ноде по её `system.tables`. Только
69
+ // объявленные в схеме настройки и только существующие на ноде таблицы —
70
+ // новые получат их из CREATE (или из SHOW CREATE копии). Правка метаданных,
71
+ // данные не переписываются.
72
+ export function buildSettingsPlan(database, tables, existingSettings) {
73
+ const statements = [];
74
+ for (const tableName in tables) {
75
+ const declared = tables[tableName].settings;
76
+ const actual = existingSettings[tableName];
77
+ if (!declared || !actual)
78
+ continue;
79
+ const changed = {};
80
+ for (const [key, value] of Object.entries(declared)) {
81
+ if (actual[key] !== String(value))
82
+ changed[key] = value;
83
+ }
84
+ if (Object.keys(changed).length) {
85
+ statements.push(`ALTER TABLE ${escapeId(database)}.${escapeId(tableName)} MODIFY SETTING ${renderSettings(changed)}`);
86
+ }
87
+ }
88
+ return statements;
89
+ }
@@ -0,0 +1,26 @@
1
+ import type { ClickHouseClient } from "@clickhouse/client";
2
+ import type { AnyColumns } from "./table.js";
3
+ export type QuerySettings = Record<string, string | number | boolean>;
4
+ export type QueryOptions = {
5
+ params?: Record<string, unknown>;
6
+ settings?: QuerySettings;
7
+ columns?: AnyColumns;
8
+ };
9
+ export type QueryJsonResult<TRow, TTotals = TRow> = {
10
+ data: TRow[];
11
+ totals?: TTotals;
12
+ rows: number;
13
+ rows_before_limit_at_least?: number;
14
+ meta: {
15
+ name: string;
16
+ type: string;
17
+ }[];
18
+ statistics: {
19
+ elapsed: number;
20
+ rows_read: number;
21
+ bytes_read: number;
22
+ };
23
+ };
24
+ export declare function runQuery<TRow>(client: ClickHouseClient, sql: string, options?: QueryOptions): Promise<TRow[]>;
25
+ export declare function runQueryJson<TRow, TTotals = TRow>(client: ClickHouseClient, sql: string, options?: QueryOptions): Promise<QueryJsonResult<TRow, TTotals>>;
26
+ export declare function runCommand(client: ClickHouseClient, sql: string, options?: Omit<QueryOptions, "columns">): Promise<void>;
package/dist/query.js ADDED
@@ -0,0 +1,60 @@
1
+ // src/query.ts
2
+ // Сырые запросы поверх @clickhouse/client: агрегаты, GROUP BY и DDL не
3
+ // выражаются Prisma-синтаксисом, а второй клиент в приложении — дубль
4
+ // соединения и настроек.
5
+ import { deserializeRows } from "./rows.js";
6
+ // те же настройки, что у findMany: ISO-даты без таймзонной неоднозначности и
7
+ // 64-битные целые строками, чтобы BigInt() был без потерь
8
+ const WIRE_SETTINGS = {
9
+ date_time_output_format: "iso",
10
+ output_format_json_quote_64bit_integers: 1,
11
+ };
12
+ function serializeParams(params = {}) {
13
+ const out = {};
14
+ for (const key in params) {
15
+ const value = params[key];
16
+ out[key] =
17
+ value instanceof Date ? Math.floor(value.getTime() / 1000) : value;
18
+ }
19
+ return out;
20
+ }
21
+ export async function runQuery(client, sql, options = {}) {
22
+ const result = await client.query({
23
+ query: sql,
24
+ query_params: serializeParams(options.params),
25
+ format: "JSONEachRow",
26
+ clickhouse_settings: { ...WIRE_SETTINGS, ...options.settings },
27
+ });
28
+ const rows = await result.json();
29
+ return options.columns
30
+ ? deserializeRows(rows, options.columns)
31
+ : rows;
32
+ }
33
+ // FORMAT JSON нужен, когда важны totals (WITH TOTALS), точный
34
+ // rows_before_limit_at_least и статистика чтения — JSONEachRow их не отдаёт
35
+ export async function runQueryJson(client, sql, options = {}) {
36
+ const result = await client.query({
37
+ query: sql,
38
+ query_params: serializeParams(options.params),
39
+ format: "JSON",
40
+ clickhouse_settings: { ...WIRE_SETTINGS, ...options.settings },
41
+ });
42
+ // у FORMAT JSON клиент типизирует поля как необязательные; сервер отдаёт их всегда
43
+ const body = (await result.json());
44
+ if (!options.columns)
45
+ return body;
46
+ return {
47
+ ...body,
48
+ data: deserializeRows(body.data, options.columns),
49
+ totals: body.totals
50
+ ? deserializeRows([body.totals], options.columns)[0]
51
+ : undefined,
52
+ };
53
+ }
54
+ export async function runCommand(client, sql, options = {}) {
55
+ await client.command({
56
+ query: sql,
57
+ query_params: serializeParams(options.params),
58
+ clickhouse_settings: options.settings,
59
+ });
60
+ }
package/dist/rows.js CHANGED
@@ -13,12 +13,21 @@ function convertValue(value, chType) {
13
13
  const nullable = /^Nullable\((.+)\)$/.exec(chType);
14
14
  if (nullable)
15
15
  return convertValue(value, nullable[1]);
16
+ // LowCardinality — способ хранения, на значение не влияет
17
+ const lowCardinality = /^LowCardinality\((.+)\)$/.exec(chType);
18
+ if (lowCardinality)
19
+ return convertValue(value, lowCardinality[1]);
16
20
  const array = /^Array\((.+)\)$/.exec(chType);
17
21
  if (array) {
18
22
  return value.map((v) => convertValue(v, array[1]));
19
23
  }
20
24
  if (BIGINT_TYPES.has(chType))
21
25
  return BigInt(value);
26
+ // Float может прийти строкой (inf/nan или quote_64bit_integers у Float64
27
+ // на старых серверах) — приводим к number; Decimal, Enum, IPv4/6 и Map
28
+ // приходят уже в нужном виде
29
+ if (chType.startsWith("Float"))
30
+ return Number(value);
22
31
  if (chType.startsWith("DateTime") || chType.startsWith("Date")) {
23
32
  return new Date(value);
24
33
  }
package/dist/schema.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { AnyTable } from "./table.js";
2
- export type ClismaSchema<TTables extends Record<string, AnyTable>> = {
2
+ import type { AnyDictionary } from "./dictionary.js";
3
+ export type ClismaSchema<TTables extends Record<string, AnyTable>, TDictionaries extends Record<string, AnyDictionary> = Record<string, never>> = {
3
4
  database?: string;
4
5
  tables: TTables;
6
+ dictionaries?: TDictionaries;
5
7
  };
6
- export declare function createClisma<TTables extends Record<string, AnyTable>>(schema: ClismaSchema<TTables>): ClismaSchema<TTables>;
8
+ export declare function createClisma<TTables extends Record<string, AnyTable>, TDictionaries extends Record<string, AnyDictionary> = Record<string, never>>(schema: ClismaSchema<TTables, TDictionaries>): ClismaSchema<TTables, TDictionaries>;
@@ -15,8 +15,10 @@ export type FindManyArgs<T> = {
15
15
  orderBy?: OrderByInput<T> | OrderByInput<T>[];
16
16
  take?: number;
17
17
  skip?: number;
18
+ final?: boolean;
19
+ settings?: Record<string, unknown>;
18
20
  };
19
- export declare function escapeId(name: string): string;
21
+ export { escapeId } from "./escape-id.js";
20
22
  export declare const NO_LIMIT = "18446744073709551615";
21
23
  export declare function createBuildContext(tableName: string, columns: AnyColumns): {
22
24
  params: Record<string, unknown>;
@@ -25,6 +27,7 @@ export declare function createBuildContext(tableName: string, columns: AnyColumn
25
27
  addParam: (value: unknown, type: string) => string;
26
28
  whereSQL: (where: Record<string, unknown>) => string;
27
29
  };
28
- export declare function tableRef(database: string, tableName: string, table: AnyTable): string;
30
+ export declare function tableRef(database: string, tableName: string, table: AnyTable, final?: boolean): string;
31
+ export declare function countExpr(table: AnyTable): string;
29
32
  export declare function checkPagination(name: string, value: number): void;
30
33
  export declare function validateUpdateData(table: AnyTable, tableName: string, data: Record<string, unknown>): string[];
@@ -1,8 +1,10 @@
1
1
  // src/sql-shared.ts
2
2
  // Shared helpers and types for the SQL builders (build-*.ts).
3
- export function escapeId(name) {
4
- return "`" + name.replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
5
- }
3
+ import { escapeId } from "./escape-id.js";
4
+ import { collapsingOf, keyColumns } from "./ddl-shared.js";
5
+ // escapeId живёт отдельным файлом: он нужен и рендеру DDL, и билдерам
6
+ // запросов, а взаимный импорт между ними создал бы цикл модулей
7
+ export { escapeId } from "./escape-id.js";
6
8
  // UInt64 max — used to emulate a bare OFFSET (skip without take)
7
9
  export const NO_LIMIT = "18446744073709551615";
8
10
  // DateTime is second-precision; the client would send Date with
@@ -136,10 +138,17 @@ export function createBuildContext(tableName, columns) {
136
138
  }
137
139
  return { params, colRef, baseType, addParam, whereSQL };
138
140
  }
139
- export function tableRef(database, tableName, table) {
141
+ export function tableRef(database, tableName, table, final = false) {
140
142
  const ref = `${escapeId(database)}.${escapeId(tableName)}`;
141
- // replacing tables must read the latest row versions
142
- return table.mutations === "replacing" ? `${ref} FINAL` : ref;
143
+ // replacing-таблицы обязаны читать последние версии строк; для остальных
144
+ // FINAL включается явно (например, чтобы прочитать коллапсирующую таблицу)
145
+ return table.mutations === "replacing" || final ? `${ref} FINAL` : ref;
146
+ }
147
+ // у коллапсирующих таблиц строка «существует», только если её знак не
148
+ // погашен парой -1 — count() посчитал бы обе строки пары
149
+ export function countExpr(table) {
150
+ const collapsing = collapsingOf(table.engine);
151
+ return collapsing ? `sum(${escapeId(collapsing.sign)})` : "count()";
143
152
  }
144
153
  export function checkPagination(name, value) {
145
154
  if (!Number.isInteger(value) || value < 0) {
@@ -153,7 +162,7 @@ export function validateUpdateData(table, tableName, data) {
153
162
  throw new Error(`updateMany requires non-empty data`);
154
163
  }
155
164
  for (const key of keys) {
156
- if (table.orderBy.includes(key)) {
165
+ if (keyColumns(table.orderBy).includes(key)) {
157
166
  throw new Error(`Cannot update ORDER BY key column "${key}" ` +
158
167
  `of table "${tableName}"`);
159
168
  }
@@ -1,6 +1,7 @@
1
1
  // src/table-client.ts
2
2
  import { buildFindManyQuery, buildCountQuery, buildUpdateQuery, buildDeleteQuery, buildLightweightDeleteQuery, validateUpdateData, escapeId, } from "./sql.js";
3
3
  import { deserializeRows } from "./rows.js";
4
+ import { collapsingOf } from "./ddl-shared.js";
4
5
  // process-unique id: ms timestamp in the high bits + 20-bit counter
5
6
  let idCounter = 0n;
6
7
  function generateId() {
@@ -20,12 +21,13 @@ export class TableClient {
20
21
  get tableRef() {
21
22
  return `${escapeId(this.database)}.${escapeId(this.tableName)}`;
22
23
  }
23
- async runQuery(sql, params) {
24
+ async runQuery(sql, params, settings = {}) {
24
25
  const result = await this.client.query({
25
26
  query: sql,
26
27
  query_params: params,
27
28
  format: "JSONEachRow",
28
29
  clickhouse_settings: {
30
+ ...settings,
29
31
  // ISO with Z: the default DateTime rendering is in server
30
32
  // timezone with no offset, which is unparseable correctly
31
33
  date_time_output_format: "iso",
@@ -37,8 +39,13 @@ export class TableClient {
37
39
  return result.json();
38
40
  }
39
41
  async findMany(args) {
42
+ // сырые строки коллапсирующей таблицы содержат и погашенные версии —
43
+ // без FINAL такой результат вводит в заблуждение
44
+ if (collapsingOf(this.table.engine) && !args?.final) {
45
+ throw new Error(`Table "${this.tableName}" uses a collapsing engine: pass final: true to findMany`);
46
+ }
40
47
  const { sql, params } = buildFindManyQuery(this.database, this.tableName, this.table, args);
41
- const rows = await this.runQuery(sql, params);
48
+ const rows = await this.runQuery(sql, params, args?.settings);
42
49
  return deserializeRows(rows, this.table.columns);
43
50
  }
44
51
  async findFirst(args) {