@housekit/orm 0.1.47 → 0.1.48
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 +34 -0
- package/dist/builders/delete.js +112 -0
- package/dist/builders/insert.d.ts +0 -91
- package/dist/builders/insert.js +393 -0
- package/dist/builders/prepared.d.ts +1 -2
- package/dist/builders/prepared.js +30 -0
- package/dist/builders/select.d.ts +0 -161
- package/dist/builders/select.js +562 -0
- package/dist/builders/select.types.js +1 -0
- package/dist/builders/update.js +136 -0
- package/dist/client.d.ts +0 -6
- package/dist/client.js +140 -0
- package/dist/codegen/zod.js +107 -0
- package/dist/column.d.ts +1 -25
- package/dist/column.js +133 -0
- package/dist/compiler.d.ts +0 -7
- package/dist/compiler.js +513 -0
- package/dist/core.js +6 -0
- package/dist/data-types.d.ts +0 -61
- package/dist/data-types.js +127 -0
- package/dist/dictionary.d.ts +0 -149
- package/dist/dictionary.js +158 -0
- package/dist/engines.d.ts +0 -385
- package/dist/engines.js +292 -0
- package/dist/expressions.d.ts +0 -10
- package/dist/expressions.js +268 -0
- package/dist/external.d.ts +0 -112
- package/dist/external.js +224 -0
- package/dist/index.d.ts +0 -51
- package/dist/index.js +139 -6853
- package/dist/logger.js +36 -0
- package/dist/materialized-views.d.ts +0 -188
- package/dist/materialized-views.js +380 -0
- package/dist/metadata.js +59 -0
- package/dist/modules/aggregates.d.ts +0 -164
- package/dist/modules/aggregates.js +121 -0
- package/dist/modules/array.d.ts +0 -98
- package/dist/modules/array.js +71 -0
- package/dist/modules/conditional.d.ts +0 -84
- package/dist/modules/conditional.js +138 -0
- package/dist/modules/conversion.d.ts +0 -147
- package/dist/modules/conversion.js +109 -0
- package/dist/modules/geo.d.ts +0 -164
- package/dist/modules/geo.js +112 -0
- package/dist/modules/hash.js +4 -0
- package/dist/modules/index.js +12 -0
- package/dist/modules/json.d.ts +0 -106
- package/dist/modules/json.js +76 -0
- package/dist/modules/math.d.ts +0 -16
- package/dist/modules/math.js +16 -0
- package/dist/modules/string.d.ts +0 -136
- package/dist/modules/string.js +89 -0
- package/dist/modules/time.d.ts +0 -123
- package/dist/modules/time.js +91 -0
- package/dist/modules/types.d.ts +0 -133
- package/dist/modules/types.js +114 -0
- package/dist/modules/window.js +140 -0
- package/dist/relational.d.ts +0 -82
- package/dist/relational.js +290 -0
- package/dist/relations.js +21 -0
- package/dist/schema-builder.d.ts +0 -90
- package/dist/schema-builder.js +140 -0
- package/dist/table.d.ts +0 -42
- package/dist/table.js +406 -0
- package/dist/utils/background-batcher.js +75 -0
- package/dist/utils/batch-transform.js +51 -0
- package/dist/utils/binary-reader.d.ts +0 -6
- package/dist/utils/binary-reader.js +334 -0
- package/dist/utils/binary-serializer.d.ts +0 -125
- package/dist/utils/binary-serializer.js +637 -0
- package/dist/utils/binary-worker-code.js +1 -0
- package/dist/utils/binary-worker-pool.d.ts +0 -34
- package/dist/utils/binary-worker-pool.js +206 -0
- package/dist/utils/binary-worker.d.ts +0 -11
- package/dist/utils/binary-worker.js +63 -0
- package/dist/utils/insert-processing.d.ts +0 -2
- package/dist/utils/insert-processing.js +163 -0
- package/dist/utils/lru-cache.js +30 -0
- package/package.json +68 -3
package/dist/logger.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function wrapClientWithLogger(client, logger) {
|
|
2
|
+
if (!logger)
|
|
3
|
+
return client;
|
|
4
|
+
const extractStats = (summary) => {
|
|
5
|
+
if (!summary)
|
|
6
|
+
return undefined;
|
|
7
|
+
const readRows = summary.read_rows ?? summary.rows_read ?? summary.rows ?? summary.result_rows ?? 0;
|
|
8
|
+
const readBytes = summary.read_bytes ?? summary.bytes_read ?? summary.bytes ?? 0;
|
|
9
|
+
return { readRows, readBytes };
|
|
10
|
+
};
|
|
11
|
+
const withTiming = async (sql, params, fn) => {
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
try {
|
|
14
|
+
const res = await fn();
|
|
15
|
+
const duration = Date.now() - start;
|
|
16
|
+
logger.logQuery(sql, params, duration, extractStats(res?.summary));
|
|
17
|
+
return res;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
logger.logError(err, sql);
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const wrapped = {
|
|
25
|
+
...client,
|
|
26
|
+
query: async (params) => {
|
|
27
|
+
const sql = typeof params === 'string' ? params : params?.query || '';
|
|
28
|
+
return withTiming(sql, params?.query_params, () => client.query(params));
|
|
29
|
+
},
|
|
30
|
+
insert: async (params) => {
|
|
31
|
+
const sql = `INSERT INTO ${params?.table ?? ''}`;
|
|
32
|
+
return withTiming(sql, params?.values, () => client.insert(params));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return wrapped;
|
|
36
|
+
}
|
|
@@ -1,92 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HouseKit Materialized Views DSL - Type-Safe Materialized Views
|
|
3
|
-
*
|
|
4
|
-
* Unlike generic ORMs which often treat materialized views as static SQL strings,
|
|
5
|
-
* HouseKit allows defining MV queries using the Query Builder for
|
|
6
|
-
* compile-time type safety. If you rename a column in the source table,
|
|
7
|
-
* TypeScript will catch the error before deployment.
|
|
8
|
-
*/
|
|
9
1
|
import { ClickHouseColumn } from './column';
|
|
10
2
|
import { type TableDefinition, type TableColumns } from './table';
|
|
11
3
|
import { EngineConfiguration } from './engines';
|
|
12
4
|
import { type SQLExpression } from './expressions';
|
|
13
|
-
/**
|
|
14
|
-
* Helper type to extract columns from a TableDefinition
|
|
15
|
-
*/
|
|
16
5
|
export type InferTableColumns<T> = T extends TableDefinition<infer TCols> ? TCols : TableColumns;
|
|
17
|
-
/**
|
|
18
|
-
* Helper type to ensure we have access to $columns
|
|
19
|
-
*/
|
|
20
6
|
type TableWithColumns<TCols extends TableColumns> = {
|
|
21
7
|
$table: string;
|
|
22
8
|
$columns: TCols;
|
|
23
9
|
};
|
|
24
|
-
/**
|
|
25
|
-
* Configuration for type-safe materialized views
|
|
26
|
-
*/
|
|
27
10
|
export interface MaterializedViewConfig<TSource extends TableDefinition<any>, TTargetCols extends TableColumns = TableColumns> {
|
|
28
|
-
/**
|
|
29
|
-
* Source table(s) for the materialized view.
|
|
30
|
-
* This is used to provide type information for the query builder.
|
|
31
|
-
*/
|
|
32
11
|
source: TSource;
|
|
33
|
-
/**
|
|
34
|
-
* The query definition for the materialized view.
|
|
35
|
-
* Use the query builder to ensure type safety.
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* ```typescript
|
|
39
|
-
* query: (qb) => qb
|
|
40
|
-
* .from(events)
|
|
41
|
-
* .select({
|
|
42
|
-
* eventType: events.event_type,
|
|
43
|
-
* count: sql`count()`
|
|
44
|
-
* })
|
|
45
|
-
* .groupBy(events.event_type)
|
|
46
|
-
* ```
|
|
47
|
-
*/
|
|
48
12
|
query: (qb: MaterializedViewQueryBuilder<TSource>) => MaterializedViewQueryBuilder<any>;
|
|
49
|
-
/**
|
|
50
|
-
* Target table to write materialized data to.
|
|
51
|
-
* If not specified, ClickHouse creates an internal table.
|
|
52
|
-
*
|
|
53
|
-
* Can be:
|
|
54
|
-
* - A string table name
|
|
55
|
-
* - A TableDefinition reference (type-safe)
|
|
56
|
-
*/
|
|
57
13
|
toTable?: string | TableDefinition<TTargetCols>;
|
|
58
|
-
/**
|
|
59
|
-
* Cluster name for distributed materialized view
|
|
60
|
-
*/
|
|
61
14
|
onCluster?: string;
|
|
62
|
-
/**
|
|
63
|
-
* Whether to populate the MV with existing data on creation.
|
|
64
|
-
* Warning: Can be slow and resource-intensive for large tables.
|
|
65
|
-
*/
|
|
66
15
|
populate?: boolean;
|
|
67
|
-
/**
|
|
68
|
-
* Engine configuration for the internal table (when toTable is not specified).
|
|
69
|
-
* Uses the type-safe Engine DSL.
|
|
70
|
-
*/
|
|
71
16
|
engine?: EngineConfiguration;
|
|
72
|
-
/**
|
|
73
|
-
* Whether to use OR REPLACE semantics
|
|
74
|
-
*/
|
|
75
17
|
orReplace?: boolean;
|
|
76
|
-
/**
|
|
77
|
-
* Optional ORDER BY for the internal storage table
|
|
78
|
-
*/
|
|
79
18
|
orderBy?: string | string[];
|
|
80
|
-
/**
|
|
81
|
-
* Optional partition by for the internal storage table
|
|
82
|
-
*/
|
|
83
19
|
partitionBy?: string | string[];
|
|
84
20
|
}
|
|
85
|
-
/**
|
|
86
|
-
* Simplified query builder for materialized view definitions.
|
|
87
|
-
* This is a subset of the full ClickHouseQueryBuilder that generates
|
|
88
|
-
* SQL without executing queries.
|
|
89
|
-
*/
|
|
90
21
|
export interface MaterializedViewQueryBuilder<TTable extends TableDefinition<any>> {
|
|
91
22
|
select<TSelection extends Record<string, ClickHouseColumn | SQLExpression>>(fields: TSelection): MaterializedViewQueryBuilder<TTable>;
|
|
92
23
|
from<TNewTable extends TableDefinition<any>>(table: TNewTable): MaterializedViewQueryBuilder<TNewTable>;
|
|
@@ -102,10 +33,6 @@ export interface MaterializedViewQueryBuilder<TTable extends TableDefinition<any
|
|
|
102
33
|
params: Record<string, unknown>;
|
|
103
34
|
};
|
|
104
35
|
}
|
|
105
|
-
/**
|
|
106
|
-
* Extended type for materialized views - contains table-like properties
|
|
107
|
-
* plus MV-specific metadata for drift detection and query tracking.
|
|
108
|
-
*/
|
|
109
36
|
export type MaterializedViewDefinition<TCols extends TableColumns, TSource extends TableDefinition<any>, TOptions = MaterializedViewConfig<TSource>> = {
|
|
110
37
|
$table: string;
|
|
111
38
|
$columns: TCols;
|
|
@@ -114,114 +41,27 @@ export type MaterializedViewDefinition<TCols extends TableColumns, TSource exten
|
|
|
114
41
|
$source: TSource;
|
|
115
42
|
$querySQL: string;
|
|
116
43
|
$config: TOptions;
|
|
117
|
-
/**
|
|
118
|
-
* Get the SQL to create the materialized view
|
|
119
|
-
*/
|
|
120
44
|
toSQL(): string;
|
|
121
|
-
/**
|
|
122
|
-
* Get all SQL statements (for views that need multiple statements)
|
|
123
|
-
*/
|
|
124
45
|
toSQLs(): string[];
|
|
125
|
-
/**
|
|
126
|
-
* Create an aliased version for use in queries
|
|
127
|
-
*/
|
|
128
46
|
as(alias: string): MaterializedViewDefinition<TCols, TSource, TOptions>;
|
|
129
47
|
} & TCols;
|
|
130
|
-
/**
|
|
131
|
-
* Result type for projection definitions using query builder
|
|
132
|
-
*/
|
|
133
48
|
export interface TypedProjectionDefinition<TSource extends TableDefinition<any>> {
|
|
134
49
|
name: string;
|
|
135
50
|
query: string;
|
|
136
51
|
sourceTable: TSource;
|
|
137
52
|
orderBy?: string[];
|
|
138
53
|
}
|
|
139
|
-
/**
|
|
140
|
-
* Create a type-safe materialized view definition.
|
|
141
|
-
* The query is defined using a query builder that validates column references
|
|
142
|
-
* at compile time.
|
|
143
|
-
*
|
|
144
|
-
* @example
|
|
145
|
-
* ```typescript
|
|
146
|
-
* import { defineMaterializedView, text, uint64, sql, Engine } from '@housekit/orm';
|
|
147
|
-
*
|
|
148
|
-
* // Source table
|
|
149
|
-
* const events = defineTable('events', {
|
|
150
|
-
* event_type: t.text('event_type'),
|
|
151
|
-
* user_id: t.text('user_id'),
|
|
152
|
-
* revenue: t.uint64('revenue'),
|
|
153
|
-
* }, { engine: Engine.MergeTree(), orderBy: 'event_type' });
|
|
154
|
-
*
|
|
155
|
-
* // Type-safe materialized view
|
|
156
|
-
* export const revenueByEvent = defineMaterializedView('revenue_by_event_mv', {
|
|
157
|
-
* event_type: t.text('event_type'),
|
|
158
|
-
* total_revenue: t.uint64('total_revenue'),
|
|
159
|
-
* event_count: t.uint64('event_count'),
|
|
160
|
-
* }, {
|
|
161
|
-
* source: events,
|
|
162
|
-
* query: (qb) => qb
|
|
163
|
-
* .from(events) // Type-safe: only accepts events columns
|
|
164
|
-
* .select({
|
|
165
|
-
* event_type: events.event_type,
|
|
166
|
-
* total_revenue: sql`sum(${events.revenue})`,
|
|
167
|
-
* event_count: sql`count()`,
|
|
168
|
-
* })
|
|
169
|
-
* .groupBy(events.event_type),
|
|
170
|
-
* engine: Engine.SummingMergeTree(['total_revenue', 'event_count']),
|
|
171
|
-
* orderBy: 'event_type',
|
|
172
|
-
* });
|
|
173
|
-
* ```
|
|
174
|
-
*/
|
|
175
54
|
export declare function chMaterializedView<TCols extends TableColumns, TSource extends TableDefinition<any>, TTarget extends TableColumns = TCols>(name: string, columns: TCols, config: MaterializedViewConfig<TSource, TTarget>): MaterializedViewDefinition<TCols, TSource, MaterializedViewConfig<TSource, TTarget>>;
|
|
176
|
-
/**
|
|
177
|
-
* Create a type-safe projection definition.
|
|
178
|
-
*
|
|
179
|
-
* Projections are precomputed aggregations stored alongside the main table.
|
|
180
|
-
* They're automatically used by ClickHouse when the query matches.
|
|
181
|
-
*
|
|
182
|
-
* @example
|
|
183
|
-
* ```typescript
|
|
184
|
-
* const events = defineTable('events', {
|
|
185
|
-
* event_type: t.text('event_type'),
|
|
186
|
-
* user_id: t.text('user_id'),
|
|
187
|
-
* created_at: t.timestamp('created_at'),
|
|
188
|
-
* }, {
|
|
189
|
-
* engine: Engine.MergeTree(),
|
|
190
|
-
* orderBy: ['event_type', 'created_at'],
|
|
191
|
-
* projections: [
|
|
192
|
-
* // Type-safe projection
|
|
193
|
-
* chProjection('events_by_user', events, (cols) => ({
|
|
194
|
-
* select: {
|
|
195
|
-
* user_id: cols.user_id,
|
|
196
|
-
* event_type: cols.event_type,
|
|
197
|
-
* event_count: sql`count()`,
|
|
198
|
-
* },
|
|
199
|
-
* groupBy: [cols.user_id, cols.event_type],
|
|
200
|
-
* orderBy: ['user_id', 'event_type'],
|
|
201
|
-
* })),
|
|
202
|
-
* ],
|
|
203
|
-
* });
|
|
204
|
-
* ```
|
|
205
|
-
*/
|
|
206
55
|
export declare function chProjection<TCols extends TableColumns, TSource extends TableDefinition<TCols>>(name: string, sourceTable: TSource & TableWithColumns<TCols>, definition: (cols: TCols) => {
|
|
207
56
|
select: Record<string, ClickHouseColumn | SQLExpression>;
|
|
208
57
|
groupBy?: (ClickHouseColumn | SQLExpression)[];
|
|
209
58
|
orderBy?: string[];
|
|
210
59
|
}): TypedProjectionDefinition<TSource>;
|
|
211
|
-
/**
|
|
212
|
-
* Convert a TypedProjectionDefinition to the format expected by defineTable
|
|
213
|
-
*/
|
|
214
60
|
export declare function toProjectionDefinition(projection: TypedProjectionDefinition<any>): {
|
|
215
61
|
name: string;
|
|
216
62
|
query: string;
|
|
217
63
|
};
|
|
218
|
-
/**
|
|
219
|
-
* Normalize SQL for comparison (removes extra whitespace, normalizes case)
|
|
220
|
-
*/
|
|
221
64
|
export declare function normalizeSQL(sql: string): string;
|
|
222
|
-
/**
|
|
223
|
-
* Compare two materialized view definitions for drift
|
|
224
|
-
*/
|
|
225
65
|
export declare function detectMaterializedViewDrift(local: MaterializedViewDefinition<any, any>, remoteSQL: string): {
|
|
226
66
|
hasDrift: boolean;
|
|
227
67
|
localSQL: string;
|
|
@@ -229,38 +69,10 @@ export declare function detectMaterializedViewDrift(local: MaterializedViewDefin
|
|
|
229
69
|
normalizedLocal: string;
|
|
230
70
|
normalizedRemote: string;
|
|
231
71
|
};
|
|
232
|
-
/**
|
|
233
|
-
* Extract the AS query from a CREATE MATERIALIZED VIEW statement
|
|
234
|
-
*/
|
|
235
72
|
export declare function extractMVQuery(createStatement: string): string | null;
|
|
236
|
-
/**
|
|
237
|
-
* Generate a Blue-Green migration plan for a Materialized View update.
|
|
238
|
-
*
|
|
239
|
-
* Instead of just dropping and recreating, we:
|
|
240
|
-
* 1. Create a "next" version of the target table (if using TO table)
|
|
241
|
-
* 2. Create a "next" version of the MV
|
|
242
|
-
* 3. Backfill data from source table using the NEW query
|
|
243
|
-
* 4. Swap them using RENAME
|
|
244
|
-
*/
|
|
245
73
|
export declare function generateBlueGreenMigration(oldMV: MaterializedViewDefinition<any, any>, newMV: MaterializedViewDefinition<any, any>, options?: {
|
|
246
74
|
backfill?: boolean;
|
|
247
75
|
}): string[];
|
|
248
|
-
/**
|
|
249
|
-
* Creates a "migration bridge" Materialized View that synchronizes data from an old table to a new one.
|
|
250
|
-
* This is extremely useful for zero-downtime migrations where you need to pipe real-time
|
|
251
|
-
* inserts from the v1 table into the v2 table while you perform a separate historical backfill.
|
|
252
|
-
*
|
|
253
|
-
* @example
|
|
254
|
-
* ```typescript
|
|
255
|
-
* const bridge = createMigrationBridge({
|
|
256
|
-
* from: tableV1,
|
|
257
|
-
* to: tableV2,
|
|
258
|
-
* mapping: {
|
|
259
|
-
* new_col: sql`upper(${tableV1.old_col})`
|
|
260
|
-
* }
|
|
261
|
-
* });
|
|
262
|
-
* ```
|
|
263
|
-
*/
|
|
264
76
|
export declare function createMigrationBridge<TSourceCols extends TableColumns, TTargetCols extends TableColumns>(options: {
|
|
265
77
|
from: TableDefinition<TSourceCols>;
|
|
266
78
|
to: TableDefinition<TTargetCols>;
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { ClickHouseColumn } from './column';
|
|
2
|
+
import { renderEngineSQL } from './engines';
|
|
3
|
+
class VirtualQueryBuilder {
|
|
4
|
+
_select = null;
|
|
5
|
+
_table = null;
|
|
6
|
+
_where = null;
|
|
7
|
+
_groupBy = [];
|
|
8
|
+
_having = null;
|
|
9
|
+
_orderBy = [];
|
|
10
|
+
_limit = null;
|
|
11
|
+
_joins = [];
|
|
12
|
+
constructor(sourceTable) {
|
|
13
|
+
if (sourceTable) {
|
|
14
|
+
this._table = sourceTable;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
select(fields) {
|
|
18
|
+
this._select = fields;
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
from(table) {
|
|
22
|
+
this._table = table;
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
where(expression) {
|
|
26
|
+
this._where = expression;
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
groupBy(...cols) {
|
|
30
|
+
this._groupBy = cols;
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
having(expression) {
|
|
34
|
+
this._having = expression;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
orderBy(col, dir = 'ASC') {
|
|
38
|
+
this._orderBy.push({ col, dir });
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
limit(val) {
|
|
42
|
+
this._limit = val;
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
innerJoin(table, on) {
|
|
46
|
+
this._joins.push({ type: 'INNER', table: table.$table, on });
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
leftJoin(table, on) {
|
|
50
|
+
this._joins.push({ type: 'LEFT', table: table.$table, on });
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
toSQL() {
|
|
54
|
+
const parts = [];
|
|
55
|
+
const params = {};
|
|
56
|
+
if (this._select) {
|
|
57
|
+
const selectParts = Object.entries(this._select).map(([alias, col]) => {
|
|
58
|
+
if (col instanceof ClickHouseColumn) {
|
|
59
|
+
const tableName = col.tableName ? `\`${col.tableName}\`.` : '';
|
|
60
|
+
return `${tableName}\`${col.name}\` AS \`${alias}\``;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const { sql, params: exprParams } = this.resolveSQLExpression(col);
|
|
64
|
+
Object.assign(params, exprParams);
|
|
65
|
+
return `${sql} AS \`${alias}\``;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
parts.push(`SELECT ${selectParts.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
parts.push('SELECT *');
|
|
72
|
+
}
|
|
73
|
+
if (this._table) {
|
|
74
|
+
parts.push(`FROM \`${this._table.$table}\``);
|
|
75
|
+
}
|
|
76
|
+
for (const join of this._joins) {
|
|
77
|
+
const { sql: onSQL } = this.resolveSQLExpression(join.on);
|
|
78
|
+
parts.push(`${join.type} JOIN \`${join.table}\` ON ${onSQL}`);
|
|
79
|
+
}
|
|
80
|
+
if (this._where) {
|
|
81
|
+
const { sql: whereSQL, params: whereParams } = this.resolveSQLExpression(this._where);
|
|
82
|
+
Object.assign(params, whereParams);
|
|
83
|
+
parts.push(`WHERE ${whereSQL}`);
|
|
84
|
+
}
|
|
85
|
+
if (this._groupBy.length > 0) {
|
|
86
|
+
const groupParts = this._groupBy.map(col => {
|
|
87
|
+
if (col instanceof ClickHouseColumn) {
|
|
88
|
+
const tableName = col.tableName ? `\`${col.tableName}\`.` : '';
|
|
89
|
+
return `${tableName}\`${col.name}\``;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const { sql } = this.resolveSQLExpression(col);
|
|
93
|
+
return sql;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
parts.push(`GROUP BY ${groupParts.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
if (this._having) {
|
|
99
|
+
const { sql: havingSQL, params: havingParams } = this.resolveSQLExpression(this._having);
|
|
100
|
+
Object.assign(params, havingParams);
|
|
101
|
+
parts.push(`HAVING ${havingSQL}`);
|
|
102
|
+
}
|
|
103
|
+
if (this._orderBy.length > 0) {
|
|
104
|
+
const orderParts = this._orderBy.map(({ col, dir }) => {
|
|
105
|
+
if (col instanceof ClickHouseColumn) {
|
|
106
|
+
const tableName = col.tableName ? `\`${col.tableName}\`.` : '';
|
|
107
|
+
return `${tableName}\`${col.name}\` ${dir}`;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const { sql } = this.resolveSQLExpression(col);
|
|
111
|
+
return `${sql} ${dir}`;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
parts.push(`ORDER BY ${orderParts.join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
if (this._limit !== null) {
|
|
117
|
+
parts.push(`LIMIT ${this._limit}`);
|
|
118
|
+
}
|
|
119
|
+
return { query: parts.join(' '), params };
|
|
120
|
+
}
|
|
121
|
+
resolveSQLExpression(expr) {
|
|
122
|
+
const params = {};
|
|
123
|
+
if (typeof expr.toSQL === 'function') {
|
|
124
|
+
const result = expr.toSQL();
|
|
125
|
+
return { sql: result.sql || String(result), params: result.params || {} };
|
|
126
|
+
}
|
|
127
|
+
if (typeof expr.sql === 'string') {
|
|
128
|
+
return { sql: expr.sql, params: expr.params || {} };
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(expr.strings)) {
|
|
131
|
+
const exprAny = expr;
|
|
132
|
+
let sql = '';
|
|
133
|
+
const strings = exprAny.strings;
|
|
134
|
+
const values = exprAny.values;
|
|
135
|
+
for (let i = 0; i < strings.length; i++) {
|
|
136
|
+
sql += strings[i];
|
|
137
|
+
if (i < values.length) {
|
|
138
|
+
const value = values[i];
|
|
139
|
+
if (value instanceof ClickHouseColumn) {
|
|
140
|
+
const tableName = value.tableName ? `\`${value.tableName}\`.` : '';
|
|
141
|
+
sql += `${tableName}\`${value.name}\``;
|
|
142
|
+
}
|
|
143
|
+
else if (typeof value === 'object' && value !== null && 'sql' in value) {
|
|
144
|
+
const nested = this.resolveSQLExpression(value);
|
|
145
|
+
sql += nested.sql;
|
|
146
|
+
Object.assign(params, nested.params);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const paramName = `p${Object.keys(params).length}`;
|
|
150
|
+
params[paramName] = value;
|
|
151
|
+
sql += `{${paramName}: String}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { sql, params };
|
|
156
|
+
}
|
|
157
|
+
return { sql: String(expr), params };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function chMaterializedView(name, columns, config) {
|
|
161
|
+
const virtualBuilder = new VirtualQueryBuilder(config.source);
|
|
162
|
+
const finalBuilder = config.query(virtualBuilder);
|
|
163
|
+
const { query: querySQL, params } = finalBuilder.toSQL();
|
|
164
|
+
for (const col of Object.values(columns)) {
|
|
165
|
+
col.tableName = name;
|
|
166
|
+
}
|
|
167
|
+
const toSQL = () => {
|
|
168
|
+
const parts = [];
|
|
169
|
+
if (config.orReplace) {
|
|
170
|
+
parts.push('CREATE OR REPLACE MATERIALIZED VIEW');
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
parts.push('CREATE MATERIALIZED VIEW IF NOT EXISTS');
|
|
174
|
+
}
|
|
175
|
+
parts.push(`\`${name}\``);
|
|
176
|
+
if (config.onCluster) {
|
|
177
|
+
parts.push(`ON CLUSTER ${config.onCluster}`);
|
|
178
|
+
}
|
|
179
|
+
if (config.toTable) {
|
|
180
|
+
const tableName = typeof config.toTable === 'string'
|
|
181
|
+
? config.toTable
|
|
182
|
+
: config.toTable.$table;
|
|
183
|
+
parts.push(`TO \`${tableName}\``);
|
|
184
|
+
}
|
|
185
|
+
else if (config.engine) {
|
|
186
|
+
const engineSQL = renderEngineSQL(config.engine);
|
|
187
|
+
parts.push(`ENGINE = ${engineSQL}`);
|
|
188
|
+
if (config.orderBy) {
|
|
189
|
+
const orderBy = Array.isArray(config.orderBy)
|
|
190
|
+
? config.orderBy.join(', ')
|
|
191
|
+
: config.orderBy;
|
|
192
|
+
parts.push(`ORDER BY (${orderBy})`);
|
|
193
|
+
}
|
|
194
|
+
if (config.partitionBy) {
|
|
195
|
+
const partitionBy = Array.isArray(config.partitionBy)
|
|
196
|
+
? config.partitionBy.join(', ')
|
|
197
|
+
: config.partitionBy;
|
|
198
|
+
parts.push(`PARTITION BY (${partitionBy})`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (config.populate) {
|
|
202
|
+
parts.push('POPULATE');
|
|
203
|
+
}
|
|
204
|
+
parts.push('AS');
|
|
205
|
+
parts.push(querySQL);
|
|
206
|
+
return parts.join(' ');
|
|
207
|
+
};
|
|
208
|
+
const definition = {
|
|
209
|
+
$table: name,
|
|
210
|
+
$columns: columns,
|
|
211
|
+
$options: config,
|
|
212
|
+
$kind: 'materializedView',
|
|
213
|
+
$source: config.source,
|
|
214
|
+
$querySQL: querySQL,
|
|
215
|
+
$config: config,
|
|
216
|
+
toSQL,
|
|
217
|
+
toSQLs: () => [toSQL()],
|
|
218
|
+
as: (alias) => {
|
|
219
|
+
const aliased = {
|
|
220
|
+
$table: alias,
|
|
221
|
+
$columns: { ...columns },
|
|
222
|
+
$options: { ...config, externallyManaged: true },
|
|
223
|
+
$kind: 'materializedView',
|
|
224
|
+
toSQL: () => ''
|
|
225
|
+
};
|
|
226
|
+
for (const [key, col] of Object.entries(columns)) {
|
|
227
|
+
const column = col;
|
|
228
|
+
const cloned = Object.create(Object.getPrototypeOf(column));
|
|
229
|
+
Object.assign(cloned, column);
|
|
230
|
+
cloned.tableName = alias;
|
|
231
|
+
aliased[key] = cloned;
|
|
232
|
+
aliased.$columns[key] = cloned;
|
|
233
|
+
}
|
|
234
|
+
return aliased;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
for (const [key, col] of Object.entries(columns)) {
|
|
238
|
+
definition[key] = col;
|
|
239
|
+
}
|
|
240
|
+
return definition;
|
|
241
|
+
}
|
|
242
|
+
export function chProjection(name, sourceTable, definition) {
|
|
243
|
+
const cols = sourceTable.$columns;
|
|
244
|
+
const def = definition(cols);
|
|
245
|
+
const selectParts = Object.entries(def.select).map(([alias, col]) => {
|
|
246
|
+
if (col instanceof ClickHouseColumn) {
|
|
247
|
+
return `\`${col.name}\` AS \`${alias}\``;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const sqlExpr = col;
|
|
251
|
+
if (typeof sqlExpr.toSQL === 'function') {
|
|
252
|
+
const { sql: sqlStr } = sqlExpr.toSQL({ ignoreTablePrefix: true });
|
|
253
|
+
return `${sqlStr} AS \`${alias}\``;
|
|
254
|
+
}
|
|
255
|
+
return `${String(col)} AS \`${alias}\``;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
let query = `SELECT ${selectParts.join(', ')}`;
|
|
259
|
+
if (def.groupBy && def.groupBy.length > 0) {
|
|
260
|
+
const groupParts = def.groupBy.map(col => {
|
|
261
|
+
if (col instanceof ClickHouseColumn) {
|
|
262
|
+
return `\`${col.name}\``;
|
|
263
|
+
}
|
|
264
|
+
const sqlExpr = col;
|
|
265
|
+
if (typeof sqlExpr.toSQL === 'function') {
|
|
266
|
+
const { sql: sqlStr } = sqlExpr.toSQL({ ignoreTablePrefix: true });
|
|
267
|
+
return sqlStr;
|
|
268
|
+
}
|
|
269
|
+
return String(col);
|
|
270
|
+
});
|
|
271
|
+
query += ` GROUP BY ${groupParts.join(', ')}`;
|
|
272
|
+
}
|
|
273
|
+
if (def.orderBy && def.orderBy.length > 0) {
|
|
274
|
+
query += ` ORDER BY ${def.orderBy.join(', ')}`;
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
name,
|
|
278
|
+
query,
|
|
279
|
+
sourceTable,
|
|
280
|
+
orderBy: def.orderBy
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
export function toProjectionDefinition(projection) {
|
|
284
|
+
return {
|
|
285
|
+
name: projection.name,
|
|
286
|
+
query: projection.query
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
export function normalizeSQL(sql) {
|
|
290
|
+
return sql
|
|
291
|
+
.replace(/\s+/g, ' ')
|
|
292
|
+
.replace(/\(\s+/g, '(')
|
|
293
|
+
.replace(/\s+\)/g, ')')
|
|
294
|
+
.replace(/,\s+/g, ', ')
|
|
295
|
+
.trim()
|
|
296
|
+
.toUpperCase();
|
|
297
|
+
}
|
|
298
|
+
function hashString(str) {
|
|
299
|
+
let hash = 0;
|
|
300
|
+
for (let i = 0; i < str.length; i++) {
|
|
301
|
+
const char = str.charCodeAt(i);
|
|
302
|
+
hash = ((hash << 5) - hash) + char;
|
|
303
|
+
hash = hash & hash;
|
|
304
|
+
}
|
|
305
|
+
return Math.abs(hash).toString(16);
|
|
306
|
+
}
|
|
307
|
+
export function detectMaterializedViewDrift(local, remoteSQL) {
|
|
308
|
+
const localSQL = local.toSQL();
|
|
309
|
+
const normalizedLocal = normalizeSQL(localSQL);
|
|
310
|
+
const normalizedRemote = normalizeSQL(remoteSQL);
|
|
311
|
+
return {
|
|
312
|
+
hasDrift: normalizedLocal !== normalizedRemote,
|
|
313
|
+
localSQL,
|
|
314
|
+
remoteSQL,
|
|
315
|
+
normalizedLocal,
|
|
316
|
+
normalizedRemote
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
export function extractMVQuery(createStatement) {
|
|
320
|
+
const match = createStatement.match(/\bAS\s+(.+)$/is);
|
|
321
|
+
return match ? match[1].trim() : null;
|
|
322
|
+
}
|
|
323
|
+
export function generateBlueGreenMigration(oldMV, newMV, options = {}) {
|
|
324
|
+
const name = newMV.$table;
|
|
325
|
+
const nextName = `${name}_next`;
|
|
326
|
+
const targetTable = newMV.$options.toTable;
|
|
327
|
+
const sqls = [];
|
|
328
|
+
if (targetTable) {
|
|
329
|
+
const targetTableName = typeof targetTable === 'string' ? targetTable : targetTable.$table;
|
|
330
|
+
const nextTargetName = `${targetTableName}_next`;
|
|
331
|
+
if (typeof targetTable !== 'string') {
|
|
332
|
+
const nextTableSQL = targetTable.toSQL().replace(new RegExp(`\`${targetTableName}\``, 'g'), `\`${nextTargetName}\``);
|
|
333
|
+
sqls.push(nextTableSQL);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
sqls.push(`CREATE TABLE \`${nextTargetName}\` AS \`${targetTableName}\``);
|
|
337
|
+
}
|
|
338
|
+
const nextMVSQL = newMV.toSQL()
|
|
339
|
+
.replace(new RegExp(`\`${name}\``, 'g'), `\`${nextName}\``)
|
|
340
|
+
.replace(new RegExp(`TO \`${targetTableName}\``, 'g'), `TO \`${nextTargetName}\``);
|
|
341
|
+
sqls.push(nextMVSQL);
|
|
342
|
+
if (options.backfill) {
|
|
343
|
+
const querySQL = newMV.$querySQL;
|
|
344
|
+
sqls.push(`INSERT INTO \`${nextTargetName}\` ${querySQL}`);
|
|
345
|
+
}
|
|
346
|
+
sqls.push(`EXCHANGE TABLES \`${name}\` AND \`${nextName}\``);
|
|
347
|
+
sqls.push(`EXCHANGE TABLES \`${targetTableName}\` AND \`${nextTargetName}\``);
|
|
348
|
+
sqls.push(`DROP VIEW IF EXISTS \`${nextName}\``);
|
|
349
|
+
sqls.push(`DROP TABLE IF EXISTS \`${nextTargetName}\``);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
sqls.push(`DROP VIEW IF EXISTS \`${name}\``);
|
|
353
|
+
sqls.push(newMV.toSQL());
|
|
354
|
+
}
|
|
355
|
+
return sqls;
|
|
356
|
+
}
|
|
357
|
+
export function createMigrationBridge(options) {
|
|
358
|
+
const fromName = options.from.$table;
|
|
359
|
+
const toName = options.to.$table;
|
|
360
|
+
const bridgeName = options.name || `${fromName}_to_${toName}_bridge_mv`;
|
|
361
|
+
const selection = {};
|
|
362
|
+
if (options.mapping) {
|
|
363
|
+
Object.assign(selection, options.mapping);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const fromCols = options.from.$columns;
|
|
367
|
+
const toCols = options.to.$columns;
|
|
368
|
+
for (const key of Object.keys(fromCols)) {
|
|
369
|
+
if (toCols[key]) {
|
|
370
|
+
selection[key] = fromCols[key];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return chMaterializedView(bridgeName, options.to.$columns, {
|
|
375
|
+
source: options.from,
|
|
376
|
+
toTable: options.to,
|
|
377
|
+
onCluster: options.onCluster,
|
|
378
|
+
query: (qb) => qb.from(options.from).select(selection)
|
|
379
|
+
});
|
|
380
|
+
}
|