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

@@ -20,7 +20,14 @@ export type FindOptions = {
export type IndexDefinition = string[] | string[][]; export type IndexDefinition = string[] | string[][];
/** /**
* MongoDB-style comparison operators for a single field value. * MongoDB-style comparison and string operators for a single field value.
*
* - Comparison operators ($eq, $ne, $lt, $lte, $gt, $gte) are available for
* all value types.
* - String operators ($startsWith, $contains) are only available when the
* field value type extends `string`.
* - Field-level $not wraps another operator set, inverting its result:
* `{ age: { $not: { $gte: 18 } } }` matches documents where age < 18.
*/ */
export type ComparisonOperators<V> = { export type ComparisonOperators<V> = {
$eq?: V; $eq?: V;
@@ -29,7 +36,8 @@ export type ComparisonOperators<V> = {
$lte?: V; $lte?: V;
$gt?: V; $gt?: V;
$gte?: V; $gte?: V;
}; $not?: ComparisonOperators<V>;
} & ([V] extends [string] ? { $startsWith?: string; $contains?: string } : {});
/** /**
* A filter value for a single field — either a plain value (equality shorthand) * A filter value for a single field — either a plain value (equality shorthand)
@@ -38,7 +46,22 @@ export type ComparisonOperators<V> = {
export type FieldFilter<V> = V | ComparisonOperators<V>; export type FieldFilter<V> = V | ComparisonOperators<V>;
/** /**
* Query filter that supports both equality shorthand and comparison operators. * Keys that represent top-level logical operators in a filter,
* as opposed to document field names.
*/
const LOGICAL_KEYS = new Set(['$and', '$or', '$not']);
/**
* Returns true when `key` is a top-level logical operator ($and, $or, $not)
* rather than a document field name.
*/
export function isLogicalKey(key: string): boolean {
return LOGICAL_KEYS.has(key);
}
/**
* Query filter that supports equality shorthand, comparison operators,
* and top-level logical operators ($and, $or, $not).
* *
* @example * @example
* // Equality shorthand * // Equality shorthand
@@ -47,11 +70,20 @@ export type FieldFilter<V> = V | ComparisonOperators<V>;
* // Comparison operators * // Comparison operators
* { age: { $gte: 18, $lt: 65 } } * { age: { $gte: 18, $lt: 65 } }
* *
* // Combined * // String operators (type-safe — only on string fields)
* { name: 'foo', age: { $gte: 18 } } * { name: { $startsWith: 'A' } }
*
* // Logical operators
* { $or: [{ name: 'foo' }, { age: { $gte: 18 } }] }
* { $not: { name: 'bar' } }
* { $and: [{ age: { $gte: 18 } }, { name: { $startsWith: 'A' } }] }
*/ */
export type Filter<T extends Record<string, any>> = { export type Filter<T extends Record<string, any>> = {
[K in keyof T]?: FieldFilter<T[K]>; [K in keyof T]?: FieldFilter<T[K]>;
} & {
$and?: Filter<T>[];
$or?: Filter<T>[];
$not?: Filter<T>;
}; };
/** /**

View File

@@ -3,7 +3,7 @@ import { Packr } from 'msgpackr';
import { AESKey } from 'src/crypto/aes-key.js'; import { AESKey } from 'src/crypto/aes-key.js';
import { Bytes } from 'src/crypto/bytes.js'; import { Bytes } from 'src/crypto/bytes.js';
import { BaseStorage, type FindOptions, type Filter, isOperatorObject } from './base-storage.js'; import { BaseStorage, type FindOptions, type Filter, isOperatorObject, isLogicalKey } from './base-storage.js';
import { encodeExtendedJsonObject, decodeExtendedJsonObject } from 'src/utils/ext-json.js'; import { encodeExtendedJsonObject, decodeExtendedJsonObject } from 'src/utils/ext-json.js';
@@ -231,12 +231,13 @@ export class EncryptedStorage<
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Convert a query filter for storage. Handles both plain equality values * Convert a query filter for storage. Handles plain equality values,
* and operator objects. * operator objects, and top-level logical operators ($and, $or, $not).
* *
* - Plaintext fields: values and operator objects pass through as-is. * - Plaintext fields: values and operator objects pass through as-is.
* - Encrypted fields: plain values are encrypted. Operator objects throw * - Encrypted fields: plain values are encrypted. Operator objects throw
* because range comparisons are meaningless on ciphertext. * because range/string comparisons are meaningless on ciphertext.
* - Logical operators: sub-filters are recursively converted.
*/ */
private async convertFilterForStorage( private async convertFilterForStorage(
filter: Filter<T>, filter: Filter<T>,
@@ -244,9 +245,27 @@ export class EncryptedStorage<
const result: Record<string, any> = {}; const result: Record<string, any> = {};
const entries = Object.entries(filter); const entries = Object.entries(filter);
// Recursively convert logical operator sub-filters.
if (filter.$and) {
result.$and = await Promise.all(
filter.$and.map((f) => this.convertFilterForStorage(f)),
);
}
if (filter.$or) {
result.$or = await Promise.all(
filter.$or.map((f) => this.convertFilterForStorage(f)),
);
}
if (filter.$not) {
result.$not = await this.convertFilterForStorage(filter.$not);
}
const encryptionTasks: Array<Promise<readonly [string, any]>> = []; const encryptionTasks: Array<Promise<readonly [string, any]>> = [];
for (const [key, value] of entries) { for (const [key, value] of entries) {
// Logical operator keys are already handled above.
if (isLogicalKey(key)) continue;
if (this.plaintextKeys.has(key)) { if (this.plaintextKeys.has(key)) {
// Plaintext field — pass through (including operator objects). // Plaintext field — pass through (including operator objects).
result[key] = isOperatorObject(value) result[key] = isOperatorObject(value)
@@ -255,8 +274,8 @@ export class EncryptedStorage<
} else if (isOperatorObject(value)) { } else if (isOperatorObject(value)) {
// Encrypted field with an operator — not supported. // Encrypted field with an operator — not supported.
throw new Error( throw new Error(
`Range operators ($lt, $gt, etc.) cannot be used on encrypted field '${key}'. ` + `Operators ($lt, $gt, $startsWith, $contains, $not, etc.) cannot be used on encrypted field '${key}'. ` +
`Add '${key}' to plaintextKeys if you need range queries on this field.`, `Add '${key}' to plaintextKeys if you need operator queries on this field.`,
); );
} else { } else {
// Encrypted field with a plain equality value — encrypt it. // Encrypted field with a plain equality value — encrypt it.

View File

@@ -5,6 +5,7 @@ import {
type Filter, type Filter,
type ComparisonOperators, type ComparisonOperators,
isOperatorObject, isOperatorObject,
isLogicalKey,
} from './base-storage.js'; } from './base-storage.js';
import { BPlusTree, type BPlusTreeEntry } from 'src/utils/btree.js'; import { BPlusTree, type BPlusTreeEntry } from 'src/utils/btree.js';
@@ -162,7 +163,9 @@ export class StorageLocalStorage<
if (resolution !== null) { if (resolution !== null) {
const { keys, resolvedFields } = resolution; const { keys, resolvedFields } = resolution;
const filterKeys = filter ? Object.keys(filter) : []; 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));
results = []; results = [];
for (const key of keys) { for (const key of keys) {
@@ -273,12 +276,22 @@ export class StorageLocalStorage<
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Checks whether a document satisfies every field in the filter. * Checks whether a document satisfies a filter.
* Supports both plain equality values and comparison operator objects. *
* 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 { private matchesFilter(item: T, filter?: Filter<T>): boolean {
if (!filter || Object.keys(filter).length === 0) return true; 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)) { for (const [key, value] of Object.entries(filter)) {
if (isLogicalKey(key)) continue;
if (isOperatorObject(value)) { if (isOperatorObject(value)) {
if (!this.matchesOperators(item[key], value)) return false; if (!this.matchesOperators(item[key], value)) return false;
} else { } else {
@@ -289,7 +302,8 @@ export class StorageLocalStorage<
} }
/** /**
* 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 { private matchesOperators(fieldValue: any, ops: ComparisonOperators<any>): boolean {
if (ops.$eq !== undefined && fieldValue !== ops.$eq) return false; if (ops.$eq !== undefined && fieldValue !== ops.$eq) return false;
@@ -298,6 +312,19 @@ export class StorageLocalStorage<
if (ops.$lte !== undefined && !(fieldValue <= ops.$lte)) return false; if (ops.$lte !== undefined && !(fieldValue <= ops.$lte)) return false;
if (ops.$gt !== undefined && !(fieldValue > ops.$gt)) return false; if (ops.$gt !== undefined && !(fieldValue > ops.$gt)) return false;
if (ops.$gte !== undefined && !(fieldValue >= ops.$gte)) 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; return true;
} }
@@ -312,7 +339,9 @@ export class StorageLocalStorage<
if (resolution !== null) { if (resolution !== null) {
const { keys, resolvedFields } = resolution; const { keys, resolvedFields } = resolution;
const filterKeys = filter ? Object.keys(filter) : []; 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));
for (const key of keys) { for (const key of keys) {
const raw = localStorage.getItem(this.docKey(key)); const raw = localStorage.getItem(this.docKey(key));
@@ -447,18 +476,38 @@ export class StorageLocalStorage<
/** /**
* Try to resolve an operator filter against a single-field B+ Tree index. * 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 * 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( private resolveOperatorViaTree(
btree: BPlusTree<any, number>, btree: BPlusTree<any, number>,
ops: ComparisonOperators<any>, ops: ComparisonOperators<any>,
): Iterable<number> | null { ): Iterable<number> | null {
if (ops.$ne !== undefined) return null; // Operators that prevent efficient index use.
if (ops.$ne !== undefined || ops.$contains !== undefined || ops.$not !== undefined) return null;
if (ops.$eq !== undefined) { if (ops.$eq !== undefined) {
return btree.get(ops.$eq) ?? []; 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);
}
let min: any = undefined; let min: any = undefined;
let max: any = undefined; let max: any = undefined;
let lowerInclusive = true; let lowerInclusive = true;

View File

@@ -5,6 +5,7 @@ import {
type Filter, type Filter,
type ComparisonOperators, type ComparisonOperators,
isOperatorObject, isOperatorObject,
isLogicalKey,
} from './base-storage.js'; } from './base-storage.js';
import { BPlusTree, type BPlusTreeEntry } from 'src/utils/btree.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. * Checks whether a document satisfies a filter.
* Supports both plain equality values and comparison operator objects. *
* 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 { private matchesFilter(item: T, filter?: Filter<T>): boolean {
if (!filter || Object.keys(filter).length === 0) { if (!filter || Object.keys(filter).length === 0) {
return true; 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)) { for (const [key, value] of Object.entries(filter)) {
if (isLogicalKey(key)) continue;
if (isOperatorObject(value)) { if (isOperatorObject(value)) {
if (!this.matchesOperators(item[key], value)) return false; if (!this.matchesOperators(item[key], value)) return false;
} else { } 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. * All operators must pass for the field to match.
*/ */
private matchesOperators(fieldValue: any, ops: ComparisonOperators<any>): boolean { 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.$lte !== undefined && !(fieldValue <= ops.$lte)) return false;
if (ops.$gt !== undefined && !(fieldValue > ops.$gt)) return false; if (ops.$gt !== undefined && !(fieldValue > ops.$gt)) return false;
if (ops.$gte !== undefined && !(fieldValue >= ops.$gte)) 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; return true;
} }
@@ -244,7 +267,9 @@ export class StorageMemory<
if (resolution !== null) { if (resolution !== null) {
const { keys, resolvedFields } = resolution; const { keys, resolvedFields } = resolution;
const filterKeys = filter ? Object.keys(filter) : []; 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]> = []; const results: Array<[number, T]> = [];
for (const key of keys) { 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. * 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 * 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( private resolveOperatorViaTree(
btree: BPlusTree<any, number>, btree: BPlusTree<any, number>,
ops: ComparisonOperators<any>, ops: ComparisonOperators<any>,
): Iterable<number> | null { ): Iterable<number> | null {
// $ne prevents efficient index use. // Operators that prevent efficient index use.
if (ops.$ne !== undefined) return null; if (ops.$ne !== undefined || ops.$contains !== undefined || ops.$not !== undefined) return null;
// $eq is a point lookup. // $eq is a point lookup.
if (ops.$eq !== undefined) { if (ops.$eq !== undefined) {
return btree.get(ops.$eq) ?? []; 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. // Extract range bounds from the remaining operators.
let min: any = undefined; let min: any = undefined;
let max: any = undefined; let max: any = undefined;
@@ -437,7 +481,12 @@ export class StorageMemory<
const { keys, resolvedFields } = resolution; const { keys, resolvedFields } = resolution;
const filterKeys = filter ? Object.keys(filter) : []; 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[] = []; const results: T[] = [];
for (const key of keys) { for (const key of keys) {