docudb
Version:
Document-based NoSQL database for NodeJS
333 lines • 12.3 kB
JavaScript
/**
* Query Module (MQL - DocuDB Query Language)
* Implements a simple and powerful query language for filtering documents
*/
import { MCO_ERROR, DocuDBError } from '../errors/errors.js';
class Query {
/** Query criteria */
criteria;
/** Sort options */
sortOptions;
/** Limit value */
limitValue;
/** Skip value */
skipValue;
/** Fields to select */
selectFields;
/**
* Creates a new query for filtering documents
* @param criteria - Search criteria using MongoDB-like query syntax
*/
constructor(criteria = {}) {
this.criteria = criteria;
this.sortOptions = null;
this.limitValue = null;
this.skipValue = 0;
this.selectFields = null;
}
/**
* Evaluates if a document matches the query criteria
* @param doc - Document to evaluate
* @returns true if the document matches the criteria
*/
matches(doc) {
return this._evaluateCriteria(doc, this.criteria);
}
/**
* Sorts results by specified fields
* @param sortBy - Fields and sort direction (1 ascending, -1 descending)
* @returns Current instance for chaining
*/
sort(sortBy) {
this.sortOptions = sortBy;
return this;
}
/**
* Limits the number of results
* @param n - Maximum number of results
* @returns Current instance for chaining
*/
limit(n) {
this.limitValue = n;
return this;
}
/**
* Skips a number of results
* @param n - Number of results to skip
* @returns Current instance for chaining
*/
skip(n) {
this.skipValue = n;
return this;
}
/**
* Selects specific fields to include in the results
* @param fields - Fields to include
* @returns Current instance for chaining
*/
select(fields) {
if (Array.isArray(fields)) {
const selectObj = {};
fields.forEach(field => (selectObj[field] = 1));
this.selectFields = selectObj;
}
else {
this.selectFields = fields;
}
return this;
}
/**
* Applies the query to a collection of documents
* @param documents - Documents to filter
* @returns Documents that match the criteria
*/
execute(documents) {
// Filter documents according to criteria
let results = [];
// Always use the matches method to evaluate criteria
// This allows handling complex queries with multiple levels of nested operators
results = documents.filter(doc => this.matches(doc));
// Apply sorting if defined
if (this.sortOptions != null) {
results = this._applySorting(results);
}
// Apply skip
if (this.skipValue > 0) {
results = results.slice(this.skipValue);
}
// Apply limit
if (this.limitValue !== null) {
results = results.slice(0, this.limitValue);
}
// Apply field projection
if (this.selectFields != null) {
results = this._applyProjection(results);
}
return results;
}
/**
* Recursively evaluates query criteria
* @param {Object} doc - Document to evaluate
* @param {QueryCriteria} criteria - Query criteria
* @returns {boolean} - true if the document matches the criteria
* @private
*/
_evaluateCriteria(doc, criteria = {}) {
// If criteria is null or undefined, always matches
if (criteria == null)
return true;
// For each key in the criteria
for (const key in criteria) {
// Special operators
if (key.startsWith('$')) {
switch (key) {
case '$and':
if (!Array.isArray(criteria[key])) {
return false;
}
// Verify that all criteria are met
for (const subCriteria of criteria[key]) {
if (!this._evaluateCriteria(doc, subCriteria)) {
return false;
}
}
continue; // Continue with next criteria after evaluating $and
case '$or':
if (!Array.isArray(criteria[key]) ||
!criteria[key].some(c => this._evaluateCriteria(doc, c))) {
return false;
}
break;
case '$not':
if (this._evaluateCriteria(doc, criteria[key])) {
return false;
}
break;
default:
throw new DocuDBError('Invalid query operator', MCO_ERROR.QUERY.INVALID_OPERATOR, { operator: key });
}
}
else {
// Get field value from document
const value = this._getNestedValue(doc, key);
// If criteria is an object, it may contain operators
if (criteria[key] !== null && typeof criteria[key] === 'object') {
// Check each operator in criteria
for (const op in criteria[key]) {
if (!this._evaluateOperator(op, value, criteria[key][op])) {
return false;
}
}
}
else if (!this._equals(value, criteria[key])) {
return false;
}
}
}
return true;
}
/**
* Evaluates a specific operator
* @param {string} operator - Operator to evaluate
* @param {*} docValue - Document value
* @param {*} criteriaValue - Criteria value
* @returns {boolean} - true if the operator condition is met
* @private
*/
_evaluateOperator(operator, docValue, criteriaValue) {
switch (operator) {
case '$eq':
return this._equals(docValue, criteriaValue);
case '$ne':
return !this._equals(docValue, criteriaValue);
case '$gt':
return docValue > criteriaValue;
case '$gte':
return docValue >= criteriaValue;
case '$lt':
return docValue < criteriaValue;
case '$lte':
return docValue <= criteriaValue;
case '$in':
if (Array.isArray(criteriaValue)) {
// If docValue is an array, check if any element matches any criteria value
if (Array.isArray(docValue)) {
return docValue.some(dv => criteriaValue.some(cv => this._equals(dv, cv)));
}
// If docValue is not an array, check if it matches any criteria value
return criteriaValue.some(v => this._equals(docValue, v));
}
return false;
case '$nin':
if (Array.isArray(criteriaValue)) {
// If docValue is an array, verify that no element matches any criteria value
if (Array.isArray(docValue)) {
return !docValue.some(dv => criteriaValue.some(cv => this._equals(dv, cv)));
}
// If docValue is not an array, verify it doesn't match any criteria value
return !criteriaValue.some(v => this._equals(docValue, v));
}
return true;
case '$exists':
return criteriaValue !== undefined ? docValue !== undefined : docValue === undefined;
case '$regex': {
if (typeof docValue !== 'string')
return false;
const flags = criteriaValue.$options ?? '';
const pattern = criteriaValue instanceof RegExp
? criteriaValue
: new RegExp(criteriaValue, flags);
return pattern.test(docValue);
}
case '$size':
return Array.isArray(docValue) && docValue.length === criteriaValue;
case '$all':
return (Array.isArray(docValue) &&
Array.isArray(criteriaValue) &&
criteriaValue.every(v => docValue.some(dv => this._equals(dv, v))));
default:
throw new DocuDBError('Invalid query operator', MCO_ERROR.QUERY.INVALID_OPERATOR, { operator });
}
}
/**
* Compares two values to determine if they are equal
* @param {*} a - First value
* @param {*} b - Second value
* @returns {boolean} - true if values are equal
* @private
*/
_equals(a, b) {
if (a === b)
return true;
// Date comparison
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// Object comparison
if (a !== null &&
b !== null &&
typeof a === 'object' &&
typeof b === 'object') {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length)
return false;
return keysA.every(key => keysB.includes(key) && this._equals(a[key], b[key]));
}
return false;
}
/**
* Gets a nested value from an object using dot notation
* @param {Object} obj - Object to get value from
* @param {string} path - Path to value using dot notation
* @returns {*} - Found value or undefined
* @private
*/
_getNestedValue(obj, path) {
if (obj === undefined || path === undefined)
return undefined;
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined)
return undefined;
current = current[part];
}
return current;
}
/**
* Applies sorting to results
* @param {Array} results - Results to sort
* @returns {Array} - Sorted results
* @private
*/
_applySorting(results) {
if (this.sortOptions == null)
return results;
return [...results].sort((a, b) => {
for (const [field, direction] of Object.entries(this.sortOptions)) {
const valueA = this._getNestedValue(a, field);
const valueB = this._getNestedValue(b, field);
// Compare values
if (valueA === undefined || valueB === undefined)
return 0;
if (valueA < valueB)
return -1 * direction;
if (valueA > valueB)
return 1 * direction;
}
return 0;
});
}
/**
* Applies field projection to results
* @param {Array} results - Results to project
* @returns {Array} - Results with projection applied
* @private
*/
_applyProjection(results) {
return results.map(doc => {
const projected = {};
for (const [field, include] of Object.entries(this.selectFields)) {
if (!Number.isNaN(include)) {
const value = this._getNestedValue(doc, field);
if (value !== undefined) {
// Handle nested fields
const parts = field.split('.');
let current = projected;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current[part] === undefined)
current[part] = {};
current = current[part];
}
current[parts[parts.length - 1]] = value;
}
}
}
return projected;
});
}
}
export default Query;
//# sourceMappingURL=query.js.map