UNPKG

@iarayan/ch-orm

Version:

A Developer-First ClickHouse ORM with Powerful CLI Tools

1,037 lines 35 kB
import { formatValue } from "../utils/helpers"; import { Raw } from "./Raw"; /** * Query builder class for constructing SQL queries with a fluent interface * Provides an Eloquent-like query building experience */ export class QueryBuilder { /** * Create a new QueryBuilder instance * @param connection - ClickHouse connection * @param table - Table name (optional) */ constructor(connection, table) { /** * Table name to query */ this.tableName = ""; /** * Query type (SELECT, INSERT, UPDATE, DELETE) */ this.queryType = "SELECT"; /** * Columns to select */ this.columns = ["*"]; /** * Where conditions */ this.wheres = []; /** * Having conditions */ this.havings = []; /** * Order by clauses */ this.orders = []; /** * Group by columns */ this.groups = []; /** * Join clauses */ this.joins = []; /** * Limit value */ this.limitValue = null; /** * Offset value */ this.offsetValue = null; /** * FINAL modifier flag */ this.finalFlag = false; /** * Sample rate value */ this.sampleRate = null; /** * Values for INSERT */ this.insertValues = []; /** * Values for UPDATE */ this.updateValues = {}; /** * WITH clause expressions */ this.withExpressions = []; this.connection = connection; if (table) { this.from(table); } } /** * Set the table to query * @param table - Table name * @returns QueryBuilder instance for chaining */ from(table) { this.tableName = table; return this; } /** * Set the table to query * @param table - Table name * @returns QueryBuilder instance for chaining */ table(table) { return this.from(table); } /** * Add a FINAL modifier to the query * @returns QueryBuilder instance for chaining */ final() { this.finalFlag = true; return this; } /** * Add a SAMPLE modifier to the query * @param rate - Sample rate (0.0 to 1.0) * @returns QueryBuilder instance for chaining */ sample(rate) { if (rate < 0 || rate > 1) { throw new Error("Sample rate must be between 0 and 1"); } this.sampleRate = rate; return this; } /** * Set the columns to select * @param columns - Column names or Raw expressions * @returns QueryBuilder instance for chaining */ select(...columns) { this.queryType = "SELECT"; this.columns = columns.length ? columns : ["*"]; return this; } /** * Add a where clause * @param column - Column name or Raw expression * @param operator - Comparison operator or value if operator is omitted * @param value - Value to compare (optional if operator is actually the value) * @returns QueryBuilder instance for chaining */ where(column, operator, value) { // Handle object style where clauses: where({ name: 'John', age: 30 }) if (typeof column === "object" && !(column instanceof Raw)) { Object.entries(column).forEach(([key, val]) => { this.where(key, "=", val); }); return this; } // Handle where(column, value) shorthand if (arguments.length === 2) { value = operator; operator = "="; } this.wheres.push({ column, operator: operator, value, boolean: "AND", }); return this; } /** * Add an OR where clause * @param column - Column name or Raw expression * @param operator - Comparison operator or value if operator is omitted * @param value - Value to compare (optional if operator is actually the value) * @returns QueryBuilder instance for chaining */ orWhere(column, operator, value) { // Handle object style where clauses: orWhere({ name: 'John', age: 30 }) if (typeof column === "object" && !(column instanceof Raw)) { const keys = Object.keys(column); // Handle first entry separately if (keys.length > 0) { const key = keys[0]; this.orWhere(key, "=", column[key]); // Handle remaining entries with AND keys.slice(1).forEach((key) => { this.where(key, "=", column[key]); }); } return this; } // Handle orWhere(column, value) shorthand if (arguments.length === 2) { value = operator; operator = "="; } this.wheres.push({ column, operator: operator, value, boolean: "OR", }); return this; } /** * Add a where not clause * @param column - Column name or Raw expression * @param operator - Comparison operator or value if operator is omitted * @param value - Value to compare (optional if operator is actually the value) * @returns QueryBuilder instance for chaining */ whereNot(column, operator, value) { // Handle whereNot(column, value) shorthand if (arguments.length === 2) { value = operator; operator = "="; } this.wheres.push({ column, operator: operator, value, boolean: "AND", not: true, }); return this; } /** * Add a where in clause * @param column - Column name or Raw expression * @param values - Array of values to check against * @returns QueryBuilder instance for chaining */ whereIn(column, values) { if (!Array.isArray(values) || values.length === 0) { throw new Error("whereIn requires a non-empty array of values"); } this.wheres.push({ column, operator: "IN", value: values, boolean: "AND", }); return this; } /** * Add an or where in clause * @param column - Column name or Raw expression * @param values - Array of values to check against * @returns QueryBuilder instance for chaining */ orWhereIn(column, values) { if (!Array.isArray(values) || values.length === 0) { throw new Error("orWhereIn requires a non-empty array of values"); } this.wheres.push({ column, operator: "IN", value: values, boolean: "OR", }); return this; } /** * Add a where not in clause * @param column - Column name or Raw expression * @param values - Array of values to check against * @returns QueryBuilder instance for chaining */ whereNotIn(column, values) { if (!Array.isArray(values) || values.length === 0) { throw new Error("whereNotIn requires a non-empty array of values"); } this.wheres.push({ column, operator: "IN", value: values, boolean: "AND", not: true, }); return this; } /** * Add a where between clause * @param column - Column name or Raw expression * @param values - Array of two values [min, max] * @returns QueryBuilder instance for chaining */ whereBetween(column, values) { if (!Array.isArray(values) || values.length !== 2) { throw new Error("whereBetween requires an array of exactly two values"); } this.wheres.push({ column, operator: "BETWEEN", value: values, boolean: "AND", }); return this; } /** * Add a where not between clause * @param column - Column name or Raw expression * @param values - Array of two values [min, max] * @returns QueryBuilder instance for chaining */ whereNotBetween(column, values) { if (!Array.isArray(values) || values.length !== 2) { throw new Error("whereNotBetween requires an array of exactly two values"); } this.wheres.push({ column, operator: "BETWEEN", value: values, boolean: "AND", not: true, }); return this; } /** * Add a where null clause * @param column - Column name or Raw expression * @returns QueryBuilder instance for chaining */ whereNull(column) { this.wheres.push({ column, operator: "IS", value: null, boolean: "AND", }); return this; } /** * Add a where not null clause * @param column - Column name or Raw expression * @returns QueryBuilder instance for chaining */ whereNotNull(column) { this.wheres.push({ column, operator: "IS", value: null, boolean: "AND", not: true, }); return this; } /** * Add a raw where clause * @param sql - Raw SQL for where clause * @param bindings - Parameter bindings for the SQL * @returns QueryBuilder instance for chaining */ whereRaw(sql, bindings = []) { // Process bindings let processedSql = sql; bindings.forEach((binding) => { processedSql = processedSql.replace("?", formatValue(binding)); }); this.wheres.push({ column: new Raw(processedSql), operator: "", value: null, boolean: "AND", }); return this; } /** * Add a raw or where clause * @param sql - Raw SQL for where clause * @param bindings - Parameter bindings for the SQL * @returns QueryBuilder instance for chaining */ orWhereRaw(sql, bindings = []) { // Process bindings let processedSql = sql; bindings.forEach((binding) => { processedSql = processedSql.replace("?", formatValue(binding)); }); this.wheres.push({ column: new Raw(processedSql), operator: "", value: null, boolean: "OR", }); return this; } /** * Add an inner join clause * @param table - Table to join * @param first - First column or a callback function * @param operator - Comparison operator * @param second - Second column * @returns QueryBuilder instance for chaining */ join(table, first, operator, second) { return this.joinWithType("INNER", table, first, operator, second); } /** * Add a left join clause * @param table - Table to join * @param first - First column or a callback function * @param operator - Comparison operator * @param second - Second column * @returns QueryBuilder instance for chaining */ leftJoin(table, first, operator, second) { return this.joinWithType("LEFT", table, first, operator, second); } /** * Add a right join clause * @param table - Table to join * @param first - First column or a callback function * @param operator - Comparison operator * @param second - Second column * @returns QueryBuilder instance for chaining */ rightJoin(table, first, operator, second) { return this.joinWithType("RIGHT", table, first, operator, second); } /** * Add a full join clause * @param table - Table to join * @param first - First column or a callback function * @param operator - Comparison operator * @param second - Second column * @returns QueryBuilder instance for chaining */ fullJoin(table, first, operator, second) { return this.joinWithType("FULL", table, first, operator, second); } /** * Add a cross join clause * @param table - Table to join * @returns QueryBuilder instance for chaining */ crossJoin(table) { this.joins.push({ table, type: "CROSS", conditions: [], }); return this; } /** * Helper method for adding join clauses of different types * @param type - Join type * @param table - Table to join * @param first - First column or a callback function * @param operator - Comparison operator * @param second - Second column * @returns QueryBuilder instance for chaining */ joinWithType(type, table, first, operator, second) { const join = { table, type, conditions: [], }; // If first is a function, it's a join callback if (typeof first === "function") { first(join); } else if (first !== undefined && operator !== undefined && second !== undefined) { join.conditions.push({ first, operator, second, boolean: "AND", }); } this.joins.push(join); return this; } /** * Add a group by clause * @param columns - Columns to group by * @returns QueryBuilder instance for chaining */ groupBy(...columns) { this.groups = [...this.groups, ...columns]; return this; } /** * Add a having clause * @param column - Column name or Raw expression * @param operator - Comparison operator or value if operator is omitted * @param value - Value to compare (optional if operator is actually the value) * @returns QueryBuilder instance for chaining */ having(column, operator, value) { // Handle having(column, value) shorthand if (arguments.length === 2) { value = operator; operator = "="; } this.havings.push({ column, operator: operator, value, boolean: "AND", }); return this; } /** * Add an or having clause * @param column - Column name or Raw expression * @param operator - Comparison operator or value if operator is omitted * @param value - Value to compare (optional if operator is actually the value) * @returns QueryBuilder instance for chaining */ orHaving(column, operator, value) { // Handle orHaving(column, value) shorthand if (arguments.length === 2) { value = operator; operator = "="; } this.havings.push({ column, operator: operator, value, boolean: "OR", }); return this; } /** * Add an order by clause * @param column - Column to order by * @param direction - Direction (ASC or DESC) * @returns QueryBuilder instance for chaining */ orderBy(column, direction = "ASC") { this.orders.push({ column, direction, }); return this; } /** * Add an order by desc clause * @param column - Column to order by * @returns QueryBuilder instance for chaining */ orderByDesc(column) { return this.orderBy(column, "DESC"); } /** * Set the limit value * @param value - Limit value * @returns QueryBuilder instance for chaining */ limit(value) { this.limitValue = value; return this; } /** * Set the offset value * @param value - Offset value * @returns QueryBuilder instance for chaining */ offset(value) { this.offsetValue = value; return this; } /** * Add a with clause * @param name - CTE name * @param query - Query builder instance or Raw expression * @returns QueryBuilder instance for chaining */ with(name, query) { this.withExpressions.push({ name, query }); return this; } /** * Set insert values * @param values - Values to insert * @returns QueryBuilder instance for chaining */ values(values) { this.queryType = "INSERT"; if (Array.isArray(values)) { this.insertValues = values; } else { this.insertValues = [values]; } return this; } /** * Set update values * @param values - Values to update * @returns QueryBuilder instance for chaining */ updateQuery(values) { this.queryType = "UPDATE"; this.updateValues = values; return this; } /** * Set query type to DELETE * @returns QueryBuilder instance for chaining */ deleteQuery() { this.queryType = "DELETE"; return this; } /** * Build the SQL query string * @returns SQL query string */ toSql() { // Build appropriate query based on type switch (this.queryType) { case "SELECT": return this.buildSelectQuery(); case "INSERT": return this.buildInsertQuery(); case "UPDATE": return this.buildUpdateQuery(); case "DELETE": return this.buildDeleteQuery(); default: throw new Error(`Unsupported query type: ${this.queryType}`); } } /** * Build a SELECT query * @returns SQL query string */ buildSelectQuery() { if (!this.tableName) { throw new Error("No table specified for select query"); } // Parts of the query const parts = []; // Add WITH clause if needed if (this.withExpressions.length > 0) { const withClauses = this.withExpressions .map(({ name, query }) => { const sql = query instanceof Raw ? query.toSql() : query.toSql(); return `${name} AS (${sql})`; }) .join(", "); parts.push(`WITH ${withClauses}`); } // Add SELECT clause const columns = this.columns .map((column) => { return column instanceof Raw ? column.toSql() : column; }) .join(", "); parts.push(`SELECT ${columns}`); // Add FROM clause let from = this.tableName; // Add FINAL modifier if needed if (this.finalFlag) { from += " FINAL"; } // Add SAMPLE modifier if needed if (this.sampleRate !== null) { from += ` SAMPLE ${this.sampleRate}`; } parts.push(`FROM ${from}`); // Add JOIN clauses if (this.joins.length > 0) { const joinClauses = this.joins .map((join) => { let clause = `${join.type} JOIN ${join.table}`; // Add ON conditions if any if (join.conditions.length > 0) { const conditions = join.conditions .map((condition, index) => { const { first, operator, second, boolean } = condition; const firstCol = first instanceof Raw ? first.toSql() : first; const secondCol = second instanceof Raw ? second.toSql() : second; const booleanOperator = index === 0 ? "" : ` ${boolean}`; return `${booleanOperator} ${firstCol} ${operator} ${secondCol}`; }) .join(" "); clause += ` ON ${conditions.trim()}`; } return clause; }) .join(" "); parts.push(joinClauses); } // Add WHERE clause if (this.wheres.length > 0) { const whereClauses = this.wheres .map((where, index) => { const { column, operator, value, boolean, not } = where; const booleanOperator = index === 0 ? "" : ` ${boolean}`; const notOperator = not ? " NOT" : ""; // Handle raw where clauses if (column instanceof Raw && operator === "") { return `${booleanOperator}${notOperator} ${column.toSql()}`; } const columnStr = column instanceof Raw ? column.toSql() : column; // Handle different operators switch (operator) { case "IN": if (!Array.isArray(value) || value.length === 0) { throw new Error("IN operator requires a non-empty array of values"); } const formattedValues = value .map((val) => formatValue(val)) .join(", "); return `${booleanOperator}${notOperator} ${columnStr} IN (${formattedValues})`; case "BETWEEN": if (!Array.isArray(value) || value.length !== 2) { throw new Error("BETWEEN operator requires an array of exactly two values"); } return `${booleanOperator}${notOperator} ${columnStr} BETWEEN ${formatValue(value[0])} AND ${formatValue(value[1])}`; case "IS": return `${booleanOperator} ${columnStr} IS${notOperator} NULL`; default: return `${booleanOperator}${notOperator} ${columnStr} ${operator} ${formatValue(value)}`; } }) .join(""); parts.push(`WHERE ${whereClauses.trim()}`); } // Add GROUP BY clause if (this.groups.length > 0) { const groupClauses = this.groups .map((group) => { return group instanceof Raw ? group.toSql() : group; }) .join(", "); parts.push(`GROUP BY ${groupClauses}`); } // Add HAVING clause if (this.havings.length > 0) { const havingClauses = this.havings .map((having, index) => { const { column, operator, value, boolean } = having; const booleanOperator = index === 0 ? "" : ` ${boolean}`; const columnStr = column instanceof Raw ? column.toSql() : column; return `${booleanOperator} ${columnStr} ${operator} ${formatValue(value)}`; }) .join(""); parts.push(`HAVING ${havingClauses.trim()}`); } // Add ORDER BY clause if (this.orders.length > 0) { const orderClauses = this.orders .map((order) => { const columnStr = order.column instanceof Raw ? order.column.toSql() : order.column; return `${columnStr} ${order.direction}`; }) .join(", "); parts.push(`ORDER BY ${orderClauses}`); } // Add LIMIT and OFFSET clauses if (this.limitValue !== null) { parts.push(`LIMIT ${this.limitValue}`); if (this.offsetValue !== null) { parts.push(`OFFSET ${this.offsetValue}`); } } return parts.join(" "); } /** * Build an INSERT query * @returns SQL query string */ buildInsertQuery() { if (!this.tableName) { throw new Error("No table specified for insert query"); } if (this.insertValues.length === 0) { throw new Error("No values specified for insert query"); } // Get columns from the first row const columns = Object.keys(this.insertValues[0]); if (columns.length === 0) { throw new Error("No columns found in insert data"); } // Build the insert query let sql = `INSERT INTO ${this.tableName} (${columns.join(", ")}) VALUES `; // Add values for each row const values = this.insertValues .map((row) => { const rowValues = columns.map((column) => formatValue(row[column])); return `(${rowValues.join(", ")})`; }) .join(", "); sql += values; return sql; } /** * Build an UPDATE query * @returns SQL query string */ buildUpdateQuery() { if (!this.tableName) { throw new Error("No table specified for update query"); } if (Object.keys(this.updateValues).length === 0) { throw new Error("No values specified for update query"); } // Build SET clause const setClauses = Object.entries(this.updateValues) .map(([column, value]) => { return `${column} = ${formatValue(value)}`; }) .join(", "); // Build the update query let sql = `ALTER TABLE ${this.tableName} UPDATE ${setClauses}`; // Add WHERE clause if (this.wheres.length > 0) { const whereClauses = this.wheres .map((where, index) => { const { column, operator, value, boolean, not } = where; const booleanOperator = index === 0 ? "" : ` ${boolean}`; const notOperator = not ? " NOT" : ""; // Handle raw where clauses if (column instanceof Raw && operator === "") { return `${booleanOperator}${notOperator} ${column.toSql()}`; } const columnStr = column instanceof Raw ? column.toSql() : column; // Handle different operators switch (operator) { case "IN": if (!Array.isArray(value) || value.length === 0) { throw new Error("IN operator requires a non-empty array of values"); } const formattedValues = value .map((val) => formatValue(val)) .join(", "); return `${booleanOperator}${notOperator} ${columnStr} IN (${formattedValues})`; case "BETWEEN": if (!Array.isArray(value) || value.length !== 2) { throw new Error("BETWEEN operator requires an array of exactly two values"); } return `${booleanOperator}${notOperator} ${columnStr} BETWEEN ${formatValue(value[0])} AND ${formatValue(value[1])}`; case "IS": return `${booleanOperator} ${columnStr} IS${notOperator} NULL`; default: return `${booleanOperator}${notOperator} ${columnStr} ${operator} ${formatValue(value)}`; } }) .join(""); sql += ` WHERE ${whereClauses.trim()}`; } return sql; } /** * Build a DELETE query * @returns SQL query string */ buildDeleteQuery() { if (!this.tableName) { throw new Error("No table specified for delete query"); } // Build the delete query (ClickHouse uses ALTER TABLE ... DELETE syntax) let sql = `ALTER TABLE ${this.tableName} DELETE`; // Add WHERE clause if (this.wheres.length > 0) { const whereClauses = this.wheres .map((where, index) => { const { column, operator, value, boolean, not } = where; const booleanOperator = index === 0 ? "" : ` ${boolean}`; const notOperator = not ? " NOT" : ""; // Handle raw where clauses if (column instanceof Raw && operator === "") { return `${booleanOperator}${notOperator} ${column.toSql()}`; } const columnStr = column instanceof Raw ? column.toSql() : column; // Handle different operators switch (operator) { case "IN": if (!Array.isArray(value) || value.length === 0) { throw new Error("IN operator requires a non-empty array of values"); } const formattedValues = value .map((val) => formatValue(val)) .join(", "); return `${booleanOperator}${notOperator} ${columnStr} IN (${formattedValues})`; case "BETWEEN": if (!Array.isArray(value) || value.length !== 2) { throw new Error("BETWEEN operator requires an array of exactly two values"); } return `${booleanOperator}${notOperator} ${columnStr} BETWEEN ${formatValue(value[0])} AND ${formatValue(value[1])}`; case "IS": return `${booleanOperator} ${columnStr} IS${notOperator} NULL`; default: return `${booleanOperator}${notOperator} ${columnStr} ${operator} ${formatValue(value)}`; } }) .join(""); sql += ` WHERE ${whereClauses.trim()}`; } else { throw new Error("DELETE queries require a WHERE clause"); } return sql; } /** * Execute the query and get results * @param options - Query options * @returns Query result */ async get(options) { const result = await this.connection.query(this.toSql(), options); return result.data; } /** * Execute the query and get the first result * @param options - Query options * @returns Single result or null if not found */ async first(options) { const results = await this.limit(1).get(options); return results && results.length > 0 ? results[0] : null; } /** * Execute the query and get a value from the first result * @param column - Column to retrieve * @param options - Query options * @returns Column value or null if not found */ async value(column, options) { const result = await this.select(column).first(options); return result ? result[column] : null; } /** * Execute the query and get an array of values from a single column * @param column - Column to retrieve * @param options - Query options * @returns Array of column values */ async pluck(column, options) { const results = await this.select(column).get(options); return results.map((result) => result[column]); } /** * Execute the query and get a count of the results * @param options - Query options * @returns Count of results */ async count(options) { // Save current columns and add count const currentColumns = [...this.columns]; // Reset columns to count(*) this.columns = [new Raw("count(*) as count")]; // Execute query const result = await this.first(options); // Restore columns this.columns = currentColumns; return result ? result.count : 0; } /** * Execute the query and determine if any results exist * @param options - Query options * @returns True if any results exist */ async exists(options) { const count = await this.count(options); return count > 0; } /** * Execute the query and determine if no results exist * @param options - Query options * @returns True if no results exist */ async doesntExist(options) { return !(await this.exists(options)); } /** * Execute the query and get the minimum value for a column * @param column - Column to get minimum for * @param options - Query options * @returns Minimum value */ async min(column, options) { const result = await this.select(new Raw(`min(${column}) as min_value`)).first(options); return result ? result.min_value : null; } /** * Execute the query and get the maximum value for a column * @param column - Column to get maximum for * @param options - Query options * @returns Maximum value */ async max(column, options) { const result = await this.select(new Raw(`max(${column}) as max_value`)).first(options); return result ? result.max_value : null; } /** * Execute the query and get the sum of values for a column * @param column - Column to sum * @param options - Query options * @returns Sum of values */ async sum(column, options) { const result = await this.select(new Raw(`sum(${column}) as sum_value`)).first(options); return result ? result.sum_value : null; } /** * Execute the query and get the average of values for a column * @param column - Column to average * @param options - Query options * @returns Average of values */ async avg(column, options) { const result = await this.select(new Raw(`avg(${column}) as avg_value`)).first(options); return result ? result.avg_value : null; } /** * Execute an insert query * @param values - Values to insert * @param options - Query options * @returns Query result */ async insert(values, options) { this.values(values); return this.connection.query(this.toSql(), options); } /** * Execute an update query * @param values - Values to update * @param options - Query options * @returns Query result */ async update(values, options) { this.updateQuery(values); return this.connection.query(this.toSql(), options); } /** * Execute a delete query * @param options - Query options * @returns Query result */ async delete(options) { this.deleteQuery(); return this.connection.query(this.toSql(), options); } /** * Execute a raw SQL query * @param sql - Raw SQL query to execute * @param options - Query options * @returns Query result data */ async rawQuery(sql, options) { const result = await this.connection.query(sql, options); return result.data; } } //# sourceMappingURL=QueryBuilder.js.map