jexidb 2.2.5 → 2.2.7
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 +253 -118
- package/package.json +1 -1
- package/src/Database.mjs +5 -3
- package/src/managers/IndexManager.mjs +23 -0
- package/src/managers/QueryManager.mjs +264 -131
package/dist/Database.cjs
CHANGED
|
@@ -1406,6 +1406,28 @@ class IndexManager {
|
|
|
1406
1406
|
return result;
|
|
1407
1407
|
}
|
|
1408
1408
|
|
|
1409
|
+
// Handle $or queries - union of the branch results
|
|
1410
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
1411
|
+
const orResult = new Set();
|
|
1412
|
+
for (const orCondition of criteria.$or) {
|
|
1413
|
+
const branchLines = this.query(orCondition, options);
|
|
1414
|
+
for (const lineNumber of branchLines) {
|
|
1415
|
+
orResult.add(lineNumber);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Other fields at the root level still narrow the union (AND semantics)
|
|
1420
|
+
const otherCriteria = {
|
|
1421
|
+
...criteria
|
|
1422
|
+
};
|
|
1423
|
+
delete otherCriteria.$or;
|
|
1424
|
+
if (Object.keys(otherCriteria).length > 0) {
|
|
1425
|
+
const otherResults = this.query(otherCriteria, options);
|
|
1426
|
+
return new Set([...orResult].filter(x => otherResults.has(x)));
|
|
1427
|
+
}
|
|
1428
|
+
return orResult;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1409
1431
|
// Handle $and queries with parallel processing optimization
|
|
1410
1432
|
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
1411
1433
|
// OPTIMIZATION: Process conditions in parallel for better performance
|
|
@@ -6349,10 +6371,18 @@ class QueryManager {
|
|
|
6349
6371
|
}
|
|
6350
6372
|
|
|
6351
6373
|
// Use indices to pre-filter and reduce streaming scope
|
|
6352
|
-
const
|
|
6374
|
+
const preFilterCriteria = this._extractIndexableCriteria(criteria);
|
|
6375
|
+
const preFilteredLines = this.indexManager.query(preFilterCriteria, streamingOptions);
|
|
6376
|
+
|
|
6377
|
+
// Fields answered by the pre-filter already match, so the streaming matcher
|
|
6378
|
+
// only evaluates the residual conditions (instead of re-checking everything).
|
|
6379
|
+
const preFilteredFields = this._collectCriteriaFields(preFilterCriteria);
|
|
6353
6380
|
|
|
6354
6381
|
// Stream only the pre-filtered records
|
|
6355
|
-
return this._streamPreFilteredRecords(preFilteredLines, criteria,
|
|
6382
|
+
return this._streamPreFilteredRecords(preFilteredLines, criteria, {
|
|
6383
|
+
...streamingOptions,
|
|
6384
|
+
_prefilteredFields: preFilteredFields
|
|
6385
|
+
});
|
|
6356
6386
|
}
|
|
6357
6387
|
}
|
|
6358
6388
|
|
|
@@ -6404,6 +6434,17 @@ class QueryManager {
|
|
|
6404
6434
|
}
|
|
6405
6435
|
}
|
|
6406
6436
|
|
|
6437
|
+
// Handle $or conditions - only when EVERY branch is pre-filterable, otherwise
|
|
6438
|
+
// the union would narrow the candidate set and lose rows.
|
|
6439
|
+
if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
|
|
6440
|
+
const orCriteria = criteria.$or.map(condition => this._extractIndexableCriteria(condition));
|
|
6441
|
+
if (orCriteria.every(condition => Object.keys(condition).length > 0)) {
|
|
6442
|
+
for (const condition of orCriteria) {
|
|
6443
|
+
indexableFields.push(...this._getIndexableFields(condition));
|
|
6444
|
+
}
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
|
|
6407
6448
|
// Handle regular field conditions
|
|
6408
6449
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6409
6450
|
if (field.startsWith('$')) continue; // Skip logical operators
|
|
@@ -6439,6 +6480,15 @@ class QueryManager {
|
|
|
6439
6480
|
}
|
|
6440
6481
|
}
|
|
6441
6482
|
|
|
6483
|
+
// Handle $or conditions - a branch that cannot be pre-filtered would make the
|
|
6484
|
+
// union too narrow, so $or is only pre-filterable when every branch is.
|
|
6485
|
+
if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
|
|
6486
|
+
const indexableOrConditions = criteria.$or.map(orCondition => this._extractIndexableCriteria(orCondition));
|
|
6487
|
+
if (indexableOrConditions.every(condition => Object.keys(condition).length > 0)) {
|
|
6488
|
+
indexableCriteria.$or = indexableOrConditions;
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6442
6492
|
// Handle $not operator - include it if it can be processed by IndexManager
|
|
6443
6493
|
if (criteria.$not && typeof criteria.$not === 'object') {
|
|
6444
6494
|
// Check if $not condition contains only indexable fields
|
|
@@ -6469,6 +6519,37 @@ class QueryManager {
|
|
|
6469
6519
|
return indexableCriteria;
|
|
6470
6520
|
}
|
|
6471
6521
|
|
|
6522
|
+
/**
|
|
6523
|
+
* Collect every field referenced by criteria, including inside $and/$or/$not
|
|
6524
|
+
* @param {Object} criteria - Criteria to inspect
|
|
6525
|
+
* @param {Set<string>} accumulator - Fields collected so far
|
|
6526
|
+
* @returns {Set<string>} - Field names
|
|
6527
|
+
*/
|
|
6528
|
+
_collectCriteriaFields(criteria, accumulator = new Set()) {
|
|
6529
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
6530
|
+
return accumulator;
|
|
6531
|
+
}
|
|
6532
|
+
if (Array.isArray(criteria)) {
|
|
6533
|
+
for (const condition of criteria) {
|
|
6534
|
+
this._collectCriteriaFields(condition, accumulator);
|
|
6535
|
+
}
|
|
6536
|
+
return accumulator;
|
|
6537
|
+
}
|
|
6538
|
+
for (const [field, value] of Object.entries(criteria)) {
|
|
6539
|
+
if (field === '$and' || field === '$or') {
|
|
6540
|
+
this._collectCriteriaFields(value, accumulator);
|
|
6541
|
+
continue;
|
|
6542
|
+
}
|
|
6543
|
+
if (field === '$not') {
|
|
6544
|
+
// $not is negated, so its fields are not "already matched" by the pre-filter
|
|
6545
|
+
continue;
|
|
6546
|
+
}
|
|
6547
|
+
if (field.startsWith('$')) continue;
|
|
6548
|
+
accumulator.add(field);
|
|
6549
|
+
}
|
|
6550
|
+
return accumulator;
|
|
6551
|
+
}
|
|
6552
|
+
|
|
6472
6553
|
/**
|
|
6473
6554
|
* Determine whether the database currently has any records (persisted or pending)
|
|
6474
6555
|
* @returns {boolean}
|
|
@@ -6674,8 +6755,8 @@ class QueryManager {
|
|
|
6674
6755
|
return !notResult;
|
|
6675
6756
|
}
|
|
6676
6757
|
|
|
6677
|
-
// OPTIMIZATION 4: Skip
|
|
6678
|
-
const
|
|
6758
|
+
// OPTIMIZATION 4: Skip only the fields that the pre-filter actually answered
|
|
6759
|
+
const prefilteredFields = options._prefilteredFields instanceof Set ? options._prefilteredFields : new Set(this._getIndexableFields(criteria));
|
|
6679
6760
|
|
|
6680
6761
|
// Handle explicit logical operators at the top level
|
|
6681
6762
|
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
@@ -6701,8 +6782,8 @@ class QueryManager {
|
|
|
6701
6782
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6702
6783
|
if (field.startsWith('$')) continue;
|
|
6703
6784
|
|
|
6704
|
-
// OPTIMIZATION 4: Skip
|
|
6705
|
-
if (
|
|
6785
|
+
// OPTIMIZATION 4: Skip fields that were already pre-filtered
|
|
6786
|
+
if (prefilteredFields.has(field)) {
|
|
6706
6787
|
continue;
|
|
6707
6788
|
}
|
|
6708
6789
|
if (!this.matchesFieldCondition(record, field, condition, options)) {
|
|
@@ -7029,7 +7110,7 @@ class QueryManager {
|
|
|
7029
7110
|
* @returns {boolean} - True if matches
|
|
7030
7111
|
*/
|
|
7031
7112
|
matchesFieldCondition(record, field, condition, options = {}) {
|
|
7032
|
-
|
|
7113
|
+
let value = record[field];
|
|
7033
7114
|
|
|
7034
7115
|
// Debug logging for all field conditions - SAMPLED: only the first record of
|
|
7035
7116
|
// each query logs per field, so a full streaming scan (which calls this for
|
|
@@ -7061,6 +7142,13 @@ class QueryManager {
|
|
|
7061
7142
|
}
|
|
7062
7143
|
}
|
|
7063
7144
|
|
|
7145
|
+
// Term-mapped array fields store numeric term IDs, so both sides are translated
|
|
7146
|
+
// to terms here; otherwise string queries would never match on the streaming path.
|
|
7147
|
+
if (Array.isArray(value) && this._isTermMappingField(field)) {
|
|
7148
|
+
value = this._termIdsToTerms(value);
|
|
7149
|
+
condition = this._normalizeTermCondition(condition);
|
|
7150
|
+
}
|
|
7151
|
+
|
|
7064
7152
|
// Handle null/undefined values
|
|
7065
7153
|
if (value === null || value === undefined) {
|
|
7066
7154
|
return condition === null || condition === undefined;
|
|
@@ -7183,17 +7271,87 @@ class QueryManager {
|
|
|
7183
7271
|
}
|
|
7184
7272
|
}
|
|
7185
7273
|
|
|
7274
|
+
/**
|
|
7275
|
+
* Whether a field stores its values as numeric term IDs (term mapping)
|
|
7276
|
+
* @param {string} field
|
|
7277
|
+
* @returns {boolean}
|
|
7278
|
+
*/
|
|
7279
|
+
_isTermMappingField(field) {
|
|
7280
|
+
const termManager = this.database && this.database.termManager;
|
|
7281
|
+
return !!(termManager && Array.isArray(termManager.termMappingFields) && termManager.termMappingFields.includes(field));
|
|
7282
|
+
}
|
|
7283
|
+
|
|
7284
|
+
/**
|
|
7285
|
+
* Translate stored term IDs back to terms for matching
|
|
7286
|
+
* @param {Array} ids
|
|
7287
|
+
* @returns {Array}
|
|
7288
|
+
*/
|
|
7289
|
+
_termIdsToTerms(ids) {
|
|
7290
|
+
const termManager = this.database.termManager;
|
|
7291
|
+
return ids.map(id => {
|
|
7292
|
+
if (typeof id !== 'number') return id;
|
|
7293
|
+
const term = termManager.getTerm(id);
|
|
7294
|
+
return term === null ? id : term;
|
|
7295
|
+
});
|
|
7296
|
+
}
|
|
7297
|
+
|
|
7298
|
+
/**
|
|
7299
|
+
* Translate numeric term IDs used in a query condition to their terms
|
|
7300
|
+
* @param {*} condition
|
|
7301
|
+
* @returns {*}
|
|
7302
|
+
*/
|
|
7303
|
+
_normalizeTermCondition(condition) {
|
|
7304
|
+
const toTerm = value => {
|
|
7305
|
+
if (typeof value !== 'number') return value;
|
|
7306
|
+
const term = this.database.termManager.getTerm(value);
|
|
7307
|
+
return term === null ? value : term;
|
|
7308
|
+
};
|
|
7309
|
+
if (typeof condition === 'number') {
|
|
7310
|
+
return toTerm(condition);
|
|
7311
|
+
}
|
|
7312
|
+
if (condition instanceof RegExp || condition === null || typeof condition !== 'object') {
|
|
7313
|
+
return condition;
|
|
7314
|
+
}
|
|
7315
|
+
if (Array.isArray(condition)) {
|
|
7316
|
+
return condition.map(toTerm);
|
|
7317
|
+
}
|
|
7318
|
+
const normalized = {};
|
|
7319
|
+
for (const [operator, operatorValue] of Object.entries(condition)) {
|
|
7320
|
+
const canonical = normalizeOperator(operator);
|
|
7321
|
+
if (canonical === '$in' || canonical === '$all' || canonical === '$nin') {
|
|
7322
|
+
const values = Array.isArray(operatorValue) ? operatorValue : [operatorValue];
|
|
7323
|
+
normalized[operator] = values.map(toTerm);
|
|
7324
|
+
} else if (canonical === '$eq') {
|
|
7325
|
+
normalized[operator] = toTerm(operatorValue);
|
|
7326
|
+
} else {
|
|
7327
|
+
normalized[operator] = operatorValue;
|
|
7328
|
+
}
|
|
7329
|
+
}
|
|
7330
|
+
return normalized;
|
|
7331
|
+
}
|
|
7332
|
+
|
|
7186
7333
|
/**
|
|
7187
7334
|
* Preprocess query to handle array field syntax automatically
|
|
7188
7335
|
* @param {Object} criteria - Query criteria
|
|
7189
7336
|
* @returns {Object} - Processed criteria
|
|
7190
7337
|
*/
|
|
7191
|
-
preprocessQuery(criteria) {
|
|
7192
|
-
if (!criteria || typeof criteria !== 'object') {
|
|
7338
|
+
preprocessQuery(criteria, nested = false) {
|
|
7339
|
+
if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
|
|
7193
7340
|
return criteria;
|
|
7194
7341
|
}
|
|
7195
7342
|
const processed = {};
|
|
7196
7343
|
for (const [field, value] of Object.entries(criteria)) {
|
|
7344
|
+
// $and / $or / $not hold nested criteria and must be processed recursively,
|
|
7345
|
+
// otherwise conditions inside logical operators never reach the $in form.
|
|
7346
|
+
if (field === '$and' || field === '$or') {
|
|
7347
|
+
processed[field] = Array.isArray(value) ? value.map(condition => this.preprocessQuery(condition, true)) : value;
|
|
7348
|
+
continue;
|
|
7349
|
+
}
|
|
7350
|
+
if (field === '$not') {
|
|
7351
|
+
processed[field] = value && typeof value === 'object' && !Array.isArray(value) ? this.preprocessQuery(value, true) : value;
|
|
7352
|
+
continue;
|
|
7353
|
+
}
|
|
7354
|
+
|
|
7197
7355
|
// Check if this is a term mapping field
|
|
7198
7356
|
const isTermMappingField = this.database.opts.termMapping && this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
|
|
7199
7357
|
if (isTermMappingField) {
|
|
@@ -7221,6 +7379,10 @@ class QueryManager {
|
|
|
7221
7379
|
} else {
|
|
7222
7380
|
processed[field] = value;
|
|
7223
7381
|
}
|
|
7382
|
+
} else if (nested && (value === null || value === undefined)) {
|
|
7383
|
+
// Nested branches may test for a missing value (e.g. $not: {field: null});
|
|
7384
|
+
// only top-level null queries are rejected as invalid.
|
|
7385
|
+
processed[field] = value;
|
|
7224
7386
|
} else {
|
|
7225
7387
|
// Invalid value for term mapping field
|
|
7226
7388
|
throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`);
|
|
@@ -7308,117 +7470,18 @@ class QueryManager {
|
|
|
7308
7470
|
}
|
|
7309
7471
|
}
|
|
7310
7472
|
|
|
7311
|
-
// Handle $and queries -
|
|
7312
|
-
|
|
7313
|
-
const
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
const notFields = Object.keys(andCondition.$not);
|
|
7317
|
-
return notFields.every(field => {
|
|
7318
|
-
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
7319
|
-
return false;
|
|
7320
|
-
}
|
|
7321
|
-
// For term mapping, $not can be processed with indices
|
|
7322
|
-
return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
|
|
7323
|
-
});
|
|
7324
|
-
}
|
|
7325
|
-
|
|
7326
|
-
// Handle regular field conditions
|
|
7327
|
-
return Object.keys(andCondition).every(field => {
|
|
7328
|
-
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
7329
|
-
return false;
|
|
7330
|
-
}
|
|
7331
|
-
const condition = andCondition[field];
|
|
7332
|
-
|
|
7333
|
-
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
7334
|
-
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
7335
|
-
if (condition instanceof RegExp) {
|
|
7336
|
-
const idxType = this.indexManager?.opts?.indexes?.[field];
|
|
7337
|
-
return idxType !== 'number' && idxType !== 'array:number';
|
|
7338
|
-
}
|
|
7339
|
-
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
7340
|
-
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
7341
|
-
const indexType = this.indexManager?.opts?.indexes?.[field];
|
|
7342
|
-
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
|
|
7343
|
-
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7344
|
-
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7345
|
-
|
|
7346
|
-
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
7347
|
-
const isTermMappingField = this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
|
|
7348
|
-
if (isTermMappingField) {
|
|
7349
|
-
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
|
|
7350
|
-
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
7351
|
-
} else {
|
|
7352
|
-
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
|
|
7353
|
-
return operators.every(op => !disallowed.includes(op));
|
|
7354
|
-
}
|
|
7355
|
-
}
|
|
7356
|
-
return true;
|
|
7357
|
-
});
|
|
7358
|
-
});
|
|
7359
|
-
if (!allAndConditionsIndexed) {
|
|
7473
|
+
// Handle $and / $or queries - every branch must be indexable to use indices
|
|
7474
|
+
for (const logicalOperator of ['$and', '$or']) {
|
|
7475
|
+
const branches = criteria[logicalOperator];
|
|
7476
|
+
if (!Array.isArray(branches)) continue;
|
|
7477
|
+
if (!branches.every(branch => this._isCriteriaIndexable(branch))) {
|
|
7360
7478
|
if (this.opts.debugMode) {
|
|
7361
|
-
console.log(
|
|
7479
|
+
console.log(`📊 QueryStrategy: STREAMING - Some ${logicalOperator} conditions not indexed or operators not supported`);
|
|
7362
7480
|
}
|
|
7363
7481
|
return 'streaming';
|
|
7364
7482
|
}
|
|
7365
7483
|
}
|
|
7366
|
-
|
|
7367
|
-
// Skip $and and $not as they're handled separately above
|
|
7368
|
-
if (field === '$and' || field === '$not') return true;
|
|
7369
|
-
if (!this.opts.indexes || !this.opts.indexes[field]) {
|
|
7370
|
-
if (this.opts.debugMode) {
|
|
7371
|
-
console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}));
|
|
7372
|
-
}
|
|
7373
|
-
return false;
|
|
7374
|
-
}
|
|
7375
|
-
|
|
7376
|
-
// Check if the field uses operators that are supported by IndexManager
|
|
7377
|
-
const condition = criteria[field];
|
|
7378
|
-
|
|
7379
|
-
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
7380
|
-
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
7381
|
-
if (condition instanceof RegExp) {
|
|
7382
|
-
const idxType = this.indexManager?.opts?.indexes?.[field];
|
|
7383
|
-
if (idxType === 'number' || idxType === 'array:number') {
|
|
7384
|
-
if (this.opts.debugMode) {
|
|
7385
|
-
console.log(`🔍 Field '${field}' uses RegExp on a numeric index - requires streaming strategy`);
|
|
7386
|
-
}
|
|
7387
|
-
return false;
|
|
7388
|
-
}
|
|
7389
|
-
return true;
|
|
7390
|
-
}
|
|
7391
|
-
if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
|
|
7392
|
-
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
7393
|
-
if (this.opts.debugMode) {
|
|
7394
|
-
console.log(`🔍 Field '${field}' has operators:`, operators);
|
|
7395
|
-
}
|
|
7396
|
-
const indexType = this.indexManager?.opts?.indexes?.[field];
|
|
7397
|
-
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
|
|
7398
|
-
const isArrayStringIndex = indexType === 'array:string';
|
|
7399
|
-
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7400
|
-
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7401
|
-
|
|
7402
|
-
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
7403
|
-
const isTermMappingField = this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
|
|
7404
|
-
|
|
7405
|
-
// With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
|
|
7406
|
-
// Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
|
|
7407
|
-
if (isTermMappingField) {
|
|
7408
|
-
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
|
|
7409
|
-
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
7410
|
-
} else {
|
|
7411
|
-
let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
|
|
7412
|
-
// Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
|
|
7413
|
-
if (isArrayStringIndex) {
|
|
7414
|
-
disallowed = disallowed.filter(op => op !== '$all');
|
|
7415
|
-
}
|
|
7416
|
-
return operators.every(op => !disallowed.includes(op));
|
|
7417
|
-
}
|
|
7418
|
-
}
|
|
7419
|
-
return true;
|
|
7420
|
-
});
|
|
7421
|
-
if (!allFieldsIndexed) {
|
|
7484
|
+
if (!this._isCriteriaIndexable(criteria)) {
|
|
7422
7485
|
if (this.opts.debugMode) {
|
|
7423
7486
|
console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
|
|
7424
7487
|
}
|
|
@@ -7457,6 +7520,76 @@ class QueryManager {
|
|
|
7457
7520
|
return 'indexed';
|
|
7458
7521
|
}
|
|
7459
7522
|
|
|
7523
|
+
/**
|
|
7524
|
+
* Whether a criteria object can be fully resolved through indexes
|
|
7525
|
+
* @param {Object} criteria - Criteria or a single logical branch
|
|
7526
|
+
* @returns {boolean} - True if every condition is index-backed
|
|
7527
|
+
*/
|
|
7528
|
+
_isCriteriaIndexable(criteria) {
|
|
7529
|
+
if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
|
|
7530
|
+
return false;
|
|
7531
|
+
}
|
|
7532
|
+
const fields = Object.keys(criteria);
|
|
7533
|
+
if (fields.length === 0) {
|
|
7534
|
+
return false;
|
|
7535
|
+
}
|
|
7536
|
+
return fields.every(field => {
|
|
7537
|
+
const value = criteria[field];
|
|
7538
|
+
if (field === '$and' || field === '$or') {
|
|
7539
|
+
return Array.isArray(value) && value.length > 0 && value.every(branch => this._isCriteriaIndexable(branch));
|
|
7540
|
+
}
|
|
7541
|
+
if (field === '$not') {
|
|
7542
|
+
// $not is only index-supported when term mapping is enabled
|
|
7543
|
+
if (!this.opts.termMapping || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
7544
|
+
return false;
|
|
7545
|
+
}
|
|
7546
|
+
const notFields = Object.keys(value);
|
|
7547
|
+
return notFields.length > 0 && notFields.every(notField => !!(this.indexManager.opts.indexes && this.indexManager.opts.indexes[notField]));
|
|
7548
|
+
}
|
|
7549
|
+
if (field.startsWith('$')) {
|
|
7550
|
+
return false;
|
|
7551
|
+
}
|
|
7552
|
+
return this._isFieldConditionIndexable(field, value);
|
|
7553
|
+
});
|
|
7554
|
+
}
|
|
7555
|
+
|
|
7556
|
+
/**
|
|
7557
|
+
* Whether a single field condition is supported by that field's index
|
|
7558
|
+
* @param {string} field - Field name
|
|
7559
|
+
* @param {*} condition - Condition value
|
|
7560
|
+
* @returns {boolean} - True if the index can answer this condition
|
|
7561
|
+
*/
|
|
7562
|
+
_isFieldConditionIndexable(field, condition) {
|
|
7563
|
+
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
7564
|
+
return false;
|
|
7565
|
+
}
|
|
7566
|
+
const indexType = this.indexManager.opts.indexes[field];
|
|
7567
|
+
|
|
7568
|
+
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
7569
|
+
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
7570
|
+
if (condition instanceof RegExp) {
|
|
7571
|
+
return indexType !== 'number' && indexType !== 'array:number';
|
|
7572
|
+
}
|
|
7573
|
+
|
|
7574
|
+
// Index lookups cannot express "is null/undefined" reliably
|
|
7575
|
+
if (condition === null || condition === undefined) {
|
|
7576
|
+
return false;
|
|
7577
|
+
}
|
|
7578
|
+
if (typeof condition !== 'object' || Array.isArray(condition)) {
|
|
7579
|
+
return true;
|
|
7580
|
+
}
|
|
7581
|
+
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
7582
|
+
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
|
|
7583
|
+
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7584
|
+
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7585
|
+
if (this._isTermMappingField(field)) {
|
|
7586
|
+
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
|
|
7587
|
+
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
7588
|
+
}
|
|
7589
|
+
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
|
|
7590
|
+
return operators.every(op => !disallowed.includes(op));
|
|
7591
|
+
}
|
|
7592
|
+
|
|
7460
7593
|
/**
|
|
7461
7594
|
* Estimate number of results for a query
|
|
7462
7595
|
* @param {Object} criteria - Query criteria
|
|
@@ -9430,6 +9563,11 @@ class Database extends events.EventEmitter {
|
|
|
9430
9563
|
throw new Error('The "fields" option is mandatory and must be an object with at least one field definition. Example: { fields: { id: "number", name: "string" } }');
|
|
9431
9564
|
}
|
|
9432
9565
|
|
|
9566
|
+
// Normalize the indexes option BEFORE term mapping detection: an indexes array
|
|
9567
|
+
// (e.g. ['nameTerms']) must become { nameTerms: 'array:string' } first, otherwise
|
|
9568
|
+
// auto-detection sees no 'array:string' entries and silently returns no fields.
|
|
9569
|
+
this.prepareIndexConfiguration();
|
|
9570
|
+
|
|
9433
9571
|
// CRITICAL FIX: Initialize serializer first - this was missing and causing crashes
|
|
9434
9572
|
this.serializer = new Serializer(this.opts);
|
|
9435
9573
|
|
|
@@ -9468,9 +9606,6 @@ class Database extends events.EventEmitter {
|
|
|
9468
9606
|
}
|
|
9469
9607
|
}
|
|
9470
9608
|
|
|
9471
|
-
// Prepare index configuration for IndexManager
|
|
9472
|
-
this.prepareIndexConfiguration();
|
|
9473
|
-
|
|
9474
9609
|
// Validate configuration after conversion
|
|
9475
9610
|
this.validateIndexConfiguration();
|
|
9476
9611
|
|
package/package.json
CHANGED
package/src/Database.mjs
CHANGED
|
@@ -596,6 +596,11 @@ class Database extends EventEmitter {
|
|
|
596
596
|
throw new Error('The "fields" option is mandatory and must be an object with at least one field definition. Example: { fields: { id: "number", name: "string" } }')
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
// Normalize the indexes option BEFORE term mapping detection: an indexes array
|
|
600
|
+
// (e.g. ['nameTerms']) must become { nameTerms: 'array:string' } first, otherwise
|
|
601
|
+
// auto-detection sees no 'array:string' entries and silently returns no fields.
|
|
602
|
+
this.prepareIndexConfiguration()
|
|
603
|
+
|
|
599
604
|
// CRITICAL FIX: Initialize serializer first - this was missing and causing crashes
|
|
600
605
|
this.serializer = new Serializer(this.opts)
|
|
601
606
|
|
|
@@ -635,9 +640,6 @@ class Database extends EventEmitter {
|
|
|
635
640
|
}
|
|
636
641
|
}
|
|
637
642
|
|
|
638
|
-
// Prepare index configuration for IndexManager
|
|
639
|
-
this.prepareIndexConfiguration()
|
|
640
|
-
|
|
641
643
|
// Validate configuration after conversion
|
|
642
644
|
this.validateIndexConfiguration()
|
|
643
645
|
|
|
@@ -1016,6 +1016,29 @@ export default class IndexManager {
|
|
|
1016
1016
|
return result;
|
|
1017
1017
|
}
|
|
1018
1018
|
|
|
1019
|
+
// Handle $or queries - union of the branch results
|
|
1020
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
1021
|
+
const orResult = new Set();
|
|
1022
|
+
|
|
1023
|
+
for (const orCondition of criteria.$or) {
|
|
1024
|
+
const branchLines = this.query(orCondition, options);
|
|
1025
|
+
for (const lineNumber of branchLines) {
|
|
1026
|
+
orResult.add(lineNumber);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Other fields at the root level still narrow the union (AND semantics)
|
|
1031
|
+
const otherCriteria = { ...criteria };
|
|
1032
|
+
delete otherCriteria.$or;
|
|
1033
|
+
|
|
1034
|
+
if (Object.keys(otherCriteria).length > 0) {
|
|
1035
|
+
const otherResults = this.query(otherCriteria, options);
|
|
1036
|
+
return new Set([...orResult].filter(x => otherResults.has(x)));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return orResult;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1019
1042
|
// Handle $and queries with parallel processing optimization
|
|
1020
1043
|
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
1021
1044
|
// OPTIMIZATION: Process conditions in parallel for better performance
|
|
@@ -286,13 +286,18 @@ export class QueryManager {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
// Use indices to pre-filter and reduce streaming scope
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
289
|
+
const preFilterCriteria = this._extractIndexableCriteria(criteria);
|
|
290
|
+
const preFilteredLines = this.indexManager.query(preFilterCriteria, streamingOptions);
|
|
291
|
+
|
|
292
|
+
// Fields answered by the pre-filter already match, so the streaming matcher
|
|
293
|
+
// only evaluates the residual conditions (instead of re-checking everything).
|
|
294
|
+
const preFilteredFields = this._collectCriteriaFields(preFilterCriteria);
|
|
295
|
+
|
|
294
296
|
// Stream only the pre-filtered records
|
|
295
|
-
return this._streamPreFilteredRecords(preFilteredLines, criteria,
|
|
297
|
+
return this._streamPreFilteredRecords(preFilteredLines, criteria, {
|
|
298
|
+
...streamingOptions,
|
|
299
|
+
_prefilteredFields: preFilteredFields
|
|
300
|
+
});
|
|
296
301
|
}
|
|
297
302
|
}
|
|
298
303
|
|
|
@@ -345,6 +350,17 @@ export class QueryManager {
|
|
|
345
350
|
indexableFields.push(...this._getIndexableFields(andCondition));
|
|
346
351
|
}
|
|
347
352
|
}
|
|
353
|
+
|
|
354
|
+
// Handle $or conditions - only when EVERY branch is pre-filterable, otherwise
|
|
355
|
+
// the union would narrow the candidate set and lose rows.
|
|
356
|
+
if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
|
|
357
|
+
const orCriteria = criteria.$or.map(condition => this._extractIndexableCriteria(condition));
|
|
358
|
+
if (orCriteria.every(condition => Object.keys(condition).length > 0)) {
|
|
359
|
+
for (const condition of orCriteria) {
|
|
360
|
+
indexableFields.push(...this._getIndexableFields(condition));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
348
364
|
|
|
349
365
|
// Handle regular field conditions
|
|
350
366
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
@@ -386,6 +402,17 @@ export class QueryManager {
|
|
|
386
402
|
indexableCriteria.$and = indexableAndConditions;
|
|
387
403
|
}
|
|
388
404
|
}
|
|
405
|
+
|
|
406
|
+
// Handle $or conditions - a branch that cannot be pre-filtered would make the
|
|
407
|
+
// union too narrow, so $or is only pre-filterable when every branch is.
|
|
408
|
+
if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
|
|
409
|
+
const indexableOrConditions = criteria.$or
|
|
410
|
+
.map(orCondition => this._extractIndexableCriteria(orCondition));
|
|
411
|
+
|
|
412
|
+
if (indexableOrConditions.every(condition => Object.keys(condition).length > 0)) {
|
|
413
|
+
indexableCriteria.$or = indexableOrConditions;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
389
416
|
|
|
390
417
|
// Handle $not operator - include it if it can be processed by IndexManager
|
|
391
418
|
if (criteria.$not && typeof criteria.$not === 'object') {
|
|
@@ -422,6 +449,43 @@ export class QueryManager {
|
|
|
422
449
|
return indexableCriteria;
|
|
423
450
|
}
|
|
424
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Collect every field referenced by criteria, including inside $and/$or/$not
|
|
454
|
+
* @param {Object} criteria - Criteria to inspect
|
|
455
|
+
* @param {Set<string>} accumulator - Fields collected so far
|
|
456
|
+
* @returns {Set<string>} - Field names
|
|
457
|
+
*/
|
|
458
|
+
_collectCriteriaFields(criteria, accumulator = new Set()) {
|
|
459
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
460
|
+
return accumulator;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (Array.isArray(criteria)) {
|
|
464
|
+
for (const condition of criteria) {
|
|
465
|
+
this._collectCriteriaFields(condition, accumulator);
|
|
466
|
+
}
|
|
467
|
+
return accumulator;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const [field, value] of Object.entries(criteria)) {
|
|
471
|
+
if (field === '$and' || field === '$or') {
|
|
472
|
+
this._collectCriteriaFields(value, accumulator);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (field === '$not') {
|
|
477
|
+
// $not is negated, so its fields are not "already matched" by the pre-filter
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (field.startsWith('$')) continue;
|
|
482
|
+
|
|
483
|
+
accumulator.add(field);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return accumulator;
|
|
487
|
+
}
|
|
488
|
+
|
|
425
489
|
/**
|
|
426
490
|
* Determine whether the database currently has any records (persisted or pending)
|
|
427
491
|
* @returns {boolean}
|
|
@@ -638,8 +702,10 @@ export class QueryManager {
|
|
|
638
702
|
return !notResult;
|
|
639
703
|
}
|
|
640
704
|
|
|
641
|
-
// OPTIMIZATION 4: Skip
|
|
642
|
-
const
|
|
705
|
+
// OPTIMIZATION 4: Skip only the fields that the pre-filter actually answered
|
|
706
|
+
const prefilteredFields = options._prefilteredFields instanceof Set
|
|
707
|
+
? options._prefilteredFields
|
|
708
|
+
: new Set(this._getIndexableFields(criteria));
|
|
643
709
|
|
|
644
710
|
// Handle explicit logical operators at the top level
|
|
645
711
|
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
@@ -666,8 +732,8 @@ export class QueryManager {
|
|
|
666
732
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
667
733
|
if (field.startsWith('$')) continue;
|
|
668
734
|
|
|
669
|
-
// OPTIMIZATION 4: Skip
|
|
670
|
-
if (
|
|
735
|
+
// OPTIMIZATION 4: Skip fields that were already pre-filtered
|
|
736
|
+
if (prefilteredFields.has(field)) {
|
|
671
737
|
continue;
|
|
672
738
|
}
|
|
673
739
|
|
|
@@ -980,7 +1046,7 @@ export class QueryManager {
|
|
|
980
1046
|
* @returns {boolean} - True if matches
|
|
981
1047
|
*/
|
|
982
1048
|
matchesFieldCondition(record, field, condition, options = {}) {
|
|
983
|
-
|
|
1049
|
+
let value = record[field];
|
|
984
1050
|
|
|
985
1051
|
// Debug logging for all field conditions - SAMPLED: only the first record of
|
|
986
1052
|
// each query logs per field, so a full streaming scan (which calls this for
|
|
@@ -1004,6 +1070,13 @@ export class QueryManager {
|
|
|
1004
1070
|
}
|
|
1005
1071
|
}
|
|
1006
1072
|
|
|
1073
|
+
// Term-mapped array fields store numeric term IDs, so both sides are translated
|
|
1074
|
+
// to terms here; otherwise string queries would never match on the streaming path.
|
|
1075
|
+
if (Array.isArray(value) && this._isTermMappingField(field)) {
|
|
1076
|
+
value = this._termIdsToTerms(value);
|
|
1077
|
+
condition = this._normalizeTermCondition(condition);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1007
1080
|
// Handle null/undefined values
|
|
1008
1081
|
if (value === null || value === undefined) {
|
|
1009
1082
|
return condition === null || condition === undefined;
|
|
@@ -1127,19 +1200,100 @@ export class QueryManager {
|
|
|
1127
1200
|
}
|
|
1128
1201
|
|
|
1129
1202
|
|
|
1203
|
+
/**
|
|
1204
|
+
* Whether a field stores its values as numeric term IDs (term mapping)
|
|
1205
|
+
* @param {string} field
|
|
1206
|
+
* @returns {boolean}
|
|
1207
|
+
*/
|
|
1208
|
+
_isTermMappingField(field) {
|
|
1209
|
+
const termManager = this.database && this.database.termManager
|
|
1210
|
+
return !!(termManager &&
|
|
1211
|
+
Array.isArray(termManager.termMappingFields) &&
|
|
1212
|
+
termManager.termMappingFields.includes(field))
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Translate stored term IDs back to terms for matching
|
|
1217
|
+
* @param {Array} ids
|
|
1218
|
+
* @returns {Array}
|
|
1219
|
+
*/
|
|
1220
|
+
_termIdsToTerms(ids) {
|
|
1221
|
+
const termManager = this.database.termManager
|
|
1222
|
+
return ids.map(id => {
|
|
1223
|
+
if (typeof id !== 'number') return id
|
|
1224
|
+
const term = termManager.getTerm(id)
|
|
1225
|
+
return term === null ? id : term
|
|
1226
|
+
})
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Translate numeric term IDs used in a query condition to their terms
|
|
1231
|
+
* @param {*} condition
|
|
1232
|
+
* @returns {*}
|
|
1233
|
+
*/
|
|
1234
|
+
_normalizeTermCondition(condition) {
|
|
1235
|
+
const toTerm = value => {
|
|
1236
|
+
if (typeof value !== 'number') return value
|
|
1237
|
+
const term = this.database.termManager.getTerm(value)
|
|
1238
|
+
return term === null ? value : term
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (typeof condition === 'number') {
|
|
1242
|
+
return toTerm(condition)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (condition instanceof RegExp || condition === null || typeof condition !== 'object') {
|
|
1246
|
+
return condition
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (Array.isArray(condition)) {
|
|
1250
|
+
return condition.map(toTerm)
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const normalized = {}
|
|
1254
|
+
for (const [operator, operatorValue] of Object.entries(condition)) {
|
|
1255
|
+
const canonical = normalizeOperator(operator)
|
|
1256
|
+
if (canonical === '$in' || canonical === '$all' || canonical === '$nin') {
|
|
1257
|
+
const values = Array.isArray(operatorValue) ? operatorValue : [operatorValue]
|
|
1258
|
+
normalized[operator] = values.map(toTerm)
|
|
1259
|
+
} else if (canonical === '$eq') {
|
|
1260
|
+
normalized[operator] = toTerm(operatorValue)
|
|
1261
|
+
} else {
|
|
1262
|
+
normalized[operator] = operatorValue
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return normalized
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1130
1268
|
/**
|
|
1131
1269
|
* Preprocess query to handle array field syntax automatically
|
|
1132
1270
|
* @param {Object} criteria - Query criteria
|
|
1133
1271
|
* @returns {Object} - Processed criteria
|
|
1134
1272
|
*/
|
|
1135
|
-
preprocessQuery(criteria) {
|
|
1136
|
-
if (!criteria || typeof criteria !== 'object') {
|
|
1273
|
+
preprocessQuery(criteria, nested = false) {
|
|
1274
|
+
if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
|
|
1137
1275
|
return criteria
|
|
1138
1276
|
}
|
|
1139
1277
|
|
|
1140
1278
|
const processed = {}
|
|
1141
1279
|
|
|
1142
1280
|
for (const [field, value] of Object.entries(criteria)) {
|
|
1281
|
+
// $and / $or / $not hold nested criteria and must be processed recursively,
|
|
1282
|
+
// otherwise conditions inside logical operators never reach the $in form.
|
|
1283
|
+
if (field === '$and' || field === '$or') {
|
|
1284
|
+
processed[field] = Array.isArray(value)
|
|
1285
|
+
? value.map(condition => this.preprocessQuery(condition, true))
|
|
1286
|
+
: value
|
|
1287
|
+
continue
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (field === '$not') {
|
|
1291
|
+
processed[field] = value && typeof value === 'object' && !Array.isArray(value)
|
|
1292
|
+
? this.preprocessQuery(value, true)
|
|
1293
|
+
: value
|
|
1294
|
+
continue
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1143
1297
|
// Check if this is a term mapping field
|
|
1144
1298
|
const isTermMappingField = this.database.opts.termMapping &&
|
|
1145
1299
|
this.database.termManager &&
|
|
@@ -1163,6 +1317,10 @@ export class QueryManager {
|
|
|
1163
1317
|
} else {
|
|
1164
1318
|
processed[field] = value
|
|
1165
1319
|
}
|
|
1320
|
+
} else if (nested && (value === null || value === undefined)) {
|
|
1321
|
+
// Nested branches may test for a missing value (e.g. $not: {field: null});
|
|
1322
|
+
// only top-level null queries are rejected as invalid.
|
|
1323
|
+
processed[field] = value
|
|
1166
1324
|
} else {
|
|
1167
1325
|
// Invalid value for term mapping field
|
|
1168
1326
|
throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
|
|
@@ -1254,130 +1412,20 @@ export class QueryManager {
|
|
|
1254
1412
|
}
|
|
1255
1413
|
}
|
|
1256
1414
|
|
|
1257
|
-
// Handle $and queries -
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
return notFields.every(field => {
|
|
1264
|
-
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
1265
|
-
return false;
|
|
1266
|
-
}
|
|
1267
|
-
// For term mapping, $not can be processed with indices
|
|
1268
|
-
return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
// Handle regular field conditions
|
|
1273
|
-
return Object.keys(andCondition).every(field => {
|
|
1274
|
-
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
1275
|
-
return false;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
const condition = andCondition[field];
|
|
1279
|
-
|
|
1280
|
-
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
1281
|
-
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
1282
|
-
if (condition instanceof RegExp) {
|
|
1283
|
-
const idxType = this.indexManager?.opts?.indexes?.[field];
|
|
1284
|
-
return idxType !== 'number' && idxType !== 'array:number';
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
1288
|
-
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
1289
|
-
const indexType = this.indexManager?.opts?.indexes?.[field]
|
|
1290
|
-
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
|
|
1291
|
-
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
|
|
1292
|
-
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
|
|
1293
|
-
|
|
1294
|
-
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
1295
|
-
const isTermMappingField = this.database.termManager &&
|
|
1296
|
-
this.database.termManager.termMappingFields &&
|
|
1297
|
-
this.database.termManager.termMappingFields.includes(field)
|
|
1298
|
-
|
|
1299
|
-
if (isTermMappingField) {
|
|
1300
|
-
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
|
|
1301
|
-
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
1302
|
-
} else {
|
|
1303
|
-
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
|
|
1304
|
-
return operators.every(op => !disallowed.includes(op));
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
return true;
|
|
1308
|
-
});
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
if (!allAndConditionsIndexed) {
|
|
1415
|
+
// Handle $and / $or queries - every branch must be indexable to use indices
|
|
1416
|
+
for (const logicalOperator of ['$and', '$or']) {
|
|
1417
|
+
const branches = criteria[logicalOperator];
|
|
1418
|
+
if (!Array.isArray(branches)) continue;
|
|
1419
|
+
|
|
1420
|
+
if (!branches.every(branch => this._isCriteriaIndexable(branch))) {
|
|
1312
1421
|
if (this.opts.debugMode) {
|
|
1313
|
-
console.log(
|
|
1422
|
+
console.log(`📊 QueryStrategy: STREAMING - Some ${logicalOperator} conditions not indexed or operators not supported`);
|
|
1314
1423
|
}
|
|
1315
1424
|
return 'streaming';
|
|
1316
1425
|
}
|
|
1317
1426
|
}
|
|
1318
|
-
|
|
1319
|
-
const allFieldsIndexed = Object.keys(criteria).every(field => {
|
|
1320
|
-
// Skip $and and $not as they're handled separately above
|
|
1321
|
-
if (field === '$and' || field === '$not') return true;
|
|
1322
|
-
|
|
1323
|
-
if (!this.opts.indexes || !this.opts.indexes[field]) {
|
|
1324
|
-
if (this.opts.debugMode) {
|
|
1325
|
-
console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}))
|
|
1326
|
-
}
|
|
1327
|
-
return false;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// Check if the field uses operators that are supported by IndexManager
|
|
1331
|
-
const condition = criteria[field];
|
|
1332
|
-
|
|
1333
|
-
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
1334
|
-
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
1335
|
-
if (condition instanceof RegExp) {
|
|
1336
|
-
const idxType = this.indexManager?.opts?.indexes?.[field];
|
|
1337
|
-
if (idxType === 'number' || idxType === 'array:number') {
|
|
1338
|
-
if (this.opts.debugMode) {
|
|
1339
|
-
console.log(`🔍 Field '${field}' uses RegExp on a numeric index - requires streaming strategy`)
|
|
1340
|
-
}
|
|
1341
|
-
return false;
|
|
1342
|
-
}
|
|
1343
|
-
return true;
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
|
|
1347
|
-
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
1348
|
-
if (this.opts.debugMode) {
|
|
1349
|
-
console.log(`🔍 Field '${field}' has operators:`, operators)
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
const indexType = this.indexManager?.opts?.indexes?.[field]
|
|
1353
|
-
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
|
|
1354
|
-
const isArrayStringIndex = indexType === 'array:string'
|
|
1355
|
-
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
|
|
1356
|
-
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
|
|
1357
|
-
|
|
1358
|
-
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
1359
|
-
const isTermMappingField = this.database.termManager &&
|
|
1360
|
-
this.database.termManager.termMappingFields &&
|
|
1361
|
-
this.database.termManager.termMappingFields.includes(field)
|
|
1362
|
-
|
|
1363
|
-
// With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
|
|
1364
|
-
// Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
|
|
1365
|
-
if (isTermMappingField) {
|
|
1366
|
-
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
|
|
1367
|
-
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
1368
|
-
} else {
|
|
1369
|
-
let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
|
|
1370
|
-
// Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
|
|
1371
|
-
if (isArrayStringIndex) {
|
|
1372
|
-
disallowed = disallowed.filter(op => op !== '$all')
|
|
1373
|
-
}
|
|
1374
|
-
return operators.every(op => !disallowed.includes(op));
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
return true;
|
|
1378
|
-
});
|
|
1379
1427
|
|
|
1380
|
-
if (!
|
|
1428
|
+
if (!this._isCriteriaIndexable(criteria)) {
|
|
1381
1429
|
if (this.opts.debugMode) {
|
|
1382
1430
|
console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
|
|
1383
1431
|
}
|
|
@@ -1417,6 +1465,91 @@ export class QueryManager {
|
|
|
1417
1465
|
return 'indexed';
|
|
1418
1466
|
}
|
|
1419
1467
|
|
|
1468
|
+
/**
|
|
1469
|
+
* Whether a criteria object can be fully resolved through indexes
|
|
1470
|
+
* @param {Object} criteria - Criteria or a single logical branch
|
|
1471
|
+
* @returns {boolean} - True if every condition is index-backed
|
|
1472
|
+
*/
|
|
1473
|
+
_isCriteriaIndexable(criteria) {
|
|
1474
|
+
if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
|
|
1475
|
+
return false;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const fields = Object.keys(criteria);
|
|
1479
|
+
if (fields.length === 0) {
|
|
1480
|
+
return false;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return fields.every(field => {
|
|
1484
|
+
const value = criteria[field];
|
|
1485
|
+
|
|
1486
|
+
if (field === '$and' || field === '$or') {
|
|
1487
|
+
return Array.isArray(value) &&
|
|
1488
|
+
value.length > 0 &&
|
|
1489
|
+
value.every(branch => this._isCriteriaIndexable(branch));
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (field === '$not') {
|
|
1493
|
+
// $not is only index-supported when term mapping is enabled
|
|
1494
|
+
if (!this.opts.termMapping || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1495
|
+
return false;
|
|
1496
|
+
}
|
|
1497
|
+
const notFields = Object.keys(value);
|
|
1498
|
+
return notFields.length > 0 && notFields.every(notField =>
|
|
1499
|
+
!!(this.indexManager.opts.indexes && this.indexManager.opts.indexes[notField])
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
if (field.startsWith('$')) {
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
return this._isFieldConditionIndexable(field, value);
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Whether a single field condition is supported by that field's index
|
|
1513
|
+
* @param {string} field - Field name
|
|
1514
|
+
* @param {*} condition - Condition value
|
|
1515
|
+
* @returns {boolean} - True if the index can answer this condition
|
|
1516
|
+
*/
|
|
1517
|
+
_isFieldConditionIndexable(field, condition) {
|
|
1518
|
+
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const indexType = this.indexManager.opts.indexes[field];
|
|
1523
|
+
|
|
1524
|
+
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
1525
|
+
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
1526
|
+
if (condition instanceof RegExp) {
|
|
1527
|
+
return indexType !== 'number' && indexType !== 'array:number';
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Index lookups cannot express "is null/undefined" reliably
|
|
1531
|
+
if (condition === null || condition === undefined) {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (typeof condition !== 'object' || Array.isArray(condition)) {
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
1540
|
+
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
|
|
1541
|
+
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
1542
|
+
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
1543
|
+
|
|
1544
|
+
if (this._isTermMappingField(field)) {
|
|
1545
|
+
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
|
|
1546
|
+
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
|
|
1550
|
+
return operators.every(op => !disallowed.includes(op));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1420
1553
|
/**
|
|
1421
1554
|
* Estimate number of results for a query
|
|
1422
1555
|
* @param {Object} criteria - Query criteria
|