import type { ClickhouseHttpClient } from './smartclickhouse.classes.httpclient.js'; import { ClickhouseResultSet } from './smartclickhouse.classes.resultset.js'; import { escapeClickhouseValue } from './smartclickhouse.types.js'; import type { TComparisonOperator } from './smartclickhouse.types.js'; interface IWhereClause { connector: 'AND' | 'OR' | ''; expression: string; } export class ClickhouseQueryBuilder> { private selectColumns: string[] = ['*']; private whereClauses: IWhereClause[] = []; private orderByClauses: string[] = []; private groupByClauses: string[] = []; private havingClauses: string[] = []; private limitValue: number | null = null; private offsetValue: number | null = null; constructor( private tableName: string, private database: string, private httpClient: ClickhouseHttpClient, ) {} // ---- SELECT ---- public select(...columns: K[]): this { this.selectColumns = columns; return this; } public selectRaw(...expressions: string[]): this { this.selectColumns = expressions; return this; } // ---- WHERE ---- public where( column: K, operator: TComparisonOperator, value: any, ): this { this.whereClauses.push({ connector: '', expression: this.buildCondition(column, operator, value), }); return this; } public and( column: K, operator: TComparisonOperator, value: any, ): this { this.whereClauses.push({ connector: 'AND', expression: this.buildCondition(column, operator, value), }); return this; } public or( column: K, operator: TComparisonOperator, value: any, ): this { this.whereClauses.push({ connector: 'OR', expression: this.buildCondition(column, operator, value), }); return this; } public whereRaw(expression: string): this { this.whereClauses.push({ connector: this.whereClauses.length > 0 ? 'AND' : '', expression, }); return this; } // ---- ORDER BY ---- public orderBy(column: (keyof T & string) | string, direction: 'ASC' | 'DESC' = 'ASC'): this { this.orderByClauses.push(`${column} ${direction}`); return this; } // ---- GROUP BY ---- public groupBy(...columns: K[]): this { this.groupByClauses.push(...columns); return this; } public having(expression: string): this { this.havingClauses.push(expression); return this; } // ---- LIMIT / OFFSET ---- public limit(count: number): this { this.limitValue = count; return this; } public offset(count: number): this { this.offsetValue = count; return this; } // ---- EXECUTION ---- public async execute(): Promise> { const sql = this.toSQL(); const rows = await this.httpClient.queryTyped(sql); return new ClickhouseResultSet(rows); } public async first(): Promise { this.limitValue = 1; const result = await this.execute(); return result.first(); } public async count(): Promise { const savedSelect = this.selectColumns; this.selectColumns = ['count() as _count']; const sql = this.toSQL(); this.selectColumns = savedSelect; const rows = await this.httpClient.queryTyped<{ _count: string }>(sql); return rows.length > 0 ? parseInt(rows[0]._count, 10) : 0; } public async toArray(): Promise { const result = await this.execute(); return result.toArray(); } // ---- SQL GENERATION ---- public toSQL(): string { const parts: string[] = []; parts.push(`SELECT ${this.selectColumns.join(', ')}`); parts.push(`FROM ${this.database}.${this.tableName}`); const whereClause = this.buildWhereClause(); if (whereClause) { parts.push(`WHERE ${whereClause}`); } if (this.groupByClauses.length > 0) { parts.push(`GROUP BY ${this.groupByClauses.join(', ')}`); } if (this.havingClauses.length > 0) { parts.push(`HAVING ${this.havingClauses.join(' AND ')}`); } if (this.orderByClauses.length > 0) { parts.push(`ORDER BY ${this.orderByClauses.join(', ')}`); } if (this.limitValue !== null) { parts.push(`LIMIT ${this.limitValue}`); } if (this.offsetValue !== null) { parts.push(`OFFSET ${this.offsetValue}`); } parts.push('FORMAT JSONEachRow'); return parts.join(' '); } /** * Build the WHERE clause string. Reused by ClickhouseTable for UPDATE/DELETE. */ public buildWhereClause(): string { if (this.whereClauses.length === 0) return ''; return this.whereClauses .map((clause, index) => { if (index === 0) return clause.expression; return `${clause.connector} ${clause.expression}`; }) .join(' '); } // ---- PRIVATE ---- private buildCondition(column: string, operator: TComparisonOperator, value: any): string { if (operator === 'IN' || operator === 'NOT IN') { const escapedValues = Array.isArray(value) ? `(${value.map(escapeClickhouseValue).join(', ')})` : escapeClickhouseValue(value); return `${column} ${operator} ${escapedValues}`; } if (operator === 'BETWEEN') { if (Array.isArray(value) && value.length === 2) { return `${column} BETWEEN ${escapeClickhouseValue(value[0])} AND ${escapeClickhouseValue(value[1])}`; } throw new Error('BETWEEN operator requires a two-element array value'); } return `${column} ${operator} ${escapeClickhouseValue(value)}`; } }