Skip to content

Commit 9aff368

Browse files
committed
[WIP] feat(clickhouse): Use ClickHouse query with parameters via HTTP interface
1 parent 8e815c2 commit 9aff368

File tree

5 files changed

+87
-13
lines changed

5 files changed

+87
-13
lines changed

packages/cubejs-clickhouse-driver/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"integration:clickhouse": "jest dist/test"
2828
},
2929
"dependencies": {
30+
"@clickhouse/client-common": "^1.8.0",
3031
"@cubejs-backend/apla-clickhouse": "^1.7",
3132
"@cubejs-backend/base-driver": "1.1.3",
3233
"@cubejs-backend/shared": "1.1.3",

packages/cubejs-clickhouse-driver/src/ClickHouseDriver.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
} from '@cubejs-backend/base-driver';
2424
import genericPool, { Pool } from 'generic-pool';
2525
import { v4 as uuidv4 } from 'uuid';
26+
// TODO get rid of sqlstring completely
2627
import sqlstring from 'sqlstring';
28+
import { formatQueryParams } from '@clickhouse/client-common';
2729

2830
import { HydrationStream, transformRow } from './HydrationStream';
2931

@@ -222,12 +224,42 @@ export class ClickHouseDriver extends BaseDriver implements DriverInterface {
222224
true;
223225
}
224226

225-
public async query(query: string, values: unknown[]) {
226-
return this.queryResponse(query, values).then((res: any) => this.normaliseResponse(res));
227+
public async query(query: string, values?: unknown[]) {
228+
return this.queryResponse(query, values).then((res: any) => this.normaliseResponse(res)).catch((err: unknown) => {
229+
throw new Error(`Failed during query; query: ${query}; values: ${values}`, { cause: err });
230+
});
227231
}
228232

229-
protected queryResponse(query: string, values: unknown[]) {
230-
const formattedQuery = sqlstring.format(query, values);
233+
protected queryResponse(query: string, values?: unknown[]) {
234+
// todo drop this
235+
console.log('queryResponse call', query, values);
236+
237+
// const formattedQuery = sqlstring.format(query, values);
238+
const formattedQuery = query;
239+
240+
// `queryOptions` object will be merged into query params via querystring.stringify
241+
// https://github.com/cube-js/apla-node-clickhouse/blob/5a6577fc97ba6911171753fc65b2cd2f6170f2f7/src/clickhouse.js#L347-L348
242+
// https://github.com/cube-js/apla-node-clickhouse/blob/5a6577fc97ba6911171753fc65b2cd2f6170f2f7/src/clickhouse.js#L265-L266
243+
// https://github.com/cube-js/apla-node-clickhouse/blob/5a6577fc97ba6911171753fc65b2cd2f6170f2f7/src/clickhouse.js#L336-L338
244+
// https://github.com/cube-js/apla-node-clickhouse/blob/5a6577fc97ba6911171753fc65b2cd2f6170f2f7/src/clickhouse.js#L173-L175
245+
246+
// We can use `toSearchParams` or `formatQueryParams` from `@clickhouse/client-common` to prepare params, and extract only interesting ones
247+
// Beware - these functions marked as "For implementations usage only - should not be re-exported", so, probably, ot could be moved or disappear completely
248+
// https://github.com/ClickHouse/clickhouse-js/blob/a15cce93545c792852e34c05ce31954c75d11486/packages/client-common/src/utils/url.ts#L57-L61
249+
250+
// HTTP interface itself is documented, so it should be fine
251+
// https://clickhouse.com/docs/en/interfaces/cli#cli-queries-with-parameters
252+
// https://clickhouse.com/docs/en/interfaces/http#cli-queries-with-parameters
253+
254+
const paramsValues = Object.fromEntries((values ?? []).map((value, idx) => {
255+
const paramName = this.paramName(idx);
256+
const paramKey = `param_${paramName}`;
257+
const preparedValue = formatQueryParams(value);
258+
return [paramKey, preparedValue];
259+
}));
260+
261+
// todo drop this
262+
console.log('queryResponse prepared', formattedQuery, paramsValues);
231263

232264
return this.withConnection((connection, queryId) => connection.querying(formattedQuery, {
233265
dataObjects: true,
@@ -241,6 +273,9 @@ export class ClickHouseDriver extends BaseDriver implements DriverInterface {
241273
//
242274
//
243275
...(this.readOnlyMode ? {} : { join_use_nulls: 1 }),
276+
277+
// Add parameter values to query string
278+
...paramsValues,
244279
}
245280
}));
246281
}
@@ -309,6 +344,15 @@ export class ClickHouseDriver extends BaseDriver implements DriverInterface {
309344
return [{ schema_name: this.config.queryOptions.database }];
310345
}
311346

347+
protected paramName(paramIndex: number): string {
348+
return `p${paramIndex}`;
349+
}
350+
351+
public param(paramIndex: number): string {
352+
// TODO not always string
353+
return `{${this.paramName(paramIndex)}:String}`;
354+
}
355+
312356
public async stream(
313357
query: string,
314358
values: unknown[],
@@ -416,7 +460,7 @@ export class ClickHouseDriver extends BaseDriver implements DriverInterface {
416460
}
417461

418462
public getTablesQuery(schemaName: string) {
419-
return this.query('SELECT name as table_name FROM system.tables WHERE database = ?', [schemaName]);
463+
return this.query('SELECT name as table_name FROM system.tables WHERE database = {p0:String}', [schemaName]);
420464
}
421465

422466
protected getExportBucket(

packages/cubejs-clickhouse-driver/test/ClickHouseDriver.test.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,23 @@ describe('ClickHouseDriver', () => {
5858
[]
5959
);
6060

61-
await driver.query('INSERT INTO test.types_test VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
61+
function mkPlaceholdersTuple(len: number): string {
62+
const parts = new Array(len).fill('').map((_, idx) => driver.param(idx));
63+
return `(${parts.join(',')})`;
64+
}
65+
66+
async function insert(table: string, values: Array<unknown>): Promise<void> {
67+
const placeholders = mkPlaceholdersTuple(values.length);
68+
await driver.query(`INSERT INTO ${table} VALUES ${placeholders}`, values);
69+
}
70+
71+
await insert('test.types_test', [
6272
'2020-01-01', '2020-01-01 00:00:00', '2020-01-01 00:00:00.000', '2020-01-01 00:00:00.000000', '2020-01-01 00:00:00.000000000', 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1.01, 1.01, 1.01, 'hello', 'world'
6373
]);
64-
await driver.query('INSERT INTO test.types_test VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
74+
await insert('test.types_test', [
6575
'2020-01-02', '2020-01-02 00:00:00', '2020-01-02 00:00:00.123', '2020-01-02 00:00:00.123456', '2020-01-02 00:00:00.123456789', 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2.02, 2.02, 2.02, 'hello', 'world'
6676
]);
67-
await driver.query('INSERT INTO test.types_test VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [
77+
await insert('test.types_test', [
6878
'2020-01-03', '2020-01-03 00:00:00', '2020-01-03 00:00:00.234', '2020-01-03 00:00:00.234567', '2020-01-03 00:00:00.234567890', 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3.03, 3.03, 3.03, 'hello', 'world'
6979
]);
7080
});
@@ -205,8 +215,8 @@ describe('ClickHouseDriver', () => {
205215
try {
206216
await driver.createSchemaIfNotExists(name);
207217
await driver.query(`CREATE TABLE ${name}.test (x Int32, s String) ENGINE Log`, []);
208-
await driver.query(`INSERT INTO ${name}.test VALUES (?, ?), (?, ?), (?, ?)`, [1, 'str1', 2, 'str2', 3, 'str3']);
209-
const values = await driver.query(`SELECT * FROM ${name}.test WHERE x = ?`, [2]);
218+
await driver.query(`INSERT INTO ${name}.test VALUES ({p0:Int32}, {p1:String}), ({p2:Int32}, {p3:String}), ({p4:Int32}, {p5:String})`, [1, 'str1', 2, 'str2', 3, 'str3']);
219+
const values = await driver.query(`SELECT * FROM ${name}.test WHERE x = {p0:Int32}`, [2]);
210220
expect(values).toEqual([{ x: '2', s: 'str2' }]);
211221
} finally {
212222
await driver.query(`DROP DATABASE ${name}`, []);
@@ -220,10 +230,10 @@ describe('ClickHouseDriver', () => {
220230
try {
221231
await driver.createSchemaIfNotExists(name);
222232
await driver.query(`CREATE TABLE ${name}.a (x Int32, s String) ENGINE Log`, []);
223-
await driver.query(`INSERT INTO ${name}.a VALUES (?, ?), (?, ?), (?, ?)`, [1, 'str1', 2, 'str2', 3, 'str3']);
233+
await driver.query(`INSERT INTO ${name}.a VALUES ({p0:Int32}, {p1:String}), ({p2:Int32}, {p3:String}), ({p4:Int32}, {p5:String})`, [1, 'str1', 2, 'str2', 3, 'str3']);
224234

225235
await driver.query(`CREATE TABLE ${name}.b (x Int32, s String) ENGINE Log`, []);
226-
await driver.query(`INSERT INTO ${name}.b VALUES (?, ?), (?, ?), (?, ?)`, [2, 'str2', 3, 'str3', 4, 'str4']);
236+
await driver.query(`INSERT INTO ${name}.b VALUES ({p0:Int32}, {p1:String}), ({p2:Int32}, {p3:String}), ({p4:Int32}, {p5:String})`, [2, 'str2', 3, 'str3', 4, 'str4']);
227237

228238
const values = await driver.query(`SELECT * FROM ${name}.a LEFT OUTER JOIN ${name}.b ON a.x = b.x`, []);
229239
expect(values).toEqual([
@@ -245,7 +255,7 @@ describe('ClickHouseDriver', () => {
245255

246256
it('datetime with specific timezone', async () => {
247257
await doWithDriver(async (driver) => {
248-
const rows = await driver.query('SELECT toDateTime(?, \'Asia/Istanbul\') as dt', [
258+
const rows = await driver.query('SELECT toDateTime({p0:String}, \'Asia/Istanbul\') as dt', [
249259
'2020-01-01 00:00:00'
250260
]);
251261
expect(rows).toEqual([{

packages/cubejs-schema-compiler/src/adapter/ClickHouseQuery.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BaseQuery } from './BaseQuery';
33
import { BaseFilter } from './BaseFilter';
44
import { UserError } from '../compiler/UserError';
55
import { BaseTimeDimension } from './BaseTimeDimension';
6+
import { ParamAllocator } from './ParamAllocator';
67

78
const GRANULARITY_TO_INTERVAL = {
89
day: 'Day',
@@ -14,6 +15,13 @@ const GRANULARITY_TO_INTERVAL = {
1415
year: 'Year',
1516
};
1617

18+
class ClickHouseParamAllocator extends ParamAllocator {
19+
public paramPlaceHolder(paramIndex: number): string {
20+
// TODO not always string
21+
return `{p${paramIndex}:String}`;
22+
}
23+
}
24+
1725
class ClickHouseFilter extends BaseFilter {
1826
public likeIgnoreCase(column, not, param, type) {
1927
const p = (!type || type === 'contains' || type === 'ends') ? '%' : '';
@@ -31,6 +39,10 @@ class ClickHouseFilter extends BaseFilter {
3139
}
3240

3341
export class ClickHouseQuery extends BaseQuery {
42+
public newParamAllocator(expressionParams) {
43+
return new ClickHouseParamAllocator(expressionParams);
44+
}
45+
3446
public newFilter(filter) {
3547
return new ClickHouseFilter(this, filter);
3648
}
@@ -268,6 +280,8 @@ export class ClickHouseQuery extends BaseQuery {
268280

269281
public sqlTemplates() {
270282
const templates = super.sqlTemplates();
283+
// TODO not every param is a string
284+
templates.params.param = '{p{{ param_index }}:String}';
271285
templates.functions.DATETRUNC = 'DATE_TRUNC({{ args_concat }})';
272286
// TODO: Introduce additional filter in jinja? or parseDateTimeBestEffort?
273287
// https://github.com/ClickHouse/ClickHouse/issues/19351

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4222,6 +4222,11 @@
42224222
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
42234223
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
42244224

4225+
"@clickhouse/client-common@^1.8.0":
4226+
version "1.8.0"
4227+
resolved "https://registry.yarnpkg.com/@clickhouse/client-common/-/client-common-1.8.0.tgz#6775c4f1f4b56f393cbacec0a1b544dafcf8d5ed"
4228+
integrity sha512-aQgH0UODGuFHfL8rgeLSrGCoh3NCoNUs0tFGl0o79iyfASfvWtT/K/X3RM0QJpXXOgXpB//T2nD5XvCFtdk32w==
4229+
42254230
"@codemirror/highlight@^0.19.0":
42264231
version "0.19.6"
42274232
resolved "https://registry.yarnpkg.com/@codemirror/highlight/-/highlight-0.19.6.tgz#7f2e066f83f5649e8e0748a3abe0aaeaf64b8ac2"

0 commit comments

Comments
 (0)