jexidb 2.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.babelrc +13 -0
- package/.gitattributes +2 -0
- package/CHANGELOG.md +132 -101
- package/LICENSE +21 -21
- package/README.md +301 -639
- package/babel.config.json +5 -0
- package/dist/Database.cjs +3896 -0
- package/docs/API.md +1051 -390
- package/docs/EXAMPLES.md +701 -177
- package/docs/README.md +194 -184
- package/examples/iterate-usage-example.js +157 -0
- package/examples/simple-iterate-example.js +115 -0
- package/jest.config.js +24 -0
- package/package.json +63 -54
- package/scripts/README.md +47 -0
- package/scripts/clean-test-files.js +75 -0
- package/scripts/prepare.js +31 -0
- package/scripts/run-tests.js +80 -0
- package/src/Database.mjs +4130 -0
- package/src/FileHandler.mjs +1101 -0
- package/src/OperationQueue.mjs +279 -0
- package/src/SchemaManager.mjs +268 -0
- package/src/Serializer.mjs +511 -0
- package/src/managers/ConcurrencyManager.mjs +257 -0
- package/src/managers/IndexManager.mjs +1403 -0
- package/src/managers/QueryManager.mjs +1273 -0
- package/src/managers/StatisticsManager.mjs +262 -0
- package/src/managers/StreamingProcessor.mjs +429 -0
- package/src/managers/TermManager.mjs +278 -0
- package/test/$not-operator-with-and.test.js +282 -0
- package/test/README.md +8 -0
- package/test/close-init-cycle.test.js +256 -0
- package/test/critical-bugs-fixes.test.js +1069 -0
- package/test/index-persistence.test.js +306 -0
- package/test/index-serialization.test.js +314 -0
- package/test/indexed-query-mode.test.js +360 -0
- package/test/iterate-method.test.js +272 -0
- package/test/query-operators.test.js +238 -0
- package/test/regex-array-fields.test.js +129 -0
- package/test/score-method.test.js +238 -0
- package/test/setup.js +17 -0
- package/test/term-mapping-minimal.test.js +154 -0
- package/test/term-mapping-simple.test.js +257 -0
- package/test/term-mapping.test.js +514 -0
- package/test/writebuffer-flush-resilience.test.js +204 -0
- package/dist/FileHandler.js +0 -688
- package/dist/IndexManager.js +0 -353
- package/dist/IntegrityChecker.js +0 -364
- package/dist/JSONLDatabase.js +0 -1333
- package/dist/index.js +0 -617
- package/docs/MIGRATION.md +0 -295
- package/examples/auto-save-example.js +0 -158
- package/examples/cjs-usage.cjs +0 -82
- package/examples/close-vs-delete-example.js +0 -71
- package/examples/esm-usage.js +0 -113
- package/examples/example-columns.idx.jdb +0 -0
- package/examples/example-columns.jdb +0 -9
- package/examples/example-options.idx.jdb +0 -0
- package/examples/example-options.jdb +0 -0
- package/examples/example-users.idx.jdb +0 -0
- package/examples/example-users.jdb +0 -5
- package/examples/simple-test.js +0 -55
- package/src/FileHandler.js +0 -674
- package/src/IndexManager.js +0 -363
- package/src/IntegrityChecker.js +0 -379
- package/src/JSONLDatabase.js +0 -1391
- package/src/index.js +0 -608
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryManager - Handles all query operations and strategies
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - find(), findOne(), count(), query()
|
|
6
|
+
* - findWithStreaming(), findWithIndexed()
|
|
7
|
+
* - matchesCriteria(), extractQueryFields()
|
|
8
|
+
* - Query strategies (INDEXED vs STREAMING)
|
|
9
|
+
* - Result estimation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class QueryManager {
|
|
13
|
+
constructor(database) {
|
|
14
|
+
this.database = database
|
|
15
|
+
this.opts = database.opts
|
|
16
|
+
this.indexManager = database.indexManager
|
|
17
|
+
this.fileHandler = database.fileHandler
|
|
18
|
+
this.serializer = database.serializer
|
|
19
|
+
this.usageStats = database.usageStats || {
|
|
20
|
+
totalQueries: 0,
|
|
21
|
+
indexedQueries: 0,
|
|
22
|
+
streamingQueries: 0,
|
|
23
|
+
indexedAverageTime: 0,
|
|
24
|
+
streamingAverageTime: 0
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Main find method with strategy selection
|
|
30
|
+
* @param {Object} criteria - Query criteria
|
|
31
|
+
* @param {Object} options - Query options
|
|
32
|
+
* @returns {Promise<Array>} - Query results
|
|
33
|
+
*/
|
|
34
|
+
async find(criteria, options = {}) {
|
|
35
|
+
if (this.database.destroyed) throw new Error('Database is destroyed')
|
|
36
|
+
if (!this.database.initialized) await this.database.init()
|
|
37
|
+
|
|
38
|
+
// Manual save is now the responsibility of the application
|
|
39
|
+
|
|
40
|
+
// Preprocess query to handle array field syntax automatically
|
|
41
|
+
const processedCriteria = this.preprocessQuery(criteria)
|
|
42
|
+
|
|
43
|
+
const finalCriteria = processedCriteria
|
|
44
|
+
|
|
45
|
+
// Validate strict indexed mode before processing
|
|
46
|
+
if (this.opts.indexedQueryMode === 'strict') {
|
|
47
|
+
this.validateStrictQuery(finalCriteria);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const startTime = Date.now();
|
|
51
|
+
this.usageStats.totalQueries++;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// Decide which strategy to use
|
|
55
|
+
const strategy = this.shouldUseStreaming(finalCriteria, options);
|
|
56
|
+
|
|
57
|
+
let results = [];
|
|
58
|
+
|
|
59
|
+
if (strategy === 'streaming') {
|
|
60
|
+
results = await this.findWithStreaming(finalCriteria, options);
|
|
61
|
+
this.usageStats.streamingQueries++;
|
|
62
|
+
this.updateAverageTime('streaming', Date.now() - startTime);
|
|
63
|
+
} else {
|
|
64
|
+
results = await this.findWithIndexed(finalCriteria, options);
|
|
65
|
+
this.usageStats.indexedQueries++;
|
|
66
|
+
this.updateAverageTime('indexed', Date.now() - startTime);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.opts.debugMode) {
|
|
70
|
+
const time = Date.now() - startTime;
|
|
71
|
+
console.log(`⏱️ Query completed in ${time}ms using ${strategy} strategy`);
|
|
72
|
+
console.log(`📊 Results: ${results.length} records`);
|
|
73
|
+
console.log(`📊 Results type: ${typeof results}, isArray: ${Array.isArray(results)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return results;
|
|
77
|
+
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (this.opts.debugMode) {
|
|
80
|
+
console.error('❌ Query failed:', error);
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find one record
|
|
88
|
+
* @param {Object} criteria - Query criteria
|
|
89
|
+
* @param {Object} options - Query options
|
|
90
|
+
* @returns {Promise<Object|null>} - First matching record or null
|
|
91
|
+
*/
|
|
92
|
+
async findOne(criteria, options = {}) {
|
|
93
|
+
if (this.database.destroyed) throw new Error('Database is destroyed')
|
|
94
|
+
if (!this.database.initialized) await this.database.init()
|
|
95
|
+
// Manual save is now the responsibility of the application
|
|
96
|
+
|
|
97
|
+
// Preprocess query to handle array field syntax automatically
|
|
98
|
+
const processedCriteria = this.preprocessQuery(criteria)
|
|
99
|
+
|
|
100
|
+
// Validate strict indexed mode before processing
|
|
101
|
+
if (this.opts.indexedQueryMode === 'strict') {
|
|
102
|
+
this.validateStrictQuery(processedCriteria);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const startTime = Date.now();
|
|
106
|
+
this.usageStats.totalQueries++;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// Decide which strategy to use
|
|
110
|
+
const strategy = this.shouldUseStreaming(processedCriteria, options);
|
|
111
|
+
|
|
112
|
+
let results = [];
|
|
113
|
+
|
|
114
|
+
if (strategy === 'streaming') {
|
|
115
|
+
results = await this.findWithStreaming(processedCriteria, { ...options, limit: 1 });
|
|
116
|
+
this.usageStats.streamingQueries++;
|
|
117
|
+
this.updateAverageTime('streaming', Date.now() - startTime);
|
|
118
|
+
} else {
|
|
119
|
+
results = await this.findWithIndexed(processedCriteria, { ...options, limit: 1 });
|
|
120
|
+
this.usageStats.indexedQueries++;
|
|
121
|
+
this.updateAverageTime('indexed', Date.now() - startTime);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.opts.debugMode) {
|
|
125
|
+
const time = Date.now() - startTime;
|
|
126
|
+
console.log(`⏱️ findOne completed in ${time}ms using ${strategy} strategy`);
|
|
127
|
+
console.log(`📊 Results: ${results.length} record(s)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Return the first result or null if no results found
|
|
131
|
+
return results.length > 0 ? results[0] : null;
|
|
132
|
+
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (this.opts.debugMode) {
|
|
135
|
+
console.error('❌ findOne failed:', error);
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Count records matching criteria
|
|
143
|
+
* @param {Object} criteria - Query criteria
|
|
144
|
+
* @param {Object} options - Query options
|
|
145
|
+
* @returns {Promise<number>} - Count of matching records
|
|
146
|
+
*/
|
|
147
|
+
async count(criteria, options = {}) {
|
|
148
|
+
if (this.database.destroyed) throw new Error('Database is destroyed')
|
|
149
|
+
if (!this.database.initialized) await this.database.init()
|
|
150
|
+
// Manual save is now the responsibility of the application
|
|
151
|
+
|
|
152
|
+
// Validate strict indexed mode before processing
|
|
153
|
+
if (this.opts.indexedQueryMode === 'strict') {
|
|
154
|
+
this.validateStrictQuery(criteria);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Use the same strategy as find method
|
|
158
|
+
const strategy = this.shouldUseStreaming(criteria, options);
|
|
159
|
+
|
|
160
|
+
let count = 0;
|
|
161
|
+
|
|
162
|
+
if (strategy === 'streaming') {
|
|
163
|
+
// Use streaming approach for non-indexed fields or large result sets
|
|
164
|
+
const results = await this.findWithStreaming(criteria, options);
|
|
165
|
+
count = results.length;
|
|
166
|
+
} else {
|
|
167
|
+
// Use indexed approach for indexed fields
|
|
168
|
+
const results = await this.findWithIndexed(criteria, options);
|
|
169
|
+
count = results.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return count;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Compatibility method that redirects to find
|
|
177
|
+
* @param {Object} criteria - Query criteria
|
|
178
|
+
* @param {Object} options - Query options
|
|
179
|
+
* @returns {Promise<Array>} - Query results
|
|
180
|
+
*/
|
|
181
|
+
async query(criteria, options = {}) {
|
|
182
|
+
return this.find(criteria, options)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Find using streaming strategy with pre-filtering optimization
|
|
187
|
+
* @param {Object} criteria - Query criteria
|
|
188
|
+
* @param {Object} options - Query options
|
|
189
|
+
* @returns {Promise<Array>} - Query results
|
|
190
|
+
*/
|
|
191
|
+
async findWithStreaming(criteria, options = {}) {
|
|
192
|
+
if (this.opts.debugMode) {
|
|
193
|
+
console.log('🌊 Using streaming strategy');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// OPTIMIZATION: Try to use indices for pre-filtering when possible
|
|
197
|
+
const indexableFields = this._getIndexableFields(criteria);
|
|
198
|
+
if (indexableFields.length > 0) {
|
|
199
|
+
if (this.opts.debugMode) {
|
|
200
|
+
console.log(`🌊 Using pre-filtered streaming with ${indexableFields.length} indexable fields`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Use indices to pre-filter and reduce streaming scope
|
|
204
|
+
const preFilteredLines = this.indexManager.query(
|
|
205
|
+
this._extractIndexableCriteria(criteria),
|
|
206
|
+
options
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Stream only the pre-filtered records
|
|
210
|
+
return this._streamPreFilteredRecords(preFilteredLines, criteria, options);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Fallback to full streaming
|
|
214
|
+
if (this.opts.debugMode) {
|
|
215
|
+
console.log('🌊 Using full streaming (no indexable fields found)');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return this._streamAllRecords(criteria, options);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Get indexable fields from criteria
|
|
223
|
+
* @param {Object} criteria - Query criteria
|
|
224
|
+
* @returns {Array} - Array of indexable field names
|
|
225
|
+
*/
|
|
226
|
+
_getIndexableFields(criteria) {
|
|
227
|
+
const indexableFields = [];
|
|
228
|
+
|
|
229
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
230
|
+
return indexableFields;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Handle $and conditions
|
|
234
|
+
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
235
|
+
for (const andCondition of criteria.$and) {
|
|
236
|
+
indexableFields.push(...this._getIndexableFields(andCondition));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Handle regular field conditions
|
|
241
|
+
for (const [field, condition] of Object.entries(criteria)) {
|
|
242
|
+
if (field.startsWith('$')) continue; // Skip logical operators
|
|
243
|
+
|
|
244
|
+
// RegExp conditions cannot be pre-filtered using indices
|
|
245
|
+
if (condition instanceof RegExp) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
250
|
+
indexableFields.push(field);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return [...new Set(indexableFields)]; // Remove duplicates
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extract indexable criteria for pre-filtering
|
|
259
|
+
* @param {Object} criteria - Full query criteria
|
|
260
|
+
* @returns {Object} - Criteria with only indexable fields
|
|
261
|
+
*/
|
|
262
|
+
_extractIndexableCriteria(criteria) {
|
|
263
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
264
|
+
return {};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const indexableCriteria = {};
|
|
268
|
+
|
|
269
|
+
// Handle $and conditions
|
|
270
|
+
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
271
|
+
const indexableAndConditions = criteria.$and
|
|
272
|
+
.map(andCondition => this._extractIndexableCriteria(andCondition))
|
|
273
|
+
.filter(condition => Object.keys(condition).length > 0);
|
|
274
|
+
|
|
275
|
+
if (indexableAndConditions.length > 0) {
|
|
276
|
+
indexableCriteria.$and = indexableAndConditions;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Handle regular field conditions
|
|
281
|
+
for (const [field, condition] of Object.entries(criteria)) {
|
|
282
|
+
if (field.startsWith('$')) continue; // Skip logical operators
|
|
283
|
+
|
|
284
|
+
// RegExp conditions cannot be pre-filtered using indices
|
|
285
|
+
if (condition instanceof RegExp) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
290
|
+
indexableCriteria[field] = condition;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return indexableCriteria;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* OPTIMIZATION 4: Stream pre-filtered records using line numbers from indices with partial index optimization
|
|
299
|
+
* @param {Set} preFilteredLines - Line numbers from index query
|
|
300
|
+
* @param {Object} criteria - Full query criteria
|
|
301
|
+
* @param {Object} options - Query options
|
|
302
|
+
* @returns {Promise<Array>} - Query results
|
|
303
|
+
*/
|
|
304
|
+
async _streamPreFilteredRecords(preFilteredLines, criteria, options = {}) {
|
|
305
|
+
if (preFilteredLines.size === 0) {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const results = [];
|
|
310
|
+
const lineNumbers = Array.from(preFilteredLines);
|
|
311
|
+
|
|
312
|
+
// OPTIMIZATION 4: Sort line numbers for efficient file reading
|
|
313
|
+
lineNumbers.sort((a, b) => a - b);
|
|
314
|
+
|
|
315
|
+
// OPTIMIZATION 4: Use batch reading for better performance
|
|
316
|
+
const batchSize = Math.min(1000, lineNumbers.length); // Read in batches of 1000
|
|
317
|
+
const batches = [];
|
|
318
|
+
|
|
319
|
+
for (let i = 0; i < lineNumbers.length; i += batchSize) {
|
|
320
|
+
batches.push(lineNumbers.slice(i, i + batchSize));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const batch of batches) {
|
|
324
|
+
// OPTIMIZATION: Use ranges instead of reading entire file
|
|
325
|
+
const ranges = this.database.getRanges(batch);
|
|
326
|
+
const groupedRanges = await this.fileHandler.groupedRanges(ranges);
|
|
327
|
+
|
|
328
|
+
const fs = await import('fs');
|
|
329
|
+
const fd = await fs.promises.open(this.fileHandler.file, 'r');
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
for (const groupedRange of groupedRanges) {
|
|
333
|
+
for await (const row of this.fileHandler.readGroupedRange(groupedRange, fd)) {
|
|
334
|
+
if (row.line && row.line.trim()) {
|
|
335
|
+
try {
|
|
336
|
+
// CRITICAL FIX: Use serializer.deserialize instead of JSON.parse to handle array format
|
|
337
|
+
const record = this.database.serializer.deserialize(row.line);
|
|
338
|
+
|
|
339
|
+
// OPTIMIZATION 4: Use optimized criteria matching for pre-filtered records
|
|
340
|
+
if (this._matchesCriteriaOptimized(record, criteria, options)) {
|
|
341
|
+
// SPACE OPTIMIZATION: Restore term IDs to terms for user (unless disabled)
|
|
342
|
+
const recordWithTerms = options.restoreTerms !== false ?
|
|
343
|
+
this.database.restoreTermIdsAfterDeserialization(record) :
|
|
344
|
+
record
|
|
345
|
+
results.push(recordWithTerms);
|
|
346
|
+
|
|
347
|
+
// Check limit
|
|
348
|
+
if (options.limit && results.length >= options.limit) {
|
|
349
|
+
return this._applyOrdering(results, options);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch (error) {
|
|
353
|
+
// Skip invalid lines
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
await fd.close();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return this._applyOrdering(results, options);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* OPTIMIZATION 4: Optimized criteria matching for pre-filtered records
|
|
369
|
+
* @param {Object} record - Record to check
|
|
370
|
+
* @param {Object} criteria - Filter criteria
|
|
371
|
+
* @param {Object} options - Query options
|
|
372
|
+
* @returns {boolean} - True if matches
|
|
373
|
+
*/
|
|
374
|
+
_matchesCriteriaOptimized(record, criteria, options = {}) {
|
|
375
|
+
if (!criteria || Object.keys(criteria).length === 0) {
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Handle $not operator at the top level
|
|
380
|
+
if (criteria.$not && typeof criteria.$not === 'object') {
|
|
381
|
+
// For $not conditions, we need to negate the result
|
|
382
|
+
// IMPORTANT: For $not conditions, we should NOT skip pre-filtered fields
|
|
383
|
+
// because we need to evaluate the actual field values to determine exclusion
|
|
384
|
+
|
|
385
|
+
// Use the regular matchesCriteria method for $not conditions to ensure proper field evaluation
|
|
386
|
+
const notResult = this.matchesCriteria(record, criteria.$not, options);
|
|
387
|
+
return !notResult;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// OPTIMIZATION 4: Skip indexable fields since they were already pre-filtered
|
|
391
|
+
const indexableFields = this._getIndexableFields(criteria);
|
|
392
|
+
|
|
393
|
+
// Handle explicit logical operators at the top level
|
|
394
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
395
|
+
let orMatches = false;
|
|
396
|
+
for (const orCondition of criteria.$or) {
|
|
397
|
+
if (this._matchesCriteriaOptimized(record, orCondition, options)) {
|
|
398
|
+
orMatches = true;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!orMatches) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
} else if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
407
|
+
for (const andCondition of criteria.$and) {
|
|
408
|
+
if (!this._matchesCriteriaOptimized(record, andCondition, options)) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Handle individual field conditions (exclude logical operators and pre-filtered fields)
|
|
415
|
+
for (const [field, condition] of Object.entries(criteria)) {
|
|
416
|
+
if (field.startsWith('$')) continue;
|
|
417
|
+
|
|
418
|
+
// OPTIMIZATION 4: Skip indexable fields that were already pre-filtered
|
|
419
|
+
if (indexableFields.includes(field)) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!this.matchesFieldCondition(record, field, condition, options)) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* OPTIMIZATION 4: Apply ordering to results
|
|
437
|
+
* @param {Array} results - Results to order
|
|
438
|
+
* @param {Object} options - Query options
|
|
439
|
+
* @returns {Array} - Ordered results
|
|
440
|
+
*/
|
|
441
|
+
_applyOrdering(results, options) {
|
|
442
|
+
if (options.orderBy) {
|
|
443
|
+
const [field, direction = 'asc'] = options.orderBy.split(' ');
|
|
444
|
+
results.sort((a, b) => {
|
|
445
|
+
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
|
|
446
|
+
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
|
|
447
|
+
return 0;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return results;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Stream all records (fallback method)
|
|
456
|
+
* @param {Object} criteria - Query criteria
|
|
457
|
+
* @param {Object} options - Query options
|
|
458
|
+
* @returns {Promise<Array>} - Query results
|
|
459
|
+
*/
|
|
460
|
+
async _streamAllRecords(criteria, options = {}) {
|
|
461
|
+
const memoryLimit = options.limit || undefined;
|
|
462
|
+
const streamingOptions = { ...options, limit: memoryLimit };
|
|
463
|
+
|
|
464
|
+
const results = await this.fileHandler.readWithStreaming(criteria, streamingOptions, (record, criteria) => {
|
|
465
|
+
return this.matchesCriteria(record, criteria, options);
|
|
466
|
+
}, this.serializer || null);
|
|
467
|
+
|
|
468
|
+
// Apply ordering if specified
|
|
469
|
+
if (options.orderBy) {
|
|
470
|
+
const [field, direction = 'asc'] = options.orderBy.split(' ');
|
|
471
|
+
results.sort((a, b) => {
|
|
472
|
+
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
|
|
473
|
+
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
|
|
474
|
+
return 0;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return results;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Find using indexed search strategy with real streaming
|
|
483
|
+
* @param {Object} criteria - Query criteria
|
|
484
|
+
* @param {Object} options - Query options
|
|
485
|
+
* @returns {Promise<Array>} - Query results
|
|
486
|
+
*/
|
|
487
|
+
async findWithIndexed(criteria, options = {}) {
|
|
488
|
+
if (this.opts.debugMode) {
|
|
489
|
+
console.log('📊 Using indexed strategy with real streaming');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let results = []
|
|
493
|
+
const limit = options.limit // No default limit - return all results unless explicitly limited
|
|
494
|
+
|
|
495
|
+
// Use IndexManager to get line numbers, then read specific records
|
|
496
|
+
const lineNumbers = this.indexManager.query(criteria, options)
|
|
497
|
+
if (this.opts.debugMode) {
|
|
498
|
+
console.log(`🔍 IndexManager returned ${lineNumbers.size} line numbers:`, Array.from(lineNumbers))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Read specific records using the line numbers
|
|
502
|
+
if (lineNumbers.size > 0) {
|
|
503
|
+
const lineNumbersArray = Array.from(lineNumbers)
|
|
504
|
+
const ranges = this.database.getRanges(lineNumbersArray)
|
|
505
|
+
const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
|
|
506
|
+
|
|
507
|
+
const fs = await import('fs')
|
|
508
|
+
const fd = await fs.promises.open(this.database.fileHandler.file, 'r')
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
for (const groupedRange of groupedRanges) {
|
|
512
|
+
for await (const row of this.database.fileHandler.readGroupedRange(groupedRange, fd)) {
|
|
513
|
+
try {
|
|
514
|
+
const record = this.database.serializer.deserialize(row.line)
|
|
515
|
+
const recordWithTerms = options.restoreTerms !== false ?
|
|
516
|
+
this.database.restoreTermIdsAfterDeserialization(record) :
|
|
517
|
+
record
|
|
518
|
+
results.push(recordWithTerms)
|
|
519
|
+
if (limit && results.length >= limit) break
|
|
520
|
+
} catch (error) {
|
|
521
|
+
// Skip invalid lines
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (limit && results.length >= limit) break
|
|
525
|
+
}
|
|
526
|
+
} finally {
|
|
527
|
+
await fd.close()
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (options.orderBy) {
|
|
532
|
+
const [field, direction = 'asc'] = options.orderBy.split(' ')
|
|
533
|
+
results.sort((a, b) => {
|
|
534
|
+
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1
|
|
535
|
+
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1
|
|
536
|
+
return 0;
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
return results
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Check if a record matches criteria
|
|
544
|
+
* @param {Object} record - Record to check
|
|
545
|
+
* @param {Object} criteria - Filter criteria
|
|
546
|
+
* @param {Object} options - Query options (for caseInsensitive, etc.)
|
|
547
|
+
* @returns {boolean} - True if matches
|
|
548
|
+
*/
|
|
549
|
+
matchesCriteria(record, criteria, options = {}) {
|
|
550
|
+
|
|
551
|
+
if (!criteria || Object.keys(criteria).length === 0) {
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Handle explicit logical operators at the top level
|
|
556
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
557
|
+
let orMatches = false;
|
|
558
|
+
for (const orCondition of criteria.$or) {
|
|
559
|
+
if (this.matchesCriteria(record, orCondition, options)) {
|
|
560
|
+
orMatches = true;
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// If $or doesn't match, return false immediately
|
|
566
|
+
if (!orMatches) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// If $or matches, continue to check other conditions if they exist
|
|
571
|
+
// Don't return true yet - we need to check other conditions
|
|
572
|
+
} else if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
573
|
+
for (const andCondition of criteria.$and) {
|
|
574
|
+
if (!this.matchesCriteria(record, andCondition, options)) {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// $and matches, continue to check other conditions if they exist
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Handle individual field conditions and $not operator
|
|
582
|
+
for (const [field, condition] of Object.entries(criteria)) {
|
|
583
|
+
// Skip logical operators that are handled above
|
|
584
|
+
if (field.startsWith('$') && field !== '$not') {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (field === '$not') {
|
|
589
|
+
// Handle $not operator - it should negate the result of its condition
|
|
590
|
+
if (typeof condition === 'object' && condition !== null) {
|
|
591
|
+
// Empty $not condition should not exclude anything
|
|
592
|
+
if (Object.keys(condition).length === 0) {
|
|
593
|
+
continue; // Don't exclude anything
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Check if the $not condition matches - if it does, this record should be excluded
|
|
597
|
+
if (this.matchesCriteria(record, condition, options)) {
|
|
598
|
+
return false; // Exclude this record
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
// Handle regular field conditions
|
|
603
|
+
if (!this.matchesFieldCondition(record, field, condition, options)) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// If we have $or conditions and they matched, return true
|
|
610
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// For other cases (no $or, or $and, or just field conditions), return true if we got this far
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Check if a field matches a condition
|
|
620
|
+
* @param {Object} record - Record to check
|
|
621
|
+
* @param {string} field - Field name
|
|
622
|
+
* @param {*} condition - Condition to match
|
|
623
|
+
* @param {Object} options - Query options
|
|
624
|
+
* @returns {boolean} - True if matches
|
|
625
|
+
*/
|
|
626
|
+
matchesFieldCondition(record, field, condition, options = {}) {
|
|
627
|
+
const value = record[field];
|
|
628
|
+
|
|
629
|
+
// Debug logging for all field conditions
|
|
630
|
+
if (this.database.opts.debugMode) {
|
|
631
|
+
console.log(`🔍 Checking field '${field}':`, { value, condition, record: record.name || record.id });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Debug logging for term mapping fields
|
|
635
|
+
if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
|
|
636
|
+
if (this.database.opts.debugMode) {
|
|
637
|
+
console.log(`🔍 Checking term mapping field '${field}':`, { value, condition, record: record.name || record.id });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Handle null/undefined values
|
|
642
|
+
if (value === null || value === undefined) {
|
|
643
|
+
return condition === null || condition === undefined;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Handle regex conditions (MUST come before object check since RegExp is an object)
|
|
647
|
+
if (condition instanceof RegExp) {
|
|
648
|
+
// For array fields, test regex against each element
|
|
649
|
+
if (Array.isArray(value)) {
|
|
650
|
+
return value.some(element => condition.test(String(element)));
|
|
651
|
+
}
|
|
652
|
+
// For non-array fields, test regex against the value directly
|
|
653
|
+
return condition.test(String(value));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Handle array conditions
|
|
657
|
+
if (Array.isArray(condition)) {
|
|
658
|
+
// For array fields, check if any element in the field matches any element in the condition
|
|
659
|
+
if (Array.isArray(value)) {
|
|
660
|
+
return condition.some(condVal => value.includes(condVal));
|
|
661
|
+
}
|
|
662
|
+
// For non-array fields, check if value is in condition
|
|
663
|
+
return condition.includes(value);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Handle object conditions (operators)
|
|
667
|
+
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
668
|
+
for (const [operator, operatorValue] of Object.entries(condition)) {
|
|
669
|
+
if (!this.matchesOperator(value, operator, operatorValue, options)) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Handle case-insensitive string comparison
|
|
677
|
+
if (options.caseInsensitive && typeof value === 'string' && typeof condition === 'string') {
|
|
678
|
+
return value.toLowerCase() === condition.toLowerCase();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Handle direct array field search (e.g., { nameTerms: 'channel' })
|
|
682
|
+
if (Array.isArray(value) && typeof condition === 'string') {
|
|
683
|
+
return value.includes(condition);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Simple equality
|
|
687
|
+
return value === condition;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Check if a value matches an operator condition
|
|
692
|
+
* @param {*} value - Value to check
|
|
693
|
+
* @param {string} operator - Operator
|
|
694
|
+
* @param {*} operatorValue - Operator value
|
|
695
|
+
* @param {Object} options - Query options
|
|
696
|
+
* @returns {boolean} - True if matches
|
|
697
|
+
*/
|
|
698
|
+
matchesOperator(value, operator, operatorValue, options = {}) {
|
|
699
|
+
switch (operator) {
|
|
700
|
+
case '$gt':
|
|
701
|
+
return value > operatorValue;
|
|
702
|
+
case '$gte':
|
|
703
|
+
return value >= operatorValue;
|
|
704
|
+
case '$lt':
|
|
705
|
+
return value < operatorValue;
|
|
706
|
+
case '$lte':
|
|
707
|
+
return value <= operatorValue;
|
|
708
|
+
case '$ne':
|
|
709
|
+
return value !== operatorValue;
|
|
710
|
+
case '$not':
|
|
711
|
+
// $not operator should be handled at the criteria level, not field level
|
|
712
|
+
// This is a fallback for backward compatibility
|
|
713
|
+
return value !== operatorValue;
|
|
714
|
+
case '$in':
|
|
715
|
+
if (Array.isArray(value)) {
|
|
716
|
+
// For array fields, check if any element in the array matches any value in operatorValue
|
|
717
|
+
return Array.isArray(operatorValue) && operatorValue.some(opVal => value.includes(opVal));
|
|
718
|
+
} else {
|
|
719
|
+
// For non-array fields, check if value is in operatorValue
|
|
720
|
+
return Array.isArray(operatorValue) && operatorValue.includes(value);
|
|
721
|
+
}
|
|
722
|
+
case '$nin':
|
|
723
|
+
if (Array.isArray(value)) {
|
|
724
|
+
// For array fields, check if NO elements in the array match any value in operatorValue
|
|
725
|
+
return Array.isArray(operatorValue) && !operatorValue.some(opVal => value.includes(opVal));
|
|
726
|
+
} else {
|
|
727
|
+
// For non-array fields, check if value is not in operatorValue
|
|
728
|
+
return Array.isArray(operatorValue) && !operatorValue.includes(value);
|
|
729
|
+
}
|
|
730
|
+
case '$regex':
|
|
731
|
+
const regex = new RegExp(operatorValue, options.caseInsensitive ? 'i' : '');
|
|
732
|
+
// For array fields, test regex against each element
|
|
733
|
+
if (Array.isArray(value)) {
|
|
734
|
+
return value.some(element => regex.test(String(element)));
|
|
735
|
+
}
|
|
736
|
+
// For non-array fields, test regex against the value directly
|
|
737
|
+
return regex.test(String(value));
|
|
738
|
+
case '$contains':
|
|
739
|
+
if (Array.isArray(value)) {
|
|
740
|
+
return value.includes(operatorValue);
|
|
741
|
+
}
|
|
742
|
+
return String(value).includes(String(operatorValue));
|
|
743
|
+
case '$all':
|
|
744
|
+
if (!Array.isArray(value) || !Array.isArray(operatorValue)) {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
return operatorValue.every(item => value.includes(item));
|
|
748
|
+
case '$exists':
|
|
749
|
+
return operatorValue ? (value !== undefined && value !== null) : (value === undefined || value === null);
|
|
750
|
+
case '$size':
|
|
751
|
+
if (Array.isArray(value)) {
|
|
752
|
+
return value.length === operatorValue;
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
default:
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Preprocess query to handle array field syntax automatically
|
|
762
|
+
* @param {Object} criteria - Query criteria
|
|
763
|
+
* @returns {Object} - Processed criteria
|
|
764
|
+
*/
|
|
765
|
+
preprocessQuery(criteria) {
|
|
766
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
767
|
+
return criteria
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const processed = {}
|
|
771
|
+
|
|
772
|
+
for (const [field, value] of Object.entries(criteria)) {
|
|
773
|
+
// Check if this is a term mapping field
|
|
774
|
+
const isTermMappingField = this.database.opts.termMapping &&
|
|
775
|
+
this.database.termManager &&
|
|
776
|
+
this.database.termManager.termMappingFields &&
|
|
777
|
+
this.database.termManager.termMappingFields.includes(field)
|
|
778
|
+
|
|
779
|
+
if (isTermMappingField) {
|
|
780
|
+
// Handle term mapping field queries
|
|
781
|
+
if (typeof value === 'string') {
|
|
782
|
+
// Convert term to $in query for term mapping fields
|
|
783
|
+
processed[field] = { $in: [value] }
|
|
784
|
+
} else if (Array.isArray(value)) {
|
|
785
|
+
// Convert array to $in query
|
|
786
|
+
processed[field] = { $in: value }
|
|
787
|
+
} else if (value && typeof value === 'object') {
|
|
788
|
+
// Handle special query operators for term mapping
|
|
789
|
+
if (value.$in) {
|
|
790
|
+
processed[field] = { $in: value.$in }
|
|
791
|
+
} else if (value.$all) {
|
|
792
|
+
processed[field] = { $all: value.$all }
|
|
793
|
+
} else {
|
|
794
|
+
processed[field] = value
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
// Invalid value for term mapping field
|
|
798
|
+
throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (this.database.opts.debugMode) {
|
|
802
|
+
console.log(`🔍 Processed term mapping query for field '${field}':`, processed[field])
|
|
803
|
+
}
|
|
804
|
+
} else {
|
|
805
|
+
// Check if this field is defined as an array in the schema
|
|
806
|
+
const indexes = this.opts.indexes || {}
|
|
807
|
+
const fieldConfig = indexes[field]
|
|
808
|
+
const isArrayField = fieldConfig &&
|
|
809
|
+
(Array.isArray(fieldConfig) && fieldConfig.includes('array') ||
|
|
810
|
+
fieldConfig === 'array:string' ||
|
|
811
|
+
fieldConfig === 'array:number' ||
|
|
812
|
+
fieldConfig === 'array:boolean')
|
|
813
|
+
|
|
814
|
+
if (isArrayField) {
|
|
815
|
+
// Handle array field queries
|
|
816
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
817
|
+
// Convert direct value to $in query for array fields
|
|
818
|
+
processed[field] = { $in: [value] }
|
|
819
|
+
} else if (Array.isArray(value)) {
|
|
820
|
+
// Convert array to $in query
|
|
821
|
+
processed[field] = { $in: value }
|
|
822
|
+
} else if (value && typeof value === 'object') {
|
|
823
|
+
// Already properly formatted query object
|
|
824
|
+
processed[field] = value
|
|
825
|
+
} else {
|
|
826
|
+
// Invalid value for array field
|
|
827
|
+
throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
|
|
828
|
+
}
|
|
829
|
+
} else {
|
|
830
|
+
// Non-array field, keep as is
|
|
831
|
+
processed[field] = value
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return processed
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Determine which query strategy to use
|
|
841
|
+
* @param {Object} criteria - Query criteria
|
|
842
|
+
* @param {Object} options - Query options
|
|
843
|
+
* @returns {string} - 'streaming' or 'indexed'
|
|
844
|
+
*/
|
|
845
|
+
shouldUseStreaming(criteria, options = {}) {
|
|
846
|
+
const { limit } = options; // No default limit
|
|
847
|
+
const totalRecords = this.database.length || 0;
|
|
848
|
+
|
|
849
|
+
// Strategy 1: Always streaming for queries without criteria
|
|
850
|
+
if (!criteria || Object.keys(criteria).length === 0) {
|
|
851
|
+
if (this.opts.debugMode) {
|
|
852
|
+
console.log('📊 QueryStrategy: STREAMING - No criteria provided');
|
|
853
|
+
}
|
|
854
|
+
return 'streaming';
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Strategy 2: Check if all fields are indexed and support the operators used
|
|
858
|
+
// First, check if $not is present at root level - if so, we need to use streaming for proper $not handling
|
|
859
|
+
if (criteria.$not && !this.opts.termMapping) {
|
|
860
|
+
if (this.opts.debugMode) {
|
|
861
|
+
console.log('📊 QueryStrategy: STREAMING - $not operator requires streaming mode');
|
|
862
|
+
}
|
|
863
|
+
return 'streaming';
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// OPTIMIZATION: For term mapping, we can process $not using indices
|
|
867
|
+
if (criteria.$not && this.opts.termMapping) {
|
|
868
|
+
// Check if all $not fields are indexed
|
|
869
|
+
const notFields = Object.keys(criteria.$not)
|
|
870
|
+
const allNotFieldsIndexed = notFields.every(field =>
|
|
871
|
+
this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
if (allNotFieldsIndexed) {
|
|
875
|
+
if (this.opts.debugMode) {
|
|
876
|
+
console.log('📊 QueryStrategy: INDEXED - $not with term mapping can use indexed strategy');
|
|
877
|
+
}
|
|
878
|
+
// Continue to check other conditions instead of forcing streaming
|
|
879
|
+
} else {
|
|
880
|
+
if (this.opts.debugMode) {
|
|
881
|
+
console.log('📊 QueryStrategy: STREAMING - $not fields not all indexed');
|
|
882
|
+
}
|
|
883
|
+
return 'streaming';
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Handle $and queries - check if all conditions in $and are indexable
|
|
888
|
+
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
889
|
+
const allAndConditionsIndexed = criteria.$and.every(andCondition => {
|
|
890
|
+
// Handle $not conditions within $and
|
|
891
|
+
if (andCondition.$not) {
|
|
892
|
+
const notFields = Object.keys(andCondition.$not);
|
|
893
|
+
return notFields.every(field => {
|
|
894
|
+
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
// For term mapping, $not can be processed with indices
|
|
898
|
+
return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Handle regular field conditions
|
|
903
|
+
return Object.keys(andCondition).every(field => {
|
|
904
|
+
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const condition = andCondition[field];
|
|
909
|
+
|
|
910
|
+
// RegExp cannot be efficiently queried using indices - must use streaming
|
|
911
|
+
if (condition instanceof RegExp) {
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
916
|
+
const operators = Object.keys(condition);
|
|
917
|
+
|
|
918
|
+
if (this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field)) {
|
|
919
|
+
return operators.every(op => {
|
|
920
|
+
return !['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'].includes(op);
|
|
921
|
+
});
|
|
922
|
+
} else {
|
|
923
|
+
return operators.every(op => {
|
|
924
|
+
return !['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'].includes(op);
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return true;
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
if (!allAndConditionsIndexed) {
|
|
933
|
+
if (this.opts.debugMode) {
|
|
934
|
+
console.log('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
|
|
935
|
+
}
|
|
936
|
+
return 'streaming';
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const allFieldsIndexed = Object.keys(criteria).every(field => {
|
|
941
|
+
// Skip $and as it's handled separately above
|
|
942
|
+
if (field === '$and') return true;
|
|
943
|
+
|
|
944
|
+
if (!this.opts.indexes || !this.opts.indexes[field]) {
|
|
945
|
+
if (this.opts.debugMode) {
|
|
946
|
+
console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}))
|
|
947
|
+
}
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Check if the field uses operators that are supported by IndexManager
|
|
952
|
+
const condition = criteria[field];
|
|
953
|
+
|
|
954
|
+
// RegExp cannot be efficiently queried using indices - must use streaming
|
|
955
|
+
if (condition instanceof RegExp) {
|
|
956
|
+
if (this.opts.debugMode) {
|
|
957
|
+
console.log(`🔍 Field '${field}' uses RegExp - requires streaming strategy`)
|
|
958
|
+
}
|
|
959
|
+
return false;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
963
|
+
const operators = Object.keys(condition);
|
|
964
|
+
if (this.opts.debugMode) {
|
|
965
|
+
console.log(`🔍 Field '${field}' has operators:`, operators)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// With term mapping enabled, we can support complex operators via partial reads
|
|
969
|
+
if (this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field)) {
|
|
970
|
+
// Term mapping fields can use complex operators with partial reads
|
|
971
|
+
return operators.every(op => {
|
|
972
|
+
// Support $in, $nin, $all, $not for term mapping fields (converted to multiple equality checks)
|
|
973
|
+
return !['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'].includes(op);
|
|
974
|
+
});
|
|
975
|
+
} else {
|
|
976
|
+
// Non-term-mapping fields only support simple equality operations
|
|
977
|
+
return operators.every(op => {
|
|
978
|
+
return !['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'].includes(op);
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return true;
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
if (!allFieldsIndexed) {
|
|
986
|
+
if (this.opts.debugMode) {
|
|
987
|
+
console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
|
|
988
|
+
}
|
|
989
|
+
return 'streaming';
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// OPTIMIZATION 2: Hybrid strategy - use pre-filtered streaming when index is empty
|
|
993
|
+
const indexData = this.indexManager.index.data || {};
|
|
994
|
+
const hasIndexData = Object.keys(indexData).length > 0;
|
|
995
|
+
if (!hasIndexData) {
|
|
996
|
+
// Check if we can use pre-filtered streaming with term mapping
|
|
997
|
+
if (this.opts.termMapping && this._canUsePreFilteredStreaming(criteria)) {
|
|
998
|
+
if (this.opts.debugMode) {
|
|
999
|
+
console.log('📊 QueryStrategy: HYBRID - Using pre-filtered streaming with term mapping');
|
|
1000
|
+
}
|
|
1001
|
+
return 'streaming'; // Will use pre-filtered streaming in findWithStreaming
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (this.opts.debugMode) {
|
|
1005
|
+
console.log('📊 QueryStrategy: STREAMING - Index is empty and no pre-filtering available');
|
|
1006
|
+
}
|
|
1007
|
+
return 'streaming';
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Strategy 3: Streaming if limit is very high (only if database has records)
|
|
1011
|
+
if (totalRecords > 0 && limit > totalRecords * this.opts.streamingThreshold) {
|
|
1012
|
+
if (this.opts.debugMode) {
|
|
1013
|
+
console.log(`📊 QueryStrategy: STREAMING - High limit (${limit} > ${Math.round(totalRecords * this.opts.streamingThreshold)})`);
|
|
1014
|
+
}
|
|
1015
|
+
return 'streaming';
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Strategy 4: Use indexed strategy when all fields are indexed and streamingThreshold is respected
|
|
1019
|
+
if (this.opts.debugMode) {
|
|
1020
|
+
console.log(`📊 QueryStrategy: INDEXED - All fields indexed, using indexed strategy`);
|
|
1021
|
+
}
|
|
1022
|
+
return 'indexed';
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Estimate number of results for a query
|
|
1027
|
+
* @param {Object} criteria - Query criteria
|
|
1028
|
+
* @param {number} totalRecords - Total records in database
|
|
1029
|
+
* @returns {number} - Estimated results
|
|
1030
|
+
*/
|
|
1031
|
+
estimateQueryResults(criteria, totalRecords) {
|
|
1032
|
+
// If database is empty, return 0
|
|
1033
|
+
if (totalRecords === 0) {
|
|
1034
|
+
if (this.opts.debugMode) {
|
|
1035
|
+
console.log(`📊 Estimation: Database empty → 0 results`);
|
|
1036
|
+
}
|
|
1037
|
+
return 0;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
let minResults = Infinity;
|
|
1041
|
+
|
|
1042
|
+
for (const [field, condition] of Object.entries(criteria)) {
|
|
1043
|
+
// Check if field is indexed
|
|
1044
|
+
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
1045
|
+
// Non-indexed field - assume it could match any record
|
|
1046
|
+
if (this.opts.debugMode) {
|
|
1047
|
+
console.log(`📊 Estimation: ${field} = non-indexed → ~${totalRecords} results`);
|
|
1048
|
+
}
|
|
1049
|
+
return totalRecords;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const fieldIndex = this.indexManager.index.data[field];
|
|
1053
|
+
|
|
1054
|
+
if (!fieldIndex) {
|
|
1055
|
+
// Non-indexed field - assume it could match any record
|
|
1056
|
+
if (this.opts.debugMode) {
|
|
1057
|
+
console.log(`📊 Estimation: ${field} = non-indexed → ~${totalRecords} results`);
|
|
1058
|
+
}
|
|
1059
|
+
return totalRecords;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
let fieldEstimate = 0;
|
|
1063
|
+
|
|
1064
|
+
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
1065
|
+
// Handle different types of operators
|
|
1066
|
+
for (const [operator, value] of Object.entries(condition)) {
|
|
1067
|
+
if (operator === '$all') {
|
|
1068
|
+
// Special handling for $all operator
|
|
1069
|
+
fieldEstimate = this.estimateAllOperator(fieldIndex, value);
|
|
1070
|
+
} else if (['$gt', '$gte', '$lt', '$lte', '$in', '$regex'].includes(operator)) {
|
|
1071
|
+
// Numeric and other operators
|
|
1072
|
+
fieldEstimate = this.estimateOperatorResults(fieldIndex, operator, value, totalRecords);
|
|
1073
|
+
} else {
|
|
1074
|
+
// Unknown operator, assume it could match any record
|
|
1075
|
+
fieldEstimate = totalRecords;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
} else {
|
|
1079
|
+
// Simple equality
|
|
1080
|
+
const recordIds = fieldIndex[condition];
|
|
1081
|
+
fieldEstimate = recordIds ? recordIds.length : 0;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (this.opts.debugMode) {
|
|
1085
|
+
console.log(`📊 Estimation: ${field} = ${fieldEstimate} results`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
minResults = Math.min(minResults, fieldEstimate);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return minResults === Infinity ? 0 : minResults;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Estimate results for $all operator
|
|
1096
|
+
* @param {Object} fieldIndex - Field index
|
|
1097
|
+
* @param {Array} values - Values to match
|
|
1098
|
+
* @returns {number} - Estimated results
|
|
1099
|
+
*/
|
|
1100
|
+
estimateAllOperator(fieldIndex, values) {
|
|
1101
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
1102
|
+
return 0;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
let minCount = Infinity;
|
|
1106
|
+
for (const value of values) {
|
|
1107
|
+
const recordIds = fieldIndex[value];
|
|
1108
|
+
const count = recordIds ? recordIds.length : 0;
|
|
1109
|
+
minCount = Math.min(minCount, count);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return minCount === Infinity ? 0 : minCount;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Estimate results for operators
|
|
1117
|
+
* @param {Object} fieldIndex - Field index
|
|
1118
|
+
* @param {string} operator - Operator
|
|
1119
|
+
* @param {*} value - Value
|
|
1120
|
+
* @param {number} totalRecords - Total records
|
|
1121
|
+
* @returns {number} - Estimated results
|
|
1122
|
+
*/
|
|
1123
|
+
estimateOperatorResults(fieldIndex, operator, value, totalRecords) {
|
|
1124
|
+
// This is a simplified estimation - in practice, you might want more sophisticated logic
|
|
1125
|
+
switch (operator) {
|
|
1126
|
+
case '$in':
|
|
1127
|
+
if (Array.isArray(value)) {
|
|
1128
|
+
let total = 0;
|
|
1129
|
+
for (const v of value) {
|
|
1130
|
+
const recordIds = fieldIndex[v];
|
|
1131
|
+
if (recordIds) total += recordIds.length;
|
|
1132
|
+
}
|
|
1133
|
+
return total;
|
|
1134
|
+
}
|
|
1135
|
+
break;
|
|
1136
|
+
case '$gt':
|
|
1137
|
+
case '$gte':
|
|
1138
|
+
case '$lt':
|
|
1139
|
+
case '$lte':
|
|
1140
|
+
// For range queries, estimate based on data distribution
|
|
1141
|
+
// This is a simplified approach - real implementation would be more sophisticated
|
|
1142
|
+
return Math.floor(totalRecords * 0.1); // Assume 10% of records match
|
|
1143
|
+
case '$regex':
|
|
1144
|
+
// Regex is hard to estimate without scanning
|
|
1145
|
+
return Math.floor(totalRecords * 0.05); // Assume 5% of records match
|
|
1146
|
+
}
|
|
1147
|
+
return 0;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Validate strict query mode
|
|
1152
|
+
* @param {Object} criteria - Query criteria
|
|
1153
|
+
*/
|
|
1154
|
+
validateStrictQuery(criteria) {
|
|
1155
|
+
if (!criteria || Object.keys(criteria).length === 0) {
|
|
1156
|
+
return; // Empty criteria are always allowed
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Handle logical operators at the top level
|
|
1160
|
+
if (criteria.$not) {
|
|
1161
|
+
this.validateStrictQuery(criteria.$not);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
1166
|
+
for (const orCondition of criteria.$or) {
|
|
1167
|
+
this.validateStrictQuery(orCondition);
|
|
1168
|
+
}
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
1173
|
+
for (const andCondition of criteria.$and) {
|
|
1174
|
+
this.validateStrictQuery(andCondition);
|
|
1175
|
+
}
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Get available indexed fields
|
|
1180
|
+
const indexedFields = Object.keys(this.indexManager.opts.indexes || {});
|
|
1181
|
+
const availableFields = indexedFields.length > 0 ? indexedFields.join(', ') : 'none';
|
|
1182
|
+
|
|
1183
|
+
// Check each field
|
|
1184
|
+
const nonIndexedFields = [];
|
|
1185
|
+
for (const [field, condition] of Object.entries(criteria)) {
|
|
1186
|
+
// Skip logical operators
|
|
1187
|
+
if (field.startsWith('$')) {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Check if field is indexed
|
|
1192
|
+
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
1193
|
+
nonIndexedFields.push(field);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Check if condition uses supported operators
|
|
1197
|
+
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
1198
|
+
const operators = Object.keys(condition);
|
|
1199
|
+
for (const op of operators) {
|
|
1200
|
+
if (!['$in', '$nin', '$contains', '$all', '>', '>=', '<', '<=', '!=', 'contains', 'regex'].includes(op)) {
|
|
1201
|
+
throw new Error(`Operator '${op}' is not supported in strict mode for field '${field}'.`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Generate appropriate error message
|
|
1208
|
+
if (nonIndexedFields.length > 0) {
|
|
1209
|
+
if (nonIndexedFields.length === 1) {
|
|
1210
|
+
throw new Error(`Strict indexed mode: Field '${nonIndexedFields[0]}' is not indexed. Available indexed fields: ${availableFields}`);
|
|
1211
|
+
} else {
|
|
1212
|
+
throw new Error(`Strict indexed mode: Fields '${nonIndexedFields.join("', '")}' are not indexed. Available indexed fields: ${availableFields}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Update average time for performance tracking
|
|
1219
|
+
* @param {string} type - Type of operation ('streaming' or 'indexed')
|
|
1220
|
+
* @param {number} time - Time taken
|
|
1221
|
+
*/
|
|
1222
|
+
updateAverageTime(type, time) {
|
|
1223
|
+
if (!this.usageStats[`${type}AverageTime`]) {
|
|
1224
|
+
this.usageStats[`${type}AverageTime`] = 0;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const currentAverage = this.usageStats[`${type}AverageTime`];
|
|
1228
|
+
const count = this.usageStats[`${type}Queries`] || 1;
|
|
1229
|
+
|
|
1230
|
+
// Calculate running average
|
|
1231
|
+
this.usageStats[`${type}AverageTime`] = (currentAverage * (count - 1) + time) / count;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* OPTIMIZATION 2: Check if we can use pre-filtered streaming with term mapping
|
|
1236
|
+
* @param {Object} criteria - Query criteria
|
|
1237
|
+
* @returns {boolean} - True if pre-filtered streaming can be used
|
|
1238
|
+
*/
|
|
1239
|
+
_canUsePreFilteredStreaming(criteria) {
|
|
1240
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Check if we have term mapping fields in the query
|
|
1245
|
+
const termMappingFields = Object.keys(this.opts.indexes || {});
|
|
1246
|
+
const queryFields = Object.keys(criteria).filter(field => !field.startsWith('$'));
|
|
1247
|
+
|
|
1248
|
+
// Check if any query field is a term mapping field
|
|
1249
|
+
const hasTermMappingFields = queryFields.some(field => termMappingFields.includes(field));
|
|
1250
|
+
|
|
1251
|
+
if (!hasTermMappingFields) {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Check if the query is simple enough for pre-filtering
|
|
1256
|
+
// Simple equality queries on term mapping fields work well with pre-filtering
|
|
1257
|
+
for (const [field, condition] of Object.entries(criteria)) {
|
|
1258
|
+
if (field.startsWith('$')) continue;
|
|
1259
|
+
|
|
1260
|
+
if (termMappingFields.includes(field)) {
|
|
1261
|
+
// For term mapping fields, simple equality or $in queries work well
|
|
1262
|
+
if (typeof condition === 'string' ||
|
|
1263
|
+
(typeof condition === 'object' && condition.$in && Array.isArray(condition.$in))) {
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Simplified term mapping - handled in TermManager
|
|
1273
|
+
}
|