UNPKG

@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
/** * 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 };