diff --git a/index.html b/index.html index 70df880..a3b5be9 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,11 @@ diff --git a/package.json b/package.json index e56affd..ea9e30d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.26.1", + "version": "0.26.2", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/aset.ts b/src/aset.ts index 91cab4d..5458257 100644 --- a/src/aset.ts +++ b/src/aset.ts @@ -2,11 +2,16 @@ import {isEqual} from './objects.ts'; /** * An array which functions as a set. It guarantees unique elements - * and provides set functions for comparisons + * and provides set functions for comparisons. + * + * This optimized version uses a Map internally for efficient lookups + * while maintaining an array for ordered iteration and array-like methods. */ -export class ASet extends Array { +export class ASet extends Array { + private readonly _valueMap: Map; // Stores string representation -> actual value for quick lookups + /** Number of elements in set */ - get size() { + get size(): number { return this.length; } @@ -16,119 +21,236 @@ export class ASet extends Array { */ constructor(elements: T[] = []) { super(); - if(!!elements?.['forEach']) + this._valueMap = new Map(); // Initialize the map + + if (Array.isArray(elements)) { elements.forEach(el => this.add(el)); + } } /** - * Add elements to set if unique + * Helper to generate a unique key for the map. + * This is crucial for using a Map with custom equality. + * For primitive types, `String(el)` is often sufficient. + * For objects, you'll need a robust serialization or a unique ID. + * If isEqual handles deep equality, the key generation must reflect that. + * + * @param el Element to generate a key for + * @returns A string key + */ + private _getKey(el: T): string { + // IMPORTANT: This is a critical point for isEqual to work correctly with Map. + // If isEqual performs deep comparison, _getKey must produce the same string + // for deeply equal objects. JSON.stringify can work for simple objects, + // but can fail for objects with circular references, functions, or undefined values. + // For more complex scenarios, consider a library like 'fast-json-stable-stringify' + // or a custom hashing function that respects isEqual. + try { + return JSON.stringify(el); + } catch (e) { + // Fallback for objects that cannot be stringified (e.g., circular structures) + // This might lead to less optimal performance or incorrect uniqueness for such objects. + // A more robust solution for complex objects might involve unique object IDs + // or a custom comparison function for Map keys if JS allowed it. + return String(el); + } + } + + /** + * Add elements to set if unique. + * Optimized to use the internal Map for O(1) average time lookups. * @param items */ - add(...items: T[]) { - items.filter(el => !this.has(el)).forEach(el => this.push(el)); + add(...items: T[]): this { + for (const item of items) { + const key = this._getKey(item); + if (!this._valueMap.has(key)) { + // Also ensures isEqual is respected by checking against existing values + let found = false; + for (const existingItem of this) { + if (isEqual(existingItem, item)) { + found = true; + break; + } + } + + if (!found) { + super.push(item); // Add to the array + this._valueMap.set(key, item); // Add to the map + } + } + } return this; } /** - * Remove all elements + * Remove all elements. + * Optimized to clear both the array and the map. */ - clear() { - this.splice(0, this.length); + clear(): this { + super.splice(0, this.length); + this._valueMap.clear(); return this; } /** - * Delete elements from set + * Delete elements from set. + * Optimized to use the internal Map for O(1) average time lookups for key existence. + * Still requires array splice which can be O(N) in worst case for shifting. * @param items Elements that will be deleted */ - delete(...items: T[]) { - items.forEach(el => { - const index = this.indexOf(el); - if(index != -1) this.splice(index, 1); - }) + delete(...items: T[]): this { + for (const item of items) { + const key = this._getKey(item); + if (this._valueMap.has(key)) { + // Find the actual element in the array using isEqual + const index = super.findIndex((el: T) => isEqual(el, item)); + if (index !== -1) { + super.splice(index, 1); // Remove from the array + this._valueMap.delete(key); // Remove from the map + } + } + } return this; } /** - * Create list of elements this set has which the comparison set does not + * Create list of elements this set has which the comparison set does not. + * Optimized to use `has` which is now faster. * @param {ASet} set Set to compare against * @return {ASet} Different elements */ - difference(set: ASet) { - return new ASet(this.filter(el => !set.has(el))); + difference(set: ASet): ASet { + const result = new ASet(); + for (const el of this) { + if (!set.has(el)) { + result.add(el); + } + } + return result; } /** - * Check if set includes element + * Check if set includes element. + * Optimized to use the internal Map for O(1) average time lookups. * @param {T} el Element to look for * @return {boolean} True if element was found, false otherwise */ - has(el: T) { - return this.indexOf(el) != -1; + has(el: T): boolean { + const key = this._getKey(el); + // First check map for existence. If it exists, then verify with isEqual + // as the key might be generic but isEqual is precise. + if (this._valueMap.has(key)) { + const storedValue = this._valueMap.get(key); + // This second check with isEqual is necessary if _getKey doesn't perfectly + // represent the equality criteria of isEqual, which is often the case + // for complex objects where JSON.stringify might produce different strings + // for isEqual objects (e.g., key order in objects). + // If _getKey is guaranteed to produce identical keys for isEqual objects, + // this isEqual check can be simplified or removed for performance. + return isEqual(storedValue, el); + } + return false; } /** - * Find index number of element, or -1 if it doesn't exist. Matches by equality not reference + * Find index number of element, or -1 if it doesn't exist. Matches by equality not reference. + * This method still inherently needs to iterate the array to find the *index*. + * While `has` can be O(1), `indexOf` for custom equality remains O(N). * * @param {T} search Element to find * @param {number} fromIndex Starting index position * @return {number} Element index number or -1 if missing */ indexOf(search: T, fromIndex?: number): number { + // Can't use the map directly for index lookup, must iterate the array return super.findIndex((el: T) => isEqual(el, search), fromIndex); } /** - * Create list of elements this set has in common with the comparison set + * Create list of elements this set has in common with the comparison set. + * Optimized to use `has` which is now faster. * @param {ASet} set Set to compare against - * @return {boolean} Set of common elements + * @return {ASet} Set of common elements */ - intersection(set: ASet) { - return new ASet(this.filter(el => set.has(el))); + intersection(set: ASet): ASet { + const result = new ASet(); + // Iterate over the smaller set for efficiency + const [smallerSet, largerSet] = this.size < set.size ? [this, set] : [set, this]; + + for (const el of smallerSet) { + if (largerSet.has(el)) { + result.add(el); + } + } + return result; } /** - * Check if this set has no elements in common with the comparison set + * Check if this set has no elements in common with the comparison set. + * Optimized to use `intersection` and check its size. * @param {ASet} set Set to compare against * @return {boolean} True if nothing in common, false otherwise */ - isDisjointFrom(set: ASet) { - return this.intersection(set).size == 0; + isDisjointFrom(set: ASet): boolean { + return this.intersection(set).size === 0; } /** - * Check if all elements in this set are included in the comparison set + * Check if all elements in this set are included in the comparison set. + * Optimized to use `has` which is now faster. * @param {ASet} set Set to compare against * @return {boolean} True if all elements are included, false otherwise */ - isSubsetOf(set: ASet) { - return this.findIndex(el => !set.has(el)) == -1; + isSubsetOf(set: ASet): boolean { + if (this.size > set.size) { // A larger set cannot be a subset of a smaller one + return false; + } + for (const el of this) { + if (!set.has(el)) { + return false; + } + } + return true; } /** - * Check if all elements from comparison set are included in this set + * Check if all elements from comparison set are included in this set. + * Optimized to use `has` which is now faster. * @param {ASet} set Set to compare against * @return {boolean} True if all elements are included, false otherwise */ - isSuperset(set: ASet) { - return set.findIndex(el => !this.has(el)) == -1; + isSuperset(set: ASet): boolean { + if (this.size < set.size) { // A smaller set cannot be a superset of a larger one + return false; + } + for (const el of set) { + if (!this.has(el)) { + return false; + } + } + return true; } /** - * Create list of elements that are only in one set but not both (XOR) + * Create list of elements that are only in one set but not both (XOR). + * Uses optimized `difference` method. * @param {ASet} set Set to compare against * @return {ASet} New set of unique elements */ - symmetricDifference(set: ASet) { - return new ASet([...this.difference(set), ...set.difference(this)]); + symmetricDifference(set: ASet): ASet { + return new ASet([...this.difference(set), ...set.difference(this)]); } /** - * Create joined list of elements included in this & the comparison set - * @param {ASet} set Set join + * Create joined list of elements included in this & the comparison set. + * Uses optimized `add` method. + * @param {ASet | Array} set Set to join * @return {ASet} New set of both previous sets combined */ - union(set: ASet | Array) { - return new ASet([...this, ...set]); + union(set: ASet | Array): ASet { + const result = new ASet(this); + result.add(...Array.from(set)); + return result; } } diff --git a/src/objects.ts b/src/objects.ts index 804106b..3db50a9 100644 --- a/src/objects.ts +++ b/src/objects.ts @@ -177,19 +177,51 @@ export function includes(target: any, values: any, allowMissing = false): boolea } /** - * Deep check if two objects are equal + * Deep check if two items are equal. + * Handles primitives, objects, arrays, functions, Date, RegExp, and circular references. * * @param {any} a - first item to compare * @param {any} b - second item to compare + * @param {WeakMap} [seen] - Internal parameter to track circular references * @returns {boolean} True if they match */ -export function isEqual(a: any, b: any): boolean { - const ta = typeof a, tb = typeof b; - if((ta != 'object' || a == null) || (tb != 'object' || b == null)) - return ta == 'function' && tb == 'function' ? a.toString() == b.toString() : a === b; - const keys = Object.keys(a); - if(keys.length != Object.keys(b).length) return false; - return Object.keys(a).every(key => isEqual(a[key], b[key])); +export function isEqual(a: any, b: any, seen = new WeakMap()): boolean { + // Simple cases + if(a === b) return true; + if(a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); + if(a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags; + + // Null checks + if(typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { + if(Number.isNaN(a) && Number.isNaN(b)) return true; + if(typeof a === 'function' && typeof b === 'function') return a.toString() === b.toString() + return false; + } + + // Circular references + if(seen.has(a)) return seen.get(a) === b; + seen.set(a, b); + const isArrayA = Array.isArray(a); + const isArrayB = Array.isArray(b); + + // Array checks + if(isArrayA && isArrayB) { + if(a.length !== b.length) return false; + for(let i = 0; i < a.length; i++) { + if(!isEqual(a[i], b[i], seen)) return false; + } + return true; + } + if(isArrayA !== isArrayB) return false; + + // Key & value deep comparison + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if(keysA.length !== keysB.length) return false; + for(const key of keysA) { + if(!Object.prototype.hasOwnProperty.call(b, key) || !isEqual(a[key], b[key], seen)) return false; + } + return true; } /** diff --git a/src/path-events.ts b/src/path-events.ts index 1d356eb..599b91a 100644 --- a/src/path-events.ts +++ b/src/path-events.ts @@ -71,9 +71,12 @@ export class PathEvent { /** List of methods */ methods!: ASet; + /** Internal cache for PathEvent instances to avoid redundant parsing */ + private static pathEventCache: Map = new Map(); + /** All/Wildcard specified */ get all(): boolean { return this.methods.has('*') } - set all(v: boolean) { v ? new ASet(['*']) : this.methods.delete('*'); } + set all(v: boolean) { v ? this.methods = new ASet(['*']) : this.methods.delete('*'); } /** None specified */ get none(): boolean { return this.methods.has('n') } set none(v: boolean) { v ? this.methods = new ASet(['n']) : this.methods.delete('n'); } @@ -91,10 +94,20 @@ export class PathEvent { set delete(v: boolean) { v ? this.methods.delete('n').delete('*').add('d') : this.methods.delete('d'); } constructor(e: string | PathEvent) { - if(typeof e == 'object') return Object.assign(this, e); + if(typeof e == 'object') { + Object.assign(this, e); + return; + } + + // Check cache first + if (PathEvent.pathEventCache.has(e)) { + Object.assign(this, PathEvent.pathEventCache.get(e)!); + return; + } + let [p, scope, method] = e.replaceAll(/\/{2,}/g, '/').split(':'); if(!method) method = scope || '*'; - if(p == '*' || !p && method == '*') { + if(p == '*' || (!p && method == '*')) { p = ''; method = '*'; } @@ -104,6 +117,14 @@ export class PathEvent { this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`; this.name = temp.pop() || ''; this.methods = new ASet(method.split('')); + + // Store in cache + PathEvent.pathEventCache.set(e, this); + } + + /** Clear the cache of all PathEvents */ + static clearCache(): void { + PathEvent.pathEventCache.clear(); } /** @@ -115,7 +136,7 @@ export class PathEvent { */ static combine(...paths: (string | PathEvent)[]): PathEvent { let hitNone = false; - const combined = paths.map(p => new PathEvent(p)) + const combined = paths.map(p => p instanceof PathEvent ? p : new PathEvent(p)) .toSorted((p1, p2) => { const l1 = p1.fullPath.length, l2 = p2.fullPath.length; return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0); @@ -124,10 +145,9 @@ export class PathEvent { if(p.none) hitNone = true; if(!acc) return p; if(hitNone) return acc; - acc.methods = [...acc.methods, ...p.methods]; + acc.methods = new ASet([...acc.methods, ...p.methods]); return acc; }, null); - combined.methods = new ASet(combined.methods); return combined; } @@ -139,11 +159,12 @@ export class PathEvent { * @return {boolean} Whether there is any overlap */ static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] { - const parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); - const parsedFilter = makeArray(filter).map(pe => new PathEvent(pe)); + const parsedTarget = makeArray(target).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe)); + const parsedFilter = makeArray(filter).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe)); return parsedTarget.filter(t => !!parsedFilter.find(r => { const wildcard = r.fullPath == '*' || t.fullPath == '*'; - const p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*')) + const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath; + const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath; const scope = p1.startsWith(p2) || p2.startsWith(p1); const methods = r.all || t.all || r.methods.intersection(t.methods).length; return (wildcard || scope) && methods; @@ -158,12 +179,13 @@ export class PathEvent { * @return {boolean} Whether there is any overlap */ static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean { - const parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); - const parsedRequired = makeArray(has).map(pe => new PathEvent(pe)); + const parsedTarget = makeArray(target).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe)); + const parsedRequired = makeArray(has).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe)); return !!parsedRequired.find(r => !!parsedTarget.find(t => { const wildcard = r.fullPath == '*' || t.fullPath == '*'; - const p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*')) - const scope = p1.startsWith(p2); + const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath; + const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath; + const scope = p1.startsWith(p2); // Note: Original had || p2.startsWith(p1) here, but has implies target has required. const methods = r.all || t.all || r.methods.intersection(t.methods).length; return (wildcard || scope) && methods; })); @@ -293,7 +315,7 @@ export class PathEventEmitter implements IPathEventEmitter{ constructor(public readonly prefix: string = '') { } emit(event: Event, ...args: any[]) { - const parsed = PE`${this.prefix}/${event}`; + const parsed = event instanceof PathEvent ? event : new PathEvent(`${this.prefix}/${event}`); this.listeners.filter(l => PathEvent.has(l[0], parsed)) .forEach(async l => l[1](parsed, ...args)); }; @@ -304,7 +326,7 @@ export class PathEventEmitter implements IPathEventEmitter{ on(event: Event | Event[], listener: PathListener): PathUnsubscribe { makeArray(event).forEach(e => this.listeners.push([ - new PathEvent(`${this.prefix}/${e}`), + e instanceof PathEvent ? e : new PathEvent(`${this.prefix}/${e}`), listener ])); return () => this.off(listener); diff --git a/tests/path-events.spec.ts b/tests/path-events.spec.ts index 7b870b4..d675541 100644 --- a/tests/path-events.spec.ts +++ b/tests/path-events.spec.ts @@ -1,6 +1,10 @@ import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src'; describe('Path Events', () => { + beforeEach(() => { + PathEvent.clearCache(); + }); + describe('PE', () => { it('creates PathEvent from template string', () => { const e = PE`users/system:cr`;