@hypequery/clickhouse 2.0.1 → 2.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/README.md +1 -1
- package/dist/cli/generate-types.d.ts +35 -1
- package/dist/cli/generate-types.js +32 -11
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/type-parsing.js +22 -4
- package/dist/core/dialects/clickhouse-dialect.d.ts.map +1 -1
- package/dist/core/dialects/clickhouse-dialect.js +2 -1
- package/dist/core/tests/integration/setup.d.ts.map +1 -1
- package/dist/core/tests/integration/setup.js +1 -3
- package/dist/core/utils/sql-expressions.d.ts.map +1 -1
- package/dist/core/utils/sql-expressions.js +6 -4
- package/dist/core/utils/sql-literals.d.ts +3 -0
- package/dist/core/utils/sql-literals.d.ts.map +1 -0
- package/dist/core/utils/sql-literals.js +39 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +2 -1
- package/dist/datasets.d.ts +53 -0
- package/dist/datasets.d.ts.map +1 -0
- package/dist/datasets.js +401 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ClickHouse client surface needed by the type generator.
|
|
3
|
+
*/
|
|
4
|
+
export interface TypeGenerationClickHouseClient {
|
|
5
|
+
query(options: { query: string; format: 'JSONEachRow' }): Promise<{
|
|
6
|
+
json(): Promise<Array<Record<string, string>>>;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for generating TypeScript type definitions from ClickHouse.
|
|
12
|
+
*/
|
|
13
|
+
export interface GenerateTypesOptions {
|
|
14
|
+
includeTables?: string[];
|
|
15
|
+
excludeTables?: string[];
|
|
16
|
+
client?: TypeGenerationClickHouseClient;
|
|
17
|
+
generatedBy?: string;
|
|
18
|
+
includeUsageExample?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Converts a ClickHouse type string to a TypeScript type string.
|
|
23
|
+
*/
|
|
24
|
+
export declare function clickhouseToTsType(type: string): string;
|
|
25
|
+
|
|
1
26
|
/**
|
|
2
27
|
* Generates TypeScript type definitions from the ClickHouse database schema
|
|
3
28
|
* @param outputPath - The file path where the type definitions will be written
|
|
29
|
+
* @param options - Options for type generation
|
|
30
|
+
*/
|
|
31
|
+
export declare function generateTypes(outputPath: string, options?: GenerateTypesOptions): Promise<void>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generates TypeScript type definition contents from a ClickHouse client.
|
|
4
35
|
*/
|
|
5
|
-
export declare function
|
|
36
|
+
export declare function generateTypeDefinitions(
|
|
37
|
+
client: TypeGenerationClickHouseClient,
|
|
38
|
+
options?: GenerateTypesOptions,
|
|
39
|
+
): Promise<string>;
|
|
@@ -19,6 +19,9 @@ dotenv.config();
|
|
|
19
19
|
* @typedef {Object} GenerateTypesOptions
|
|
20
20
|
* @property {string[]} [includeTables] - List of tables to include
|
|
21
21
|
* @property {string[]} [excludeTables] - List of tables to exclude
|
|
22
|
+
* @property {object} [client] - ClickHouse client to use instead of ClickHouseConnection
|
|
23
|
+
* @property {string} [generatedBy] - Package name to include in the generated file header
|
|
24
|
+
* @property {boolean} [includeUsageExample] - Whether to append the historical usage example
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -28,8 +31,30 @@ dotenv.config();
|
|
|
28
31
|
* @returns {Promise<void>}
|
|
29
32
|
*/
|
|
30
33
|
export async function generateTypes(outputPath, options = {}) {
|
|
31
|
-
const client = ClickHouseConnection.getClient();
|
|
32
|
-
const
|
|
34
|
+
const client = options.client || ClickHouseConnection.getClient();
|
|
35
|
+
const typeDefinitions = await generateTypeDefinitions(client, options);
|
|
36
|
+
|
|
37
|
+
// Ensure the output directory exists
|
|
38
|
+
const outputDir = path.dirname(path.resolve(outputPath));
|
|
39
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
// Write the file
|
|
42
|
+
await fs.writeFile(path.resolve(outputPath), typeDefinitions);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generates TypeScript type definition contents from a ClickHouse client.
|
|
47
|
+
* @param {object} client - ClickHouse client with a query method
|
|
48
|
+
* @param {GenerateTypesOptions} [options] - Options for type generation
|
|
49
|
+
* @returns {Promise<string>}
|
|
50
|
+
*/
|
|
51
|
+
export async function generateTypeDefinitions(client, options = {}) {
|
|
52
|
+
const {
|
|
53
|
+
includeTables = [],
|
|
54
|
+
excludeTables = [],
|
|
55
|
+
generatedBy = '@hypequery/clickhouse',
|
|
56
|
+
includeUsageExample = true,
|
|
57
|
+
} = options;
|
|
33
58
|
|
|
34
59
|
// Get all tables
|
|
35
60
|
const tablesQuery = await client.query({
|
|
@@ -52,7 +77,7 @@ export async function generateTypes(outputPath, options = {}) {
|
|
|
52
77
|
console.warn('Warning: No tables match the filter criteria. Check your include/exclude options.');
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
let typeDefinitions = `// Generated by
|
|
80
|
+
let typeDefinitions = `// Generated by ${generatedBy}
|
|
56
81
|
// This file defines TypeScript types based on your ClickHouse database schema
|
|
57
82
|
|
|
58
83
|
/**
|
|
@@ -95,8 +120,8 @@ export interface IntrospectedSchema {`;
|
|
|
95
120
|
typeDefinitions += '\n}\n\n';
|
|
96
121
|
}
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
if (includeUsageExample) {
|
|
124
|
+
typeDefinitions += `
|
|
100
125
|
/**
|
|
101
126
|
* Usage example:
|
|
102
127
|
*
|
|
@@ -114,13 +139,9 @@ export interface IntrospectedSchema {`;
|
|
|
114
139
|
* .execute();
|
|
115
140
|
*/
|
|
116
141
|
`;
|
|
142
|
+
}
|
|
117
143
|
|
|
118
|
-
|
|
119
|
-
const outputDir = path.dirname(path.resolve(outputPath));
|
|
120
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
121
|
-
|
|
122
|
-
// Write the file
|
|
123
|
-
await fs.writeFile(path.resolve(outputPath), typeDefinitions);
|
|
144
|
+
return typeDefinitions;
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
/**
|
package/dist/cli/index.d.ts
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// CLI module exports
|
|
2
|
-
export { generateTypes } from './generate-types.js';
|
|
2
|
+
export { clickhouseToTsType, generateTypeDefinitions, generateTypes } from './generate-types.js';
|
package/dist/cli/type-parsing.js
CHANGED
|
@@ -33,10 +33,20 @@ function splitTopLevelArgs(value) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function unwrapType(type, wrapperName) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
// Trim whitespace and handle case-insensitive matching
|
|
37
|
+
const trimmedType = type.trim();
|
|
38
|
+
const lowerType = trimmedType.toLowerCase();
|
|
39
|
+
const lowerWrapper = wrapperName.toLowerCase();
|
|
40
|
+
const prefix = `${lowerWrapper}(`;
|
|
41
|
+
|
|
42
|
+
if (lowerType.startsWith(prefix) && trimmedType.endsWith(')')) {
|
|
43
|
+
// Extract inner type, preserving original case
|
|
44
|
+
const innerStart = trimmedType.indexOf('(') + 1;
|
|
45
|
+
const innerEnd = trimmedType.lastIndexOf(')');
|
|
46
|
+
return trimmedType.slice(innerStart, innerEnd).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
function getPrimitiveTsType(type) {
|
|
@@ -45,6 +55,8 @@ function getPrimitiveTsType(type) {
|
|
|
45
55
|
switch (lowerType) {
|
|
46
56
|
case 'string':
|
|
47
57
|
case 'uuid':
|
|
58
|
+
case 'ipv4':
|
|
59
|
+
case 'ipv6':
|
|
48
60
|
return 'string';
|
|
49
61
|
case 'int8':
|
|
50
62
|
case 'int16':
|
|
@@ -72,9 +84,15 @@ function getPrimitiveTsType(type) {
|
|
|
72
84
|
case 'bool':
|
|
73
85
|
case 'boolean':
|
|
74
86
|
return 'boolean';
|
|
87
|
+
case 'json':
|
|
88
|
+
return 'unknown';
|
|
75
89
|
default:
|
|
76
90
|
if (type.startsWith('FixedString(')) return 'string';
|
|
77
91
|
if (type.startsWith('Decimal(')) return 'number';
|
|
92
|
+
if (type.startsWith('Decimal32(')) return 'number';
|
|
93
|
+
if (type.startsWith('Decimal64(')) return 'number';
|
|
94
|
+
if (type.startsWith('Decimal128(')) return 'number';
|
|
95
|
+
if (type.startsWith('Decimal256(')) return 'number';
|
|
78
96
|
if (type.startsWith('DateTime64(')) return 'string';
|
|
79
97
|
if (type.startsWith('DateTime(')) return 'string';
|
|
80
98
|
if (type.startsWith('Enum8(')) return 'string';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clickhouse-dialect.d.ts","sourceRoot":"","sources":["../../../src/core/dialects/clickhouse-dialect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"clickhouse-dialect.d.ts","sourceRoot":"","sources":["../../../src/core/dialects/clickhouse-dialect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAG3E,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAExE,qBAAa,iBAAkB,YAAW,UAAU;IAClD,QAAQ,CAAC,IAAI,gBAAgB;IAC7B,OAAO,CAAC,SAAS,CAAsB;IAEvC,YAAY,CAAC,KAAK,EAAE,eAAe,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,mBAAmB,GAAG,aAAa;IA6D3F,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM;IAQ5E,cAAc,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM;CAK1D"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SQLFormatter } from '../formatters/sql-formatter.js';
|
|
2
|
+
import { formatIntervalLiteral } from '../utils/sql-literals.js';
|
|
2
3
|
export class ClickHouseDialect {
|
|
3
4
|
name = 'clickhouse';
|
|
4
5
|
formatter = new SQLFormatter();
|
|
@@ -52,7 +53,7 @@ export class ClickHouseDialect {
|
|
|
52
53
|
}
|
|
53
54
|
formatTimeInterval(column, interval, method) {
|
|
54
55
|
if (method === 'toStartOfInterval') {
|
|
55
|
-
return `${method}(${column}, INTERVAL ${interval})`;
|
|
56
|
+
return `${method}(${column}, INTERVAL ${formatIntervalLiteral(interval)})`;
|
|
56
57
|
}
|
|
57
58
|
return `${method}(${column})`;
|
|
58
59
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../../../src/core/tests/integration/setup.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,SAAS,
|
|
1
|
+
{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../../../src/core/tests/integration/setup.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,SAAS,EAQV,MAAM,kDAAkD,CAAC;AA4B1D,eAAO,MAAM,wBAAwB;;;;;;EAyBpC,CAAC;AAGF,eAAO,MAAM,2BAA2B,yGAevC,CAAC;AAGF,eAAO,MAAM,kBAAkB,GAAU,eAAe,MAAM,KAAG,OAAO,CAAC,OAAO,CAE/E,CAAC;AAGF,eAAO,MAAM,wBAAwB,QAAa,OAAO,CAAC,IAAI,CAO7D,CAAC;AAGF,eAAO,MAAM,iBAAiB,GAC5B,oBAAgB,EAChB,sBAAoB,KACnB,OAAO,CAAC,IAAI,CAOd,CAAC;AAGF,eAAO,MAAM,uBAAuB,QAAa,OAAO,CAAC,IAAI,CAU5D,CAAC;AAGF,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,KAAK,CAAC;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,OAAO,CAAC;QACnB,IAAI,EAAE,MAAM,EAAE,CAAC;KAChB,CAAC,CAAC;IACH,KAAK,EAAE,KAAK,CAAC;QACX,EAAE,EAAE,MAAM,CAAC;QACX,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC,CAAC;IACH,MAAM,EAAE,KAAK,CAAC;QACZ,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC,CAAC;CACJ;AAED,OAAO,EAAE,yBAAyB,EAAE,sBAAsB,EAAE,SAAS,EAAE,CAAC;AAKxE,eAAO,MAAM,iBAAiB,QAAa,OAAO,CAAC,IAAI,CAuBtD,CAAC"}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { ClickHouseConnection } from '../../connection.js';
|
|
2
2
|
import { logger as hypeQueryLogger } from '../../utils/logger.js';
|
|
3
|
-
import { CLICKHOUSE_CONTAINER_NAME, TEST_CONNECTION_CONFIG, TEST_DATA, detectComposeCommand, ensureDockerDaemon, isContainerRunning as sharedIsContainerRunning, seedClickHouseDatabase, startClickHouseContainer as sharedStartClickHouseContainer, stopClickHouseContainer as sharedStopClickHouseContainer, waitForClickHouse as sharedWaitForClickHouse,
|
|
4
|
-
// @ts-expect-error: shared test harness is plain JS
|
|
5
|
-
} from '../../../../../../testing/clickhouse/harness.mjs';
|
|
3
|
+
import { CLICKHOUSE_CONTAINER_NAME, TEST_CONNECTION_CONFIG, TEST_DATA, detectComposeCommand, ensureDockerDaemon, isContainerRunning as sharedIsContainerRunning, seedClickHouseDatabase, startClickHouseContainer as sharedStartClickHouseContainer, stopClickHouseContainer as sharedStopClickHouseContainer, waitForClickHouse as sharedWaitForClickHouse, } from '../../../../../../testing/clickhouse/harness.mjs';
|
|
6
4
|
// Disable the hypequery logger to prevent "logs after tests" errors
|
|
7
5
|
// This must be done early in the setup, before any queries run
|
|
8
6
|
hypeQueryLogger.configure({ enabled: false });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sql-expressions.d.ts","sourceRoot":"","sources":["../../../src/core/utils/sql-expressions.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sql-expressions.d.ts","sourceRoot":"","sources":["../../../src/core/utils/sql-expressions.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatIntervalLiteral, quoteStringLiteral } from './sql-literals.js';
|
|
1
2
|
/**
|
|
2
3
|
* Creates a raw SQL expression
|
|
3
4
|
* @param sql The SQL expression string
|
|
@@ -34,17 +35,18 @@ export function toDateTime(field, alias) {
|
|
|
34
35
|
}
|
|
35
36
|
export function formatDateTime(field, format, options = {}) {
|
|
36
37
|
const { timezone, alias } = options;
|
|
37
|
-
let sql = `formatDateTime(${field},
|
|
38
|
+
let sql = `formatDateTime(${field}, ${quoteStringLiteral(format)}`;
|
|
38
39
|
if (timezone) {
|
|
39
|
-
sql += `,
|
|
40
|
+
sql += `, ${quoteStringLiteral(timezone)}`;
|
|
40
41
|
}
|
|
41
42
|
sql += ')';
|
|
42
43
|
return alias ? rawAs(sql, alias) : raw(sql);
|
|
43
44
|
}
|
|
44
45
|
export function toStartOfInterval(field, interval, alias) {
|
|
46
|
+
const sql = `toStartOfInterval(${field}, INTERVAL ${formatIntervalLiteral(interval)})`;
|
|
45
47
|
return alias
|
|
46
|
-
? rawAs(
|
|
47
|
-
: raw(
|
|
48
|
+
? rawAs(sql, alias)
|
|
49
|
+
: raw(sql);
|
|
48
50
|
}
|
|
49
51
|
function toStartOfUnit(functionName, field, alias) {
|
|
50
52
|
return alias
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql-literals.d.ts","sourceRoot":"","sources":["../../../src/core/utils/sql-literals.ts"],"names":[],"mappings":"AA2BA,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAc9D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAExD"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const INTERVAL_UNITS = {
|
|
2
|
+
second: 'SECOND',
|
|
3
|
+
seconds: 'SECOND',
|
|
4
|
+
minute: 'MINUTE',
|
|
5
|
+
minutes: 'MINUTE',
|
|
6
|
+
hour: 'HOUR',
|
|
7
|
+
hours: 'HOUR',
|
|
8
|
+
day: 'DAY',
|
|
9
|
+
days: 'DAY',
|
|
10
|
+
week: 'WEEK',
|
|
11
|
+
weeks: 'WEEK',
|
|
12
|
+
month: 'MONTH',
|
|
13
|
+
months: 'MONTH',
|
|
14
|
+
quarter: 'QUARTER',
|
|
15
|
+
quarters: 'QUARTER',
|
|
16
|
+
year: 'YEAR',
|
|
17
|
+
years: 'YEAR',
|
|
18
|
+
millisecond: 'MILLISECOND',
|
|
19
|
+
milliseconds: 'MILLISECOND',
|
|
20
|
+
microsecond: 'MICROSECOND',
|
|
21
|
+
microseconds: 'MICROSECOND',
|
|
22
|
+
nanosecond: 'NANOSECOND',
|
|
23
|
+
nanoseconds: 'NANOSECOND',
|
|
24
|
+
};
|
|
25
|
+
const INTERVAL_PATTERN = /^(\d+)\s+([a-z]+)$/i;
|
|
26
|
+
export function formatIntervalLiteral(interval) {
|
|
27
|
+
const normalized = interval.trim();
|
|
28
|
+
const match = normalized.match(INTERVAL_PATTERN);
|
|
29
|
+
const unit = match ? INTERVAL_UNITS[match[2].toLowerCase()] : undefined;
|
|
30
|
+
if (!match || !unit) {
|
|
31
|
+
throw new Error(`Invalid time interval: "${interval}". Expected "<number> <unit>" where unit is ` +
|
|
32
|
+
'one of second, minute, hour, day, week, month, quarter, year, millisecond, microsecond, nanosecond ' +
|
|
33
|
+
'(e.g. "1 day", "15 minute").');
|
|
34
|
+
}
|
|
35
|
+
return `${match[1]} ${unit}`;
|
|
36
|
+
}
|
|
37
|
+
export function quoteStringLiteral(value) {
|
|
38
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
|
|
39
|
+
}
|
package/dist/core/utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/core/utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/core/utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,CAc9C;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,CAgBvE"}
|
package/dist/core/utils.js
CHANGED
|
@@ -6,7 +6,8 @@ export function escapeValue(value) {
|
|
|
6
6
|
return value.toString();
|
|
7
7
|
}
|
|
8
8
|
else if (typeof value === 'string') {
|
|
9
|
-
|
|
9
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
|
10
|
+
return `'${escaped}'`;
|
|
10
11
|
}
|
|
11
12
|
else if (value instanceof Date) {
|
|
12
13
|
return `'${value.toISOString()}'`;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClickHouse Semantic Backend
|
|
3
|
+
*
|
|
4
|
+
* This module implements the SemanticBackend interface from @hypequery/datasets
|
|
5
|
+
* for ClickHouse databases. It translates database-agnostic semantic plans
|
|
6
|
+
* (PlanNode) into ClickHouse-specific SQL and executes them.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - @hypequery/datasets: Owns semantic planning, validation, and execution protocol
|
|
10
|
+
* - @hypequery/clickhouse: Implements SQL translation and execution for ClickHouse
|
|
11
|
+
*
|
|
12
|
+
* Usage (recommended): pass the query builder you already use for
|
|
13
|
+
* hand-written queries, so semantic and ad hoc queries share one connection.
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createDatasetClient } from '@hypequery/datasets';
|
|
16
|
+
* import { createQueryBuilder } from '@hypequery/clickhouse';
|
|
17
|
+
*
|
|
18
|
+
* const db = createQueryBuilder({
|
|
19
|
+
* url: process.env.CLICKHOUSE_URL,
|
|
20
|
+
* username: process.env.CLICKHOUSE_USER,
|
|
21
|
+
* password: process.env.CLICKHOUSE_PASSWORD,
|
|
22
|
+
* database: process.env.CLICKHOUSE_DATABASE,
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* const analytics = createDatasetClient({ queryBuilder: db });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Advanced: `createBackend` exposes the database-agnostic SemanticBackend
|
|
29
|
+
* protocol directly. Reach for it when you want a standalone backend instance
|
|
30
|
+
* rather than sharing a query builder.
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createBackend } from '@hypequery/clickhouse/datasets';
|
|
33
|
+
*
|
|
34
|
+
* const analytics = createDatasetClient({
|
|
35
|
+
* backend: createBackend({ url, username, password, database }),
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
import { type SemanticBackend } from '@hypequery/datasets';
|
|
40
|
+
import type { CreateQueryBuilderConfig } from './core/query-builder.js';
|
|
41
|
+
import type { SchemaDefinition } from './core/types/builder-state.js';
|
|
42
|
+
export type CreateBackendConfig = CreateQueryBuilderConfig;
|
|
43
|
+
/**
|
|
44
|
+
* Create ClickHouse Semantic Backend
|
|
45
|
+
*
|
|
46
|
+
* Creates a SemanticBackend implementation that translates database-agnostic
|
|
47
|
+
* semantic plans into ClickHouse SQL and executes them.
|
|
48
|
+
*
|
|
49
|
+
* @param config - ClickHouse connection configuration
|
|
50
|
+
* @returns SemanticBackend interface for executing semantic queries
|
|
51
|
+
*/
|
|
52
|
+
export declare function createBackend<Schema extends SchemaDefinition<Schema>>(config: CreateBackendConfig): SemanticBackend;
|
|
53
|
+
//# sourceMappingURL=datasets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"datasets.d.ts","sourceRoot":"","sources":["../src/datasets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAGL,KAAK,eAAe,EAGrB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,yBAAyB,CAAC;AACxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEtE,MAAM,MAAM,mBAAmB,GAAG,wBAAwB,CAAC;AAuW3D;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,MAAM,SAAS,gBAAgB,CAAC,MAAM,CAAC,EACnE,MAAM,EAAE,mBAAmB,GAC1B,eAAe,CAqDjB"}
|
package/dist/datasets.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClickHouse Semantic Backend
|
|
3
|
+
*
|
|
4
|
+
* This module implements the SemanticBackend interface from @hypequery/datasets
|
|
5
|
+
* for ClickHouse databases. It translates database-agnostic semantic plans
|
|
6
|
+
* (PlanNode) into ClickHouse-specific SQL and executes them.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - @hypequery/datasets: Owns semantic planning, validation, and execution protocol
|
|
10
|
+
* - @hypequery/clickhouse: Implements SQL translation and execution for ClickHouse
|
|
11
|
+
*
|
|
12
|
+
* Usage (recommended): pass the query builder you already use for
|
|
13
|
+
* hand-written queries, so semantic and ad hoc queries share one connection.
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createDatasetClient } from '@hypequery/datasets';
|
|
16
|
+
* import { createQueryBuilder } from '@hypequery/clickhouse';
|
|
17
|
+
*
|
|
18
|
+
* const db = createQueryBuilder({
|
|
19
|
+
* url: process.env.CLICKHOUSE_URL,
|
|
20
|
+
* username: process.env.CLICKHOUSE_USER,
|
|
21
|
+
* password: process.env.CLICKHOUSE_PASSWORD,
|
|
22
|
+
* database: process.env.CLICKHOUSE_DATABASE,
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* const analytics = createDatasetClient({ queryBuilder: db });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Advanced: `createBackend` exposes the database-agnostic SemanticBackend
|
|
29
|
+
* protocol directly. Reach for it when you want a standalone backend instance
|
|
30
|
+
* rather than sharing a query builder.
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createBackend } from '@hypequery/clickhouse/datasets';
|
|
33
|
+
*
|
|
34
|
+
* const analytics = createDatasetClient({
|
|
35
|
+
* backend: createBackend({ url, username, password, database }),
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
import { createQueryBuilder } from './core/query-builder.js';
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// ClickHouse SQL Generation Utilities
|
|
42
|
+
// =============================================================================
|
|
43
|
+
const GRAIN_FUNCTIONS = {
|
|
44
|
+
day: 'toStartOfDay',
|
|
45
|
+
week: 'toStartOfWeek',
|
|
46
|
+
month: 'toStartOfMonth',
|
|
47
|
+
quarter: 'toStartOfQuarter',
|
|
48
|
+
year: 'toStartOfYear',
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* SQL Literal Rendering
|
|
52
|
+
* Safely escapes values for direct SQL inclusion
|
|
53
|
+
*/
|
|
54
|
+
function renderLiteral(value) {
|
|
55
|
+
if (value === null)
|
|
56
|
+
return 'NULL';
|
|
57
|
+
if (typeof value === 'number')
|
|
58
|
+
return String(value);
|
|
59
|
+
if (typeof value === 'boolean')
|
|
60
|
+
return value ? '1' : '0';
|
|
61
|
+
// SQL string escaping: single quotes are escaped by doubling them
|
|
62
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Time Grain Rendering
|
|
66
|
+
* Converts semantic grain to ClickHouse function
|
|
67
|
+
*/
|
|
68
|
+
function renderGrain(field, unit) {
|
|
69
|
+
return `${GRAIN_FUNCTIONS[unit]}(${field})`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Filter Rendering - Applies filters using query builder WHERE clauses
|
|
73
|
+
* This is the preferred method when working with the query builder
|
|
74
|
+
*/
|
|
75
|
+
function applyFilters(builder, filters) {
|
|
76
|
+
let qb = builder;
|
|
77
|
+
for (const filter of filters) {
|
|
78
|
+
qb = qb.where(filter.field, filter.operator, filter.value);
|
|
79
|
+
}
|
|
80
|
+
return qb;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Type guard to check if value is a valid literal for SQL rendering
|
|
84
|
+
*/
|
|
85
|
+
function isLiteralValue(value) {
|
|
86
|
+
return (typeof value === 'string' ||
|
|
87
|
+
typeof value === 'number' ||
|
|
88
|
+
typeof value === 'boolean' ||
|
|
89
|
+
value === null);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Safely render a filter value as a SQL literal
|
|
93
|
+
* Throws if value is not a valid literal type
|
|
94
|
+
*/
|
|
95
|
+
function renderFilterValue(value) {
|
|
96
|
+
if (!isLiteralValue(value)) {
|
|
97
|
+
throw new Error(`Invalid filter value type: ${typeof value}`);
|
|
98
|
+
}
|
|
99
|
+
return renderLiteral(value);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Filter Condition Rendering - Converts filter to SQL WHERE clause string
|
|
103
|
+
* Used for filtered aggregations (IF conditions) where query builder can't be used
|
|
104
|
+
*/
|
|
105
|
+
function renderFilterCondition(filter) {
|
|
106
|
+
const { field, operator, value } = filter;
|
|
107
|
+
switch (operator) {
|
|
108
|
+
case 'eq':
|
|
109
|
+
return `${field} = ${renderFilterValue(value)}`;
|
|
110
|
+
case 'neq':
|
|
111
|
+
return `${field} != ${renderFilterValue(value)}`;
|
|
112
|
+
case 'gt':
|
|
113
|
+
return `${field} > ${renderFilterValue(value)}`;
|
|
114
|
+
case 'gte':
|
|
115
|
+
return `${field} >= ${renderFilterValue(value)}`;
|
|
116
|
+
case 'lt':
|
|
117
|
+
return `${field} < ${renderFilterValue(value)}`;
|
|
118
|
+
case 'lte':
|
|
119
|
+
return `${field} <= ${renderFilterValue(value)}`;
|
|
120
|
+
case 'like':
|
|
121
|
+
return `${field} LIKE ${renderFilterValue(value)}`;
|
|
122
|
+
case 'in':
|
|
123
|
+
case 'notIn': {
|
|
124
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
125
|
+
throw new Error(`"${operator}" filters require a non-empty array.`);
|
|
126
|
+
}
|
|
127
|
+
const values = value.map(renderFilterValue).join(', ');
|
|
128
|
+
const op = operator === 'in' ? 'IN' : 'NOT IN';
|
|
129
|
+
return `${field} ${op} (${values})`;
|
|
130
|
+
}
|
|
131
|
+
case 'between': {
|
|
132
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
133
|
+
throw new Error('"between" filters require a two-item array.');
|
|
134
|
+
}
|
|
135
|
+
return `${field} BETWEEN ${renderFilterValue(value[0])} AND ${renderFilterValue(value[1])}`;
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
throw new Error(`Unsupported filter operator "${operator}".`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Filtered Aggregation Field Rendering
|
|
143
|
+
* Generates ClickHouse IF() expressions for conditional aggregations
|
|
144
|
+
* Example: SUM(if(status = 'completed', amount, 0))
|
|
145
|
+
*/
|
|
146
|
+
function renderFilteredAggregationField(aggregation) {
|
|
147
|
+
if (!aggregation.filters?.length) {
|
|
148
|
+
return aggregation.field;
|
|
149
|
+
}
|
|
150
|
+
// Combine multiple filters with AND
|
|
151
|
+
const condition = aggregation.filters
|
|
152
|
+
.map(renderFilterCondition)
|
|
153
|
+
.map((part) => `(${part})`)
|
|
154
|
+
.join(' AND ');
|
|
155
|
+
// Use appropriate fallback: 0 for SUM, NULL for others
|
|
156
|
+
const fallback = aggregation.aggregation === 'sum' ? '0' : 'NULL';
|
|
157
|
+
return `if(${condition}, ${aggregation.field}, ${fallback})`;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Expression Rendering
|
|
161
|
+
* Converts semantic expressions (formulas) to SQL
|
|
162
|
+
* Used for derived metrics
|
|
163
|
+
*/
|
|
164
|
+
function renderExpression(expression) {
|
|
165
|
+
switch (expression.kind) {
|
|
166
|
+
case 'ref':
|
|
167
|
+
return expression.name;
|
|
168
|
+
case 'literal':
|
|
169
|
+
return renderLiteral(expression.value);
|
|
170
|
+
case 'binary': {
|
|
171
|
+
const operators = {
|
|
172
|
+
add: '+',
|
|
173
|
+
subtract: '-',
|
|
174
|
+
multiply: '*',
|
|
175
|
+
divide: '/',
|
|
176
|
+
};
|
|
177
|
+
const op = operators[expression.operator];
|
|
178
|
+
const left = renderExpression(expression.left);
|
|
179
|
+
const right = renderExpression(expression.right);
|
|
180
|
+
return `(${left}) ${op} (${right})`;
|
|
181
|
+
}
|
|
182
|
+
case 'function': {
|
|
183
|
+
const args = expression.args.map(renderExpression);
|
|
184
|
+
// Special case functions with custom SQL
|
|
185
|
+
if (expression.name === 'nullIfZero') {
|
|
186
|
+
return `NULLIF(${args[0]}, 0)`;
|
|
187
|
+
}
|
|
188
|
+
if (expression.name === 'coalesce') {
|
|
189
|
+
return `COALESCE(${args.join(', ')})`;
|
|
190
|
+
}
|
|
191
|
+
// Standard functions
|
|
192
|
+
const functionMap = {
|
|
193
|
+
round: 'ROUND',
|
|
194
|
+
floor: 'FLOOR',
|
|
195
|
+
ceil: 'CEIL',
|
|
196
|
+
};
|
|
197
|
+
const fn = functionMap[expression.name];
|
|
198
|
+
if (!fn) {
|
|
199
|
+
throw new Error(`Unsupported function: ${expression.name}`);
|
|
200
|
+
}
|
|
201
|
+
return `${fn}(${args.join(', ')})`;
|
|
202
|
+
}
|
|
203
|
+
default:
|
|
204
|
+
throw new Error('Unsupported semantic expression.');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// =============================================================================
|
|
208
|
+
// Query Builder Integration
|
|
209
|
+
// =============================================================================
|
|
210
|
+
/**
|
|
211
|
+
* Apply Aggregations
|
|
212
|
+
* Translates semantic aggregations to query builder method calls
|
|
213
|
+
*/
|
|
214
|
+
function applyAggregations(builder, plan) {
|
|
215
|
+
let qb = builder;
|
|
216
|
+
for (const aggregation of plan.aggregations) {
|
|
217
|
+
const field = renderFilteredAggregationField(aggregation);
|
|
218
|
+
const { name, aggregation: aggType } = aggregation;
|
|
219
|
+
switch (aggType) {
|
|
220
|
+
case 'sum':
|
|
221
|
+
qb = qb.sum(field, name);
|
|
222
|
+
break;
|
|
223
|
+
case 'count':
|
|
224
|
+
qb = qb.count(field, name);
|
|
225
|
+
break;
|
|
226
|
+
case 'countDistinct':
|
|
227
|
+
qb = qb.countDistinct(field, name);
|
|
228
|
+
break;
|
|
229
|
+
case 'avg':
|
|
230
|
+
qb = qb.avg(field, name);
|
|
231
|
+
break;
|
|
232
|
+
case 'min':
|
|
233
|
+
qb = qb.min(field, name);
|
|
234
|
+
break;
|
|
235
|
+
case 'max':
|
|
236
|
+
qb = qb.max(field, name);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return qb;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Append Order/Limit/Offset
|
|
244
|
+
* Applies result modifiers to query builder
|
|
245
|
+
*/
|
|
246
|
+
function appendOrderLimitOffset(builder, plan) {
|
|
247
|
+
let qb = builder;
|
|
248
|
+
// Order by
|
|
249
|
+
for (const order of plan.orderBy ?? []) {
|
|
250
|
+
qb = qb.orderBy(order.field, order.direction.toUpperCase());
|
|
251
|
+
}
|
|
252
|
+
// Pagination
|
|
253
|
+
if (plan.limit != null)
|
|
254
|
+
qb = qb.limit(plan.limit);
|
|
255
|
+
if (plan.offset != null)
|
|
256
|
+
qb = qb.offset(plan.offset);
|
|
257
|
+
return qb;
|
|
258
|
+
}
|
|
259
|
+
// =============================================================================
|
|
260
|
+
// Plan Translation to SQL
|
|
261
|
+
// =============================================================================
|
|
262
|
+
/**
|
|
263
|
+
* Build Aggregate Query
|
|
264
|
+
* Translates semantic aggregate plan to ClickHouse query builder
|
|
265
|
+
*/
|
|
266
|
+
function buildAggregateQuery(queryBuilder, plan) {
|
|
267
|
+
let qb = queryBuilder.table(plan.source);
|
|
268
|
+
// Build SELECT and GROUP BY for dimensions
|
|
269
|
+
const selectParts = [];
|
|
270
|
+
const groupByParts = [];
|
|
271
|
+
// Time grain (period column)
|
|
272
|
+
if (plan.grain) {
|
|
273
|
+
const grainSql = renderGrain(plan.grain.field, plan.grain.unit);
|
|
274
|
+
selectParts.push(`${grainSql} AS ${plan.grain.output}`);
|
|
275
|
+
groupByParts.push(plan.grain.output);
|
|
276
|
+
}
|
|
277
|
+
// Dimensions
|
|
278
|
+
for (const dimension of plan.dimensions) {
|
|
279
|
+
const columnSql = dimension.field === dimension.name
|
|
280
|
+
? dimension.name
|
|
281
|
+
: `${dimension.field} AS ${dimension.name}`;
|
|
282
|
+
selectParts.push(columnSql);
|
|
283
|
+
groupByParts.push(dimension.name);
|
|
284
|
+
}
|
|
285
|
+
// Apply SELECT clause
|
|
286
|
+
if (selectParts.length > 0) {
|
|
287
|
+
qb = qb.select(selectParts);
|
|
288
|
+
}
|
|
289
|
+
// Apply aggregations (measures)
|
|
290
|
+
qb = applyAggregations(qb, plan);
|
|
291
|
+
// Apply GROUP BY
|
|
292
|
+
if (groupByParts.length > 0) {
|
|
293
|
+
qb = qb.groupBy(groupByParts);
|
|
294
|
+
}
|
|
295
|
+
// Apply tenant filter (auto-injected)
|
|
296
|
+
if (plan.tenant) {
|
|
297
|
+
qb = qb.where(plan.tenant.field, plan.tenant.operator, plan.tenant.value);
|
|
298
|
+
}
|
|
299
|
+
// Apply user filters
|
|
300
|
+
qb = applyFilters(qb, plan.filters);
|
|
301
|
+
// Apply order/limit/offset
|
|
302
|
+
return appendOrderLimitOffset(qb, plan);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Build Derived Metric SQL
|
|
306
|
+
* Generates CTE-based query for derived metrics (formulas over base metrics)
|
|
307
|
+
*
|
|
308
|
+
* Note: Uses string concatenation for outer query since query builder
|
|
309
|
+
* doesn't support CTEs natively. Inner query uses query builder for safety.
|
|
310
|
+
*/
|
|
311
|
+
function buildDerivedSQL(queryBuilder, plan) {
|
|
312
|
+
if (plan.input.kind !== 'aggregate') {
|
|
313
|
+
throw new Error('ClickHouse datasets currently supports derived metrics over aggregate input plans only.');
|
|
314
|
+
}
|
|
315
|
+
// Build inner aggregate query using query builder
|
|
316
|
+
const inputQuery = buildAggregateQuery(queryBuilder, plan.input);
|
|
317
|
+
const { sql, parameters } = inputQuery.toSQLWithParams();
|
|
318
|
+
// Passthrough columns (grain + dimensions)
|
|
319
|
+
const passthrough = [
|
|
320
|
+
...(plan.input.grain ? [plan.input.grain.output] : []),
|
|
321
|
+
...plan.input.dimensions.map((dim) => dim.name),
|
|
322
|
+
];
|
|
323
|
+
// Derived metric calculations
|
|
324
|
+
const metricSelects = plan.metrics.map((metric) => `${renderExpression(metric.expression)} AS ${metric.name}`);
|
|
325
|
+
// Build outer query with CTE
|
|
326
|
+
const allSelects = [...passthrough, ...metricSelects];
|
|
327
|
+
let outerSql = `WITH base AS (${sql}) SELECT ${allSelects.join(', ')} FROM base`;
|
|
328
|
+
// ORDER BY
|
|
329
|
+
if (plan.orderBy?.length) {
|
|
330
|
+
const orderClauses = plan.orderBy.map((order) => `${order.field} ${order.direction.toUpperCase()}`);
|
|
331
|
+
outerSql += ` ORDER BY ${orderClauses.join(', ')}`;
|
|
332
|
+
}
|
|
333
|
+
// LIMIT and OFFSET
|
|
334
|
+
if (plan.limit != null) {
|
|
335
|
+
outerSql += ` LIMIT ${plan.limit}`;
|
|
336
|
+
}
|
|
337
|
+
if (plan.offset != null) {
|
|
338
|
+
outerSql += ` OFFSET ${plan.offset}`;
|
|
339
|
+
}
|
|
340
|
+
return { sql: outerSql, parameters };
|
|
341
|
+
}
|
|
342
|
+
// =============================================================================
|
|
343
|
+
// Semantic Backend Implementation
|
|
344
|
+
// =============================================================================
|
|
345
|
+
/**
|
|
346
|
+
* Create ClickHouse Semantic Backend
|
|
347
|
+
*
|
|
348
|
+
* Creates a SemanticBackend implementation that translates database-agnostic
|
|
349
|
+
* semantic plans into ClickHouse SQL and executes them.
|
|
350
|
+
*
|
|
351
|
+
* @param config - ClickHouse connection configuration
|
|
352
|
+
* @returns SemanticBackend interface for executing semantic queries
|
|
353
|
+
*/
|
|
354
|
+
export function createBackend(config) {
|
|
355
|
+
const queryBuilder = createQueryBuilder(config);
|
|
356
|
+
return {
|
|
357
|
+
/**
|
|
358
|
+
* Execute a semantic plan and return results
|
|
359
|
+
*/
|
|
360
|
+
async execute(plan) {
|
|
361
|
+
const start = Date.now();
|
|
362
|
+
if (plan.kind === 'aggregate') {
|
|
363
|
+
// Base metrics: use query builder for full safety
|
|
364
|
+
const query = buildAggregateQuery(queryBuilder, plan);
|
|
365
|
+
const { sql } = query.toSQLWithParams();
|
|
366
|
+
const data = await query.execute();
|
|
367
|
+
return {
|
|
368
|
+
data,
|
|
369
|
+
meta: {
|
|
370
|
+
sql,
|
|
371
|
+
timingMs: Date.now() - start,
|
|
372
|
+
tenant: plan.tenant?.operator === 'eq' ? plan.tenant.value : undefined,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// Derived metrics: CTE query with formulas
|
|
377
|
+
const { sql, parameters } = buildDerivedSQL(queryBuilder, plan);
|
|
378
|
+
const data = await queryBuilder.rawQuery(sql, parameters);
|
|
379
|
+
const tenant = plan.input.kind === 'aggregate' && plan.input.tenant?.operator === 'eq'
|
|
380
|
+
? plan.input.tenant.value
|
|
381
|
+
: undefined;
|
|
382
|
+
return {
|
|
383
|
+
data,
|
|
384
|
+
meta: {
|
|
385
|
+
sql,
|
|
386
|
+
timingMs: Date.now() - start,
|
|
387
|
+
tenant,
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
/**
|
|
392
|
+
* Generate SQL without executing
|
|
393
|
+
*/
|
|
394
|
+
async explain(plan) {
|
|
395
|
+
if (plan.kind === 'aggregate') {
|
|
396
|
+
return { sql: buildAggregateQuery(queryBuilder, plan).toSQLWithParams().sql };
|
|
397
|
+
}
|
|
398
|
+
return { sql: buildDerivedSQL(queryBuilder, plan).sql };
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/clickhouse",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "ClickHouse typescript query builder",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
"import": "./dist/index.js",
|
|
15
15
|
"require": "./dist/index.js"
|
|
16
16
|
},
|
|
17
|
+
"./datasets": {
|
|
18
|
+
"types": "./dist/datasets.d.ts",
|
|
19
|
+
"import": "./dist/datasets.js",
|
|
20
|
+
"require": "./dist/datasets.js"
|
|
21
|
+
},
|
|
17
22
|
"./cli": {
|
|
18
23
|
"types": "./dist/cli/index.d.ts",
|
|
19
24
|
"import": "./dist/cli/index.js",
|
|
@@ -33,11 +38,15 @@
|
|
|
33
38
|
"dotenv": "^16.0.0"
|
|
34
39
|
},
|
|
35
40
|
"peerDependencies": {
|
|
36
|
-
"@clickhouse/client-web": "^0.2.0 || ^1.0.0"
|
|
41
|
+
"@clickhouse/client-web": "^0.2.0 || ^1.0.0",
|
|
42
|
+
"@hypequery/datasets": "^0.1.0 || ^0.2.0"
|
|
37
43
|
},
|
|
38
44
|
"peerDependenciesMeta": {
|
|
39
45
|
"@clickhouse/client-web": {
|
|
40
46
|
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"@hypequery/datasets": {
|
|
49
|
+
"optional": true
|
|
41
50
|
}
|
|
42
51
|
},
|
|
43
52
|
"devDependencies": {
|
|
@@ -51,7 +60,8 @@
|
|
|
51
60
|
"typedoc-plugin-markdown": "^4.6.0",
|
|
52
61
|
"typescript": "^5.7.3",
|
|
53
62
|
"@vitest/coverage-v8": "^2.1.6",
|
|
54
|
-
"vitest": "^2.1.6"
|
|
63
|
+
"vitest": "^2.1.6",
|
|
64
|
+
"@hypequery/datasets": "0.2.0"
|
|
55
65
|
},
|
|
56
66
|
"ts-node": {
|
|
57
67
|
"esm": true,
|