diff --git a/index.html b/index.html index a3b5be9..70df880 100644 --- a/index.html +++ b/index.html @@ -1,11 +1,9 @@ diff --git a/package.json b/package.json index ea9e30d..e56affd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.26.2", + "version": "0.26.1", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/aset.ts b/src/aset.ts index 5458257..91cab4d 100644 --- a/src/aset.ts +++ b/src/aset.ts @@ -2,16 +2,11 @@ import {isEqual} from './objects.ts'; /** * An array which functions as a set. It guarantees unique elements - * 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. + * and provides set functions for comparisons */ -export class ASet extends Array { - private readonly _valueMap: Map; // Stores string representation -> actual value for quick lookups - +export class ASet extends Array { /** Number of elements in set */ - get size(): number { + get size() { return this.length; } @@ -21,236 +16,119 @@ export class ASet extends Array { */ constructor(elements: T[] = []) { super(); - this._valueMap = new Map(); // Initialize the map - - if (Array.isArray(elements)) { + if(!!elements?.['forEach']) elements.forEach(el => this.add(el)); - } } /** - * 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. + * Add elements to set if unique * @param items */ - 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 - } - } - } + add(...items: T[]) { + items.filter(el => !this.has(el)).forEach(el => this.push(el)); return this; } /** - * Remove all elements. - * Optimized to clear both the array and the map. + * Remove all elements */ - clear(): this { - super.splice(0, this.length); - this._valueMap.clear(); + clear() { + this.splice(0, this.length); return this; } /** - * 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. + * Delete elements from set * @param items Elements that will be deleted */ - 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 - } - } - } + delete(...items: T[]) { + items.forEach(el => { + const index = this.indexOf(el); + if(index != -1) this.splice(index, 1); + }) return this; } /** - * Create list of elements this set has which the comparison set does not. - * Optimized to use `has` which is now faster. + * Create list of elements this set has which the comparison set does not * @param {ASet} set Set to compare against * @return {ASet} Different elements */ - difference(set: ASet): ASet { - const result = new ASet(); - for (const el of this) { - if (!set.has(el)) { - result.add(el); - } - } - return result; + difference(set: ASet) { + return new ASet(this.filter(el => !set.has(el))); } /** - * Check if set includes element. - * Optimized to use the internal Map for O(1) average time lookups. + * Check if set includes element * @param {T} el Element to look for * @return {boolean} True if element was found, false otherwise */ - 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; + has(el: T) { + return this.indexOf(el) != -1; } /** - * 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). + * Find index number of element, or -1 if it doesn't exist. Matches by equality not reference * * @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. - * Optimized to use `has` which is now faster. + * Create list of elements this set has in common with the comparison set * @param {ASet} set Set to compare against - * @return {ASet} Set of common elements + * @return {boolean} Set of common elements */ - 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; + intersection(set: ASet) { + return new ASet(this.filter(el => set.has(el))); } /** - * Check if this set has no elements in common with the comparison set. - * Optimized to use `intersection` and check its size. + * Check if this set has no elements in common with the comparison set * @param {ASet} set Set to compare against * @return {boolean} True if nothing in common, false otherwise */ - isDisjointFrom(set: ASet): boolean { - return this.intersection(set).size === 0; + isDisjointFrom(set: ASet) { + return this.intersection(set).size == 0; } /** - * Check if all elements in this set are included in the comparison set. - * Optimized to use `has` which is now faster. + * Check if all elements in this set are included in the comparison set * @param {ASet} set Set to compare against * @return {boolean} True if all elements are included, false otherwise */ - 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; + isSubsetOf(set: ASet) { + return this.findIndex(el => !set.has(el)) == -1; } /** - * Check if all elements from comparison set are included in this set. - * Optimized to use `has` which is now faster. + * Check if all elements from comparison set are included in this set * @param {ASet} set Set to compare against * @return {boolean} True if all elements are included, false otherwise */ - 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; + isSuperset(set: ASet) { + return set.findIndex(el => !this.has(el)) == -1; } /** - * Create list of elements that are only in one set but not both (XOR). - * Uses optimized `difference` method. + * Create list of elements that are only in one set but not both (XOR) * @param {ASet} set Set to compare against * @return {ASet} New set of unique elements */ - symmetricDifference(set: ASet): ASet { - return new ASet([...this.difference(set), ...set.difference(this)]); + symmetricDifference(set: ASet) { + return new ASet([...this.difference(set), ...set.difference(this)]); } /** - * Create joined list of elements included in this & the comparison set. - * Uses optimized `add` method. - * @param {ASet | Array} set Set to join + * Create joined list of elements included in this & the comparison set + * @param {ASet} set Set join * @return {ASet} New set of both previous sets combined */ - union(set: ASet | Array): ASet { - const result = new ASet(this); - result.add(...Array.from(set)); - return result; + union(set: ASet | Array) { + return new ASet([...this, ...set]); } } diff --git a/src/objects.ts b/src/objects.ts index 3db50a9..804106b 100644 --- a/src/objects.ts +++ b/src/objects.ts @@ -177,51 +177,19 @@ export function includes(target: any, values: any, allowMissing = false): boolea } /** - * Deep check if two items are equal. - * Handles primitives, objects, arrays, functions, Date, RegExp, and circular references. + * Deep check if two objects are equal * * @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, 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; +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])); } /** diff --git a/src/path-events.ts b/src/path-events.ts index 599b91a..1d356eb 100644 --- a/src/path-events.ts +++ b/src/path-events.ts @@ -71,12 +71,9 @@ 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 ? this.methods = new ASet(['*']) : this.methods.delete('*'); } + set all(v: boolean) { v ? 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'); } @@ -94,20 +91,10 @@ 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') { - Object.assign(this, e); - return; - } - - // Check cache first - if (PathEvent.pathEventCache.has(e)) { - Object.assign(this, PathEvent.pathEventCache.get(e)!); - return; - } - + if(typeof e == 'object') return Object.assign(this, e); let [p, scope, method] = e.replaceAll(/\/{2,}/g, '/').split(':'); if(!method) method = scope || '*'; - if(p == '*' || (!p && method == '*')) { + if(p == '*' || !p && method == '*') { p = ''; method = '*'; } @@ -117,14 +104,6 @@ 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(); } /** @@ -136,7 +115,7 @@ export class PathEvent { */ static combine(...paths: (string | PathEvent)[]): PathEvent { let hitNone = false; - const combined = paths.map(p => p instanceof PathEvent ? p : new PathEvent(p)) + const combined = paths.map(p => new PathEvent(p)) .toSorted((p1, p2) => { const l1 = p1.fullPath.length, l2 = p2.fullPath.length; return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0); @@ -145,9 +124,10 @@ export class PathEvent { if(p.none) hitNone = true; if(!acc) return p; if(hitNone) return acc; - acc.methods = new ASet([...acc.methods, ...p.methods]); + acc.methods = [...acc.methods, ...p.methods]; return acc; }, null); + combined.methods = new ASet(combined.methods); return combined; } @@ -159,12 +139,11 @@ 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 => pe instanceof PathEvent ? pe : new PathEvent(pe)); - const parsedFilter = makeArray(filter).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe)); + const parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); + const parsedFilter = makeArray(filter).map(pe => new PathEvent(pe)); return parsedTarget.filter(t => !!parsedFilter.find(r => { const wildcard = r.fullPath == '*' || t.fullPath == '*'; - 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 p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*')) const scope = p1.startsWith(p2) || p2.startsWith(p1); const methods = r.all || t.all || r.methods.intersection(t.methods).length; return (wildcard || scope) && methods; @@ -179,13 +158,12 @@ 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 => pe instanceof PathEvent ? pe : new PathEvent(pe)); - const parsedRequired = makeArray(has).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe)); + const parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); + const parsedRequired = makeArray(has).map(pe => new PathEvent(pe)); return !!parsedRequired.find(r => !!parsedTarget.find(t => { const wildcard = r.fullPath == '*' || t.fullPath == '*'; - 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 p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*')) + const scope = p1.startsWith(p2); const methods = r.all || t.all || r.methods.intersection(t.methods).length; return (wildcard || scope) && methods; })); @@ -315,7 +293,7 @@ export class PathEventEmitter implements IPathEventEmitter{ constructor(public readonly prefix: string = '') { } emit(event: Event, ...args: any[]) { - const parsed = event instanceof PathEvent ? event : new PathEvent(`${this.prefix}/${event}`); + const parsed = PE`${this.prefix}/${event}`; this.listeners.filter(l => PathEvent.has(l[0], parsed)) .forEach(async l => l[1](parsed, ...args)); }; @@ -326,7 +304,7 @@ export class PathEventEmitter implements IPathEventEmitter{ on(event: Event | Event[], listener: PathListener): PathUnsubscribe { makeArray(event).forEach(e => this.listeners.push([ - e instanceof PathEvent ? e : new PathEvent(`${this.prefix}/${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 d675541..7b870b4 100644 --- a/tests/path-events.spec.ts +++ b/tests/path-events.spec.ts @@ -1,10 +1,6 @@ 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`;