jexidb 2.1.0 → 2.1.2
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/dist/Database.cjs +9253 -437
- package/package.json +9 -2
- package/src/Database.mjs +1572 -212
- package/src/FileHandler.mjs +83 -44
- package/src/OperationQueue.mjs +23 -23
- package/src/SchemaManager.mjs +325 -268
- package/src/Serializer.mjs +234 -24
- package/src/managers/IndexManager.mjs +778 -87
- package/src/managers/QueryManager.mjs +340 -67
- package/src/managers/TermManager.mjs +7 -7
- package/src/utils/operatorNormalizer.mjs +116 -0
- package/.babelrc +0 -13
- package/.gitattributes +0 -2
- package/CHANGELOG.md +0 -140
- package/babel.config.json +0 -5
- package/docs/API.md +0 -1051
- package/docs/EXAMPLES.md +0 -701
- package/docs/README.md +0 -194
- package/examples/iterate-usage-example.js +0 -157
- package/examples/simple-iterate-example.js +0 -115
- package/jest.config.js +0 -24
- package/scripts/README.md +0 -47
- package/scripts/clean-test-files.js +0 -75
- package/scripts/prepare.js +0 -31
- package/scripts/run-tests.js +0 -80
- package/test/$not-operator-with-and.test.js +0 -282
- package/test/README.md +0 -8
- package/test/close-init-cycle.test.js +0 -256
- package/test/critical-bugs-fixes.test.js +0 -1069
- package/test/index-persistence.test.js +0 -306
- package/test/index-serialization.test.js +0 -314
- package/test/indexed-query-mode.test.js +0 -360
- package/test/iterate-method.test.js +0 -272
- package/test/query-operators.test.js +0 -238
- package/test/regex-array-fields.test.js +0 -129
- package/test/score-method.test.js +0 -238
- package/test/setup.js +0 -17
- package/test/term-mapping-minimal.test.js +0 -154
- package/test/term-mapping-simple.test.js +0 -257
- package/test/term-mapping.test.js +0 -514
- package/test/writebuffer-flush-resilience.test.js +0 -204
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeOperator } from '../utils/operatorNormalizer.mjs'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* QueryManager - Handles all query operations and strategies
|
|
3
5
|
*
|
|
@@ -35,6 +37,9 @@ export class QueryManager {
|
|
|
35
37
|
if (this.database.destroyed) throw new Error('Database is destroyed')
|
|
36
38
|
if (!this.database.initialized) await this.database.init()
|
|
37
39
|
|
|
40
|
+
// Rebuild indexes if needed (when index was corrupted/missing)
|
|
41
|
+
await this.database._rebuildIndexesIfNeeded()
|
|
42
|
+
|
|
38
43
|
// Manual save is now the responsibility of the application
|
|
39
44
|
|
|
40
45
|
// Preprocess query to handle array field syntax automatically
|
|
@@ -44,8 +49,8 @@ export class QueryManager {
|
|
|
44
49
|
|
|
45
50
|
// Validate strict indexed mode before processing
|
|
46
51
|
if (this.opts.indexedQueryMode === 'strict') {
|
|
47
|
-
this.validateStrictQuery(finalCriteria);
|
|
48
|
-
}
|
|
52
|
+
this.validateStrictQuery(finalCriteria, options);
|
|
53
|
+
}
|
|
49
54
|
|
|
50
55
|
const startTime = Date.now();
|
|
51
56
|
this.usageStats.totalQueries++;
|
|
@@ -99,7 +104,7 @@ export class QueryManager {
|
|
|
99
104
|
|
|
100
105
|
// Validate strict indexed mode before processing
|
|
101
106
|
if (this.opts.indexedQueryMode === 'strict') {
|
|
102
|
-
this.validateStrictQuery(processedCriteria);
|
|
107
|
+
this.validateStrictQuery(processedCriteria, options);
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
const startTime = Date.now();
|
|
@@ -147,11 +152,15 @@ export class QueryManager {
|
|
|
147
152
|
async count(criteria, options = {}) {
|
|
148
153
|
if (this.database.destroyed) throw new Error('Database is destroyed')
|
|
149
154
|
if (!this.database.initialized) await this.database.init()
|
|
155
|
+
|
|
156
|
+
// Rebuild indexes if needed (when index was corrupted/missing)
|
|
157
|
+
await this.database._rebuildIndexesIfNeeded()
|
|
158
|
+
|
|
150
159
|
// Manual save is now the responsibility of the application
|
|
151
160
|
|
|
152
161
|
// Validate strict indexed mode before processing
|
|
153
162
|
if (this.opts.indexedQueryMode === 'strict') {
|
|
154
|
-
this.validateStrictQuery(criteria);
|
|
163
|
+
this.validateStrictQuery(criteria, options);
|
|
155
164
|
}
|
|
156
165
|
|
|
157
166
|
// Use the same strategy as find method
|
|
@@ -164,9 +173,50 @@ export class QueryManager {
|
|
|
164
173
|
const results = await this.findWithStreaming(criteria, options);
|
|
165
174
|
count = results.length;
|
|
166
175
|
} else {
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
// OPTIMIZATION: For indexed strategy, use indexManager.query().size directly
|
|
177
|
+
// This avoids reading actual records from the file - much faster!
|
|
178
|
+
const lineNumbers = this.indexManager.query(criteria, options);
|
|
179
|
+
|
|
180
|
+
if (lineNumbers.size === 0) {
|
|
181
|
+
const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
|
|
182
|
+
if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
|
|
183
|
+
// Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
|
|
184
|
+
if (this.database.opts.allowIndexRebuild) {
|
|
185
|
+
if (this.opts.debugMode) {
|
|
186
|
+
console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`);
|
|
187
|
+
}
|
|
188
|
+
this.database._indexRebuildNeeded = true
|
|
189
|
+
await this.database._rebuildIndexesIfNeeded()
|
|
190
|
+
|
|
191
|
+
// Retry indexed query after rebuild
|
|
192
|
+
const retryLineNumbers = this.indexManager.query(criteria, options)
|
|
193
|
+
if (retryLineNumbers.size > 0) {
|
|
194
|
+
if (this.opts.debugMode) {
|
|
195
|
+
console.log(`✅ Index rebuild successful, using indexed strategy.`);
|
|
196
|
+
}
|
|
197
|
+
count = retryLineNumbers.size
|
|
198
|
+
} else {
|
|
199
|
+
// Still no results after rebuild, fall back to streaming
|
|
200
|
+
if (this.opts.debugMode) {
|
|
201
|
+
console.log(`⚠️ Index rebuild did not help, falling back to streaming count.`);
|
|
202
|
+
}
|
|
203
|
+
const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
|
|
204
|
+
count = streamingResults.length
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
// allowIndexRebuild is false, fall back to streaming
|
|
208
|
+
if (this.opts.debugMode) {
|
|
209
|
+
console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming count.`);
|
|
210
|
+
}
|
|
211
|
+
const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
|
|
212
|
+
count = streamingResults.length
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
count = 0
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
count = lineNumbers.size;
|
|
219
|
+
}
|
|
170
220
|
}
|
|
171
221
|
|
|
172
222
|
return count;
|
|
@@ -189,33 +239,43 @@ export class QueryManager {
|
|
|
189
239
|
* @returns {Promise<Array>} - Query results
|
|
190
240
|
*/
|
|
191
241
|
async findWithStreaming(criteria, options = {}) {
|
|
242
|
+
const streamingOptions = { ...options }
|
|
243
|
+
const forceFullScan = streamingOptions.forceFullScan === true
|
|
244
|
+
delete streamingOptions.forceFullScan
|
|
245
|
+
|
|
192
246
|
if (this.opts.debugMode) {
|
|
193
|
-
|
|
247
|
+
if (forceFullScan) {
|
|
248
|
+
console.log('🌊 Using streaming strategy (forced full scan to bypass missing index data)');
|
|
249
|
+
} else {
|
|
250
|
+
console.log('🌊 Using streaming strategy');
|
|
251
|
+
}
|
|
194
252
|
}
|
|
195
253
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
254
|
+
if (!forceFullScan) {
|
|
255
|
+
// OPTIMIZATION: Try to use indices for pre-filtering when possible
|
|
256
|
+
const indexableFields = this._getIndexableFields(criteria);
|
|
257
|
+
if (indexableFields.length > 0) {
|
|
258
|
+
if (this.opts.debugMode) {
|
|
259
|
+
console.log(`🌊 Using pre-filtered streaming with ${indexableFields.length} indexable fields`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Use indices to pre-filter and reduce streaming scope
|
|
263
|
+
const preFilteredLines = this.indexManager.query(
|
|
264
|
+
this._extractIndexableCriteria(criteria),
|
|
265
|
+
streamingOptions
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Stream only the pre-filtered records
|
|
269
|
+
return this._streamPreFilteredRecords(preFilteredLines, criteria, streamingOptions);
|
|
201
270
|
}
|
|
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
271
|
}
|
|
212
272
|
|
|
213
273
|
// Fallback to full streaming
|
|
214
274
|
if (this.opts.debugMode) {
|
|
215
|
-
console.log('🌊 Using full streaming (no indexable fields found)');
|
|
275
|
+
console.log('🌊 Using full streaming (no indexable fields found or forced)');
|
|
216
276
|
}
|
|
217
277
|
|
|
218
|
-
return this._streamAllRecords(criteria,
|
|
278
|
+
return this._streamAllRecords(criteria, streamingOptions);
|
|
219
279
|
}
|
|
220
280
|
|
|
221
281
|
/**
|
|
@@ -277,9 +337,26 @@ export class QueryManager {
|
|
|
277
337
|
}
|
|
278
338
|
}
|
|
279
339
|
|
|
340
|
+
// Handle $not operator - include it if it can be processed by IndexManager
|
|
341
|
+
if (criteria.$not && typeof criteria.$not === 'object') {
|
|
342
|
+
// Check if $not condition contains only indexable fields
|
|
343
|
+
const notFields = Object.keys(criteria.$not);
|
|
344
|
+
const allNotFieldsIndexed = notFields.every(field =>
|
|
345
|
+
this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (allNotFieldsIndexed && notFields.length > 0) {
|
|
349
|
+
// Extract indexable criteria from $not condition
|
|
350
|
+
const indexableNotCriteria = this._extractIndexableCriteria(criteria.$not);
|
|
351
|
+
if (Object.keys(indexableNotCriteria).length > 0) {
|
|
352
|
+
indexableCriteria.$not = indexableNotCriteria;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
280
357
|
// Handle regular field conditions
|
|
281
358
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
282
|
-
if (field.startsWith('$')) continue; // Skip logical operators
|
|
359
|
+
if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
|
|
283
360
|
|
|
284
361
|
// RegExp conditions cannot be pre-filtered using indices
|
|
285
362
|
if (condition instanceof RegExp) {
|
|
@@ -294,6 +371,96 @@ export class QueryManager {
|
|
|
294
371
|
return indexableCriteria;
|
|
295
372
|
}
|
|
296
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Determine whether the database currently has any records (persisted or pending)
|
|
376
|
+
* @returns {boolean}
|
|
377
|
+
*/
|
|
378
|
+
_hasAnyRecords() {
|
|
379
|
+
if (!this.database) {
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (Array.isArray(this.database.offsets) && this.database.offsets.length > 0) {
|
|
384
|
+
return true
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (Array.isArray(this.database.writeBuffer) && this.database.writeBuffer.length > 0) {
|
|
388
|
+
return true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (typeof this.database.length === 'number' && this.database.length > 0) {
|
|
392
|
+
return true
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return false
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Extract all indexed fields referenced in the criteria
|
|
400
|
+
* @param {Object} criteria
|
|
401
|
+
* @param {Set<string>} accumulator
|
|
402
|
+
* @returns {Array<string>}
|
|
403
|
+
*/
|
|
404
|
+
_extractIndexedFields(criteria, accumulator = new Set()) {
|
|
405
|
+
if (!criteria) {
|
|
406
|
+
return Array.from(accumulator)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (Array.isArray(criteria)) {
|
|
410
|
+
for (const item of criteria) {
|
|
411
|
+
this._extractIndexedFields(item, accumulator)
|
|
412
|
+
}
|
|
413
|
+
return Array.from(accumulator)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (typeof criteria !== 'object') {
|
|
417
|
+
return Array.from(accumulator)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (const [key, value] of Object.entries(criteria)) {
|
|
421
|
+
if (key.startsWith('$')) {
|
|
422
|
+
this._extractIndexedFields(value, accumulator)
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
accumulator.add(key)
|
|
427
|
+
|
|
428
|
+
if (Array.isArray(value)) {
|
|
429
|
+
for (const nested of value) {
|
|
430
|
+
this._extractIndexedFields(nested, accumulator)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return Array.from(accumulator)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Identify indexed fields present in criteria whose index data is missing
|
|
440
|
+
* @param {Object} criteria
|
|
441
|
+
* @returns {Array<string>}
|
|
442
|
+
*/
|
|
443
|
+
_getIndexedFieldsWithMissingData(criteria) {
|
|
444
|
+
if (!this.indexManager || !criteria) {
|
|
445
|
+
return []
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const indexedFields = this._extractIndexedFields(criteria)
|
|
449
|
+
const missing = []
|
|
450
|
+
|
|
451
|
+
for (const field of indexedFields) {
|
|
452
|
+
if (!this.indexManager.isFieldIndexed(field)) {
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!this.indexManager.hasUsableIndexData(field)) {
|
|
457
|
+
missing.push(field)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return missing
|
|
462
|
+
}
|
|
463
|
+
|
|
297
464
|
/**
|
|
298
465
|
* OPTIMIZATION 4: Stream pre-filtered records using line numbers from indices with partial index optimization
|
|
299
466
|
* @param {Set} preFilteredLines - Line numbers from index query
|
|
@@ -498,33 +665,111 @@ export class QueryManager {
|
|
|
498
665
|
console.log(`🔍 IndexManager returned ${lineNumbers.size} line numbers:`, Array.from(lineNumbers))
|
|
499
666
|
}
|
|
500
667
|
|
|
668
|
+
if (lineNumbers.size === 0) {
|
|
669
|
+
const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
|
|
670
|
+
if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
|
|
671
|
+
// Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
|
|
672
|
+
if (this.database.opts.allowIndexRebuild) {
|
|
673
|
+
if (this.opts.debugMode) {
|
|
674
|
+
console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`)
|
|
675
|
+
}
|
|
676
|
+
this.database._indexRebuildNeeded = true
|
|
677
|
+
await this.database._rebuildIndexesIfNeeded()
|
|
678
|
+
|
|
679
|
+
// Retry indexed query after rebuild
|
|
680
|
+
const retryLineNumbers = this.indexManager.query(criteria, options)
|
|
681
|
+
if (retryLineNumbers.size > 0) {
|
|
682
|
+
if (this.opts.debugMode) {
|
|
683
|
+
console.log(`✅ Index rebuild successful, using indexed strategy.`)
|
|
684
|
+
}
|
|
685
|
+
// Update lineNumbers to use rebuilt index results
|
|
686
|
+
lineNumbers.clear()
|
|
687
|
+
for (const lineNumber of retryLineNumbers) {
|
|
688
|
+
lineNumbers.add(lineNumber)
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
// Still no results after rebuild, fall back to streaming
|
|
692
|
+
if (this.opts.debugMode) {
|
|
693
|
+
console.log(`⚠️ Index rebuild did not help, falling back to streaming.`)
|
|
694
|
+
}
|
|
695
|
+
return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
// allowIndexRebuild is false, fall back to streaming
|
|
699
|
+
if (this.opts.debugMode) {
|
|
700
|
+
console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming.`)
|
|
701
|
+
}
|
|
702
|
+
return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
501
707
|
// Read specific records using the line numbers
|
|
502
708
|
if (lineNumbers.size > 0) {
|
|
503
709
|
const lineNumbersArray = Array.from(lineNumbers)
|
|
504
|
-
const
|
|
505
|
-
const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
|
|
710
|
+
const persistedCount = Array.isArray(this.database.offsets) ? this.database.offsets.length : 0
|
|
506
711
|
|
|
507
|
-
|
|
508
|
-
const
|
|
712
|
+
// Separate lineNumbers into file records and writeBuffer records
|
|
713
|
+
const fileLineNumbers = []
|
|
714
|
+
const writeBufferLineNumbers = []
|
|
509
715
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
716
|
+
for (const lineNumber of lineNumbersArray) {
|
|
717
|
+
if (lineNumber >= persistedCount) {
|
|
718
|
+
// This lineNumber points to writeBuffer
|
|
719
|
+
writeBufferLineNumbers.push(lineNumber)
|
|
720
|
+
} else {
|
|
721
|
+
// This lineNumber points to file
|
|
722
|
+
fileLineNumbers.push(lineNumber)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Read records from file
|
|
727
|
+
if (fileLineNumbers.length > 0) {
|
|
728
|
+
const ranges = this.database.getRanges(fileLineNumbers)
|
|
729
|
+
if (ranges.length > 0) {
|
|
730
|
+
const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
|
|
731
|
+
|
|
732
|
+
const fs = await import('fs')
|
|
733
|
+
const fd = await fs.promises.open(this.database.fileHandler.file, 'r')
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
for (const groupedRange of groupedRanges) {
|
|
737
|
+
for await (const row of this.database.fileHandler.readGroupedRange(groupedRange, fd)) {
|
|
738
|
+
try {
|
|
739
|
+
const record = this.database.serializer.deserialize(row.line)
|
|
740
|
+
const recordWithTerms = options.restoreTerms !== false ?
|
|
741
|
+
this.database.restoreTermIdsAfterDeserialization(record) :
|
|
742
|
+
record
|
|
743
|
+
results.push(recordWithTerms)
|
|
744
|
+
if (limit && results.length >= limit) break
|
|
745
|
+
} catch (error) {
|
|
746
|
+
// Skip invalid lines
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (limit && results.length >= limit) break
|
|
750
|
+
}
|
|
751
|
+
} finally {
|
|
752
|
+
await fd.close()
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Read records from writeBuffer
|
|
758
|
+
if (writeBufferLineNumbers.length > 0 && this.database.writeBuffer) {
|
|
759
|
+
for (const lineNumber of writeBufferLineNumbers) {
|
|
760
|
+
if (limit && results.length >= limit) break
|
|
761
|
+
|
|
762
|
+
const writeBufferIndex = lineNumber - persistedCount
|
|
763
|
+
if (writeBufferIndex >= 0 && writeBufferIndex < this.database.writeBuffer.length) {
|
|
764
|
+
const record = this.database.writeBuffer[writeBufferIndex]
|
|
765
|
+
if (record) {
|
|
515
766
|
const recordWithTerms = options.restoreTerms !== false ?
|
|
516
767
|
this.database.restoreTermIdsAfterDeserialization(record) :
|
|
517
768
|
record
|
|
518
769
|
results.push(recordWithTerms)
|
|
519
|
-
if (limit && results.length >= limit) break
|
|
520
|
-
} catch (error) {
|
|
521
|
-
// Skip invalid lines
|
|
522
770
|
}
|
|
523
771
|
}
|
|
524
|
-
if (limit && results.length >= limit) break
|
|
525
772
|
}
|
|
526
|
-
} finally {
|
|
527
|
-
await fd.close()
|
|
528
773
|
}
|
|
529
774
|
}
|
|
530
775
|
|
|
@@ -666,7 +911,8 @@ export class QueryManager {
|
|
|
666
911
|
// Handle object conditions (operators)
|
|
667
912
|
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
668
913
|
for (const [operator, operatorValue] of Object.entries(condition)) {
|
|
669
|
-
|
|
914
|
+
const normalizedOperator = normalizeOperator(operator);
|
|
915
|
+
if (!this.matchesOperator(value, normalizedOperator, operatorValue, options)) {
|
|
670
916
|
return false;
|
|
671
917
|
}
|
|
672
918
|
}
|
|
@@ -697,6 +943,8 @@ export class QueryManager {
|
|
|
697
943
|
*/
|
|
698
944
|
matchesOperator(value, operator, operatorValue, options = {}) {
|
|
699
945
|
switch (operator) {
|
|
946
|
+
case '$eq':
|
|
947
|
+
return value === operatorValue;
|
|
700
948
|
case '$gt':
|
|
701
949
|
return value > operatorValue;
|
|
702
950
|
case '$gte':
|
|
@@ -757,6 +1005,7 @@ export class QueryManager {
|
|
|
757
1005
|
}
|
|
758
1006
|
}
|
|
759
1007
|
|
|
1008
|
+
|
|
760
1009
|
/**
|
|
761
1010
|
* Preprocess query to handle array field syntax automatically
|
|
762
1011
|
* @param {Object} criteria - Query criteria
|
|
@@ -913,16 +1162,23 @@ export class QueryManager {
|
|
|
913
1162
|
}
|
|
914
1163
|
|
|
915
1164
|
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
916
|
-
const operators = Object.keys(condition);
|
|
1165
|
+
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
1166
|
+
const indexType = this.indexManager?.opts?.indexes?.[field]
|
|
1167
|
+
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
|
|
1168
|
+
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
|
|
1169
|
+
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
|
|
917
1170
|
|
|
918
|
-
if
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1171
|
+
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
1172
|
+
const isTermMappingField = this.database.termManager &&
|
|
1173
|
+
this.database.termManager.termMappingFields &&
|
|
1174
|
+
this.database.termManager.termMappingFields.includes(field)
|
|
1175
|
+
|
|
1176
|
+
if (isTermMappingField) {
|
|
1177
|
+
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
|
|
1178
|
+
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
922
1179
|
} else {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
});
|
|
1180
|
+
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
|
|
1181
|
+
return operators.every(op => !disallowed.includes(op));
|
|
926
1182
|
}
|
|
927
1183
|
}
|
|
928
1184
|
return true;
|
|
@@ -938,8 +1194,8 @@ export class QueryManager {
|
|
|
938
1194
|
}
|
|
939
1195
|
|
|
940
1196
|
const allFieldsIndexed = Object.keys(criteria).every(field => {
|
|
941
|
-
// Skip $and as
|
|
942
|
-
if (field === '$and') return true;
|
|
1197
|
+
// Skip $and and $not as they're handled separately above
|
|
1198
|
+
if (field === '$and' || field === '$not') return true;
|
|
943
1199
|
|
|
944
1200
|
if (!this.opts.indexes || !this.opts.indexes[field]) {
|
|
945
1201
|
if (this.opts.debugMode) {
|
|
@@ -960,23 +1216,34 @@ export class QueryManager {
|
|
|
960
1216
|
}
|
|
961
1217
|
|
|
962
1218
|
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
963
|
-
const operators = Object.keys(condition);
|
|
1219
|
+
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
964
1220
|
if (this.opts.debugMode) {
|
|
965
1221
|
console.log(`🔍 Field '${field}' has operators:`, operators)
|
|
966
1222
|
}
|
|
967
1223
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1224
|
+
const indexType = this.indexManager?.opts?.indexes?.[field]
|
|
1225
|
+
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
|
|
1226
|
+
const isArrayStringIndex = indexType === 'array:string'
|
|
1227
|
+
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
|
|
1228
|
+
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
|
|
1229
|
+
|
|
1230
|
+
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
1231
|
+
const isTermMappingField = this.database.termManager &&
|
|
1232
|
+
this.database.termManager.termMappingFields &&
|
|
1233
|
+
this.database.termManager.termMappingFields.includes(field)
|
|
1234
|
+
|
|
1235
|
+
// With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
|
|
1236
|
+
// Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
|
|
1237
|
+
if (isTermMappingField) {
|
|
1238
|
+
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
|
|
1239
|
+
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
975
1240
|
} else {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1241
|
+
let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
|
|
1242
|
+
// Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
|
|
1243
|
+
if (isArrayStringIndex) {
|
|
1244
|
+
disallowed = disallowed.filter(op => op !== '$all')
|
|
1245
|
+
}
|
|
1246
|
+
return operators.every(op => !disallowed.includes(op));
|
|
980
1247
|
}
|
|
981
1248
|
}
|
|
982
1249
|
return true;
|
|
@@ -1150,28 +1417,34 @@ export class QueryManager {
|
|
|
1150
1417
|
/**
|
|
1151
1418
|
* Validate strict query mode
|
|
1152
1419
|
* @param {Object} criteria - Query criteria
|
|
1420
|
+
* @param {Object} options - Query options
|
|
1153
1421
|
*/
|
|
1154
|
-
validateStrictQuery(criteria) {
|
|
1422
|
+
validateStrictQuery(criteria, options = {}) {
|
|
1423
|
+
// Allow bypassing strict mode validation with allowNonIndexed option
|
|
1424
|
+
if (options.allowNonIndexed === true) {
|
|
1425
|
+
return; // Skip validation for this query
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1155
1428
|
if (!criteria || Object.keys(criteria).length === 0) {
|
|
1156
1429
|
return; // Empty criteria are always allowed
|
|
1157
1430
|
}
|
|
1158
1431
|
|
|
1159
1432
|
// Handle logical operators at the top level
|
|
1160
1433
|
if (criteria.$not) {
|
|
1161
|
-
this.validateStrictQuery(criteria.$not);
|
|
1434
|
+
this.validateStrictQuery(criteria.$not, options);
|
|
1162
1435
|
return;
|
|
1163
1436
|
}
|
|
1164
1437
|
|
|
1165
1438
|
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
1166
1439
|
for (const orCondition of criteria.$or) {
|
|
1167
|
-
this.validateStrictQuery(orCondition);
|
|
1440
|
+
this.validateStrictQuery(orCondition, options);
|
|
1168
1441
|
}
|
|
1169
1442
|
return;
|
|
1170
1443
|
}
|
|
1171
1444
|
|
|
1172
1445
|
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
1173
1446
|
for (const andCondition of criteria.$and) {
|
|
1174
|
-
this.validateStrictQuery(andCondition);
|
|
1447
|
+
this.validateStrictQuery(andCondition, options);
|
|
1175
1448
|
}
|
|
1176
1449
|
return;
|
|
1177
1450
|
}
|
|
@@ -38,19 +38,19 @@ export default class TermManager {
|
|
|
38
38
|
/**
|
|
39
39
|
* Get term ID without incrementing count (for IndexManager use)
|
|
40
40
|
* @param {string} term - Term to get ID for
|
|
41
|
-
* @returns {number} - Numeric ID for the term
|
|
41
|
+
* @returns {number|undefined} - Numeric ID for the term, or undefined if not found
|
|
42
|
+
* CRITICAL: Does NOT create new IDs - only returns existing ones
|
|
43
|
+
* This prevents creating invalid term IDs during queries when terms haven't been loaded yet
|
|
42
44
|
*/
|
|
43
45
|
getTermIdWithoutIncrement(term) {
|
|
44
46
|
if (this.termToId.has(term)) {
|
|
45
47
|
return this.termToId.get(term)
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return id
|
|
50
|
+
// CRITICAL FIX: Don't create new IDs during queries
|
|
51
|
+
// If term doesn't exist, return undefined
|
|
52
|
+
// This ensures queries only work with terms that were actually saved to the database
|
|
53
|
+
return undefined
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|