diff --git a/AGENTS.md b/AGENTS.md index d345277..431a839 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ Haro is a modern immutable DataStore for collections of records with indexing, v ## Project Structure - `src/haro.js` - Main Haro class and factory function - `src/constants.js` - String and number constants +- `src/query-strategy.js` - Strategy pattern for predicate matching (`ValueMatcher`, `PredicateStrategy`) - `tests/` - Unit tests using Node.js native test runner - `dist/` - Built distribution files (generated) - `types/haro.d.ts` - TypeScript definitions @@ -54,9 +55,10 @@ npm run benchmark # Run benchmarks - `#fromCache(cached)` - Returns cloned result (non-immutable) or frozen result (immutable) from cache - `#toCache(cacheKey, records)` - Stores results in cache if enabled - `#getIndexKeysFrom(arg, source, getValueFn)` - Generates composite index keys using a getter callback +- `#getIndexValues(field, source)` - Extracts index values for a field, handling composite indexes and scalar/array fields. Centralizes deduplicated logic from `#setIndex()` and `#deleteIndex()` - `#getNestedValue(obj, path)` - Retrieves nested values using dot notation (e.g., `user.profile.city`) - `#sortKeys(a, b)` - Type-aware comparator: strings use `localeCompare`, numbers use subtraction, mixed types coerced to string -- `#merge(a, b, override)` - Deep merges values, skips prototype pollution keys (`__proto__`, `constructor`, `prototype`) +- `#merge(a, b)` - Deep merges values, skips prototype pollution keys (`__proto__`, `constructor`, `prototype`). `override` parameter removed - `#invalidateCache()` - Clears cache if enabled and not in batch mode - `#getCacheKey(domain, ...args)` - Generates SHA-256 hash cache key from arguments - `#clone(arg)` - Deep clones values via `structuredClone` or JSON fallback diff --git a/coverage.txt b/coverage.txt index 4652cdf..4f72fbe 100644 --- a/coverage.txt +++ b/coverage.txt @@ -1,11 +1,12 @@ ℹ start of coverage report -ℹ -------------------------------------------------------------- -ℹ file | line % | branch % | funcs % | uncovered lines -ℹ -------------------------------------------------------------- -ℹ src | | | | -ℹ constants.js | 100.00 | 100.00 | 100.00 | -ℹ haro.js | 100.00 | 97.86 | 97.62 | -ℹ -------------------------------------------------------------- -ℹ all files | 100.00 | 97.86 | 97.62 | -ℹ -------------------------------------------------------------- +ℹ ------------------------------------------------------------------- +ℹ file | line % | branch % | funcs % | uncovered lines +ℹ ------------------------------------------------------------------- +ℹ src | | | | +ℹ constants.js | 100.00 | 100.00 | 100.00 | +ℹ haro.js | 100.00 | 98.48 | 98.77 | +ℹ query-strategy.js | 100.00 | 100.00 | 100.00 | +ℹ ------------------------------------------------------------------- +ℹ all files | 100.00 | 98.61 | 98.90 | +ℹ ------------------------------------------------------------------- ℹ end of coverage report diff --git a/dist/haro.cjs b/dist/haro.cjs index de279ad..3594a8a 100644 --- a/dist/haro.cjs +++ b/dist/haro.cjs @@ -36,6 +36,7 @@ const STRING_RECORD_NOT_FOUND = "Record not found"; // Integer constants const INT_0 = 0; const INT_2 = 2; +const INT_256 = 256; // Number constants const CACHE_SIZE_DEFAULT = 1000; @@ -77,6 +78,98 @@ const PROP_VERSIONING = "versioning"; const PROP_VERSIONS = "versions"; const PROP_WARN_ON_FULL_SCAN = "warnOnFullScan"; +/** + * Low-level value matcher for predicate matching. + * @class + */ +class ValueMatcher { + /** + * Matches a single value against a predicate. + * @param {*} val - Value to test + * @param {*} pred - Predicate value or RegExp + * @returns {boolean} Whether value matches predicate + */ + static match(val, pred) { + if (pred instanceof RegExp) return pred.test(val); + if (val instanceof RegExp) return val.test(pred); + return val === pred; + } +} + +/** + * Predicate strategy for matching records against field predicates. + * Supports AND (every) and OR (some) logic for array predicates. + * @class + */ +class PredicateStrategy { + /** + * Creates a predicate strategy. + * @param {string} op - Operator: '||' (OR) or '&&' (AND) + */ + constructor(op) { + this.op = op; + } + + /** + * Factory method to create a strategy instance. + * @param {string} op - Operator: '||' (OR) or '&&' (AND) + * @returns {PredicateStrategy} + */ + static of(op) { + return new PredicateStrategy(op); + } + + /** + * Checks if a record matches a predicate object. + * @param {Object} record - Record to test + * @param {Object} predicate - Field-value predicate map + * @param {Function} getNestedValue - Function to retrieve nested values (record, path) => value + * @returns {boolean} Whether record matches all predicate fields + */ + matches(record, predicate, getNestedValue) { + return Object.keys(predicate).every((key) => { + return this.#matchField(record, key, predicate[key], getNestedValue); + }); + } + + /** + * Matches a single field's predicate against a record value. + * @param {Object} record - Record containing the value + * @param {string} key - Field path + * @param {*} pred - Predicate for the field (value, array of values, or RegExp) + * @param {Function} getNestedValue - Function to retrieve nested values + * @returns {boolean} Whether the field matches the predicate + */ + #matchField(record, key, pred, getNestedValue) { + const val = getNestedValue(record, key); + + // Array predicate matching against record value + if (Array.isArray(pred)) { + if (Array.isArray(val)) { + return this.#matchArrayPred(pred, (p) => val.includes(p)); + } + return this.#matchArrayPred(pred, (p) => val === p); + } + + // Record field is an array, check if any element matches + if (Array.isArray(val)) { + return val.some((v) => ValueMatcher.match(v, pred)); + } + + return ValueMatcher.match(val, pred); + } + + /** + * Applies the strategy's operator to an array of predicates. + * @param {Array} preds - Array of predicates + * @param {Function} fn - Matcher function (pred) => boolean + * @returns {boolean} Result of AND/OR evaluation + */ + #matchArrayPred(preds, fn) { + return this.op === STRING_DOUBLE_AND ? preds.every(fn) : preds.some(fn); + } +} + /** * Haro is an immutable DataStore with indexing, versioning, and batch operations. * Provides a Map-like interface with advanced querying capabilities. @@ -396,6 +489,21 @@ class Haro { return result; } + /** + * Extracts index values for a field from a source object. + * Handles both composite indexes and scalar/array fields. + * @param {string} field - Field name or composite index path + * @param {Object} source - Source object + * @returns {Array} Array of index values + */ + #getIndexValues(field, source) { + if (field.includes(this.#delimiter)) { + return this.#getIndexKeysFrom(field, source, (f, s) => this.#getNestedValue(s, f)); + } + const val = this.#getNestedValue(source, field); + return Array.isArray(val) ? val : [val]; + } + /** * Removes a record from all indexes. * @param {string} key - Record key @@ -406,11 +514,7 @@ class Haro { this.#index.forEach((i) => { const idx = this.#indexes.get(i); if (!idx) return; - const values = i.includes(this.#delimiter) - ? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f)) - : Array.isArray(this.#getNestedValue(data, i)) - ? this.#getNestedValue(data, i) - : [this.#getNestedValue(data, i)]; + const values = this.#getIndexValues(i, data); const len = values.length; for (let j = 0; j < len; j++) { const value = values[j]; @@ -649,9 +753,9 @@ class Haro { * @param {boolean} [override=false] - Override arrays * @returns {*} Merged result */ - #merge(a, b, override = false) { + #merge(a, b) { if (Array.isArray(a) && Array.isArray(b)) { - a = override ? b : a.concat(b); + a = a.concat(b); } else if ( typeof a === STRING_OBJECT && a !== null && @@ -662,10 +766,7 @@ class Haro { const keysLen = keys.length; for (let i = 0; i < keysLen; i++) { const key = keys[i]; - if (key === STRING_PROTO || key === STRING_CONSTRUCTOR || key === STRING_PROTOTYPE) { - continue; - } - a[key] = this.#merge(a[key], b[key], override); + a[key] = this.#merge(a[key], b[key]); } } else { a = b; @@ -753,6 +854,9 @@ class Haro { const result = new Set(); const fn = typeof value === STRING_FUNCTION; const rgex = value && typeof value.test === STRING_FUNCTION; + if (rgex && value.source.length > INT_256) { + throw new Error(STRING_ERROR_SEARCH_VALUE); + } const indices = index ? (Array.isArray(index) ? index : [index]) : this.#index; const indicesLen = indices.length; @@ -807,7 +911,11 @@ class Haro { if (key === null) { key = data[this.#key] ?? crypto$1.randomUUID(); } - let x = { ...data, [this.#key]: key }; + const pollutionProps = new Set([STRING_PROTO, STRING_CONSTRUCTOR, STRING_PROTOTYPE]); + const safeData = Object.fromEntries( + Object.entries(data).filter(([k]) => !pollutionProps.has(k)), + ); + let x = { ...safeData, [this.#key]: key }; if (!this.#data.has(key)) { if (this.#versioning && !this.#inBatch) { this.#versions.set(key, new Set()); @@ -853,11 +961,7 @@ class Haro { idx = new Map(); this.#indexes.set(field, idx); } - const values = field.includes(this.#delimiter) - ? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f)) - : Array.isArray(this.#getNestedValue(data, field)) - ? this.#getNestedValue(data, field) - : [this.#getNestedValue(data, field)]; + const values = this.#getIndexValues(field, data); const valuesLen = values.length; for (let j = 0; j < valuesLen; j++) { const value = values[j]; @@ -878,17 +982,14 @@ class Haro { * @example * store.sort((a, b) => a.age - b.age); */ - sort(fn, frozen = false) { + sort(fn) { if (typeof fn !== STRING_FUNCTION) { throw new Error(STRING_ERROR_SORT_FN_TYPE); } - const dataSize = this.#data.size; - let result = this.limit(INT_0, dataSize).sort(fn); - if (frozen) { - result = Object.freeze(result); - } - - return result; + const result = Array.from(this.#data.values()) + .map((v) => (this.#immutable ? this.#clone(v) : v)) + .sort(fn); + return this.#freezeResult(result); } /** @@ -965,48 +1066,6 @@ class Haro { return this.#data.values(); } - /** - * Matches a record against a predicate. - * @param {Object} record - Record to test - * @param {Object} predicate - Predicate object - * @param {string} op - Operator: '||' or '&&' - * @returns {boolean} True if matches - */ - #matchesPredicate(record, predicate, op) { - const keys = Object.keys(predicate); - - return keys.every((key) => { - const pred = predicate[key]; - // Use nested value extraction for dot notation paths - const val = this.#getNestedValue(record, key); - if (Array.isArray(pred)) { - if (Array.isArray(val)) { - return op === STRING_DOUBLE_AND - ? pred.every((p) => val.includes(p)) - : pred.some((p) => val.includes(p)); - } - return op === STRING_DOUBLE_AND - ? pred.every((p) => val === p) - : pred.some((p) => val === p); - } - if (Array.isArray(val)) { - return val.some((v) => { - if (pred instanceof RegExp) { - return pred.test(v); - } - if (v instanceof RegExp) { - return v.test(pred); - } - return v === pred; - }); - } - if (pred instanceof RegExp) { - return pred.test(val); - } - return val === pred; - }); - } - /** * Filters records with predicate logic supporting AND/OR on arrays. * @param {Object} [predicate={}] - Field-value pairs @@ -1038,7 +1097,10 @@ class Haro { if (this.#warnOnFullScan) { console.warn("where(): performing full table scan - consider adding an index"); } - return this.filter((a) => this.#matchesPredicate(a, predicate, op)); + const strategy = PredicateStrategy.of(op); + return this.filter((a) => + strategy.matches(a, predicate, (r, k) => this.#getNestedValue(r, k)), + ); } // Try to use indexes for better performance @@ -1093,10 +1155,11 @@ class Haro { } } // Filter candidates with full predicate logic + const strategy = PredicateStrategy.of(op); const results = []; for (const key of candidateKeys) { const record = this.get(key); - if (this.#matchesPredicate(record, predicate, op)) { + if (strategy.matches(record, predicate, (r, k) => this.#getNestedValue(r, k))) { results.push(record); } } @@ -1104,6 +1167,10 @@ class Haro { this.#toCache(cacheKey, results); return this.#freezeResult(results); } + + // Indexed keys matched but no candidates found - return empty array + this.#toCache(cacheKey, []); + return this.#freezeResult([]); } } diff --git a/dist/haro.js b/dist/haro.js index 399c578..f831708 100644 --- a/dist/haro.js +++ b/dist/haro.js @@ -31,6 +31,7 @@ const STRING_RECORD_NOT_FOUND = "Record not found"; // Integer constants const INT_0 = 0; const INT_2 = 2; +const INT_256 = 256; // Number constants const CACHE_SIZE_DEFAULT = 1000; @@ -71,6 +72,96 @@ const PROP_KEY = "key"; const PROP_VERSIONING = "versioning"; const PROP_VERSIONS = "versions"; const PROP_WARN_ON_FULL_SCAN = "warnOnFullScan";/** + * Low-level value matcher for predicate matching. + * @class + */ +class ValueMatcher { + /** + * Matches a single value against a predicate. + * @param {*} val - Value to test + * @param {*} pred - Predicate value or RegExp + * @returns {boolean} Whether value matches predicate + */ + static match(val, pred) { + if (pred instanceof RegExp) return pred.test(val); + if (val instanceof RegExp) return val.test(pred); + return val === pred; + } +} + +/** + * Predicate strategy for matching records against field predicates. + * Supports AND (every) and OR (some) logic for array predicates. + * @class + */ +class PredicateStrategy { + /** + * Creates a predicate strategy. + * @param {string} op - Operator: '||' (OR) or '&&' (AND) + */ + constructor(op) { + this.op = op; + } + + /** + * Factory method to create a strategy instance. + * @param {string} op - Operator: '||' (OR) or '&&' (AND) + * @returns {PredicateStrategy} + */ + static of(op) { + return new PredicateStrategy(op); + } + + /** + * Checks if a record matches a predicate object. + * @param {Object} record - Record to test + * @param {Object} predicate - Field-value predicate map + * @param {Function} getNestedValue - Function to retrieve nested values (record, path) => value + * @returns {boolean} Whether record matches all predicate fields + */ + matches(record, predicate, getNestedValue) { + return Object.keys(predicate).every((key) => { + return this.#matchField(record, key, predicate[key], getNestedValue); + }); + } + + /** + * Matches a single field's predicate against a record value. + * @param {Object} record - Record containing the value + * @param {string} key - Field path + * @param {*} pred - Predicate for the field (value, array of values, or RegExp) + * @param {Function} getNestedValue - Function to retrieve nested values + * @returns {boolean} Whether the field matches the predicate + */ + #matchField(record, key, pred, getNestedValue) { + const val = getNestedValue(record, key); + + // Array predicate matching against record value + if (Array.isArray(pred)) { + if (Array.isArray(val)) { + return this.#matchArrayPred(pred, (p) => val.includes(p)); + } + return this.#matchArrayPred(pred, (p) => val === p); + } + + // Record field is an array, check if any element matches + if (Array.isArray(val)) { + return val.some((v) => ValueMatcher.match(v, pred)); + } + + return ValueMatcher.match(val, pred); + } + + /** + * Applies the strategy's operator to an array of predicates. + * @param {Array} preds - Array of predicates + * @param {Function} fn - Matcher function (pred) => boolean + * @returns {boolean} Result of AND/OR evaluation + */ + #matchArrayPred(preds, fn) { + return this.op === STRING_DOUBLE_AND ? preds.every(fn) : preds.some(fn); + } +}/** * Haro is an immutable DataStore with indexing, versioning, and batch operations. * Provides a Map-like interface with advanced querying capabilities. * @class @@ -389,6 +480,21 @@ class Haro { return result; } + /** + * Extracts index values for a field from a source object. + * Handles both composite indexes and scalar/array fields. + * @param {string} field - Field name or composite index path + * @param {Object} source - Source object + * @returns {Array} Array of index values + */ + #getIndexValues(field, source) { + if (field.includes(this.#delimiter)) { + return this.#getIndexKeysFrom(field, source, (f, s) => this.#getNestedValue(s, f)); + } + const val = this.#getNestedValue(source, field); + return Array.isArray(val) ? val : [val]; + } + /** * Removes a record from all indexes. * @param {string} key - Record key @@ -399,11 +505,7 @@ class Haro { this.#index.forEach((i) => { const idx = this.#indexes.get(i); if (!idx) return; - const values = i.includes(this.#delimiter) - ? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f)) - : Array.isArray(this.#getNestedValue(data, i)) - ? this.#getNestedValue(data, i) - : [this.#getNestedValue(data, i)]; + const values = this.#getIndexValues(i, data); const len = values.length; for (let j = 0; j < len; j++) { const value = values[j]; @@ -642,9 +744,9 @@ class Haro { * @param {boolean} [override=false] - Override arrays * @returns {*} Merged result */ - #merge(a, b, override = false) { + #merge(a, b) { if (Array.isArray(a) && Array.isArray(b)) { - a = override ? b : a.concat(b); + a = a.concat(b); } else if ( typeof a === STRING_OBJECT && a !== null && @@ -655,10 +757,7 @@ class Haro { const keysLen = keys.length; for (let i = 0; i < keysLen; i++) { const key = keys[i]; - if (key === STRING_PROTO || key === STRING_CONSTRUCTOR || key === STRING_PROTOTYPE) { - continue; - } - a[key] = this.#merge(a[key], b[key], override); + a[key] = this.#merge(a[key], b[key]); } } else { a = b; @@ -746,6 +845,9 @@ class Haro { const result = new Set(); const fn = typeof value === STRING_FUNCTION; const rgex = value && typeof value.test === STRING_FUNCTION; + if (rgex && value.source.length > INT_256) { + throw new Error(STRING_ERROR_SEARCH_VALUE); + } const indices = index ? (Array.isArray(index) ? index : [index]) : this.#index; const indicesLen = indices.length; @@ -800,7 +902,11 @@ class Haro { if (key === null) { key = data[this.#key] ?? randomUUID(); } - let x = { ...data, [this.#key]: key }; + const pollutionProps = new Set([STRING_PROTO, STRING_CONSTRUCTOR, STRING_PROTOTYPE]); + const safeData = Object.fromEntries( + Object.entries(data).filter(([k]) => !pollutionProps.has(k)), + ); + let x = { ...safeData, [this.#key]: key }; if (!this.#data.has(key)) { if (this.#versioning && !this.#inBatch) { this.#versions.set(key, new Set()); @@ -846,11 +952,7 @@ class Haro { idx = new Map(); this.#indexes.set(field, idx); } - const values = field.includes(this.#delimiter) - ? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f)) - : Array.isArray(this.#getNestedValue(data, field)) - ? this.#getNestedValue(data, field) - : [this.#getNestedValue(data, field)]; + const values = this.#getIndexValues(field, data); const valuesLen = values.length; for (let j = 0; j < valuesLen; j++) { const value = values[j]; @@ -871,17 +973,14 @@ class Haro { * @example * store.sort((a, b) => a.age - b.age); */ - sort(fn, frozen = false) { + sort(fn) { if (typeof fn !== STRING_FUNCTION) { throw new Error(STRING_ERROR_SORT_FN_TYPE); } - const dataSize = this.#data.size; - let result = this.limit(INT_0, dataSize).sort(fn); - if (frozen) { - result = Object.freeze(result); - } - - return result; + const result = Array.from(this.#data.values()) + .map((v) => (this.#immutable ? this.#clone(v) : v)) + .sort(fn); + return this.#freezeResult(result); } /** @@ -958,48 +1057,6 @@ class Haro { return this.#data.values(); } - /** - * Matches a record against a predicate. - * @param {Object} record - Record to test - * @param {Object} predicate - Predicate object - * @param {string} op - Operator: '||' or '&&' - * @returns {boolean} True if matches - */ - #matchesPredicate(record, predicate, op) { - const keys = Object.keys(predicate); - - return keys.every((key) => { - const pred = predicate[key]; - // Use nested value extraction for dot notation paths - const val = this.#getNestedValue(record, key); - if (Array.isArray(pred)) { - if (Array.isArray(val)) { - return op === STRING_DOUBLE_AND - ? pred.every((p) => val.includes(p)) - : pred.some((p) => val.includes(p)); - } - return op === STRING_DOUBLE_AND - ? pred.every((p) => val === p) - : pred.some((p) => val === p); - } - if (Array.isArray(val)) { - return val.some((v) => { - if (pred instanceof RegExp) { - return pred.test(v); - } - if (v instanceof RegExp) { - return v.test(pred); - } - return v === pred; - }); - } - if (pred instanceof RegExp) { - return pred.test(val); - } - return val === pred; - }); - } - /** * Filters records with predicate logic supporting AND/OR on arrays. * @param {Object} [predicate={}] - Field-value pairs @@ -1031,7 +1088,10 @@ class Haro { if (this.#warnOnFullScan) { console.warn("where(): performing full table scan - consider adding an index"); } - return this.filter((a) => this.#matchesPredicate(a, predicate, op)); + const strategy = PredicateStrategy.of(op); + return this.filter((a) => + strategy.matches(a, predicate, (r, k) => this.#getNestedValue(r, k)), + ); } // Try to use indexes for better performance @@ -1086,10 +1146,11 @@ class Haro { } } // Filter candidates with full predicate logic + const strategy = PredicateStrategy.of(op); const results = []; for (const key of candidateKeys) { const record = this.get(key); - if (this.#matchesPredicate(record, predicate, op)) { + if (strategy.matches(record, predicate, (r, k) => this.#getNestedValue(r, k))) { results.push(record); } } @@ -1097,6 +1158,10 @@ class Haro { this.#toCache(cacheKey, results); return this.#freezeResult(results); } + + // Indexed keys matched but no candidates found - return empty array + this.#toCache(cacheKey, []); + return this.#freezeResult([]); } } diff --git a/docs/API.md b/docs/API.md index 6f327a0..907c3c6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -21,7 +21,7 @@ Haro is an immutable DataStore with indexing, versioning, and batch operations. - [search()](#searchvalue-index) - [filter()](#filterfn) - [sortBy()](#sortbyindex) - - [sort()](#sortfn-frozen) + - [sort()](#sortfn) - [limit()](#limitoffset-max) - [Batch Operations](#batch-operations) - [setMany()](#setmanyrecords) @@ -38,6 +38,7 @@ Haro is an immutable DataStore with indexing, versioning, and batch operations. - [#fromCache(cached)](#fromcachecached) - [#toCache(cacheKey, records)](#tocachekey-records) - [#getIndexKeysFrom(arg, source, getValueFn)](#getindexkeysfromarg-source-getvaluefn) + - [#getIndexValues(field, source)](#getindexvaluesfield-source) - [Index Management](#index-management) - [reindex()](#reindexindex) - [Cache Control Methods](#cache-control-methods) @@ -52,6 +53,9 @@ Haro is an immutable DataStore with indexing, versioning, and batch operations. - [size](#size) - [Factory Function](#factory-function) - [haro()](#harodata-config) +- [Query Strategy](#query-strategy) + - [ValueMatcher](#valuematcher) + - [PredicateStrategy](#predicstrategy) --- @@ -116,6 +120,8 @@ Sets or updates a record with automatic indexing. **Returns:** Object - Stored record +**Security:** Keys named `__proto__`, `constructor`, or `prototype` in `data` are silently filtered to prevent prototype pollution attacks. + **Example:** ```javascript store.set(null, {name: 'John'}); @@ -210,13 +216,13 @@ store.find({'user.email': 'john@example.com', 'user.profile.department': 'IT'}); ### where(predicate, op) -Filters records with predicate logic supporting AND/OR on arrays. Supports dot notation for nested fields. +Filters records with predicate logic supporting AND/OR on arrays. Supports dot notation for nested fields. When indexed fields are found in the predicate, an index-based optimization is used. When no indexed fields match, a full table scan is performed as a fallback. Returns an empty array when indexed queries find no candidates. **Parameters:** - `predicate` (Object): Field-value pairs (supports dot notation for nested paths) - `op` (string): Operator: '||' (OR) or '&&' (AND) (default: `'||'`) -**Returns:** Promise> - Matching records (async) +**Returns:** Promise> - Matching records (async). Always returns an array (never void/undefined). **Example:** ```javascript @@ -227,6 +233,8 @@ const results = await store.where({tags: ['admin', 'user']}, '||'); const filtered = await store.where({'user.profile.department': 'IT', 'user.status': 'active'}); ``` +**Note:** Query predicates are matched via an extensible Strategy Pattern (`src/query-strategy.js`) implementing AND/OR logic across array predicates. When indexed fields match the predicate, an index-based optimization is used. When no indexed fields match, a full table scan is performed. Always returns an array (never void/undefined), even when indexed queries find no candidates. + --- ### search(value, index) @@ -239,12 +247,16 @@ Searches for records containing a value. **Returns:** Promise> - Matching records (async) +**Throws:** Error if value is null/undefined or if a RegExp has a source pattern longer than 256 characters (ReDoS protection) + **Example:** ```javascript const results = await store.search('john'); const matches = await store.search(/^admin/, 'role'); ``` +**Security:** Regular expression patterns with `source.length > 256` are rejected to prevent ReDoS (Regular Expression Denial of Service) attacks. + --- ### filter(fn) @@ -283,13 +295,12 @@ store.sortBy('age'); --- -### sort(fn, frozen) +### sort(fn) -Sorts records using a comparator function. +Sorts records using a comparator function and returns a frozen array in immutable mode. **Parameters:** - `fn` (Function): Comparator (a, b) => number -- `frozen` (boolean): Return frozen records (default: `false`) **Returns:** Array - Sorted records @@ -300,6 +311,8 @@ Sorts records using a comparator function. store.sort((a, b) => a.age - b.age); ``` +**Note:** In immutable mode, results are automatically frozen. The `frozen` parameter has been removed as immutable mode now handles this via `#freezeResult()`. + --- ### limit(offset, max) @@ -512,6 +525,40 @@ Generates composite index keys from data objects or where clauses using a value --- +### #getIndexValues(field, source) + +Extracts index values for a field from a source object, handling both composite indexes and scalar/array fields. Centralizes the logic formerly duplicated in `#setIndex()` and `#deleteIndex()` into a single method. + +**Parameters:** +- `field` (string): Field name or composite index path +- `source` (Object): Source object + +**Returns:** Array - Array of index values + +**Example:** +```javascript +// Internal - used by #setIndex(), #deleteIndex() +``` + +--- + +### #getIndexValues(field, source) + +Extracts index values for a field from a source object, handling both composite indexes and scalar/array fields. Centralizes the logic formerly duplicated in `#setIndex()` and `#deleteIndex()`. + +**Parameters:** +- `field` (string): Field name or composite index path +- `source` (Object): Source object + +**Returns:** Array - Array of index values + +**Example:** +```javascript +// Internal - used by #setIndex(), #deleteIndex() +``` + +--- + ## Index Management ### reindex(index) @@ -645,4 +692,47 @@ const store = haro([{id: 1, name: 'John'}], {index: ['name']}); --- +## Query Strategy + +Haro uses an extensible strategy pattern for predicate matching, implemented in the `src/query-strategy.js` module. This encapsulates the `#matchesPredicate` logic into reusable classes. + +### ValueMatcher + +Low-level value comparison logic used by all strategies. + +**Static Method:** +- `ValueMatcher.match(val, pred)` - Compares a value against a predicate + - Handles string/number equality + - Handles RegExp matching (in either direction) + +**Example:** +```javascript +import { ValueMatcher } from './query-strategy.js'; +ValueMatcher.match('hello', 'hello'); // true +ValueMatcher.match('hello', /^hel/); // true +ValueMatcher.match('hello', /xyz/); // false +``` + +### PredicateStrategy + +Implements AND/OR logic for matching records against field-level predicates. + +**Static Method:** +- `PredicateStrategy.of(op)` - Factory creating strategy with `'&&'` (AND) or `'||'` (OR) logic +- `strategy.matches(record, predicate, getNestedValue)` - Checks if a record matches all predicate fields + +**Example:** +```javascript +import { PredicateStrategy } from './query-strategy.js'; + +const strategy = PredicateStrategy.of('&&'); +strategy.matches( + { name: 'John', role: 'admin' }, + { name: 'John', role: 'admin' }, + (record, key) => record[key] +); // true +``` + +--- + *Generated from src/haro.js* diff --git a/docs/TECHNICAL_DOCUMENTATION.md b/docs/TECHNICAL_DOCUMENTATION.md index 7485ec2..53ea211 100644 --- a/docs/TECHNICAL_DOCUMENTATION.md +++ b/docs/TECHNICAL_DOCUMENTATION.md @@ -204,14 +204,51 @@ Haro uses private helper methods to centralize cross-cutting concerns. These are - `#freezeResult(result)` - Freezes individual values or arrays when immutable mode is enabled. Replaces inline `Object.freeze()` throughout all return paths in `get()`, `find()`, `filter()`, `limit()`, `map()`, `toArray()`, `sort()`, `sortBy()`, `search()`, and `where()`. - `#fromCache(cached)` - Returns a cached result, cloning when mutable or freezing when immutable. Used by `search()` and `where()` for cache read. - `#toCache(cacheKey, records)` - Stores results in cache when enabled. Used by `search()` and `where()` for cache write. -- `# getIndexKeysFrom(arg, source, getValueFn)` - Generates composite index keys using a value getter callback. Consolidates the former `#getIndexKeys` and `#getIndexKeysForWhere` methods. Used by `#setIndex()`, `#deleteIndex()`, and `find()`. +- `#getIndexKeysFrom(arg, source, getValueFn)` - Generates composite index keys using a value getter callback. Consolidates the former `#getIndexKeys` and `#getIndexKeysForWhere` methods. Used by `#setIndex()`, `#deleteIndex()`, and `find()`. +- `#getIndexValues(field, source)` - Extracts index values for a field from a source object, handling both composite indexes and scalar/array fields. Centralizes the deduplicated logic formerly in `#setIndex()` and `#deleteIndex()`. - `#getNestedValue(obj, path)` - Retrieves nested values using dot notation (e.g., `user.profile.city`). Used by indexing, querying, and predicate matching. - `#sortKeys(a, b)` - Type-aware comparator: strings use `localeCompare`, numbers use subtraction, mixed types coerced to string. Used by `find()`, `sortBy()`, and index key generation. -- `#merge(a, b, override)` - Deep merges values, skipping prototype pollution keys (`__proto__`, `constructor`, `prototype`). Used internally by `set()`. +- `#merge(a, b)` - Deep merges values, skipping prototype pollution keys (`__proto__`, `constructor`, `prototype`). Used internally by `set()`. The `override` parameter has been removed. - `#invalidateCache()` - Clears cache if enabled and not in batch mode (`#inBatch`). Called by write operations and lazy re-creation after `clear()`. - `#getCacheKey(domain, ...args)` - Generates SHA-256 hash cache key from arguments for `search()` and `where()`. - `#clone(arg)` - Deep clones values via `structuredClone` or JSON fallback. Used by `forEach()` to ensure non-mutable callback data and version snapshots. +### Query Strategy + +Haro delegates predicate matching to an extensible Strategy Pattern implemented in `src/query-strategy.js`. The former `#matchesPredicate` method was refactored into separate classes following the Single Responsibility Principle: + +``` +┌──────────────────────────┐ +│ PredicateStrategy │ +│ - of(op) factory │ +│ - matches() method │ +└──────────┬───────────────┘ + │ delegates to + ▼ +┌──────────────────────────┐ ┌─────────────────────┐ +│ ValueMatcher │ │ Strategy Modes │ +│ - match(val, pred) │ │ OR (||) = some() │ +│ Handles: │ │ AND (&&) = every() │ +│ • string/num equality │ └─────────────────────┘ +│ • RegExp (either dir.) │ +└──────────────────────────┘ +``` + +**Classes:** +- `ValueMatcher` - Low-level value comparison. Handles string/number equality and RegExp matching in both directions (`pred.test(val)` and `val.test(pred)`). +- `PredicateStrategy` - Composite strategy with `AND`/`OR` logic. Factory method `of(op)` creates strategy instances. The `matches(record, predicate, getNestedValue)` method checks if all predicate fields match the record. + +**Algorithm:** +For a predicate object `{field1: val1, field2: val2}`: +$$\text{match} = \bigwedge_{f} \text{fieldMatch}(\text{record}, f, \text{predicate}[f])$$ + +Where `fieldMatch` uses `AND` or `OR` logic depending on the strategy mode when the predicate value is an array. + +**Architecture benefit:** Predicating logic is now a standalone module, making it: +- Testable in isolation +- Extensible (new predicate types can be added without modifying Haro) +- Decoupled from the DataStore implementation + ### Index Maintenance ```mermaid @@ -271,6 +308,7 @@ Haro's operations are grounded in computer science fundamentals, providing predi | WHERE (cached) | $O(1)$ | Direct cache lookup | | WHERE (uncached) | $O(1)$ to $O(n)$ | Indexed lookup or full scan fallback | | FILTER | $O(n)$ | Predicate evaluation per record | +| SORT | $O(n \log n)$ | Full sort via comparator (returns frozen array in immutable mode) | | SORTBY | $O(k \log k + n)$ | Sorting by indexed field (k = unique indexed values) | | LIMIT | $O(m)$ | m = max records to return | @@ -383,6 +421,52 @@ $$\text{return} = \begin{cases} \text{freeze}(\text{clone}(\text{cached})) & \te $$\text{cache}.\text{set}(\text{cacheKey}, \text{records}) \text{ when } \text{cacheEnabled}$$ +### Security Model + +Haro implements two security-focused protections against common injection attacks: + +#### Prototype Pollution Prevention + +When setting records, `set()` silently filters keys named `__proto__`, `constructor`, and `prototype` from the input data before storing. This prevents JavaScript prototype pollution attacks that could compromise the application: + +```javascript +// Safe - prototype pollution keys are filtered +store.set('key1', { + __proto__: { polluted: true }, // filtered out + constructor: { polluted: true }, // filtered out + prototype: { polluted: true }, // filtered out + safeField: 'value' // stored normally +}); + +// The store is safe: +Object.prototype.polluted === undefined; // true +``` + +This protection operates at the `set()` level (via spreading only safe keys into the record object) and at the `#merge()` level (skipping these keys during deep merge). + +#### Regular Expression Denial of Service (ReDoS) Defense + +The `search()` method validates RegExp patterns before execution. Regular expressions with a source pattern longer than 256 characters are rejected with an error: + +```javascript +// Safe - normal patterns +await store.search(/^admin.*/, 'role'); // works +await store.search(/[a-z]*/, 'name'); // works + +// Rejected - potential ReDoS vector +await store.search(/a{200}/, 'field'); // throws Error +``` + +The threshold of 256 characters balances security with practical use cases (complex search patterns should use indexed queries, not regex). + +#### Attack Surface Summary + +| Threat | Mitigation | Location | +|--------|-----------|----------| +| Prototype pollution | Filter `__proto__`, `constructor`, `prototype` | `set()`, `#merge()` | +| ReDoS (regex injection) | Limit RegExp source length to 256 chars | `search()` | +| Cache mutation | Deep clone on read, freeze in immutable mode | `#fromCache()` | + ## Operations ### CRUD Operations Performance diff --git a/src/constants.js b/src/constants.js index e9b5c92..0876d71 100644 --- a/src/constants.js +++ b/src/constants.js @@ -29,7 +29,9 @@ export const STRING_RECORD_NOT_FOUND = "Record not found"; // Integer constants export const INT_0 = 0; +export const INT_1 = 1; export const INT_2 = 2; +export const INT_256 = 256; // Number constants export const CACHE_SIZE_DEFAULT = 1000; diff --git a/src/haro.js b/src/haro.js index 2226495..f718613 100644 --- a/src/haro.js +++ b/src/haro.js @@ -4,6 +4,7 @@ import { CACHE_SIZE_DEFAULT, INT_0, INT_2, + INT_256, PROP_DELIMITER, PROP_ID, PROP_IMMUTABLE, @@ -17,8 +18,6 @@ import { STRING_COMMA, STRING_CONSTRUCTOR, STRING_DOT, - STRING_DOUBLE_AND, - STRING_DOUBLE_PIPE, STRING_EMPTY, STRING_ERROR_BATCH_DELETEMANY, STRING_ERROR_BATCH_SETMANY, @@ -43,6 +42,7 @@ import { STRING_NUMBER, STRING_OBJECT, STRING_PIPE, + STRING_DOUBLE_PIPE, STRING_PROTOTYPE, STRING_PROTO, STRING_RECORD_NOT_FOUND, @@ -52,6 +52,7 @@ import { STRING_STRING, STRING_UNDERSCORE, } from "./constants.js"; +import { PredicateStrategy } from "./query-strategy.js"; /** * Haro is an immutable DataStore with indexing, versioning, and batch operations. @@ -372,6 +373,21 @@ export class Haro { return result; } + /** + * Extracts index values for a field from a source object. + * Handles both composite indexes and scalar/array fields. + * @param {string} field - Field name or composite index path + * @param {Object} source - Source object + * @returns {Array} Array of index values + */ + #getIndexValues(field, source) { + if (field.includes(this.#delimiter)) { + return this.#getIndexKeysFrom(field, source, (f, s) => this.#getNestedValue(s, f)); + } + const val = this.#getNestedValue(source, field); + return Array.isArray(val) ? val : [val]; + } + /** * Removes a record from all indexes. * @param {string} key - Record key @@ -382,11 +398,7 @@ export class Haro { this.#index.forEach((i) => { const idx = this.#indexes.get(i); if (!idx) return; - const values = i.includes(this.#delimiter) - ? this.#getIndexKeysFrom(i, data, (f, s) => this.#getNestedValue(s, f)) - : Array.isArray(this.#getNestedValue(data, i)) - ? this.#getNestedValue(data, i) - : [this.#getNestedValue(data, i)]; + const values = this.#getIndexValues(i, data); const len = values.length; for (let j = 0; j < len; j++) { const value = values[j]; @@ -625,9 +637,9 @@ export class Haro { * @param {boolean} [override=false] - Override arrays * @returns {*} Merged result */ - #merge(a, b, override = false) { + #merge(a, b) { if (Array.isArray(a) && Array.isArray(b)) { - a = override ? b : a.concat(b); + a = a.concat(b); } else if ( typeof a === STRING_OBJECT && a !== null && @@ -638,10 +650,7 @@ export class Haro { const keysLen = keys.length; for (let i = 0; i < keysLen; i++) { const key = keys[i]; - if (key === STRING_PROTO || key === STRING_CONSTRUCTOR || key === STRING_PROTOTYPE) { - continue; - } - a[key] = this.#merge(a[key], b[key], override); + a[key] = this.#merge(a[key], b[key]); } } else { a = b; @@ -729,6 +738,9 @@ export class Haro { const result = new Set(); const fn = typeof value === STRING_FUNCTION; const rgex = value && typeof value.test === STRING_FUNCTION; + if (rgex && value.source.length > INT_256) { + throw new Error(STRING_ERROR_SEARCH_VALUE); + } const indices = index ? (Array.isArray(index) ? index : [index]) : this.#index; const indicesLen = indices.length; @@ -783,7 +795,11 @@ export class Haro { if (key === null) { key = data[this.#key] ?? uuid(); } - let x = { ...data, [this.#key]: key }; + const pollutionProps = new Set([STRING_PROTO, STRING_CONSTRUCTOR, STRING_PROTOTYPE]); + const safeData = Object.fromEntries( + Object.entries(data).filter(([k]) => !pollutionProps.has(k)), + ); + let x = { ...safeData, [this.#key]: key }; if (!this.#data.has(key)) { if (this.#versioning && !this.#inBatch) { this.#versions.set(key, new Set()); @@ -829,11 +845,7 @@ export class Haro { idx = new Map(); this.#indexes.set(field, idx); } - const values = field.includes(this.#delimiter) - ? this.#getIndexKeysFrom(field, data, (f, s) => this.#getNestedValue(s, f)) - : Array.isArray(this.#getNestedValue(data, field)) - ? this.#getNestedValue(data, field) - : [this.#getNestedValue(data, field)]; + const values = this.#getIndexValues(field, data); const valuesLen = values.length; for (let j = 0; j < valuesLen; j++) { const value = values[j]; @@ -854,17 +866,14 @@ export class Haro { * @example * store.sort((a, b) => a.age - b.age); */ - sort(fn, frozen = false) { + sort(fn) { if (typeof fn !== STRING_FUNCTION) { throw new Error(STRING_ERROR_SORT_FN_TYPE); } - const dataSize = this.#data.size; - let result = this.limit(INT_0, dataSize).sort(fn); - if (frozen) { - result = Object.freeze(result); - } - - return result; + const result = Array.from(this.#data.values()) + .map((v) => (this.#immutable ? this.#clone(v) : v)) + .sort(fn); + return this.#freezeResult(result); } /** @@ -941,48 +950,6 @@ export class Haro { return this.#data.values(); } - /** - * Matches a record against a predicate. - * @param {Object} record - Record to test - * @param {Object} predicate - Predicate object - * @param {string} op - Operator: '||' or '&&' - * @returns {boolean} True if matches - */ - #matchesPredicate(record, predicate, op) { - const keys = Object.keys(predicate); - - return keys.every((key) => { - const pred = predicate[key]; - // Use nested value extraction for dot notation paths - const val = this.#getNestedValue(record, key); - if (Array.isArray(pred)) { - if (Array.isArray(val)) { - return op === STRING_DOUBLE_AND - ? pred.every((p) => val.includes(p)) - : pred.some((p) => val.includes(p)); - } - return op === STRING_DOUBLE_AND - ? pred.every((p) => val === p) - : pred.some((p) => val === p); - } - if (Array.isArray(val)) { - return val.some((v) => { - if (pred instanceof RegExp) { - return pred.test(v); - } - if (v instanceof RegExp) { - return v.test(pred); - } - return v === pred; - }); - } - if (pred instanceof RegExp) { - return pred.test(val); - } - return val === pred; - }); - } - /** * Filters records with predicate logic supporting AND/OR on arrays. * @param {Object} [predicate={}] - Field-value pairs @@ -1014,7 +981,10 @@ export class Haro { if (this.#warnOnFullScan) { console.warn("where(): performing full table scan - consider adding an index"); } - return this.filter((a) => this.#matchesPredicate(a, predicate, op)); + const strategy = PredicateStrategy.of(op); + return this.filter((a) => + strategy.matches(a, predicate, (r, k) => this.#getNestedValue(r, k)), + ); } // Try to use indexes for better performance @@ -1069,10 +1039,11 @@ export class Haro { } } // Filter candidates with full predicate logic + const strategy = PredicateStrategy.of(op); const results = []; for (const key of candidateKeys) { const record = this.get(key); - if (this.#matchesPredicate(record, predicate, op)) { + if (strategy.matches(record, predicate, (r, k) => this.#getNestedValue(r, k))) { results.push(record); } } @@ -1080,6 +1051,10 @@ export class Haro { this.#toCache(cacheKey, results); return this.#freezeResult(results); } + + // Indexed keys matched but no candidates found - return empty array + this.#toCache(cacheKey, []); + return this.#freezeResult([]); } } diff --git a/src/query-strategy.js b/src/query-strategy.js new file mode 100644 index 0000000..755a47a --- /dev/null +++ b/src/query-strategy.js @@ -0,0 +1,93 @@ +import { STRING_DOUBLE_AND } from "./constants.js"; + +/** + * Low-level value matcher for predicate matching. + * @class + */ +export class ValueMatcher { + /** + * Matches a single value against a predicate. + * @param {*} val - Value to test + * @param {*} pred - Predicate value or RegExp + * @returns {boolean} Whether value matches predicate + */ + static match(val, pred) { + if (pred instanceof RegExp) return pred.test(val); + if (val instanceof RegExp) return val.test(pred); + return val === pred; + } +} + +/** + * Predicate strategy for matching records against field predicates. + * Supports AND (every) and OR (some) logic for array predicates. + * @class + */ +export class PredicateStrategy { + /** + * Creates a predicate strategy. + * @param {string} op - Operator: '||' (OR) or '&&' (AND) + */ + constructor(op) { + this.op = op; + } + + /** + * Factory method to create a strategy instance. + * @param {string} op - Operator: '||' (OR) or '&&' (AND) + * @returns {PredicateStrategy} + */ + static of(op) { + return new PredicateStrategy(op); + } + + /** + * Checks if a record matches a predicate object. + * @param {Object} record - Record to test + * @param {Object} predicate - Field-value predicate map + * @param {Function} getNestedValue - Function to retrieve nested values (record, path) => value + * @returns {boolean} Whether record matches all predicate fields + */ + matches(record, predicate, getNestedValue) { + return Object.keys(predicate).every((key) => { + return this.#matchField(record, key, predicate[key], getNestedValue); + }); + } + + /** + * Matches a single field's predicate against a record value. + * @param {Object} record - Record containing the value + * @param {string} key - Field path + * @param {*} pred - Predicate for the field (value, array of values, or RegExp) + * @param {Function} getNestedValue - Function to retrieve nested values + * @returns {boolean} Whether the field matches the predicate + */ + #matchField(record, key, pred, getNestedValue) { + const val = getNestedValue(record, key); + + // Array predicate matching against record value + if (Array.isArray(pred)) { + if (Array.isArray(val)) { + return this.#matchArrayPred(pred, (p) => val.includes(p)); + } + return this.#matchArrayPred(pred, (p) => val === p); + } + + // Record field is an array, check if any element matches + if (Array.isArray(val)) { + return val.some((v) => ValueMatcher.match(v, pred)); + } + + return ValueMatcher.match(val, pred); + } + + /** + * Applies the strategy's operator to an array of predicates. + * @param {Array} preds - Array of predicates + * @param {Function} fn - Matcher function (pred) => boolean + * @returns {boolean} Result of AND/OR evaluation + */ + #matchArrayPred(preds, fn) { + return this.op === STRING_DOUBLE_AND ? preds.every(fn) : preds.some(fn); + } +} diff --git a/tests/unit/edge-cases.test.js b/tests/unit/edge-cases.test.js index fc26d72..bcedcd1 100644 --- a/tests/unit/edge-cases.test.js +++ b/tests/unit/edge-cases.test.js @@ -143,4 +143,62 @@ describe("Edge Cases Coverage", () => { } }); }); + + describe("prototype pollution prevention", () => { + it("should prevent __proto__ pollution during initial set", () => { + const store = new Haro({ index: ["name"] }); + + store.set("user1", { + name: "John", + __proto__: { evil: "injected" }, + }); + + const record = store.get("user1"); + assert.strictEqual(record.name, "John"); + assert.notStrictEqual(record.__proto__, { evil: "injected" }); + }); + + it("should prevent __proto__ pollution during update via merge", () => { + const store = new Haro({ index: ["name"] }); + + store.set("user1", { name: "John", age: 30 }); + store.set("user1", { + name: "Jane", + __proto__: { evil: "injected" }, + }); + + const record = store.get("user1"); + assert.strictEqual(record.name, "Jane"); + assert.strictEqual(record.age, 30); + assert.strictEqual(record.hasOwnProperty("__proto__"), false); + }); + + it("should prevent constructor pollution during update", () => { + const store = new Haro({ index: ["name"] }); + + store.set("user1", { name: "John", age: 30 }); + store.set("user1", { + name: "Jane", + constructor: { evil: "injected" }, + }); + + const record = store.get("user1"); + assert.strictEqual(record.name, "Jane"); + assert.strictEqual(record.age, 30); + }); + + it("should prevent prototype pollution during update", () => { + const store = new Haro({ index: ["name"] }); + + store.set("user1", { name: "John", age: 30 }); + store.set("user1", { + name: "Jane", + prototype: { evil: "injected" }, + }); + + const record = store.get("user1"); + assert.strictEqual(record.name, "Jane"); + assert.strictEqual(record.age, 30); + }); + }); }); diff --git a/tests/unit/query-strategy.test.js b/tests/unit/query-strategy.test.js new file mode 100644 index 0000000..472f8ac --- /dev/null +++ b/tests/unit/query-strategy.test.js @@ -0,0 +1,216 @@ +import assert from "node:assert"; +import { describe, it, beforeEach } from "node:test"; +import { haro } from "../../src/haro.js"; +import { PredicateStrategy, ValueMatcher } from "../../src/query-strategy.js"; +import { STRING_DOUBLE_AND, STRING_DOUBLE_PIPE } from "../../src/constants.js"; + +describe("ValueMatcher", () => { + it("should match exact values", () => { + assert.strictEqual(ValueMatcher.match("hello", "hello"), true); + assert.strictEqual(ValueMatcher.match("hello", "world"), false); + }); + + it("should match numeric values", () => { + assert.strictEqual(ValueMatcher.match(42, 42), true); + assert.strictEqual(ValueMatcher.match(42, 43), false); + }); + + it("should match RegExp against value", () => { + assert.strictEqual(ValueMatcher.match("hello", /^hel/), true); + assert.strictEqual(ValueMatcher.match("hello", /xyz/), false); + }); + + it("should test value RegExp against predicate", () => { + assert.strictEqual(ValueMatcher.match(/^hel/, "hello"), true); + assert.strictEqual(ValueMatcher.match(/xyz/, "hello"), false); + }); + + it("should handle undefined values", () => { + assert.strictEqual(ValueMatcher.match(undefined, undefined), true); + assert.strictEqual(ValueMatcher.match(undefined, null), false); + }); +}); + +describe("PredicateStrategy", () => { + describe("OR mode", () => { + let strategy; + + beforeEach(() => { + strategy = PredicateStrategy.of(STRING_DOUBLE_PIPE); + }); + + it("should match all fields with simple values", () => { + const record = { name: "John", age: 30 }; + const predicate = { name: "John" }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + true, + ); + }); + + it("should return false when field does not match", () => { + const record = { name: "John", age: 30 }; + const predicate = { name: "Jane" }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + false, + ); + }); + + it("should match all fields in AND across fields (OR within)", () => { + const record = { name: "John", role: "admin" }; + const predicate = { name: "John", role: "guest" }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + false, + ); + }); + + it("should handle array predicates with OR logic", () => { + const record = { name: "John" }; + const predicate = { name: ["Jane", "Bob", "John"] }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + true, // "John" is in the array + ); + }); + + it("should handle array predicate with no match", () => { + const record = { name: "Jane" }; + const predicate = { name: ["Bob", "Alice"] }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + false, + ); + }); + + it("should handle array-valued fields with OR logic", () => { + const record = { tags: ["admin", "user"] }; + const predicate = { tags: "moderator" }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + false, + ); + }); + + it("should handle RegExp in predicates", () => { + const record = { email: "john@example.com" }; + const predicate = { email: /^john@/ }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + true, + ); + }); + }); + + describe("AND mode", () => { + let strategy; + + beforeEach(() => { + strategy = PredicateStrategy.of(STRING_DOUBLE_AND); + }); + + it("should match all fields", () => { + const record = { name: "John", role: "admin" }; + const predicate = { name: "John", role: "admin" }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + true, + ); + }); + + it("should return false when one field fails", () => { + const record = { name: "John", role: "admin" }; + const predicate = { name: "John", role: "guest" }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + false, + ); + }); + + it("should handle array predicates with AND logic", () => { + const record = { tags: ["admin"] }; + const predicate = { tags: ["admin", "user"] }; + // AND means all predicates must be present + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + false, // "user" not in record.tags + ); + }); + + it("should handle array predicates with AND when all present", () => { + const record = { tags: ["admin", "user"] }; + const predicate = { tags: ["admin", "user"] }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + true, // both present + ); + }); + + it("should handle array-valued fields with AND logic when predicates present", () => { + const record = { roles: ["admin", "user"] }; + const predicate = { roles: ["admin", "user"] }; + // AND means all predicate values must be in the record's array + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + true, + ); + }); + + it("should fail AND logic when predicate value missing from array field", () => { + const record = { roles: ["admin"] }; + const predicate = { roles: ["admin", "user"] }; + assert.strictEqual( + strategy.matches(record, predicate, (r, k) => r[k]), + false, + ); + }); + }); + + describe("nested values", () => { + it("should handle dot notation paths", () => { + const record = { profile: { name: "John" } }; + const predicate = { "profile.name": "John" }; + const getNested = (o, p) => { + const parts = p.split("."); + let val = o; + for (const part of parts) { + if (val === undefined || val === null) return undefined; + val = val[part]; + } + return val; + }; + const strategy = PredicateStrategy.of(STRING_DOUBLE_PIPE); + assert.strictEqual(strategy.matches(record, predicate, getNested), true); + }); + }); +}); + +describe("PredicateStrategy integration with Haro", () => { + let store; + + beforeEach(() => { + store = haro(null, { index: ["name", "role", "profile.city"] }); + store.set("1", { name: "John", role: "admin" }); + store.set("2", { name: "Jane", role: "guest" }); + store.set("3", { name: "Bob", role: "admin" }); + }); + + it("should work with where() OR operator", async () => { + const results = await store.where({ role: "admin" }, "||"); + assert.strictEqual(results.length, 2); + assert.ok(results.some((r) => r.name === "John")); + assert.ok(results.some((r) => r.name === "Bob")); + }); + + it("should work with where() AND operator", async () => { + const results = await store.where({ name: "John", role: "admin" }, "&&"); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].name, "John"); + }); + + it("should return empty array for no matches", async () => { + const results = await store.where({ name: "NonExistent" }, "||"); + assert.strictEqual(results.length, 0); + }); +}); diff --git a/tests/unit/search.test.js b/tests/unit/search.test.js index 03b9daa..8c6cece 100644 --- a/tests/unit/search.test.js +++ b/tests/unit/search.test.js @@ -64,6 +64,15 @@ describe("Searching and Filtering", () => { assert.strictEqual(results.length, 1); assert.strictEqual(results[0].name, "Alice"); }); + + it("should throw error for regex with source longer than 256 characters", async () => { + const tooLongRegex = new RegExp("a".repeat(300)); + + await assert.rejects( + () => store.search(tooLongRegex, "name"), + /search: value cannot be null or undefined/, + ); + }); }); describe("filter()", () => { @@ -307,6 +316,16 @@ describe("Searching and Filtering", () => { assert.strictEqual(results.length, 1); assert.strictEqual(results[0].id, "1"); }); + + it("should return empty array when indexed fields exist but index maps are cleared", async () => { + const indexedStore = new Haro({ index: ["name"] }); + indexedStore.override([]); + + const results = await indexedStore.where({ name: "John" }); + + assert.strictEqual(results.length, 0); + assert.ok(Array.isArray(results)); + }); }); describe("sortBy()", () => { diff --git a/tests/unit/utilities.test.js b/tests/unit/utilities.test.js index b5ded0c..ca12e0c 100644 --- a/tests/unit/utilities.test.js +++ b/tests/unit/utilities.test.js @@ -91,9 +91,16 @@ describe("Utility Methods", () => { assert.strictEqual(results[2].name, "Charlie"); }); - it("should return frozen results when frozen=true", () => { - const results = store.sort((a, b) => a.age - b.age, true); + it("should return frozen results when immutable=true", () => { + const immutableStore = new Haro({ immutable: true }); + immutableStore.set("user1", { id: "user1", name: "Charlie", age: 30 }); + immutableStore.set("user2", { id: "user2", name: "Alice", age: 25 }); + immutableStore.set("user3", { id: "user3", name: "Bob", age: 35 }); + const results = immutableStore.sort((a, b) => a.age - b.age); assert.strictEqual(Object.isFrozen(results), true); + assert.strictEqual(results[0].name, "Alice"); + assert.strictEqual(results[1].name, "Charlie"); + assert.strictEqual(results[2].name, "Bob"); }); }); diff --git a/types/haro.d.ts b/types/haro.d.ts index de925cb..3570fb8 100644 --- a/types/haro.d.ts +++ b/types/haro.d.ts @@ -197,23 +197,15 @@ export class Haro { */ map(fn: (value: any, key: string) => any): any[]; - /** - * Internal helper method for predicate matching with support for arrays and regex - * @param record - Record to test against predicate - * @param predicate - Predicate object with field-value pairs - * @param op - Operator for array matching ('||' for OR, '&&' for AND) - * @returns True if record matches predicate criteria - */ - matchesPredicate(record: any, predicate: Record, op: string): boolean; /** - * Merges two values together with support for arrays and objects + * Deep merges two values together with support for arrays and objects. + * Skips prototype pollution keys (__proto__, constructor, prototype). * @param a - First value (target) * @param b - Second value (source) - * @param override - Whether to override arrays instead of concatenating * @returns Merged result */ - merge(a: any, b: any, override?: boolean): any; + merge(a: any, b: any): any; /** * Lifecycle hook executed after batch operations for custom postprocessing @@ -299,12 +291,12 @@ export class Haro { setIndex(key: string, data: any, indice: string | null): Haro; /** - * Sorts all records using a comparator function + * Sorts all records using a comparator function. + * In immutable mode, results are automatically frozen. * @param fn - Comparator function for sorting (a, b) => number - * @param frozen - Whether to return frozen records * @returns Sorted array of records */ - sort(fn: (a: any, b: any) => number, frozen?: boolean): any; + sort(fn: (a: any, b: any) => number): any; /** * Comparator function for sorting keys with type-aware comparison logic