sveltekit-sync
Version:
Local-first sync engine for SvelteKit
367 lines (366 loc) • 11.9 kB
JavaScript
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;
});
}
}