adonis-odm
Version:
A comprehensive MongoDB ODM for AdonisJS with Lucid-style API, type-safe relationships, embedded documents, and transaction support
695 lines (694 loc) • 22.8 kB
JavaScript
/**
* Advanced query builder for embedded documents with full type safety
*
* Supports:
* - Complex filtering and searching
* - Pagination for large datasets
* - Multiple sorting criteria
* - Performance optimizations
* - Aggregation operations
* - All methods from ModelQueryBuilder for consistency
*/
export class EmbeddedQueryBuilder {
items;
filters = [];
orFilters = [];
sortOptions = [];
limitValue;
skipValue;
selectFields;
searchTerm;
searchFields;
constructor(items) {
this.items = items;
}
where(field, operatorOrValue, value) {
let operator;
let actualValue;
if (arguments.length === 2) {
// where(field, value) - equals comparison
operator = '=';
actualValue = operatorOrValue;
}
else {
// where(field, operator, value) - operator comparison
operator = operatorOrValue;
actualValue = value;
}
this.filters.push((item) => {
const fieldValue = item[field];
switch (operator) {
case '=':
case '==':
return fieldValue === actualValue;
case '!=':
case '<>':
return fieldValue !== actualValue;
case '>':
return actualValue !== undefined && actualValue !== null && fieldValue > actualValue;
case '>=':
return actualValue !== undefined && actualValue !== null && fieldValue >= actualValue;
case '<':
return actualValue !== undefined && actualValue !== null && fieldValue < actualValue;
case '<=':
return actualValue !== undefined && actualValue !== null && fieldValue <= actualValue;
case 'like':
return (typeof fieldValue === 'string' &&
typeof actualValue === 'string' &&
fieldValue.includes(actualValue));
case 'in':
return Array.isArray(actualValue) && actualValue.includes(fieldValue);
case 'not in':
return Array.isArray(actualValue) && !actualValue.includes(fieldValue);
default:
return fieldValue === actualValue;
}
});
return this;
}
andWhere(field, operatorOrValue, value) {
if (arguments.length === 2) {
return this.where(field, operatorOrValue);
}
else {
return this.where(field, operatorOrValue, value);
}
}
orWhere(field, operatorOrValue, value) {
let operator;
let actualValue;
if (arguments.length === 2) {
// orWhere(field, value) - equals comparison
operator = '=';
actualValue = operatorOrValue;
}
else {
// orWhere(field, operator, value)
operator = operatorOrValue;
actualValue = value;
}
this.orFilters.push((item) => {
const fieldValue = item[field];
switch (operator) {
case '=':
case '==':
return fieldValue === actualValue;
case '!=':
case '<>':
return fieldValue !== actualValue;
case '>':
return actualValue !== undefined && actualValue !== null && fieldValue > actualValue;
case '>=':
return actualValue !== undefined && actualValue !== null && fieldValue >= actualValue;
case '<':
return actualValue !== undefined && actualValue !== null && fieldValue < actualValue;
case '<=':
return actualValue !== undefined && actualValue !== null && fieldValue <= actualValue;
case 'like':
return (typeof fieldValue === 'string' &&
typeof actualValue === 'string' &&
fieldValue.includes(actualValue));
case 'in':
return Array.isArray(actualValue) && actualValue.includes(fieldValue);
case 'not in':
return Array.isArray(actualValue) && !actualValue.includes(fieldValue);
default:
return fieldValue === actualValue;
}
});
return this;
}
whereNot(field, operatorOrValue, value) {
let operator;
let actualValue;
if (arguments.length === 2) {
// whereNot(field, value) - not equals comparison
operator = '!=';
actualValue = operatorOrValue;
}
else {
// whereNot(field, operator, value)
operator = operatorOrValue;
actualValue = value;
}
this.filters.push((item) => {
const fieldValue = item[field];
switch (operator) {
case '=':
case '==':
return fieldValue !== actualValue;
case '!=':
case '<>':
return fieldValue === actualValue;
case '>':
return actualValue === undefined || actualValue === null || fieldValue <= actualValue;
case '>=':
return actualValue === undefined || actualValue === null || fieldValue < actualValue;
case '<':
return actualValue === undefined || actualValue === null || fieldValue >= actualValue;
case '<=':
return actualValue === undefined || actualValue === null || fieldValue > actualValue;
case 'like':
return !(typeof fieldValue === 'string' &&
typeof actualValue === 'string' &&
fieldValue.includes(actualValue));
case 'in':
return !(Array.isArray(actualValue) && actualValue.includes(fieldValue));
case 'not in':
return Array.isArray(actualValue) && actualValue.includes(fieldValue);
default:
return fieldValue !== actualValue;
}
});
return this;
}
andWhereNot(field, operatorOrValue, value) {
return this.whereNot(field, operatorOrValue, value);
}
orWhereNot(field, operatorOrValue, value) {
let operator = operatorOrValue;
if (arguments.length === 2) {
// orWhereNot(field, value) - not equals comparison
value = operatorOrValue;
operator = '!=';
}
this.orFilters.push((item) => {
const fieldValue = item[field];
switch (operator) {
case '=':
case '==':
return fieldValue !== value;
case '!=':
case '<>':
return fieldValue === value;
case '>':
return value === undefined || value === null || fieldValue <= value;
case '>=':
return value === undefined || value === null || fieldValue < value;
case '<':
return value === undefined || value === null || fieldValue >= value;
case '<=':
return value === undefined || value === null || fieldValue > value;
case 'like':
return !(typeof fieldValue === 'string' &&
typeof value === 'string' &&
fieldValue.includes(value));
case 'in':
if (!Array.isArray(value) || fieldValue === undefined)
return true;
return !value.includes(fieldValue);
case 'not in':
if (!Array.isArray(value) || fieldValue === undefined)
return false;
return value.includes(fieldValue);
default:
return fieldValue !== value;
}
});
return this;
}
/**
* Add a where like condition (case-sensitive)
*/
whereLike(field, value) {
this.filters.push((item) => {
const fieldValue = item[field];
if (typeof fieldValue !== 'string')
return false;
const pattern = value.replace(/%/g, '.*');
const regex = new RegExp(pattern);
return regex.test(fieldValue);
});
return this;
}
/**
* Add a where ilike condition (case-insensitive)
*/
whereILike(field, value) {
this.filters.push((item) => {
const fieldValue = item[field];
if (typeof fieldValue !== 'string')
return false;
const pattern = value.replace(/%/g, '.*');
const regex = new RegExp(pattern, 'i');
return regex.test(fieldValue);
});
return this;
}
/**
* Add a where in condition
*/
whereIn(field, values) {
this.filters.push((item) => {
const fieldValue = item[field];
return values.includes(fieldValue);
});
return this;
}
/**
* Add an OR where in condition
*/
orWhereIn(field, values) {
this.orFilters.push((item) => {
const fieldValue = item[field];
return values.includes(fieldValue);
});
return this;
}
/**
* Add a where not in condition
*/
whereNotIn(field, values) {
this.filters.push((item) => {
const fieldValue = item[field];
return !values.includes(fieldValue);
});
return this;
}
/**
* Add an OR where not in condition
*/
orWhereNotIn(field, values) {
this.orFilters.push((item) => {
const fieldValue = item[field];
return !values.includes(fieldValue);
});
return this;
}
/**
* Add a where between condition
*/
whereBetween(field, range) {
this.filters.push((item) => {
const fieldValue = item[field];
return fieldValue >= range[0] && fieldValue <= range[1];
});
return this;
}
/**
* Add an OR where between condition
*/
orWhereBetween(field, range) {
this.orFilters.push((item) => {
const fieldValue = item[field];
return fieldValue >= range[0] && fieldValue <= range[1];
});
return this;
}
/**
* Add a where not between condition
*/
whereNotBetween(field, range) {
this.filters.push((item) => {
const fieldValue = item[field];
return fieldValue < range[0] || fieldValue > range[1];
});
return this;
}
/**
* Add an OR where not between condition
*/
orWhereNotBetween(field, range) {
this.orFilters.push((item) => {
const fieldValue = item[field];
return fieldValue < range[0] || fieldValue > range[1];
});
return this;
}
/**
* Filter embedded documents where field is not null
*/
whereNotNull(field) {
this.filters.push((item) => {
const fieldValue = item[field];
return fieldValue !== null && fieldValue !== undefined;
});
return this;
}
/**
* Add an OR where not null condition
*/
orWhereNotNull(field) {
this.orFilters.push((item) => {
const fieldValue = item[field];
return fieldValue !== null && fieldValue !== undefined;
});
return this;
}
/**
* Filter embedded documents where field is null
*/
whereNull(field) {
this.filters.push((item) => {
const fieldValue = item[field];
return fieldValue === null || fieldValue === undefined;
});
return this;
}
/**
* Add an OR where null condition
*/
orWhereNull(field) {
this.orFilters.push((item) => {
const fieldValue = item[field];
return fieldValue === null || fieldValue === undefined;
});
return this;
}
/**
* Add a where exists condition
*/
whereExists(field) {
this.filters.push((item) => {
return field in item;
});
return this;
}
/**
* Add an OR where exists condition
*/
orWhereExists(field) {
this.orFilters.push((item) => {
return field in item;
});
return this;
}
/**
* Add a where not exists condition
*/
whereNotExists(field) {
this.filters.push((item) => {
return !(field in item);
});
return this;
}
/**
* Add an OR where not exists condition
*/
orWhereNotExists(field) {
this.orFilters.push((item) => {
return !(field in item);
});
return this;
}
/**
* Sort embedded documents with type safety
*/
orderBy(field, direction = 'asc') {
this.sortOptions.push({ field, direction });
return this;
}
/**
* Limit the number of results
*/
limit(count) {
this.limitValue = count;
return this;
}
/**
* Skip a number of results
*/
skip(count) {
this.skipValue = count;
return this;
}
/**
* Alias for skip - for pagination compatibility
*/
offset(count) {
return this.skip(count);
}
/**
* Set pagination using page and perPage
*/
forPage(page, perPage) {
const skip = (page - 1) * perPage;
return this.skip(skip).limit(perPage);
}
/**
* Select specific fields to return (for performance with large objects)
*/
select(...fields) {
this.selectFields = fields;
return this;
}
/**
* Search across multiple fields with a single term
*/
search(term, fields) {
this.searchTerm = term;
if (fields) {
this.searchFields = fields;
}
return this;
}
/**
* Add multiple where conditions with AND logic
*/
whereAll(conditions) {
conditions.forEach(({ field, operator, value }) => {
this.where(field, operator, value);
});
return this;
}
/**
* Add multiple where conditions with OR logic
*/
whereAny(conditions) {
this.orFilters.push((item) => {
return conditions.some(({ field, operator, value }) => {
const fieldValue = item[field];
switch (operator) {
case '=':
case '==':
return fieldValue === value;
case '!=':
case '<>':
return fieldValue !== value;
case '>':
return value !== undefined && value !== null && fieldValue > value;
case '>=':
return value !== undefined && value !== null && fieldValue >= value;
case '<':
return value !== undefined && value !== null && fieldValue < value;
case '<=':
return value !== undefined && value !== null && fieldValue <= value;
case 'like':
return (typeof fieldValue === 'string' &&
typeof value === 'string' &&
fieldValue.toLowerCase().includes(value.toLowerCase()));
case 'in':
return Array.isArray(value) && value.includes(fieldValue);
case 'not in':
return Array.isArray(value) && !value.includes(fieldValue);
default:
return fieldValue === value;
}
});
});
return this;
}
/**
* Filter by date range
*/
whereDateBetween(field, startDate, endDate) {
this.filters.push((item) => {
const fieldValue = item[field];
if (!fieldValue)
return false;
const itemDate = fieldValue instanceof Date ? fieldValue : new Date(fieldValue);
const start = startDate instanceof Date ? startDate : new Date(startDate);
const end = endDate instanceof Date ? endDate : new Date(endDate);
return itemDate >= start && itemDate <= end;
});
return this;
}
/**
* Filter by array contains
*/
whereArrayContains(field, value) {
this.filters.push((item) => {
const fieldValue = item[field];
return Array.isArray(fieldValue) && fieldValue.includes(value);
});
return this;
}
/**
* Filter by regex pattern
*/
whereRegex(field, pattern, flags) {
this.filters.push((item) => {
const fieldValue = item[field];
if (typeof fieldValue !== 'string')
return false;
const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern, flags);
return regex.test(fieldValue);
});
return this;
}
/**
* Execute the query and return filtered results
*/
get() {
let results = [...this.items];
// Apply search filter if specified
if (this.searchTerm) {
const searchLower = this.searchTerm.toLowerCase();
results = results.filter((item) => {
const fieldsToSearch = this.searchFields || Object.keys(item);
return fieldsToSearch.some((field) => {
const value = item[field];
if (typeof value === 'string') {
return value.toLowerCase().includes(searchLower);
}
if (typeof value === 'number') {
return value.toString().includes(this.searchTerm);
}
return false;
});
});
}
// Apply AND filters
for (const filter of this.filters) {
results = results.filter(filter);
}
// Apply OR filters (if any item passes any OR filter, include it)
if (this.orFilters.length > 0) {
results = results.filter((item) => {
return this.orFilters.some((orFilter) => orFilter(item));
});
}
// Apply sorting
for (const { field, direction } of this.sortOptions) {
results.sort((a, b) => {
const aValue = a[field];
const bValue = b[field];
if (aValue < bValue)
return direction === 'asc' ? -1 : 1;
if (aValue > bValue)
return direction === 'asc' ? 1 : -1;
return 0;
});
}
// Apply skip
if (this.skipValue) {
results = results.slice(this.skipValue);
}
// Apply limit
if (this.limitValue) {
results = results.slice(0, this.limitValue);
}
// Apply field selection if specified
if (this.selectFields) {
results = results.map((item) => {
const selected = {};
this.selectFields.forEach((field) => {
;
selected[field] = item[field];
});
return selected;
});
}
return results;
}
/**
* Get the first result
*/
first() {
const results = this.get();
return results.length > 0 ? results[0] : null;
}
/**
* Get the count of results
*/
count() {
return this.get().length;
}
/**
* Check if any results exist
*/
exists() {
return this.count() > 0;
}
/**
* Get paginated results with metadata
*/
paginate(page, perPage) {
// Get total count before pagination
const total = this.count();
const totalPages = Math.ceil(total / perPage);
// Apply pagination
const data = this.forPage(page, perPage).get();
return {
data,
pagination: {
currentPage: page,
perPage,
total,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
},
};
}
/**
* Get distinct values for a field
*/
distinct(field) {
const values = this.get().map((item) => item[field]);
return [...new Set(values)];
}
/**
* Group results by a field value
*/
groupBy(field) {
const results = this.get();
const groups = new Map();
results.forEach((item) => {
const key = item[field];
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(item);
});
return groups;
}
/**
* Get aggregated statistics for numeric fields
*/
aggregate(field) {
const allValues = this.get().map((item) => item[field]);
const numericValues = allValues.filter((value) => typeof value === 'number');
if (numericValues.length === 0) {
return null;
}
const sum = numericValues.reduce((acc, val) => acc + val, 0);
const count = numericValues.length;
const avg = sum / count;
const min = Math.min(...numericValues);
const max = Math.max(...numericValues);
return { count, sum, avg, min, max };
}
/**
* Execute a custom callback on the results
*/
tap(callback) {
callback(this.get());
return this;
}
/**
* Clone the query builder for reuse
*/
clone() {
const cloned = new EmbeddedQueryBuilder(this.items);
cloned.filters = [...this.filters];
cloned.orFilters = [...this.orFilters];
cloned.sortOptions = [...this.sortOptions];
cloned.limitValue = this.limitValue;
cloned.skipValue = this.skipValue;
cloned.selectFields = this.selectFields ? [...this.selectFields] : undefined;
cloned.searchTerm = this.searchTerm;
cloned.searchFields = this.searchFields ? [...this.searchFields] : undefined;
return cloned;
}
}