@svadmin/clickhouse 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@svadmin/clickhouse",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "ClickHouse analytical DataProvider adapter for svadmin",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "src/**/*.ts",
9
+ "!src/**/*.test.*",
10
+ "!src/**/*.spec.*"
11
+ ],
12
+ "main": "src/index.ts",
13
+ "types": "src/index.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "default": "./src/index.ts"
18
+ }
19
+ },
20
+ "peerDependencies": {
21
+ "@svadmin/core": "^0.79.0"
22
+ },
23
+ "keywords": [
24
+ "svadmin",
25
+ "clickhouse",
26
+ "analytics",
27
+ "data-provider",
28
+ "olap"
29
+ ],
30
+ "license": "MIT",
31
+ "author": "zuohuadong",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/vibeunion/svadmin.git",
35
+ "directory": "packages/clickhouse"
36
+ },
37
+ "homepage": "https://github.com/vibeunion/svadmin#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/vibeunion/svadmin/issues"
40
+ },
41
+ "svadmin": {
42
+ "capabilities": [
43
+ "data"
44
+ ],
45
+ "core": "^0.55.0",
46
+ "stability": "stable",
47
+ "migration": null
48
+ }
49
+ }
@@ -0,0 +1,289 @@
1
+ import type {
2
+ DataProvider,
3
+ GetListParams,
4
+ GetListResult,
5
+ GetOneParams,
6
+ GetOneResult,
7
+ CreateParams,
8
+ CreateResult,
9
+ UpdateParams,
10
+ UpdateResult,
11
+ DeleteParams,
12
+ DeleteResult,
13
+ CustomParams,
14
+ CustomResult,
15
+ BaseRecord,
16
+ Filter,
17
+ Sort,
18
+ } from '@svadmin/core';
19
+
20
+ export interface ClickHouseClientLike {
21
+ query: (params: { query: string; format?: string; query_params?: Record<string, unknown> }) => Promise<{
22
+ json: () => Promise<unknown>;
23
+ }>;
24
+ }
25
+
26
+ export interface ClickHouseOptions {
27
+ /** ClickHouse HTTP endpoint URL if no client is provided, e.g. 'http://localhost:8123' */
28
+ url?: string;
29
+ /** Optional database name */
30
+ database?: string;
31
+ /** Optional HTTP basic authorization header */
32
+ authorization?: string;
33
+ /** Custom fetch function */
34
+ fetch?: typeof fetch;
35
+ }
36
+
37
+ function sanitizeIdentifier(name: string): string {
38
+ if (!/^[a-zA-Z0-9_]+$/.test(name)) {
39
+ throw new Error(`Invalid ClickHouse table or column name: ${name}`);
40
+ }
41
+ return name;
42
+ }
43
+
44
+ function quoteString(value: string): string {
45
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
46
+ }
47
+
48
+ function quoteValue(value: unknown): string {
49
+ if (typeof value === 'string') return quoteString(value);
50
+ if (typeof value === 'number') {
51
+ if (!Number.isFinite(value)) throw new Error('Unsupported ClickHouse filter value: expected a finite number');
52
+ return String(value);
53
+ }
54
+ if (typeof value === 'boolean') return value ? '1' : '0';
55
+ if (value === null) return 'NULL';
56
+ throw new Error(`Unsupported ClickHouse filter value: ${String(value)}`);
57
+ }
58
+
59
+ function quoteList(value: unknown, operator: string): string {
60
+ if (!Array.isArray(value) || value.length === 0) {
61
+ throw new Error(`Unsupported ClickHouse filter operator ${operator}: expected a non-empty array`);
62
+ }
63
+ return value.map(quoteValue).join(', ');
64
+ }
65
+
66
+ function escapedPattern(value: unknown): string {
67
+ return String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'");
68
+ }
69
+
70
+ function buildFilterClause(filter: Filter): string {
71
+ if ('field' in filter) {
72
+ const field = sanitizeIdentifier(filter.field);
73
+ const value = filter.value;
74
+ switch (filter.operator) {
75
+ case 'eq':
76
+ case 'eqs':
77
+ return `${field} = ${quoteValue(value)}`;
78
+ case 'ne':
79
+ case 'nes':
80
+ return `${field} != ${quoteValue(value)}`;
81
+ case 'lt':
82
+ return `${field} < ${quoteValue(value)}`;
83
+ case 'lte':
84
+ return `${field} <= ${quoteValue(value)}`;
85
+ case 'gt':
86
+ return `${field} > ${quoteValue(value)}`;
87
+ case 'gte':
88
+ return `${field} >= ${quoteValue(value)}`;
89
+ case 'in':
90
+ return `${field} IN (${quoteList(value, filter.operator)})`;
91
+ case 'nin':
92
+ return `${field} NOT IN (${quoteList(value, filter.operator)})`;
93
+ case 'ina':
94
+ return `hasAll(${field}, [${quoteList(value, filter.operator)}])`;
95
+ case 'nina':
96
+ return `NOT hasAll(${field}, [${quoteList(value, filter.operator)}])`;
97
+ case 'contains':
98
+ case 'containss':
99
+ return `${field} LIKE '%${escapedPattern(value)}%'`;
100
+ case 'ncontains':
101
+ case 'ncontainss':
102
+ return `${field} NOT LIKE '%${escapedPattern(value)}%'`;
103
+ case 'startswith':
104
+ case 'startswiths':
105
+ return `${field} LIKE '${escapedPattern(value)}%'`;
106
+ case 'nstartswith':
107
+ case 'nstartswiths':
108
+ return `${field} NOT LIKE '${escapedPattern(value)}%'`;
109
+ case 'endswith':
110
+ case 'endswiths':
111
+ return `${field} LIKE '%${escapedPattern(value)}'`;
112
+ case 'nendswith':
113
+ case 'nendswiths':
114
+ return `${field} NOT LIKE '%${escapedPattern(value)}'`;
115
+ case 'null':
116
+ return `${field} IS NULL`;
117
+ case 'nnull':
118
+ return `${field} IS NOT NULL`;
119
+ case 'between':
120
+ case 'nbetween': {
121
+ if (!Array.isArray(value) || value.length !== 2) {
122
+ throw new Error(`Unsupported ClickHouse filter operator ${filter.operator}: expected two values`);
123
+ }
124
+ const clause = `${field} BETWEEN ${quoteValue(value[0])} AND ${quoteValue(value[1])}`;
125
+ return filter.operator === 'between' ? clause : `NOT (${clause})`;
126
+ }
127
+ default:
128
+ throw new Error(`Unsupported ClickHouse filter operator: ${String((filter as { operator: unknown }).operator)}`);
129
+ }
130
+ }
131
+
132
+ if (!Array.isArray(filter.value) || filter.value.length === 0) {
133
+ throw new Error(`Unsupported ClickHouse logical operator ${filter.operator}: expected a non-empty filter group`);
134
+ }
135
+ const clauses = filter.value.map(buildFilterClause);
136
+ return `(${clauses.join(` ${filter.operator.toUpperCase()} `)})`;
137
+ }
138
+
139
+ function buildWhereClause(filters?: Filter[]): string {
140
+ if (!filters || filters.length === 0) return '';
141
+ return `WHERE ${filters.map(buildFilterClause).join(' AND ')}`;
142
+ }
143
+
144
+ function buildOrderBy(sorters?: Sort[]): string {
145
+ if (!sorters || sorters.length === 0) return '';
146
+ const parts = sorters.map((s: Sort) => `${sanitizeIdentifier(s.field)} ${s.order.toUpperCase()}`);
147
+ return `ORDER BY ${parts.join(', ')}`;
148
+ }
149
+
150
+ export function createClickHouseDataProvider(
151
+ clientOrOptions: ClickHouseClientLike | ClickHouseOptions,
152
+ ): DataProvider {
153
+ const isClient = 'query' in clientOrOptions && typeof clientOrOptions.query === 'function';
154
+ const customFetch = !isClient ? (clientOrOptions as ClickHouseOptions).fetch ?? fetch : fetch;
155
+ const baseUrl = !isClient ? (clientOrOptions as ClickHouseOptions).url ?? 'http://localhost:8123' : '';
156
+ const database = !isClient ? (clientOrOptions as ClickHouseOptions).database : undefined;
157
+ const authHeader = !isClient ? (clientOrOptions as ClickHouseOptions).authorization : undefined;
158
+
159
+ async function executeSql(sql: string): Promise<Record<string, unknown>[]> {
160
+ if (isClient) {
161
+ const res = await (clientOrOptions as ClickHouseClientLike).query({
162
+ query: sql,
163
+ format: 'JSON',
164
+ });
165
+ const data = (await res.json()) as { data?: Record<string, unknown>[] };
166
+ return data.data ?? [];
167
+ }
168
+
169
+ const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
170
+ if (authHeader) headers['Authorization'] = authHeader;
171
+ if (database) headers['X-ClickHouse-Database'] = database;
172
+
173
+ const response = await customFetch(`${baseUrl}/?default_format=JSON`, {
174
+ method: 'POST',
175
+ headers,
176
+ body: sql,
177
+ });
178
+
179
+ if (!response.ok) {
180
+ const errText = await response.text();
181
+ throw new Error(`ClickHouse query failed (${response.status}): ${errText}`);
182
+ }
183
+
184
+ const body = (await response.json()) as { data?: Record<string, unknown>[] };
185
+ return body.data ?? [];
186
+ }
187
+
188
+ return {
189
+ getApiUrl: () => baseUrl,
190
+
191
+ async getList<TData extends BaseRecord = BaseRecord>(params: GetListParams): Promise<GetListResult<TData>> {
192
+ const table = sanitizeIdentifier(params.resource);
193
+ const where = buildWhereClause(params.filters);
194
+ const order = buildOrderBy(params.sorters);
195
+
196
+ const page = params.pagination?.current ?? 1;
197
+ const pageSize = params.pagination?.pageSize ?? 20;
198
+ const offset = (page - 1) * pageSize;
199
+
200
+ const dataQuery = `SELECT * FROM ${table} ${where} ${order} LIMIT ${pageSize} OFFSET ${offset} FORMAT JSON`;
201
+ const countQuery = `SELECT count() AS total FROM ${table} ${where} FORMAT JSON`;
202
+
203
+ const [data, countResult] = await Promise.all([
204
+ executeSql(dataQuery),
205
+ executeSql(countQuery),
206
+ ]);
207
+
208
+ const firstRow = countResult[0];
209
+ const countVal = firstRow ? firstRow['total'] : undefined;
210
+ const total = Number(countVal ?? data.length);
211
+
212
+ return {
213
+ data: data as unknown as TData[],
214
+ total,
215
+ };
216
+ },
217
+
218
+ async getOne<TData extends BaseRecord = BaseRecord>(params: GetOneParams): Promise<GetOneResult<TData>> {
219
+ const table = sanitizeIdentifier(params.resource);
220
+ const idStr = typeof params.id === 'string' ? quoteString(params.id) : quoteValue(params.id);
221
+ const sql = `SELECT * FROM ${table} WHERE id = ${idStr} LIMIT 1 FORMAT JSON`;
222
+ const rows = await executeSql(sql);
223
+
224
+ if (rows.length === 0) {
225
+ throw new Error(`Record with id ${params.id} not found in ${params.resource}`);
226
+ }
227
+
228
+ return {
229
+ data: rows[0] as unknown as TData,
230
+ };
231
+ },
232
+
233
+ async create<TData extends BaseRecord = BaseRecord>(params: CreateParams): Promise<CreateResult<TData>> {
234
+ const table = sanitizeIdentifier(params.resource);
235
+ const variables = params.variables as Record<string, unknown>;
236
+ const keys = Object.keys(variables).map(sanitizeIdentifier);
237
+ const values = Object.values(variables).map((val) =>
238
+ quoteValue(val),
239
+ );
240
+
241
+ const sql = `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${values.join(', ')})`;
242
+ await executeSql(sql);
243
+
244
+ return {
245
+ data: variables as unknown as TData,
246
+ };
247
+ },
248
+
249
+ async update<TData extends BaseRecord = BaseRecord>(params: UpdateParams): Promise<UpdateResult<TData>> {
250
+ const table = sanitizeIdentifier(params.resource);
251
+ const idStr = typeof params.id === 'string' ? quoteString(params.id) : quoteValue(params.id);
252
+ const variables = params.variables as Record<string, unknown>;
253
+ const assignments = Object.entries(variables).map(([k, v]) => {
254
+ const val = quoteValue(v);
255
+ return `${sanitizeIdentifier(k)} = ${val}`;
256
+ });
257
+
258
+ const sql = `ALTER TABLE ${table} UPDATE ${assignments.join(', ')} WHERE id = ${idStr}`;
259
+ await executeSql(sql);
260
+
261
+ return {
262
+ data: { id: params.id, ...variables } as unknown as TData,
263
+ };
264
+ },
265
+
266
+ async deleteOne<TData extends BaseRecord = BaseRecord>(params: DeleteParams): Promise<DeleteResult<TData>> {
267
+ const table = sanitizeIdentifier(params.resource);
268
+ const idStr = typeof params.id === 'string' ? quoteString(params.id) : quoteValue(params.id);
269
+ const sql = `ALTER TABLE ${table} DELETE WHERE id = ${idStr}`;
270
+ await executeSql(sql);
271
+
272
+ return {
273
+ data: { id: params.id } as unknown as TData,
274
+ };
275
+ },
276
+
277
+ async custom<TData = unknown>(params: CustomParams): Promise<CustomResult<TData>> {
278
+ const metaQuery = params.meta && typeof params.meta['query'] === 'string' ? (params.meta['query'] as string) : undefined;
279
+ const sql = metaQuery ?? params.url;
280
+ if (!sql) {
281
+ throw new Error('ClickHouse custom query requires params.meta.query or params.url');
282
+ }
283
+ const data = await executeSql(sql);
284
+ return {
285
+ data: data as unknown as TData,
286
+ };
287
+ },
288
+ };
289
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { createClickHouseDataProvider } from './clickhouse-data-provider.js';
2
+ export type {
3
+ ClickHouseClientLike,
4
+ ClickHouseOptions,
5
+ } from './clickhouse-data-provider.js';
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.