UNPKG

sveltekit-sync

Version:
367 lines (366 loc) 11.9 kB
import { isOperator, OPERATOR_SYMBOL } from './operators.js'; import { isFieldCondition, isOrderByCondition, createFieldsProxy } from './field-proxy.js'; export class QueryBuilder { collection; conditions = []; orderByClauses = []; limitCount = null; offsetCount = 0; fieldsProxy; constructor(collection) { this.collection = collection; this.fieldsProxy = createFieldsProxy(); } /** * Add a where condition - supports multiple syntaxes: * * 1. Callback: .where(todo => todo.completed === false) * 2. Object: .where({ completed: false }) * 3. Object with operators: .where({ priority: gte(5) }) * 4. Proxy callback: .where(f => f.completed.eq(false)) * 5. Field condition: .where(fields.completed.eq(false)) */ where(condition) { const type = this.detectConditionType(condition); this.conditions.push({ type, condition }); return this; } /** * Add multiple AND conditions at once */ whereAll(...conditions) { for (const condition of conditions) { this.where(condition); } return this; } /** * Add an OR condition group */ orWhere(...conditions) { // Import dynamically to avoid circular deps const orCondition = { [OPERATOR_SYMBOL]: true, type: 'or', conditions: conditions }; this.conditions.push({ type: 'logical', condition: orCondition }); return this; } /** * Order by field - supports multiple syntaxes: * * 1. String: .orderBy('createdAt', 'desc') * 2. Callback: .orderBy(f => f.createdAt.desc()) * 3. Condition: .orderBy(fields.createdAt.desc()) */ orderBy(input, direction = 'asc') { if (typeof input === 'string') { this.orderByClauses.push({ field: input, direction }); } else if (typeof input === 'function') { const result = input(this.fieldsProxy); if (isOrderByCondition(result)) { this.orderByClauses.push({ field: result.fieldName, direction: result.direction }); } } else if (isOrderByCondition(input)) { this.orderByClauses.push({ field: input.fieldName, direction: input.direction }); } return this; } /** * Limit results */ limit(count) { this.limitCount = count; return this; } /** * Skip results (for pagination) */ offset(count) { this.offsetCount = count; return this; } /** * Alias for offset */ skip(count) { return this.offset(count); } /** * Execute query and return results */ async get() { let results = [...this.collection.data]; // Apply where conditions results = this.applyConditions(results); // Apply ordering results = this.applyOrdering(results); // Apply offset if (this.offsetCount > 0) { results = results.slice(this.offsetCount); } // Apply limit if (this.limitCount !== null) { results = results.slice(0, this.limitCount); } return results; } /** * Get first matching result */ async first() { const results = await this.clone().limit(1).get(); return results[0] ?? null; } /** * Get last matching result */ async last() { const results = await this.get(); return results[results.length - 1] ?? null; } /** * Count matching results */ async count() { const results = this.applyConditions([...this.collection.data]); return results.length; } /** * Check if any results match */ async exists() { const first = await this.first(); return first !== null; } /** * Paginate results */ async paginate(page, perPage = 10) { const allFiltered = this.applyConditions([...this.collection.data]); const total = allFiltered.length; const totalPages = Math.ceil(total / perPage); const data = await this .offset((page - 1) * perPage) .limit(perPage) .get(); return { data, total, page, perPage, totalPages, hasMore: page < totalPages }; } /** * Delete all matching records */ async delete() { const results = await this.get(); for (const item of results) { await this.collection.delete(item.id); } return results.length; } /** * Update all matching records */ async update(data) { const results = await this.get(); for (const item of results) { await this.collection.update(item.id, data); } return results.length; } /** * Get IDs of matching records */ async pluck(field) { const results = await this.get(); return results.map(item => item[field]); } // Aggregate methods async sum(field) { const results = await this.get(); return results.reduce((acc, item) => acc + (Number(item[field]) || 0), 0); } async avg(field) { const results = await this.get(); if (results.length === 0) return 0; const sum = results.reduce((acc, item) => acc + (Number(item[field]) || 0), 0); return sum / results.length; } async min(field) { const results = await this.clone().orderBy(field, 'asc').limit(1).get(); return results[0]?.[field] ?? null; } async max(field) { const results = await this.clone().orderBy(field, 'desc').limit(1).get(); return results[0]?.[field] ?? null; } // Private methods clone() { const newBuilder = new QueryBuilder(this.collection); newBuilder.conditions = [...this.conditions]; newBuilder.orderByClauses = [...this.orderByClauses]; newBuilder.limitCount = this.limitCount; newBuilder.offsetCount = this.offsetCount; return newBuilder; } detectConditionType(condition) { if (typeof condition === 'function') { // Try to detect if it's a proxy callback or item callback // by checking if it uses the fields proxy try { const testResult = condition(this.fieldsProxy); if (isFieldCondition(testResult)) { return 'field'; } } catch { // It's a regular callback } return 'callback'; } if (isFieldCondition(condition)) { return 'field'; } if (this.isLogicalOperator(condition)) { return 'logical'; } return 'object'; } isLogicalOperator(value) { return value !== null && typeof value === 'object' && OPERATOR_SYMBOL in value && 'conditions' in value; } applyConditions(items) { return items.filter(item => { return this.conditions.every(stored => this.evaluateCondition(item, stored)); }); } evaluateCondition(item, stored) { const { type, condition } = stored; switch (type) { case 'callback': // Direct callback: (item) => item.completed === false return condition(item); case 'field': // Proxy callback or direct field condition if (typeof condition === 'function') { const fieldCond = condition(this.fieldsProxy); return this.evaluateFieldCondition(item, fieldCond); } return this.evaluateFieldCondition(item, condition); case 'object': // Object syntax: { completed: false, priority: gte(5) } return this.evaluateObjectCondition(item, condition); case 'logical': return this.evaluateLogicalOperator(item, condition); default: return true; } } evaluateFieldCondition(item, fieldCond) { const itemValue = item[fieldCond.fieldName]; return this.evaluateOperator(itemValue, fieldCond.operator); } evaluateObjectCondition(item, obj) { for (const [key, value] of Object.entries(obj)) { const itemValue = item[key]; if (isOperator(value)) { if (!this.evaluateOperator(itemValue, value)) { return false; } } else { // Direct equality if (itemValue !== value) { return false; } } } return true; } evaluateLogicalOperator(item, logical) { switch (logical.type) { case 'and': return logical.conditions.every(cond => this.evaluateCondition(item, { type: this.detectConditionType(cond), condition: cond })); case 'or': return logical.conditions.some(cond => this.evaluateCondition(item, { type: this.detectConditionType(cond), condition: cond })); case 'not': return !this.evaluateCondition(item, { type: this.detectConditionType(logical.conditions[0]), condition: logical.conditions[0] }); default: return true; } } evaluateOperator(itemValue, operator) { const { type, value } = operator; switch (type) { case 'eq': return itemValue === value; case 'ne': return itemValue !== value; case 'gt': return itemValue > value; case 'gte': return itemValue >= value; case 'lt': return itemValue < value; case 'lte': return itemValue <= value; case 'in': return Array.isArray(value) && value.includes(itemValue); case 'notIn': return Array.isArray(value) && !value.includes(itemValue); case 'contains': if (typeof itemValue === 'string') { return itemValue.includes(value); } if (Array.isArray(itemValue)) { return itemValue.includes(value); } return false; case 'startsWith': return typeof itemValue === 'string' && itemValue.startsWith(value); case 'endsWith': return typeof itemValue === 'string' && itemValue.endsWith(value); case 'between': const [min, max] = value; return itemValue >= min && itemValue <= max; case 'isNull': return itemValue === null || itemValue === undefined; case 'isNotNull': return itemValue !== null && itemValue !== undefined; default: return true; } } applyOrdering(items) { if (this.orderByClauses.length === 0) { return items; } return [...items].sort((a, b) => { for (const { field, direction } of this.orderByClauses) { const aVal = a[field]; const bVal = b[field]; let comparison = 0; if (aVal < bVal) comparison = -1; else if (aVal > bVal) comparison = 1; if (comparison !== 0) { return direction === 'desc' ? -comparison : comparison; } } return 0; }); } }