@andreasnicolaou/query-builder
Version:
A flexible and type-safe query builder for constructing complex conditional expressions with support for nested groups, various operators, and function calls.
400 lines (398 loc) • 15.1 kB
JavaScript
/**
* Query builder for constructing complex conditional expressions.
* Supports nested groups, various operators, and function calls.
* @author Andreas Nicolaou <anicolaou66@gmail.com>
*/
class QueryBuilder {
conditions = [];
skipOptions = {
null: true,
undefined: true,
emptyString: true,
emptyArray: true,
nan: true,
emptyObject: true,
};
/**
* Creates a function call object that can be used as a value in conditions.
* @param name The name of the function to call
* @param args Arguments to pass to the function
* @returns A FunctionCall object representing the function invocation
* @memberof QueryBuilder
*/
static fn(name, ...args) {
return { $fn: name, args };
}
/**
* Validates if an operator is compatible with a value type
* @param operator The operator to validate
* @param value The value to validate against
* @returns Validation result with error message if invalid
* @memberof QueryBuilder
*/
static validateOperator(operator, value) {
const nullOps = ['is null', 'is not null', 'is empty', 'is not empty'];
const rangeOps = ['between', 'not between'];
const setOps = ['in', 'not in'];
// Null operators shouldn't have values
if (nullOps.includes(operator) && value !== undefined) {
return { valid: false, error: `Operator '${operator}' should not have a value` };
}
// Range operators need arrays with 2 elements
if (rangeOps.includes(operator)) {
if (!Array.isArray(value) || value.length !== 2) {
return { valid: false, error: `Operator '${operator}' requires an array with exactly 2 values` };
}
}
// Set operators need arrays
if (setOps.includes(operator)) {
if (!Array.isArray(value)) {
return { valid: false, error: `Operator '${operator}' requires an array value` };
}
}
return { valid: true };
}
/**
* Adds a BETWEEN condition.
*/
between(field, range, logicalOperator = 'and') {
return this.where(field, 'between', range, logicalOperator);
}
/**
* Adds an equals (=) condition.
*/
equals(field, value, logicalOperator = 'and') {
return this.where(field, '=', value, logicalOperator);
}
/**
* Adds a greater than (>) condition.
*/
greaterThan(field, value, logicalOperator = 'and') {
return this.where(field, '>', value, logicalOperator);
}
/**
* Adds a greater than or equal (>=) condition.
*/
greaterThanOrEqual(field, value, logicalOperator = 'and') {
return this.where(field, '>=', value, logicalOperator);
}
/**
* Creates a nested group of conditions.
* @param callback A function that receives a new QueryBuilder for the nested conditions
* @param logicalOperator The logical operator to combine with previous conditions (default: 'and')
* @returns The query builder instance for chaining
* @memberof QueryBuilder
*/
group(callback, logicalOperator = 'and') {
const nested = new QueryBuilder().skipWhen(this.skipOptions);
callback(nested);
const nestedConditions = nested.toJSON();
if (nestedConditions.length > 0) {
if (this.conditions.length > 0 && typeof this.conditions[this.conditions.length - 1] !== 'string') {
this.conditions.push(logicalOperator); // Insert the logical operator between groups
}
this.conditions.push({ group: nestedConditions });
}
return this;
}
/**
* Adds an ILIKE (case-insensitive like) condition.
*/
ilike(field, value, logicalOperator = 'and') {
return this.where(field, 'ilike', value, logicalOperator);
}
/**
* Adds an IN condition.
*/
in(field, values, logicalOperator = 'and') {
return this.where(field, 'in', values, logicalOperator);
}
/**
* Adds an IS EMPTY condition.
*/
isEmpty(field, logicalOperator = 'and') {
return this.where(field, 'is empty', undefined, logicalOperator);
}
/**
* Adds an IS NOT EMPTY condition.
*/
isNotEmpty(field, logicalOperator = 'and') {
return this.where(field, 'is not empty', undefined, logicalOperator);
}
/**
* Adds an IS NOT NULL condition.
*/
isNotNull(field, logicalOperator = 'and') {
return this.where(field, 'is not null', undefined, logicalOperator);
}
/**
* Adds an IS NULL condition.
*/
isNull(field, logicalOperator = 'and') {
return this.where(field, 'is null', undefined, logicalOperator);
}
/**
* Adds a less than (<) condition.
*/
lessThan(field, value, logicalOperator = 'and') {
return this.where(field, '<', value, logicalOperator);
}
/**
* Adds a less than or equal (<=) condition.
*/
lessThanOrEqual(field, value, logicalOperator = 'and') {
return this.where(field, '<=', value, logicalOperator);
}
/**
* Adds a LIKE condition.
*/
like(field, value, logicalOperator = 'and') {
return this.where(field, 'like', value, logicalOperator);
}
/**
* Adds a loose equals (==) condition.
*/
looseEquals(field, value, logicalOperator = 'and') {
return this.where(field, '==', value, logicalOperator);
}
/**
* Adds a NOT BETWEEN condition.
*/
notBetween(field, range, logicalOperator = 'and') {
return this.where(field, 'not between', range, logicalOperator);
}
/**
* Adds a not equals (!=) condition.
*/
notEquals(field, value, logicalOperator = 'and') {
return this.where(field, '!=', value, logicalOperator);
}
/**
* Adds a NOT IN condition.
*/
notIn(field, values, logicalOperator = 'and') {
return this.where(field, 'not in', values, logicalOperator);
}
/**
* Configures which values should be skipped when adding conditions.
* @param options Configuration for value skipping behavior
* @returns The query builder instance for chaining
* @memberof QueryBuilder
*/
skipWhen(options) {
this.skipOptions = {
null: true,
undefined: true,
emptyString: true,
emptyArray: true,
nan: true,
emptyObject: false,
...options,
};
return this;
}
/**
* Adds a strict equals (===) condition.
*/
strictEquals(field, value, logicalOperator = 'and') {
return this.where(field, '===', value, logicalOperator);
}
/**
* Adds a strict not equals (!==) condition.
*/
strictNotEquals(field, value, logicalOperator = 'and') {
return this.where(field, '!==', value, logicalOperator);
}
/**
* Serializes the query builder's conditions to a JSON-compatible structure.
* @returns The serialized conditions array
* @memberof QueryBuilder
*/
toJSON() {
return this.conditions;
}
/**
* Converts the query builder's conditions to a human-readable string representation.
* @returns A string representation of the query conditions
* @memberof QueryBuilder
*/
/**
* Converts the query builder's conditions to a human-readable string representation.
* @param options Optional formatting options (arrayStyle: 'sql' | 'php')
* @returns A string representation of the query conditions
* @memberof QueryBuilder
*/
toString(options) {
return this.formatConditions(this.conditions, false, options?.arrayStyle || 'parens');
}
/**
* Adds a condition to the query builder.
* @param field The field/column to compare
* @param operator The comparison operator to use
* @param value The value to compare against (optional for some operators)
* @param logicalOperator The logical operator to combine with previous conditions (default: 'and')
* @returns The query builder instance for chaining
* @memberof QueryBuilder
*/
where(field, operator, value, logicalOperator = 'and') {
// Don't skip values for null operators (they don't need values)
const nullOps = ['is null', 'is not null', 'is empty', 'is not empty'];
const isNullOperator = nullOps.includes(operator);
if (!isNullOperator && this.shouldSkipValue(value)) {
return this;
}
if (this.conditions.length > 0) {
this.conditions.push(logicalOperator); // Insert logical operator between conditions
}
this.conditions.push({ field, operator, value });
return this;
}
/**
* Formats conditions into a string.
* @param conditions The conditions array to format
* @param isSubquery Whether this is formatting a nested subquery
* @returns Formatted string representation of the conditions
* @memberof QueryBuilder
*/
formatConditions(conditions, isSubquery = false, arrayStyle = 'parens') {
const result = [];
for (const cond of conditions) {
if (typeof cond === 'string') {
// This is a logical operator (and/or)
result.push(cond);
}
else if ('group' in cond) {
const groupStr = this.formatConditions(cond.group, true, arrayStyle);
if (groupStr) {
result.push(`(${groupStr})`);
}
}
else {
let conditionStr = `${cond.field} ${cond.operator}`;
// Handle null operators (no value needed)
const nullOps = ['is null', 'is not null', 'is empty', 'is not empty'];
const isNullOperator = nullOps.includes(cond.operator);
if (!isNullOperator && cond.value !== undefined) {
if (Array.isArray(cond.value) && this.isConditionArray(cond.value)) {
conditionStr += ` ${this.formatConditions(cond.value, true, arrayStyle)}`;
}
else if (Array.isArray(cond.value)) {
// Handle 'between' and 'not between' with array values
const rangeOps = ['between', 'not between'];
if (rangeOps.includes(cond.operator)) {
conditionStr += ` ${cond.value
.flat()
.map((v) => this.valueToString(v, arrayStyle))
.join(' and ')}`;
}
else {
// Use arrayStyle for array formatting
const arrStr = cond.value
.flat()
.map((v) => this.valueToString(v, arrayStyle))
.join(', ');
if (arrayStyle === 'brackets') {
conditionStr += ` [${arrStr}]`;
}
else {
conditionStr += ` (${arrStr})`;
}
}
}
else {
conditionStr += ` ${this.valueToString(cond.value, arrayStyle)}`;
}
}
else if (!isNullOperator) {
conditionStr += ` undefined`;
}
result.push(conditionStr);
}
}
let finalString = result.join(' ');
finalString = finalString.replace(/\(\((.*?)\)\)/g, '($1)');
return isSubquery ? `(${finalString})` : finalString;
}
/**
* Checks if a value is a condition array (QueryBuilderSerialized).
* @param value The value to check
* @returns True if the value is a condition array, false otherwise
* @memberof QueryBuilder
*/
isConditionArray(value) {
return (Array.isArray(value) && value.some((item) => typeof item === 'object' && item !== null && 'operator' in item));
}
/**
* Checks if a value should be skipped based on current skip options
* @param value The value to check
* @returns True if the value should be skipped
* @memberof QueryBuilder
*/
shouldSkipValue(value) {
if (!this.skipOptions)
return false;
if (value === null && this.skipOptions.null !== false) {
return true;
}
if (value === undefined && this.skipOptions.undefined !== false) {
return true;
}
if (typeof value === 'string' && value === '' && this.skipOptions.emptyString !== false) {
return true;
}
if (Array.isArray(value) && this.skipOptions.emptyArray !== false) {
if (value.length === 0 ||
value.every((item) => item === null || item === undefined || item === '' || (typeof item === 'number' && isNaN(item)))) {
return true;
}
}
if (typeof value === 'number' && isNaN(value) && this.skipOptions.nan !== false) {
return true;
}
if (typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
!('$fn' in value) && // Don't skip FunctionCall objects
Object.keys(value).length === 0 &&
this.skipOptions.emptyObject) {
return true;
}
return false;
}
/**
* Converts a value to its string representation for query formatting.
* @param value The value to convert
* @returns String representation of the value
* @memberof QueryBuilder
*/
valueToString(value, arrayStyle = 'parens') {
if (value === null) {
return 'null';
}
if (value === undefined) {
/* istanbul ignore next */
return 'undefined';
}
if (typeof value === 'string') {
return `'${value.replace(/'/g, "''")}'`;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value.toString();
}
if (Array.isArray(value)) {
/* istanbul ignore next */
const arrStr = value
.flat()
.map((v) => this.valueToString(v, arrayStyle))
.join(', ');
/* istanbul ignore next */
return arrayStyle === 'brackets' ? `[${arrStr}]` : `(${arrStr})`;
}
if (typeof value === 'object' && value !== null && '$fn' in value) {
const args = value.args?.map((arg) => this.valueToString(arg, arrayStyle)).join(', ') || '';
return `${value.$fn}(${args})`;
}
return JSON.stringify(value);
}
}
export { QueryBuilder };