215 lines
5.6 KiB
TypeScript
215 lines
5.6 KiB
TypeScript
|
|
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<T extends Record<string, any>> {
|
||
|
|
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<K extends keyof T & string>(...columns: K[]): this {
|
||
|
|
this.selectColumns = columns;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
|
||
|
|
public selectRaw(...expressions: string[]): this {
|
||
|
|
this.selectColumns = expressions;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- WHERE ----
|
||
|
|
|
||
|
|
public where<K extends keyof T & string>(
|
||
|
|
column: K,
|
||
|
|
operator: TComparisonOperator,
|
||
|
|
value: any,
|
||
|
|
): this {
|
||
|
|
this.whereClauses.push({
|
||
|
|
connector: '',
|
||
|
|
expression: this.buildCondition(column, operator, value),
|
||
|
|
});
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
|
||
|
|
public and<K extends keyof T & string>(
|
||
|
|
column: K,
|
||
|
|
operator: TComparisonOperator,
|
||
|
|
value: any,
|
||
|
|
): this {
|
||
|
|
this.whereClauses.push({
|
||
|
|
connector: 'AND',
|
||
|
|
expression: this.buildCondition(column, operator, value),
|
||
|
|
});
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
|
||
|
|
public or<K extends keyof T & string>(
|
||
|
|
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<K extends keyof T & string>(...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<ClickhouseResultSet<T>> {
|
||
|
|
const sql = this.toSQL();
|
||
|
|
const rows = await this.httpClient.queryTyped<T>(sql);
|
||
|
|
return new ClickhouseResultSet<T>(rows);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async first(): Promise<T | null> {
|
||
|
|
this.limitValue = 1;
|
||
|
|
const result = await this.execute();
|
||
|
|
return result.first();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async count(): Promise<number> {
|
||
|
|
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<T[]> {
|
||
|
|
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)}`;
|
||
|
|
}
|
||
|
|
}
|