jexidb
Version:
JexiDB is a pure JS NPM library for managing data on disk efficiently, without the need for a server.
1,311 lines (1,133 loc) • 61.4 kB
JavaScript
import { normalizeOperator } from '../utils/operatorNormalizer.mjs'
import { promises as fs } from 'fs'
/**
* QueryManager - Handles all query operations and strategies
*
* Responsibilities:
* - find(), findOne(), count(), query()
* - findWithStreaming(), findWithIndexed()
* - matchesCriteria(), extractQueryFields()
* - Query strategies (INDEXED vs STREAMING)
* - Result estimation
*/
export class QueryManager {
constructor(database) {
this.database = database
this.opts = database.opts
this.indexManager = database.indexManager
this.fileHandler = database.fileHandler
this.serializer = database.serializer
this.usageStats = database.usageStats || {
totalQueries: 0,
indexedQueries: 0,
streamingQueries: 0,
indexedAverageTime: 0,
streamingAverageTime: 0
}
}
/**
* Main find method with strategy selection
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {Promise<Array>} - Query results
*/
async find(criteria, options = {}) {
if (this.database.destroyed) throw new Error('Database is destroyed')
if (!this.database.initialized) await this.database.init()
// Rebuild indexes if needed (when index was corrupted/missing)
await this.database._rebuildIndexesIfNeeded()
// Manual save is now the responsibility of the application
// Preprocess query to handle array field syntax automatically
const processedCriteria = this.preprocessQuery(criteria)
const finalCriteria = processedCriteria
// Validate strict indexed mode before processing
if (this.opts.indexedQueryMode === 'strict') {
this.validateStrictQuery(finalCriteria, options);
}
const startTime = Date.now();
this.usageStats.totalQueries++;
try {
// Decide which strategy to use
const strategy = this.shouldUseStreaming(finalCriteria, options);
let results = [];
if (strategy === 'streaming') {
results = await this.findWithStreaming(finalCriteria, options);
this.usageStats.streamingQueries++;
this.updateAverageTime('streaming', Date.now() - startTime);
} else {
results = await this.findWithIndexed(finalCriteria, options);
this.usageStats.indexedQueries++;
this.updateAverageTime('indexed', Date.now() - startTime);
}
if (this.opts.debugMode) {
const time = Date.now() - startTime;
console.log(`⏱️ Query completed in ${time}ms using ${strategy} strategy`);
console.log(`📊 Results: ${results.length} records`);
console.log(`📊 Results type: ${typeof results}, isArray: ${Array.isArray(results)}`);
}
return results;
} catch (error) {
if (this.opts.debugMode) {
console.error('❌ Query failed:', error);
}
throw error;
}
}
/**
* Find one record
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {Promise<Object|null>} - First matching record or null
*/
async findOne(criteria, options = {}) {
if (this.database.destroyed) throw new Error('Database is destroyed')
if (!this.database.initialized) await this.database.init()
// Manual save is now the responsibility of the application
// Preprocess query to handle array field syntax automatically
const processedCriteria = this.preprocessQuery(criteria)
// Validate strict indexed mode before processing
if (this.opts.indexedQueryMode === 'strict') {
this.validateStrictQuery(processedCriteria, options);
}
const startTime = Date.now();
this.usageStats.totalQueries++;
try {
// Decide which strategy to use
const strategy = this.shouldUseStreaming(processedCriteria, options);
let results = [];
if (strategy === 'streaming') {
results = await this.findWithStreaming(processedCriteria, { ...options, limit: 1 });
this.usageStats.streamingQueries++;
this.updateAverageTime('streaming', Date.now() - startTime);
} else {
results = await this.findWithIndexed(processedCriteria, { ...options, limit: 1 });
this.usageStats.indexedQueries++;
this.updateAverageTime('indexed', Date.now() - startTime);
}
if (this.opts.debugMode) {
const time = Date.now() - startTime;
console.log(`⏱️ findOne completed in ${time}ms using ${strategy} strategy`);
console.log(`📊 Results: ${results.length} record(s)`);
}
// Return the first result or null if no results found
return results.length > 0 ? results[0] : null;
} catch (error) {
if (this.opts.debugMode) {
console.error('❌ findOne failed:', error);
}
throw error;
}
}
/**
* Count records matching criteria
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {Promise<number>} - Count of matching records
*/
async count(criteria, options = {}) {
if (this.database.destroyed) throw new Error('Database is destroyed')
if (!this.database.initialized) await this.database.init()
// Rebuild indexes if needed (when index was corrupted/missing)
await this.database._rebuildIndexesIfNeeded()
// Manual save is now the responsibility of the application
// Validate strict indexed mode before processing
if (this.opts.indexedQueryMode === 'strict') {
this.validateStrictQuery(criteria, options);
}
// Use the same strategy as find method
const strategy = this.shouldUseStreaming(criteria, options);
let count = 0;
if (strategy === 'streaming') {
// Use streaming approach for non-indexed fields or large result sets
const results = await this.findWithStreaming(criteria, options);
count = results.length;
} else {
try {
await this.database._ensureLazyIndexLoaded()
} catch (error) {
if (this.opts.debugMode) {
console.log('⚠️ _ensureLazyIndexLoaded failed in count, falling back to streaming', error.message || error)
}
const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
return streamingResults.length
}
// This avoids reading actual records from the file - much faster!
const lineNumbers = this.indexManager.query(criteria, options);
if (lineNumbers.size === 0) {
const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
// Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
if (this.database.opts.allowIndexRebuild) {
if (this.opts.debugMode) {
console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`);
}
this.database._indexRebuildNeeded = true
await this.database._rebuildIndexesIfNeeded()
// Retry indexed query after rebuild
const retryLineNumbers = this.indexManager.query(criteria, options)
if (retryLineNumbers.size > 0) {
if (this.opts.debugMode) {
console.log(`✅ Index rebuild successful, using indexed strategy.`);
}
count = retryLineNumbers.size
} else {
// Still no results after rebuild, fall back to streaming
if (this.opts.debugMode) {
console.log(`⚠️ Index rebuild did not help, falling back to streaming count.`);
}
const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
count = streamingResults.length
}
} else {
// allowIndexRebuild is false, fall back to streaming
if (this.opts.debugMode) {
console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming count.`);
}
const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
count = streamingResults.length
}
} else {
count = 0
}
} else {
count = lineNumbers.size;
}
}
return count;
}
/**
* Compatibility method that redirects to find
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {Promise<Array>} - Query results
*/
async query(criteria, options = {}) {
return this.find(criteria, options)
}
/**
* Find using streaming strategy with pre-filtering optimization
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {Promise<Array>} - Query results
*/
async findWithStreaming(criteria, options = {}) {
const streamingOptions = { ...options }
const forceFullScan = streamingOptions.forceFullScan === true
delete streamingOptions.forceFullScan
if (this.opts.debugMode) {
if (forceFullScan) {
console.log('🌊 Using streaming strategy (forced full scan to bypass missing index data)');
} else {
console.log('🌊 Using streaming strategy');
}
}
if (!forceFullScan) {
// OPTIMIZATION: Try to use indices for pre-filtering when possible
const indexableFields = this._getIndexableFields(criteria);
if (indexableFields.length > 0) {
try {
await this.database._ensureLazyIndexLoaded()
} catch (error) {
if (this.opts.debugMode) {
console.log('⚠️ _ensureLazyIndexLoaded failed, falling back to full streaming', error.message || error)
}
return this._streamAllRecords(criteria, streamingOptions)
}
const usableIndexableFields = indexableFields.filter(field => this.indexManager.hasUsableIndexData(field));
if (usableIndexableFields.length !== indexableFields.length) {
if (this.opts.debugMode) {
console.log('🌊 Falling back to full streaming because some indexed fields lack usable index data');
}
return this._streamAllRecords(criteria, streamingOptions)
}
if (this.opts.debugMode) {
console.log(`🌊 Using pre-filtered streaming with ${indexableFields.length} indexable fields`);
}
// Use indices to pre-filter and reduce streaming scope
const preFilteredLines = this.indexManager.query(
this._extractIndexableCriteria(criteria),
streamingOptions
);
// Stream only the pre-filtered records
return this._streamPreFilteredRecords(preFilteredLines, criteria, streamingOptions);
}
}
// Fallback to full streaming
if (this.opts.debugMode) {
console.log('🌊 Using full streaming (no indexable fields found or forced)');
}
return this._streamAllRecords(criteria, streamingOptions);
}
/**
* Get indexable fields from criteria
* @param {Object} criteria - Query criteria
* @returns {Array} - Array of indexable field names
*/
_getIndexableFields(criteria) {
const indexableFields = [];
if (!criteria || typeof criteria !== 'object') {
return indexableFields;
}
// Handle $and conditions
if (criteria.$and && Array.isArray(criteria.$and)) {
for (const andCondition of criteria.$and) {
indexableFields.push(...this._getIndexableFields(andCondition));
}
}
// Handle regular field conditions
for (const [field, condition] of Object.entries(criteria)) {
if (field.startsWith('$')) continue; // Skip logical operators
// RegExp conditions cannot be pre-filtered using indices
if (condition instanceof RegExp) {
continue;
}
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
indexableFields.push(field);
}
}
return [...new Set(indexableFields)]; // Remove duplicates
}
/**
* Extract indexable criteria for pre-filtering
* @param {Object} criteria - Full query criteria
* @returns {Object} - Criteria with only indexable fields
*/
_extractIndexableCriteria(criteria) {
if (!criteria || typeof criteria !== 'object') {
return {};
}
const indexableCriteria = {};
// Handle $and conditions
if (criteria.$and && Array.isArray(criteria.$and)) {
const indexableAndConditions = criteria.$and
.map(andCondition => this._extractIndexableCriteria(andCondition))
.filter(condition => Object.keys(condition).length > 0);
if (indexableAndConditions.length > 0) {
indexableCriteria.$and = indexableAndConditions;
}
}
// Handle $not operator - include it if it can be processed by IndexManager
if (criteria.$not && typeof criteria.$not === 'object') {
// Check if $not condition contains only indexable fields
const notFields = Object.keys(criteria.$not);
const allNotFieldsIndexed = notFields.every(field =>
this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
);
if (allNotFieldsIndexed && notFields.length > 0) {
// Extract indexable criteria from $not condition
const indexableNotCriteria = this._extractIndexableCriteria(criteria.$not);
if (Object.keys(indexableNotCriteria).length > 0) {
indexableCriteria.$not = indexableNotCriteria;
}
}
}
// Handle regular field conditions
for (const [field, condition] of Object.entries(criteria)) {
if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
// RegExp conditions cannot be pre-filtered using indices
if (condition instanceof RegExp) {
continue;
}
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
indexableCriteria[field] = condition;
}
}
return indexableCriteria;
}
/**
* Determine whether the database currently has any records (persisted or pending)
* @returns {boolean}
*/
_hasAnyRecords() {
if (!this.database) {
return false
}
if (Array.isArray(this.database.offsets) && this.database.offsets.length > 0) {
return true
}
if (Array.isArray(this.database.writeBuffer) && this.database.writeBuffer.length > 0) {
return true
}
if (typeof this.database.length === 'number' && this.database.length > 0) {
return true
}
return false
}
/**
* Extract all indexed fields referenced in the criteria
* @param {Object} criteria
* @param {Set<string>} accumulator
* @returns {Array<string>}
*/
_extractIndexedFields(criteria, accumulator = new Set()) {
if (!criteria) {
return Array.from(accumulator)
}
if (Array.isArray(criteria)) {
for (const item of criteria) {
this._extractIndexedFields(item, accumulator)
}
return Array.from(accumulator)
}
if (typeof criteria !== 'object') {
return Array.from(accumulator)
}
for (const [key, value] of Object.entries(criteria)) {
if (key.startsWith('$')) {
this._extractIndexedFields(value, accumulator)
continue
}
accumulator.add(key)
if (Array.isArray(value)) {
for (const nested of value) {
this._extractIndexedFields(nested, accumulator)
}
}
}
return Array.from(accumulator)
}
/**
* Identify indexed fields present in criteria whose index data is missing
* @param {Object} criteria
* @returns {Array<string>}
*/
_getIndexedFieldsWithMissingData(criteria) {
if (!this.indexManager || !criteria) {
return []
}
const indexedFields = this._extractIndexedFields(criteria)
const missing = []
for (const field of indexedFields) {
if (!this.indexManager.isFieldIndexed(field)) {
continue
}
if (!this.indexManager.hasUsableIndexData(field)) {
missing.push(field)
}
}
return missing
}
/**
* OPTIMIZATION 4: Stream pre-filtered records using line numbers from indices with partial index optimization
* @param {Set} preFilteredLines - Line numbers from index query
* @param {Object} criteria - Full query criteria
* @param {Object} options - Query options
* @returns {Promise<Array>} - Query results
*/
async _streamPreFilteredRecords(preFilteredLines, criteria, options = {}) {
if (preFilteredLines.size === 0) {
return [];
}
const results = [];
const lineNumbers = Array.from(preFilteredLines);
// OPTIMIZATION 4: Sort line numbers for efficient file reading
lineNumbers.sort((a, b) => a - b);
// OPTIMIZATION 4: Use batch reading for better performance
const batchSize = Math.min(1000, lineNumbers.length); // Read in batches of 1000
const batches = [];
for (let i = 0; i < lineNumbers.length; i += batchSize) {
batches.push(lineNumbers.slice(i, i + batchSize));
}
const persistedCount = Array.isArray(this.database.offsets) ? this.database.offsets.length : 0
const fileLineNumbers = []
const writeBufferLineNumbers = []
for (const lineNumber of lineNumbers) {
if (lineNumber >= persistedCount) {
writeBufferLineNumbers.push(lineNumber)
} else {
fileLineNumbers.push(lineNumber)
}
}
if (fileLineNumbers.length > 0) {
const fileBatches = []
for (let i = 0; i < fileLineNumbers.length; i += batchSize) {
fileBatches.push(fileLineNumbers.slice(i, i + batchSize));
}
for (const batch of fileBatches) {
// OPTIMIZATION: Use ranges instead of reading entire file
const ranges = this.database.getRanges(batch);
const groupedRanges = await this.fileHandler.groupedRanges(ranges);
const fd = await fs.open(this.fileHandler.file, 'r');
try {
for (const groupedRange of groupedRanges) {
for await (const row of this.fileHandler.readGroupedRange(groupedRange, fd)) {
if (row.line && row.line.trim()) {
try {
const record = this.database.serializer.deserialize(row.line);
if (this._matchesCriteriaOptimized(record, criteria, options)) {
const recordWithTerms = options.restoreTerms !== false ?
this.database.restoreTermIdsAfterDeserialization(record) :
record
results.push(recordWithTerms);
if (options.limit && results.length >= options.limit) {
return this._applyOrdering(results, options);
}
}
} catch (error) {
continue;
}
}
}
}
} finally {
await fd.close();
}
}
}
if (writeBufferLineNumbers.length > 0) {
writeBufferLineNumbers.sort((a, b) => a - b)
for (const lineNumber of writeBufferLineNumbers) {
const writeBufferIndex = lineNumber - persistedCount
const record = this.database.writeBuffer[writeBufferIndex]
if (!record) continue
if (this._matchesCriteriaOptimized(record, criteria, options)) {
const recordWithTerms = options.restoreTerms !== false ?
this.database.restoreTermIdsAfterDeserialization(record) :
record
results.push(recordWithTerms)
if (options.limit && results.length >= options.limit) {
return this._applyOrdering(results, options);
}
}
}
}
return this._applyOrdering(results, options);
}
/**
* OPTIMIZATION 4: Optimized criteria matching for pre-filtered records
* @param {Object} record - Record to check
* @param {Object} criteria - Filter criteria
* @param {Object} options - Query options
* @returns {boolean} - True if matches
*/
_matchesCriteriaOptimized(record, criteria, options = {}) {
if (!criteria || Object.keys(criteria).length === 0) {
return true;
}
// Handle $not operator at the top level
if (criteria.$not && typeof criteria.$not === 'object') {
// For $not conditions, we need to negate the result
// IMPORTANT: For $not conditions, we should NOT skip pre-filtered fields
// because we need to evaluate the actual field values to determine exclusion
// Use the regular matchesCriteria method for $not conditions to ensure proper field evaluation
const notResult = this.matchesCriteria(record, criteria.$not, options);
return !notResult;
}
// OPTIMIZATION 4: Skip indexable fields since they were already pre-filtered
const indexableFields = this._getIndexableFields(criteria);
// Handle explicit logical operators at the top level
if (criteria.$or && Array.isArray(criteria.$or)) {
let orMatches = false;
for (const orCondition of criteria.$or) {
if (this._matchesCriteriaOptimized(record, orCondition, options)) {
orMatches = true;
break;
}
}
if (!orMatches) {
return false;
}
} else if (criteria.$and && Array.isArray(criteria.$and)) {
for (const andCondition of criteria.$and) {
if (!this._matchesCriteriaOptimized(record, andCondition, options)) {
return false;
}
}
}
// Handle individual field conditions (exclude logical operators and pre-filtered fields)
for (const [field, condition] of Object.entries(criteria)) {
if (field.startsWith('$')) continue;
// OPTIMIZATION 4: Skip indexable fields that were already pre-filtered
if (indexableFields.includes(field)) {
continue;
}
if (!this.matchesFieldCondition(record, field, condition, options)) {
return false;
}
}
if (criteria.$or && Array.isArray(criteria.$or)) {
return true;
}
return true;
}
/**
* OPTIMIZATION 4: Apply ordering to results
* @param {Array} results - Results to order
* @param {Object} options - Query options
* @returns {Array} - Ordered results
*/
_applyOrdering(results, options) {
if (options.orderBy) {
const [field, direction = 'asc'] = options.orderBy.split(' ');
results.sort((a, b) => {
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
return 0;
});
}
return results;
}
/**
* Stream all records (fallback method)
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {Promise<Array>} - Query results
*/
async _streamAllRecords(criteria, options = {}) {
const memoryLimit = options.limit || undefined;
const streamingOptions = { ...options, limit: memoryLimit };
const results = await this.fileHandler.readWithStreaming(criteria, streamingOptions, (record, criteria) => {
return this.matchesCriteria(record, criteria, options);
}, this.serializer || null);
// SPACE OPTIMIZATION: Restore term IDs to terms for user (unless disabled)
const resultsWithTerms = options.restoreTerms !== false ?
results.map(record => this.database.restoreTermIdsAfterDeserialization(record)) :
results;
// Apply ordering if specified
if (options.orderBy) {
const [field, direction = 'asc'] = options.orderBy.split(' ');
resultsWithTerms.sort((a, b) => {
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
return 0;
});
}
return resultsWithTerms;
}
/**
* Find using indexed search strategy with real streaming
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {Promise<Array>} - Query results
*/
async findWithIndexed(criteria, options = {}) {
if (this.opts.debugMode) {
console.log('📊 Using indexed strategy with real streaming');
}
try {
await this.database._ensureLazyIndexLoaded()
} catch (error) {
if (this.opts.debugMode) {
console.log('⚠️ _ensureLazyIndexLoaded failed, falling back to full streaming', error.message || error)
}
return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
}
let results = []
const limit = options.limit // No default limit - return all results unless explicitly limited
// Use IndexManager to get line numbers, then read specific records
const lineNumbers = this.indexManager.query(criteria, options)
if (this.opts.debugMode) {
console.log(`🔍 IndexManager returned ${lineNumbers.size} line numbers:`, Array.from(lineNumbers))
}
if (lineNumbers.size === 0) {
const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
// Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
if (this.database.opts.allowIndexRebuild) {
if (this.opts.debugMode) {
console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`)
}
this.database._indexRebuildNeeded = true
await this.database._rebuildIndexesIfNeeded()
// Retry indexed query after rebuild
const retryLineNumbers = this.indexManager.query(criteria, options)
if (retryLineNumbers.size > 0) {
if (this.opts.debugMode) {
console.log(`✅ Index rebuild successful, using indexed strategy.`)
}
// Update lineNumbers to use rebuilt index results
lineNumbers.clear()
for (const lineNumber of retryLineNumbers) {
lineNumbers.add(lineNumber)
}
} else {
// Still no results after rebuild, fall back to streaming
if (this.opts.debugMode) {
console.log(`⚠️ Index rebuild did not help, falling back to streaming.`)
}
return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
}
} else {
// allowIndexRebuild is false, fall back to streaming
if (this.opts.debugMode) {
console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming.`)
}
return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
}
}
}
// Read specific records using the line numbers
if (lineNumbers.size > 0) {
const lineNumbersArray = Array.from(lineNumbers)
const persistedCount = Array.isArray(this.database.offsets) ? this.database.offsets.length : 0
// Separate lineNumbers into file records and writeBuffer records
const fileLineNumbers = []
const writeBufferLineNumbers = []
for (const lineNumber of lineNumbersArray) {
if (lineNumber >= persistedCount) {
// This lineNumber points to writeBuffer
writeBufferLineNumbers.push(lineNumber)
} else {
// This lineNumber points to file
fileLineNumbers.push(lineNumber)
}
}
// Read records from file
if (fileLineNumbers.length > 0) {
const ranges = this.database.getRanges(fileLineNumbers)
if (ranges.length > 0) {
const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
const fd = await fs.open(this.database.fileHandler.file, 'r')
try {
for (const groupedRange of groupedRanges) {
for await (const row of this.database.fileHandler.readGroupedRange(groupedRange, fd)) {
try {
const record = this.database.serializer.deserialize(row.line)
const recordWithTerms = options.restoreTerms !== false ?
this.database.restoreTermIdsAfterDeserialization(record) :
record
recordWithTerms._ = row._
results.push(recordWithTerms)
if (limit && results.length >= limit) break
} catch (error) {
// Skip invalid lines
}
}
if (limit && results.length >= limit) break
}
} finally {
await fd.close()
}
}
}
// Read records from writeBuffer
if (writeBufferLineNumbers.length > 0 && this.database.writeBuffer) {
for (const lineNumber of writeBufferLineNumbers) {
if (limit && results.length >= limit) break
const writeBufferIndex = lineNumber - persistedCount
if (writeBufferIndex >= 0 && writeBufferIndex < this.database.writeBuffer.length) {
const record = this.database.writeBuffer[writeBufferIndex]
if (record) {
const recordWithTerms = options.restoreTerms !== false ?
this.database.restoreTermIdsAfterDeserialization(record) :
record
recordWithTerms._ = lineNumber
results.push(recordWithTerms)
}
}
}
}
}
if (options.orderBy) {
const [field, direction = 'asc'] = options.orderBy.split(' ')
results.sort((a, b) => {
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1
return 0;
})
}
return results
}
/**
* Check if a record matches criteria
* @param {Object} record - Record to check
* @param {Object} criteria - Filter criteria
* @param {Object} options - Query options (for caseInsensitive, etc.)
* @returns {boolean} - True if matches
*/
matchesCriteria(record, criteria, options = {}) {
if (!criteria || Object.keys(criteria).length === 0) {
return true;
}
// Handle explicit logical operators at the top level
if (criteria.$or && Array.isArray(criteria.$or)) {
let orMatches = false;
for (const orCondition of criteria.$or) {
if (this.matchesCriteria(record, orCondition, options)) {
orMatches = true;
break;
}
}
// If $or doesn't match, return false immediately
if (!orMatches) {
return false;
}
// If $or matches, continue to check other conditions if they exist
// Don't return true yet - we need to check other conditions
} else if (criteria.$and && Array.isArray(criteria.$and)) {
for (const andCondition of criteria.$and) {
if (!this.matchesCriteria(record, andCondition, options)) {
return false;
}
}
// $and matches, continue to check other conditions if they exist
}
// Handle individual field conditions and $not operator
for (const [field, condition] of Object.entries(criteria)) {
// Skip logical operators that are handled above
if (field.startsWith('$') && field !== '$not') {
continue;
}
if (field === '$not') {
// Handle $not operator - it should negate the result of its condition
if (typeof condition === 'object' && condition !== null) {
// Empty $not condition should not exclude anything
if (Object.keys(condition).length === 0) {
continue; // Don't exclude anything
}
// Check if the $not condition matches - if it does, this record should be excluded
if (this.matchesCriteria(record, condition, options)) {
return false; // Exclude this record
}
}
} else {
// Handle regular field conditions
if (!this.matchesFieldCondition(record, field, condition, options)) {
return false;
}
}
}
// If we have $or conditions and they matched, return true
if (criteria.$or && Array.isArray(criteria.$or)) {
return true;
}
// For other cases (no $or, or $and, or just field conditions), return true if we got this far
return true;
}
/**
* Check if a field matches a condition
* @param {Object} record - Record to check
* @param {string} field - Field name
* @param {*} condition - Condition to match
* @param {Object} options - Query options
* @returns {boolean} - True if matches
*/
matchesFieldCondition(record, field, condition, options = {}) {
const value = record[field];
// Debug logging for all field conditions
if (this.database.opts.debugMode) {
console.log(`🔍 Checking field '${field}':`, { value, condition, record: record.name || record.id });
}
// Debug logging for term mapping fields
if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
if (this.database.opts.debugMode) {
console.log(`🔍 Checking term mapping field '${field}':`, { value, condition, record: record.name || record.id });
}
}
// Handle null/undefined values
if (value === null || value === undefined) {
return condition === null || condition === undefined;
}
// Handle regex conditions (MUST come before object check since RegExp is an object)
if (condition instanceof RegExp) {
// For array fields, test regex against each element
if (Array.isArray(value)) {
return value.some(element => condition.test(String(element)));
}
// For non-array fields, test regex against the value directly
return condition.test(String(value));
}
// Handle array conditions
if (Array.isArray(condition)) {
// For array fields, check if any element in the field matches any element in the condition
if (Array.isArray(value)) {
return condition.some(condVal => value.includes(condVal));
}
// For non-array fields, check if value is in condition
return condition.includes(value);
}
// Handle object conditions (operators)
if (typeof condition === 'object' && !Array.isArray(condition)) {
for (const [operator, operatorValue] of Object.entries(condition)) {
const normalizedOperator = normalizeOperator(operator);
if (!this.matchesOperator(value, normalizedOperator, operatorValue, options)) {
return false;
}
}
return true;
}
// Handle case-insensitive string comparison
if (options.caseInsensitive && typeof value === 'string' && typeof condition === 'string') {
return value.toLowerCase() === condition.toLowerCase();
}
// Handle direct array field search (e.g., { nameTerms: 'channel' })
if (Array.isArray(value) && typeof condition === 'string') {
return value.includes(condition);
}
// Simple equality
return value === condition;
}
/**
* Check if a value matches an operator condition
* @param {*} value - Value to check
* @param {string} operator - Operator
* @param {*} operatorValue - Operator value
* @param {Object} options - Query options
* @returns {boolean} - True if matches
*/
matchesOperator(value, operator, operatorValue, options = {}) {
switch (operator) {
case '$eq':
return value === operatorValue;
case '$gt':
return value > operatorValue;
case '$gte':
return value >= operatorValue;
case '$lt':
return value < operatorValue;
case '$lte':
return value <= operatorValue;
case '$ne':
return value !== operatorValue;
case '$not':
// $not operator should be handled at the criteria level, not field level
// This is a fallback for backward compatibility
return value !== operatorValue;
case '$in':
if (Array.isArray(value)) {
// For array fields, check if any element in the array matches any value in operatorValue
return Array.isArray(operatorValue) && operatorValue.some(opVal => value.includes(opVal));
} else {
// For non-array fields, check if value is in operatorValue
return Array.isArray(operatorValue) && operatorValue.includes(value);
}
case '$nin':
if (Array.isArray(value)) {
// For array fields, check if NO elements in the array match any value in operatorValue
return Array.isArray(operatorValue) && !operatorValue.some(opVal => value.includes(opVal));
} else {
// For non-array fields, check if value is not in operatorValue
return Array.isArray(operatorValue) && !operatorValue.includes(value);
}
case '$regex':
const regex = new RegExp(operatorValue, options.caseInsensitive ? 'i' : '');
// For array fields, test regex against each element
if (Array.isArray(value)) {
return value.some(element => regex.test(String(element)));
}
// For non-array fields, test regex against the value directly
return regex.test(String(value));
case '$contains':
if (Array.isArray(value)) {
return value.includes(operatorValue);
}
return String(value).includes(String(operatorValue));
case '$all':
if (!Array.isArray(value) || !Array.isArray(operatorValue)) {
return false;
}
return operatorValue.every(item => value.includes(item));
case '$exists':
return operatorValue ? (value !== undefined && value !== null) : (value === undefined || value === null);
case '$size':
if (Array.isArray(value)) {
return value.length === operatorValue;
}
return false;
default:
return false;
}
}
/**
* Preprocess query to handle array field syntax automatically
* @param {Object} criteria - Query criteria
* @returns {Object} - Processed criteria
*/
preprocessQuery(criteria) {
if (!criteria || typeof criteria !== 'object') {
return criteria
}
const processed = {}
for (const [field, value] of Object.entries(criteria)) {
// Check if this is a term mapping field
const isTermMappingField = this.database.opts.termMapping &&
this.database.termManager &&
this.database.termManager.termMappingFields &&
this.database.termManager.termMappingFields.includes(field)
if (isTermMappingField) {
// Handle term mapping field queries
if (typeof value === 'string') {
// Convert term to $in query for term mapping fields
processed[field] = { $in: [value] }
} else if (Array.isArray(value)) {
// Convert array to $in query
processed[field] = { $in: value }
} else if (value && typeof value === 'object') {
// Handle special query operators for term mapping
if (value.$in) {
processed[field] = { $in: value.$in }
} else if (value.$all) {
processed[field] = { $all: value.$all }
} else {
processed[field] = value
}
} else {
// Invalid value for term mapping field
throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
}
if (this.database.opts.debugMode) {
console.log(`🔍 Processed term mapping query for field '${field}':`, processed[field])
}
} else {
// Check if this field is defined as an array in the schema
const indexes = this.opts.indexes || {}
const fieldConfig = indexes[field]
const isArrayField = fieldConfig &&
(Array.isArray(fieldConfig) && fieldConfig.includes('array') ||
fieldConfig === 'array:string' ||
fieldConfig === 'array:number' ||
fieldConfig === 'array:boolean')
if (isArrayField) {
// Handle array field queries
if (typeof value === 'string' || typeof value === 'number') {
// Convert direct value to $in query for array fields
processed[field] = { $in: [value] }
} else if (Array.isArray(value)) {
// Convert array to $in query
processed[field] = { $in: value }
} else if (value && typeof value === 'object') {
// Already properly formatted query object
processed[field] = value
} else {
// Invalid value for array field
throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
}
} else {
// Non-array field, keep as is
processed[field] = value
}
}
}
return processed
}
/**
* Determine which query strategy to use
* @param {Object} criteria - Query criteria
* @param {Object} options - Query options
* @returns {string} - 'streaming' or 'indexed'
*/
shouldUseStreaming(criteria, options = {}) {
const { limit } = options; // No default limit
const totalRecords = this.database.length || 0;
// Strategy 1: Always streaming for queries without criteria
if (!criteria || Object.keys(criteria).length === 0) {
if (this.opts.debugMode) {
console.log('📊 QueryStrategy: STREAMING - No criteria provided');
}
return 'streaming';
}
// Strategy 2: Check if all fields are indexed and support the operators used
// First, check if $not is present at root level - if so, we need to use streaming for proper $not handling
if (criteria.$not && !this.opts.termMapping) {
if (this.opts.debugMode) {
console.log('📊 QueryStrategy: STREAMING - $not operator requires streaming mode');
}
return 'streaming';
}
// OPTIMIZATION: For term mapping, we can process $not using indices
if (criteria.$not && this.opts.termMapping) {
// Check if all $not fields are indexed
const notFields = Object.keys(criteria.$not)
const allNotFieldsIndexed = notFields.every(field =>
this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
)
if (allNotFieldsIndexed) {
if (this.opts.debugMode) {
console.log('📊 QueryStrategy: INDEXED - $not with term mapping can use indexed strategy');
}
// Continue to check other conditions instead of forcing streaming
} else {
if (this.opts.debugMode) {
console.log('📊 QueryStrategy: STREAMING - $not fields not all indexed');
}
return 'streaming';
}
}
// Handle $and queries - check if all conditions in $and are indexable
if (criteria.$and && Array.isArray(criteria.$and)) {
const allAndConditionsIndexed = criteria.$and.every(andCondition => {
// Handle $not conditions within $and
if (andCondition.$not) {
const notFields = Object.keys(andCondition.$not);
return notFields.every(field => {
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
return false;
}
// For term mapping, $not can be processed with indices
return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
});
}
// Handle regular field conditions
return Object.keys(andCondition).every(field => {
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
return false;
}
const condition = andCondition[field];
// RegExp cannot be efficiently queried using indices - must use streaming
if (condition instanceof RegExp) {
return false;
}
if (typeof condition === 'object' && !Array.isArray(condition)) {
const operators = Object.keys(condition).map(op => normalizeOperator(op));
const indexType = this.indexManager?.opts?.indexes?.[field]
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
// Check if this is a term mapping field (array:string or string fields with term mapping)
const isTermMappingField = this.database.termManager &&
this.database.termManager.termMappingFields &&
this.database.termManager.termMappingFields.includes(field)
if (isTermMappingField) {
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
return operators.every(op => !termMappingDisallowed.includes(op));
} else {
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
return operators.every(op => !disallowed.includes(op));
}
}
return true;
});
});
if (!allAndConditionsIndexed) {
if (this.opts.debugMode) {
console.log('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
}
return 'streaming';
}
}
const allFieldsIndexed = Object.keys(criteria).every(field => {
// Skip $and and $not as they're handled separately above
if (field === '$and' || field === '$not') return true;
if (!this.opts.indexes || !this.opts.indexes[field]) {
if (this.opts.debugMode) {
console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}))
}
return false;
}
// Check if the field uses operators that are supported by IndexManager
const condition = criteria[field];
// RegExp cannot be efficiently queried using indices - must use streaming
if (condition instanceof RegExp) {
if (this.opts.debugMode) {
console.log(`🔍 Field '${field}' uses RegExp - requires streaming strategy`)
}
return false;
}
if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
const operators = Object.keys(condition).map(op => normalizeOperator(op));
if (this.opts.debugMode) {
console.log(`🔍 Field '${field}' has operators:`, operators)
}
const indexType = this.indexManager?.opts?.indexes?.[field]
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
const isArrayStringIndex = indexType === 'array:string'
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
// Check if this is a term mapping field (array:string or string fields with term mapping)
const isTermMappingField = this.database.termManager &&
this.database.termManager.termMappingFields &&
this.database.termManager.termMappingFields.includes(field)
// With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
// Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
if (isTermMappingField) {
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte',