@js-ak/db-manager
Version:
552 lines (551 loc) • 23.3 kB
JavaScript
import * as Helpers from "../helpers/index.js";
import { QueryHandler } from "./query-handler.js";
/**
* A class to build and execute SQL queries using a fluent API.
* The `QueryBuilder` handles various SQL operations like SELECT, INSERT, UPDATE, DELETE, and JOINs,
* as well as raw SQL query construction and execution.
*/
export class QueryBuilder {
#dataSourceRaw;
#dataSourcePrepared;
#client;
#queryHandler;
#logger;
#executeSql;
#executeSqlStream;
#joinTypes = {
CROSS: "CROSS",
"FULL OUTER": "FULL OUTER",
INNER: "INNER",
LEFT: "LEFT",
RIGHT: "RIGHT",
};
/**
* Creates an instance of QueryBuilder.
*
* @param dataSource - The raw SQL data source string.
* @param client - The PostgreSQL client or connection pool.
* @param [options] - Optional settings for the QueryBuilder.
* @param [options.isLoggerEnabled] - Enable or disable logging.
* @param [options.logger] - Custom logger instance.
* @param [options.queryHandler] - Custom query handler instance.
*/
constructor(dataSource, client, options) {
this.#dataSourceRaw = dataSource;
this.#client = client;
const chunks = dataSource.toLowerCase().split(" ").filter((e) => e && e !== "as");
const as = chunks[1]?.trim();
if (as) {
this.#dataSourcePrepared = as;
}
else {
this.#dataSourcePrepared = dataSource;
}
const { isLoggerEnabled, logger, queryHandler } = options || {};
this.#queryHandler = queryHandler || new QueryHandler({
dataSourcePrepared: this.#dataSourcePrepared,
dataSourceRaw: this.#dataSourceRaw,
});
const preparedOptions = Helpers.setLoggerAndExecutor(this.#client, { isLoggerEnabled, logger });
const { executeSqlStream } = Helpers.setStreamExecutor(this.#client, { isLoggerEnabled, logger });
this.#executeSql = preparedOptions.executeSql;
this.#executeSqlStream = executeSqlStream;
this.#logger = preparedOptions.logger;
}
/**
* Indicates if the current query is a subquery.
*
*/
get isSubquery() {
return this.#queryHandler.isSubquery;
}
/**
* Creates a deep copy of the current QueryBuilder instance.
*
* @returns A new QueryBuilder instance cloned from the current one.
*/
clone() {
const queryHandler = new QueryHandler(this.#queryHandler.optionsToClone);
return new QueryBuilder(this.#dataSourceRaw, this.#client, { logger: this.#logger, queryHandler });
}
/**
* Compares and generates the SQL query and its values.
*
* @returns An object containing the SQL query string and its values.
*/
compareQuery() {
return this.#queryHandler.compareQuery();
}
/**
* Processes the input string `data`, optionally replacing all occurrences of `$?` with values from the `values` array.
* If the `#for` property is not set, it initializes it with "FOR " unless the `data` string starts with "FOR" (case-insensitive).
* The processed or original data is then appended to the `#for` property.
*
* @param data - The string to be processed and potentially replaced.
* @param [values] - An optional array of values to replace placeholders in the `data` string.
*
* @returns The `QueryBuilder` instance to allow for method chaining.
*/
rawFor(data, values) {
this.#queryHandler.rawFor(data, values);
return this;
}
/**
* Deletes records from the database.
*
* @returns The current QueryBuilder instance for method chaining.
*/
delete() {
this.#queryHandler.delete();
return this;
}
/**
* Inserts records into the database.
*
* @param options - The options for the insert operation.
* @param [options.isUseDefaultValues] - Use default values for missing columns when options.params is an array. Defaults to false.
* @param [options.onConflict] - Conflict resolution strategy.
* @param options.params - The parameters to insert.
* @param [options.updateColumn] - Optional default system column for updates.
*
* @returns The current QueryBuilder instance for method chaining.
*/
insert(options) {
this.#queryHandler.insert(options);
return this;
}
/**
* Processes the input string `data`, optionally replacing placeholders with values from the `values` array.
* The processed or original data is then used to set the `#mainQuery` property with an `INSERT INTO` clause.
*
* This method does not return any value.
*
* @param data - The string to be processed and potentially replaced.
* @param [values] - An optional array of values to replace placeholders in the `data` string.
*
* @returns The `QueryBuilder` instance to allow for method chaining.
*/
rawInsert(data, values) {
this.#queryHandler.rawInsert(data, values);
return this;
}
/**
* Updates records in the database.
*
* @param options - The options for the update operation.
* @param [options.onConflict] - Conflict resolution strategy.
* @param options.params - The parameters to update.
* @param [options.updateColumn] - Optional default system column for updates.
*
* @returns The current QueryBuilder instance for method chaining.
*/
update(options) {
this.#queryHandler.update(options);
return this;
}
/**
* Specifies the columns to select in the SQL query.
*
* @param data - An array of column names to select.
*
* @returns The current QueryBuilder instance for method chaining.
*/
select(data) {
this.#queryHandler.select(data);
return this;
}
/**
* Specifies the data source for the SQL query.
*
* @param data - The data source, either as a string or another QueryBuilder instance.
* @param [values] - An optional array of values to replace placeholders in the `data` string.
*
* @returns The current QueryBuilder instance for method chaining.
* @throws {Error} If the `data` is a QueryBuilder instance but not a subquery.
*/
from(data, values) {
if (data instanceof QueryBuilder) {
if (!data.isSubquery) {
throw new Error("data must be a query builder subquery");
}
const { query, values } = data.compareQuery();
this.#queryHandler.from(query, values);
return this;
}
else {
this.#queryHandler.from(data, values);
return this;
}
}
/**
* Converts the current query into a subquery.
*
* @param [name] - An optional name for the subquery.
*
* @returns The current QueryBuilder instance for method chaining.
*/
toSubquery(name) {
this.#queryHandler.toSubquery(name);
return this;
}
/**
* Processes the input string `data`, optionally replacing all occurrences of `$?` with values from the `values` array.
* The processed or original data is then appended to the `#join` array.
*
* @param data - The string to be processed and potentially replaced.
* @param [values] - An optional array of values to replace placeholders in the `data` string.
*
* @returns The `QueryBuilder` instance to allow for method chaining.
*/
rawJoin(data, values) {
this.#queryHandler.rawJoin(data, values);
return this;
}
/**
* Specifies a `RIGHT JOIN` clause in the SQL query.
*
* @param data - The data for the `RIGHT JOIN` clause.
* @param data.targetTableName - The name of the table to join with.
* @param [data.targetTableNameAs] - Optional alias for the target table.
* @param data.targetField - The field in the target table to join on.
* @param [data.initialTableName] - Optional name of the initial table.
* @param data.initialField - The field in the initial table to join on.
*
* @returns The current QueryBuilder instance for method chaining.
*/
rightJoin(data) {
this.#queryHandler.rightJoin(data);
return this;
}
/**
* Specifies a `LEFT JOIN` clause in the SQL query.
*
* @param data - The data for the `LEFT JOIN` clause.
* @param data.targetTableName - The name of the table to join with.
* @param [data.targetTableNameAs] - Optional alias for the target table.
* @param data.targetField - The field in the target table to join on.
* @param [data.initialTableName] - Optional name of the initial table.
* @param data.initialField - The field in the initial table to join on.
*
* @returns The current QueryBuilder instance for method chaining.
*/
leftJoin(data) {
this.#queryHandler.leftJoin(data);
return this;
}
/**
* Specifies an `INNER JOIN` clause in the SQL query.
*
* @param data - The data for the `INNER JOIN` clause.
* @param data.targetTableName - The name of the table to join with.
* @param [data.targetTableNameAs] - Optional alias for the target table.
* @param data.targetField - The field in the target table to join on.
* @param [data.initialTableName] - Optional name of the initial table.
* @param data.initialField - The field in the initial table to join on.
*
* @returns The current QueryBuilder instance for method chaining.
*/
innerJoin(data) {
this.#queryHandler.innerJoin(data);
return this;
}
/**
* Specifies a `FULL OUTER JOIN` clause in the SQL query.
*
* @param data - The data for the `FULL OUTER JOIN` clause.
* @param data.targetTableName - The name of the table to join with.
* @param [data.targetTableNameAs] - Optional alias for the target table.
* @param data.targetField - The field in the target table to join on.
* @param [data.initialTableName] - Optional name of the initial table.
* @param data.initialField - The field in the initial table to join on.
*
* @returns The current QueryBuilder instance for method chaining.
*/
fullOuterJoin(data) {
this.#queryHandler.fullOuterJoin(data);
return this;
}
/**
* Specifies a `CROSS JOIN` clause in the SQL query.
*
* @param data - The data for the `CROSS JOIN` clause.
* @param data.targetTableName - The name of the table to join with.
* @param [data.targetTableNameAs] - Optional alias for the target table.
* @param data.targetField - The field in the target table to join on.
* @param [data.initialTableName] - Optional name of the initial table.
* @param data.initialField - The field in the initial table to join on.
*
* @returns The current QueryBuilder instance for method chaining.
*/
crossJoin(data) {
this.#queryHandler.crossJoin(data);
return this;
}
/**
* Specifies a JOIN clause in the SQL query with options for join type and lateral joins.
*
* @param data - The configuration for the JOIN clause.
* @param [data.alias] - Optional alias for the target table or subquery.
* @param [data.isLateral] - Indicates if the join should be a lateral join. Throws an error if set to true and the target is not a QueryBuilder instance.
* @param [data.onCondition] - An optional condition for the join operation. Includes query and values for parameterized queries.
* @param data.target - The target table or subquery to join with. Can be a string or a QueryBuilder instance.
* @param [data.type] - The type of join operation to perform (e.g., "CROSS", "FULL OUTER", "INNER", "LEFT", "RIGHT"). Defaults to "INNER".
*
* @returns The current QueryBuilder instance for method chaining.
*
* @throws Error if an unsupported join type is provided or if isLateral is true and target is not a QueryBuilder instance.
*/
join(data) {
const isTargetQb = data.target instanceof QueryBuilder;
if (!isTargetQb && data.isLateral) {
throw new Error("target must be a instance of QueryBuilder");
}
const type = data.type || "INNER";
if (!this.#joinTypes[type]) {
throw new Error(`Unsupported join type: ${type}`);
}
const { query: targetQuery, values: targetValues } = isTargetQb
? data.target.compareQuery()
: { query: data.target, values: [] };
const join = `${type} JOIN ${data.isLateral ? "LATERAL " : ""}`;
const offset = targetValues.length;
const { query: onConditionQuery, values: onConditionValues, } = data.onCondition || { query: "" };
const isConditionQueryParamsExists = onConditionQuery?.search(/\$\d/) !== -1;
const isConditionValuesExists = !!onConditionValues?.length;
if (isConditionQueryParamsExists && !isConditionValuesExists) {
throw new Error("onCondition.query must be parameterized");
}
if (!isConditionQueryParamsExists && isConditionValuesExists) {
throw new Error("onCondition.values must be parameterized");
}
const isNeedUpdateOnCondition = isConditionQueryParamsExists && isConditionValuesExists;
const updatedOnCondition = isNeedUpdateOnCondition
? ` ON (${onConditionQuery.replace(/\$(\d+)/g, (_match, p1) => `$${parseInt(p1) + offset}`)})`
: onConditionQuery;
const query = `${join}${targetQuery}${data.alias ? ` AS ${data.alias}` : ""}${updatedOnCondition}`;
const values = [...(targetValues || []), ...(onConditionValues || [])];
this.#queryHandler.rawJoin(query, values);
return this;
}
/**
* Specifies a `WHERE` clause for the SQL query.
*
* @param data - The data for the `WHERE` clause.
* @param [data.params] - Search parameters for the `WHERE` clause.
* @param [data.paramsOr] - Optional OR conditions for the `WHERE` clause.
*
* @returns The current QueryBuilder instance for method chaining.
*/
where(data) {
this.#queryHandler.where(data);
return this;
}
/**
* Processes the input object `data`, which contains a `query` string, and optionally replaces placeholders with values from the `values` array.
* It then formats the processed query as part of a `WITH` clause. The formatted clause is appended to the `#with` property,
* ensuring that multiple clauses are separated by commas.
*
* If the `#with` property is not set, it initializes it with the `WITH` clause; otherwise, it appends additional clauses.
*
* @param data - The object containing the name and query to be processed.
* @param data.name - The name or alias to be used in the `WITH` clause.
* @param data.query - The query string to be processed and potentially replaced.
* @param [values] - An optional array of values to replace placeholders in the `data.query` string.
*
* @returns The `QueryBuilder` instance to allow for method chaining.
*/
with(data, values) {
this.#queryHandler.with(data, values);
return this;
}
/**
* Processes the input string `data`, optionally replacing all occurrences of `$?` with values from the `values` array.
* If the `#mainWhere` property is not set, it initializes it with "WHERE " unless the `data` string starts with "WHERE" (case-insensitive).
* The processed or original data is then appended to the `#mainWhere` property.
*
* @param data - The string to be processed and potentially replaced.
* @param [values] - An optional array of values to replace placeholders in the `data` string.
*
* @returns The `QueryBuilder` instance to allow for method chaining.
*/
rawWhere(data, values) {
this.#queryHandler.rawWhere(data, values);
return this;
}
/**
* Processes the input string `data`, optionally replacing placeholders with values from the `values` array.
* If the `#mainQuery` property is not set, it initializes it with an `UPDATE` clause, unless the `data` string starts with "UPDATE" (case-insensitive).
* The processed or original data is then appended to the `#mainQuery` property.
*
* This method does not return any value.
*
* @param data - The string to be processed and potentially replaced.
* @param [values] - An optional array of values to replace placeholders in the `data` string.
*
* @returns The `QueryBuilder` instance to allow for method chaining.
*/
rawUpdate(data, values) {
this.#queryHandler.rawUpdate(data, values);
return this;
}
/**
* Specifies pagination for the SQL query.
*
* @param [data] - The data for pagination.
* @param [data].limit - The maximum number of rows to return.
* @param [data].offset - The number of rows to skip before starting to return rows.
*
* @returns The current QueryBuilder instance for method chaining.
*/
pagination(data) {
if (!data)
return this;
this.#queryHandler.pagination(data);
return this;
}
/**
* Specifies an `ORDER BY` clause for the SQL query.
*
* @param [data] - An array of objects specifying the columns and sorting order.
* @param [data][].column - The column name to order by.
* @param [data][].sorting - The sorting direction (`ASC` or `DESC`).
*
* @returns The current QueryBuilder instance for method chaining.
*/
orderBy(data) {
if (!data?.length)
return this;
this.#queryHandler.orderBy(data);
return this;
}
/**
* Specifies a `GROUP BY` clause for the SQL query.
*
* @param [data] - An array of column names to group by.
*
* @returns The current QueryBuilder instance for method chaining.
*/
groupBy(data) {
if (!data?.length)
return this;
this.#queryHandler.groupBy(data);
return this;
}
/**
* Specifies a `HAVING` clause for the SQL query.
*
* @param data - The data for the `HAVING` clause.
* @param [data.params] - Search parameters for the `HAVING` clause.
* @param [data.paramsOr] - Optional OR conditions for the `HAVING` clause.
*
* @returns The current QueryBuilder instance for method chaining.
*/
having(data) {
this.#queryHandler.having(data);
return this;
}
/**
* Processes the input string `data`, optionally replacing all occurrences of `$?` with values from the `values` array.
* If the `#mainHaving` property is not set, it initializes it with "HAVING " unless the `data` string starts with "HAVING" (case-insensitive).
* The processed or original data is then appended to the `#mainHaving` property.
*
* @param data - The string to be processed and potentially replaced.
* @param [values] - An optional array of values to replace placeholders in the `data` string.
*
* @returns The `QueryBuilder` instance to allow for method chaining.
*/
rawHaving(data, values) {
this.#queryHandler.rawHaving(data, values);
return this;
}
/**
* Specifies a `RETURNING` clause for the SQL query.
*
* @param data - An array of column names to return after the query is executed.
*
* @returns The current QueryBuilder instance for method chaining.
*/
returning(data) {
this.#queryHandler.returning(data);
return this;
}
/**
* Executes the SQL query and returns the result.
*
* @returns A promise that resolves to an array of result rows.
*/
async execute() {
const sql = this.compareQuery();
return (await this.#executeSql(sql)).rows;
}
/**
* Executes the SQL query and returns a readable stream of typed rows.
*
* Useful for processing large result sets without loading them entirely into memory.
*
* @param [data] - Optional execution parameters.
* @param [data.streamOptions] - Optional configuration for stream behavior:
* - `batchSize`: Number of rows fetched from the database per batch.
* - `highWaterMark`: Maximum number of rows buffered internally before pausing reads.
* - `rowMode`: If set to `"array"`, rows are returned as arrays instead of objects.
* - `types`: Custom type parser map for PostgreSQL data types.
*
* @returns A readable stream that emits rows of type `T` on the `"data"` event.
*/
async executeQueryStream(data) {
const { streamOptions } = data || {};
const sql = this.compareQuery();
return this.#executeSqlStream(sql, streamOptions);
}
/**
* Executes a SQL custom query with specified data and values, and returns the result.
*
* This method executes a SQL query provided as a string with optional parameter values, and returns the result rows.
*
* @note All previously passed options are ignored.
*
* @param data - The SQL query string to execute.
* @param [values=[]] - Optional array of values to be used in the query.
*
* @returns A promise that resolves to an array of result rows.
*/
async executeRawQuery(data, values) {
return (await this.#executeSql({ query: data, values: values || [] })).rows;
}
/**
* Executes a raw SQL query with the specified values and returns a readable stream of rows.
*
* @note All previously passed options are ignored.
* @note Use this method for large datasets to avoid loading all data into memory.
*
* @param data - The SQL query string to execute.
* @param [values=[]] - Optional array of values to be used in the query.
*
* @returns A readable stream of result rows.
*/
async executeRawQueryStream(data, values) {
return this.#executeSqlStream({ query: data, values: values || [] });
}
/**
* Processes the input `data` using `compareFields` and `getFieldsToSearch` helpers and returns an object with the query string, order number and values.
*
* @param data - The input data to be processed.
* @param [options] - Optional object containing the start order number.
*
* @returns An object containing the query string, order number and values.
*/
compareParams(data, options) {
const { startOrderNumber } = options || {};
const { queryArray, queryOrArray, values } = Helpers.compareFields(data.params, data.paramsOr);
const { orderNumber, searchFields, } = Helpers.getFieldsToSearch({ queryArray, queryOrArray });
// Remove the "WHERE " prefix
const searchFieldsPrepared = searchFields.trim().slice(6);
return {
orderNumber,
query: startOrderNumber
? searchFieldsPrepared.replace(/\$(\d+)/g, (_match, p1) => `$${parseInt(p1) + startOrderNumber}`)
: searchFieldsPrepared,
values,
};
}
}