String filters. Logic operators in filters

This commit is contained in:
2026-02-25 16:27:01 +11:00
parent 64b811f330
commit f7c89046d1
4 changed files with 175 additions and 26 deletions

View File

@@ -5,6 +5,7 @@ import {
type Filter,
type ComparisonOperators,
isOperatorObject,
isLogicalKey,
} from './base-storage.js';
import { BPlusTree, type BPlusTreeEntry } from 'src/utils/btree.js';
@@ -202,15 +203,24 @@ export class StorageMemory<
// ---------------------------------------------------------------------------
/**
* Checks whether a document satisfies every field in the filter.
* Supports both plain equality values and comparison operator objects.
* Checks whether a document satisfies a filter.
*
* Handles top-level logical operators ($and, $or, $not) first via
* recursion, then evaluates remaining field-level conditions.
*/
private matchesFilter(item: T, filter?: Filter<T>): boolean {
if (!filter || Object.keys(filter).length === 0) {
return true;
}
// Top-level logical operators.
if (filter.$and && !filter.$and.every((f) => this.matchesFilter(item, f))) return false;
if (filter.$or && !filter.$or.some((f) => this.matchesFilter(item, f))) return false;
if (filter.$not && this.matchesFilter(item, filter.$not)) return false;
// Field-level conditions (skip logical operator keys).
for (const [key, value] of Object.entries(filter)) {
if (isLogicalKey(key)) continue;
if (isOperatorObject(value)) {
if (!this.matchesOperators(item[key], value)) return false;
} else {
@@ -221,7 +231,7 @@ export class StorageMemory<
}
/**
* Evaluate a set of comparison operators against a single field value.
* Evaluate a set of comparison / string operators against a single field value.
* All operators must pass for the field to match.
*/
private matchesOperators(fieldValue: any, ops: ComparisonOperators<any>): boolean {
@@ -231,6 +241,19 @@ export class StorageMemory<
if (ops.$lte !== undefined && !(fieldValue <= ops.$lte)) return false;
if (ops.$gt !== undefined && !(fieldValue > ops.$gt)) return false;
if (ops.$gte !== undefined && !(fieldValue >= ops.$gte)) return false;
if (ops.$startsWith !== undefined) {
if (typeof fieldValue !== 'string' || !fieldValue.startsWith(ops.$startsWith)) return false;
}
if (ops.$contains !== undefined) {
if (typeof fieldValue !== 'string' || !fieldValue.includes(ops.$contains)) return false;
}
// Field-level $not: invert the enclosed operator set.
if (ops.$not !== undefined) {
if (this.matchesOperators(fieldValue, ops.$not)) return false;
}
return true;
}
@@ -244,7 +267,9 @@ export class StorageMemory<
if (resolution !== null) {
const { keys, resolvedFields } = resolution;
const filterKeys = filter ? Object.keys(filter) : [];
const needsVerification = filterKeys.some((k) => !resolvedFields.includes(k));
const hasLogicalOps = filterKeys.some(isLogicalKey);
const needsVerification = hasLogicalOps
|| filterKeys.some((k) => !isLogicalKey(k) && !resolvedFields.includes(k));
const results: Array<[number, T]> = [];
for (const key of keys) {
@@ -376,20 +401,39 @@ export class StorageMemory<
/**
* Try to resolve an operator filter against a single-field B+ Tree index.
* Returns a flat array of matching internal keys, or null if the
* operators can't be served by the tree ($ne).
* operators can't be efficiently served by the tree.
*
* Supported acceleration:
* - `$eq` → point lookup via `.get()`
* - `$gt/$gte/$lt/$lte` → range scan via `.range()`
* - `$startsWith` → converted to a range scan on the prefix
* - `$ne`, `$contains`, `$not` → cannot use index, returns null
*/
private resolveOperatorViaTree(
btree: BPlusTree<any, number>,
ops: ComparisonOperators<any>,
): Iterable<number> | null {
// $ne prevents efficient index use.
if (ops.$ne !== undefined) return null;
// Operators that prevent efficient index use.
if (ops.$ne !== undefined || ops.$contains !== undefined || ops.$not !== undefined) return null;
// $eq is a point lookup.
if (ops.$eq !== undefined) {
return btree.get(ops.$eq) ?? [];
}
// $startsWith is converted to a range scan: "abc" → ["abc", "abd").
if (ops.$startsWith !== undefined) {
const prefix = ops.$startsWith;
if (prefix.length === 0) return null;
const upper = prefix.slice(0, -1)
+ String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1);
const entries = btree.range(prefix, upper, {
lowerInclusive: true,
upperInclusive: false,
});
return this.flattenEntryKeys(entries);
}
// Extract range bounds from the remaining operators.
let min: any = undefined;
let max: any = undefined;
@@ -437,7 +481,12 @@ export class StorageMemory<
const { keys, resolvedFields } = resolution;
const filterKeys = filter ? Object.keys(filter) : [];
const needsVerification = filterKeys.some((k) => !resolvedFields.includes(k));
// Logical operators ($and/$or/$not) are never resolved by the index,
// and any unresolved field conditions also require verification.
const hasLogicalOps = filterKeys.some(isLogicalKey);
const needsVerification = hasLogicalOps
|| filterKeys.some((k) => !isLogicalKey(k) && !resolvedFields.includes(k));
const results: T[] = [];
for (const key of keys) {