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.

217 lines (216 loc) 8.26 kB
/** * Query builder for constructing complex conditional expressions. * Supports nested groups, various operators, and function calls. * @author Andreas Nicolaou <anicolaou66@gmail.com> */ export class QueryBuilder { conditions = []; skipOptions; /** * 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 }; } /** * 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; } /** * 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; } /** * 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 */ toString() { return this.formatConditions(this.conditions); } /** * 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') { if (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) { const result = []; let previousWasOperator = false; for (const cond of conditions) { if (typeof cond === 'string') { if (!previousWasOperator) { result.push(cond); } previousWasOperator = true; } else if ('group' in cond) { const groupStr = this.formatConditions(cond.group, true); if (groupStr) { result.push(`(${groupStr})`); } previousWasOperator = false; } else { if (result.length > 0 && !previousWasOperator) { result.push('and'); } let conditionStr = `${cond.field} ${cond.operator}`; if (cond.value !== undefined) { if (Array.isArray(cond.value) && this.isConditionArray(cond.value)) { conditionStr += ` ${this.formatConditions(cond.value, true)}`; } else if (Array.isArray(cond.value)) { conditionStr += ` (${cond.value.map((v) => this.valueToString(v)).join(', ')})`; } else { conditionStr += ` ${this.valueToString(cond.value)}`; } } else { conditionStr += ` undefined`; } result.push(conditionStr); previousWasOperator = false; } } 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) { if (value === null) { return 'null'; } if (value === undefined) { return 'undefined'; } if (typeof value === 'string') { return `'${value.replace(/'/g, "''")}'`; } if (typeof value === 'number' || typeof value === 'boolean') { return value.toString(); } if (Array.isArray(value)) { return value.map((v) => this.valueToString(v)).join(', '); } if (typeof value === 'object' && value !== null && '$fn' in value) { const args = value.args?.map((arg) => this.valueToString(arg)).join(', ') || ''; return `${value.$fn}(${args})`; } return JSON.stringify(value); } }