UNPKG

indexed-collection

Version:

A zero-dependency library of classes that make filtering, sorting and observing changes to arrays easier and more efficient.

772 lines (753 loc) 22 kB
//#region src/builders/keyExtractBuilder.ts function buildMultipleKeyExtract(getKeys) { const result = (item) => getKeys(item); result.isMultiple = true; return result; } //#endregion //#region src/core/defaultCollectionViewOption.ts const defaultFilter = () => true; const defaultSort = () => 0; const defaultCollectionViewOption = Object.freeze({ filter: defaultFilter, sort: defaultSort }); //#endregion //#region src/signals/Signal.ts /** * Signal is a base class for all signals. * */ var Signal = class { constructor(type, target) { this.type = type; this.target = target; } }; //#endregion //#region src/signals/CollectionAddSignal.ts var CollectionAddSignal = class CollectionAddSignal extends Signal { static type = Symbol("COLLECTION_ADD"); constructor(target, added) { super(CollectionAddSignal.type, target); this.added = added; } }; //#endregion //#region src/signals/CollectionChangeSignal.ts var CollectionChangeSignal = class CollectionChangeSignal extends Signal { static type = Symbol("COLLECTION_CHANGE"); constructor(target, detail) { super(CollectionChangeSignal.type, target); this.detail = detail; } }; //#endregion //#region src/signals/CollectionRemoveSignal.ts var CollectionRemoveSignal = class CollectionRemoveSignal extends Signal { static type = Symbol("COLLECTION_REMOVE"); constructor(target, removed) { super(CollectionRemoveSignal.type, target); this.removed = removed; } }; //#endregion //#region src/signals/CollectionUpdateSignal.ts var CollectionUpdateSignal = class CollectionUpdateSignal extends Signal { static type = Symbol("COLLECTION_UPDATE"); constructor(target, updated) { super(CollectionUpdateSignal.type, target); this.updated = updated; } }; //#endregion //#region src/signals/SignalObserver.ts /** * Signal observer is a class that can be used to observe signals * It supports multiple observers for a single signal type and vice versa */ var SignalObserver = class { typeToHandleMap; handlerToTypeMap = /* @__PURE__ */ new Map(); constructor() { this.typeToHandleMap = /* @__PURE__ */ new Map(); } /** * Notify all observers of a signal by the signal's type * @param signal */ notifyObservers(signal) { const handlers = this.typeToHandleMap.get(signal.type); if (handlers) for (const handler of handlers) handler(signal); } /** * Register an observer for a signal type * @param type The type of a signal * @param handler The handler to be called when the signal is emitted */ registerObserver(type, handler) { const handlers = this.typeToHandleMap.get(type) ?? /* @__PURE__ */ new Set(); handlers.add(handler); this.typeToHandleMap.set(type, handlers); const types = this.handlerToTypeMap.get(handler) ?? /* @__PURE__ */ new Set(); types.add(type); this.handlerToTypeMap.set(handler, types); } /** * Unregister an observer for a signal type * @param handler The handle to be unregistered * @param type (Optional) The type of a signal (if not provided, all types associated with the handle will be unregistered) */ unregisterObserver(handler, type) { let relevantSignalTypes; if (type == null) relevantSignalTypes = this.handlerToTypeMap.get(handler); else relevantSignalTypes = new Set([type]); for (const type$1 of relevantSignalTypes) { const handlers = this.typeToHandleMap.get(type$1); if (handlers) handlers.delete(handler); } this.handlerToTypeMap.delete(handler); } }; //#endregion //#region src/collections/util.ts function mergeCollectionChangeDetail(a, b) { const added = a.added ? b.added ? a.added.concat(b.added) : a.added : b.added; const removed = a.removed ? b.removed ? a.removed.concat(b.removed) : a.removed : b.removed; const updated = a.updated ? b.updated ? a.updated.concat(b.updated) : a.updated : b.updated; return { added: added ?? [], removed: removed ?? [], updated: updated ?? [] }; } function filterCollectionChangeDetail(change, filter) { const added = change.added.filter(filter); const removed = change.removed.filter(filter); const updated = change.updated.filter((item) => filter(item.newValue)); return { added, removed, updated }; } //#endregion //#region src/collections/CollectionViewBase.ts /** * CollectionView is a view onto a collection of data. Most common use case would be * having the collection reduced by filter and/or sorted according to various criteria * without modifying the underlying data. */ var CollectionViewBase = class extends SignalObserver { _source; _option; _cachedItems = []; constructor(source, option = defaultCollectionViewOption) { super(); this._source = source; this._option = Object.assign({}, defaultCollectionViewOption, option); this.rebuildCache(); this._source.registerObserver(CollectionChangeSignal.type, this.source_onChange.bind(this)); } /** * Reindex the entire collection */ rebuild(deep = false) { if (deep) this.source.rebuild(deep); this.rebuildCache(); } rebuildCache() { this._cachedItems = this.applyFilterAndSort(this._source.items); } applyFilterAndSort(list) { const filtered = this._option.filter === defaultCollectionViewOption.filter ? [...list] : list.filter(this._option.filter); return this.sort === defaultSort ? filtered : filtered.sort(this.sort); } returnItemIfPassesFilter(item) { if (item === void 0) return void 0; return this.filter(item) ? item : void 0; } source_onChange(signal) { this.rebuildCache(); this.notifyChange(signal); } notifyChange(signal) { const changes = filterCollectionChangeDetail(signal.detail, this.filter); const addedCount = changes.added.length; const removedCount = changes.removed.length; const updatedCount = changes.updated.length; if (addedCount === 0 && removedCount === 0 && updatedCount === 0) return; this.notifyObservers(new CollectionChangeSignal(this, changes)); if (addedCount > 0) this.notifyObservers(new CollectionAddSignal(this, changes.added)); if (removedCount > 0) this.notifyObservers(new CollectionRemoveSignal(this, changes.removed)); if (updatedCount > 0) this.notifyObservers(new CollectionUpdateSignal(this, changes.updated)); } get count() { return this._cachedItems.length; } get items() { return this._cachedItems; } exists(item) { if (!this._source.exists(item)) return false; return Boolean(this.returnItemIfPassesFilter(item)); } get source() { return this._source; } get filter() { return this._option.filter; } get sort() { return this._option.sort; } }; //#endregion //#region src/core/CollectionNature.ts const CollectionNature = { Set: "set", Array: "array" }; //#endregion //#region src/core/defaultCollectionOption.ts const defaultCollectionOption = Object.freeze({ nature: CollectionNature.Set }); //#endregion //#region src/core/internals/InternalList.ts /** * A simple list that outputs a new array of the list if * the based array has been modified * * This is an unsupported internal class not meant to be used * beyond internal usage */ var InternalList = class { _isSynced = false; _output = []; constructor(source) { this.source = source; } /** * Mark the cached output as stale so it will be regenerated on next access. */ invalidate() { this._isSynced = false; } /** * Number of items currently stored in the list. */ get count() { return this.source.length; } /** * Return a cached array of the current contents. The cache is refreshed * lazily when the list has been invalidated. */ get output() { if (!this._isSynced) { this._isSynced = true; this._output = this.source.concat(); } return this._output; } /** * Check if the given item exists in the list. */ exists(item) { return this.source.includes(item); } /** * Append an item to the list. */ add(item) { this.source.push(item); this.invalidate(); } /** * Remove the first occurrence of the item from the list. */ remove(item) { const index = this.source.findIndex((listItem) => listItem === item); if (index >= 0) { this.source.splice(index, 1); this.invalidate(); } } /** * Replace an existing item with a new one if found. */ update(newItem, oldItem) { const index = this.source.findIndex((listItem) => listItem === oldItem); if (index >= 0) { this.source[index] = newItem; this.invalidate(); } } /** * Move an item to a position before another item. */ moveBefore(item, before) { const itemIndex = this.source.findIndex((listItem) => listItem === item); const beforeIndex = this.source.findIndex((listItem) => listItem === before); if (itemIndex >= 0 && beforeIndex >= 0) { this.source.splice(itemIndex, 1); this.source.splice(beforeIndex, 0, item); this.invalidate(); } } /** * Move an item to a position after another item. */ moveAfter(item, after) { const itemIndex = this.source.findIndex((listItem) => listItem === item); const afterIndex = this.source.findIndex((listItem) => listItem === after); if (itemIndex >= 0 && afterIndex >= 0) { this.source.splice(itemIndex, 1); const targetIndex = itemIndex < afterIndex ? afterIndex : afterIndex + 1; this.source.splice(targetIndex, 0, item); this.invalidate(); } } }; //#endregion //#region src/core/internals/InternalSetList.ts /** * An internal data structure that's based on a set * and output a new array if the set has changed * Change of set is notified through invalidate() method * * This is an unsupported internal class not meant to be used * beyond internal usage */ var InternalSetList = class { _isSynced = false; _output = []; constructor(source) { this.source = source; } /** * Mark the cached output as stale so it will be regenerated on next access. */ invalidate() { this._isSynced = false; } /** * Number of items currently stored in the underlying set. */ get count() { return this.source.size; } /** * Return a cached array of the set's contents. The array is refreshed lazily * when the set has changed. */ get output() { if (!this._isSynced) { this._isSynced = true; this._output = Array.from(this.source); } return this._output; } /** * Add an item to the set. */ add(item) { const sizeBefore = this.source.size; this.source.add(item); if (this.source.size !== sizeBefore) this.invalidate(); } /** * Check whether the item exists in the set. */ exists(item) { return this.source.has(item); } /** * Remove an item from the set. */ remove(item) { const didDelete = this.source.delete(item); if (didDelete) this.invalidate(); } /** * Replace an existing item with a new one if it is present. */ update(newItem, oldItem) { if (this.source.has(oldItem)) { this.source.delete(oldItem); this.source.add(newItem); this.invalidate(); } } /** * Sets have no order so this is a no-op. */ moveBefore(_item, _before) { return; } /** * Sets have no order so this is a no-op. */ moveAfter(_item, _after) { return; } }; //#endregion //#region src/collections/IndexedCollectionBase.ts var IndexedCollectionBase = class extends SignalObserver { _allItemList = new InternalSetList(/* @__PURE__ */ new Set()); indexes = /* @__PURE__ */ new Set(); _pauseChangeSignal = false; _hasPendingChangeSignal = false; _pendingChange = {}; option; constructor(initialValues, additionalIndexes = [], option = defaultCollectionOption) { super(); this.option = Object.assign({}, defaultCollectionOption, option); this.buildIndexes(additionalIndexes); if (this.option.nature === CollectionNature.Set) this._allItemList = new InternalSetList(/* @__PURE__ */ new Set()); else this._allItemList = new InternalList([]); if (initialValues) this.addRange(initialValues); } rebuild() { this.reindex(); } reindex() { for (const index of this.indexes) index.reset(); const items = this._allItemList.output; for (const item of items) for (const index of this.indexes) index.index(item); } /** * Rebuild indexes * @param indexes * @param autoReindex if true, all items will be reindexed */ buildIndexes(indexes, autoReindex = true) { this.indexes = new Set(indexes); if (autoReindex) this.reindex(); } add(item) { if (this.exists(item)) return false; this._allItemList.add(item); for (const index of this.indexes) index.index(item); this.notifyChange({ added: [item] }); return true; } addRange(items) { let rawItems; if (Array.isArray(items)) rawItems = items; else if (items instanceof Set) rawItems = Array.from(items); else rawItems = items.items; this.pauseChangeSignal(); const result = []; for (const item of rawItems) result.push(this.add(item)); this.resumeChangeSignal(); return result; } exists(item) { return this._allItemList.exists(item); } /** * Remove item from the collection * @param item * @returns */ remove(item) { if (!this.exists(item)) return false; this._allItemList.remove(item); for (const index of this.indexes) index.removeFromIndex(item); this.notifyChange({ removed: [item] }); return true; } update(newItem, oldItem) { if (!this.exists(oldItem)) return false; for (const index of this.indexes) index.removeFromIndex(oldItem); this._allItemList.update(newItem, oldItem); for (const index of this.indexes) index.index(newItem); this.notifyChange({ updated: [{ oldValue: oldItem, newValue: newItem }] }); return true; } /** * Move item before the specified item * @param item The item to move * @param before */ moveBefore(item, before) { this._allItemList.moveBefore(item, before); } /** * Move item after the specified item * @param item The item to move * @param after */ moveAfter(item, after) { this._allItemList.moveAfter(item, after); } get items() { return this._allItemList.output; } get count() { return this._allItemList.count; } notifyChange(change) { if (this._pauseChangeSignal) { this._hasPendingChangeSignal = true; this._pendingChange = mergeCollectionChangeDetail(this._pendingChange, change); return; } const changes = mergeCollectionChangeDetail({}, change); this.notifyObservers(new CollectionChangeSignal(this, changes)); if (changes.added.length > 0) this.notifyObservers(new CollectionAddSignal(this, changes.added)); if (changes.removed.length > 0) this.notifyObservers(new CollectionRemoveSignal(this, changes.removed)); if (changes.updated.length > 0) this.notifyObservers(new CollectionUpdateSignal(this, changes.updated)); } /** * Pause change signal when collection content has changed * This is useful when the collection is undergoing batch changes * that the collection would not cause too many down stream change reaction * during batch update. * * If there are any changes during pause period, resumeChangeEvent * will dispatch change event. */ pauseChangeSignal() { if (!this._pauseChangeSignal) { this._pauseChangeSignal = true; this._hasPendingChangeSignal = false; } } /** * Resume change signal from its pause state * if there are any pending changes, change signal will be notified */ resumeChangeSignal() { if (this._pauseChangeSignal) { this._pauseChangeSignal = false; if (this._hasPendingChangeSignal) { this._hasPendingChangeSignal = false; this.notifyChange(this._pendingChange); this._pendingChange = {}; } } } }; //#endregion //#region src/indexes/IndexBase.ts const emptyArray = Object.freeze([]); /** * Base class for index implementations. An index maps one or more keys to the * items they reference. Keys are extracted using the provided key extraction * functions. */ var IndexBase = class { _keyFns; option; internalMap = /* @__PURE__ */ new Map(); constructor(keyFns, option = defaultCollectionOption) { this._keyFns = keyFns; this.option = Object.freeze(Object.assign({}, defaultCollectionOption, option)); } /** * Add an item to the index under all of the keys produced by the key * extractors. * * @param item The item to index * @returns True if the item was inserted for at least one key */ index(item) { const keys = this.getKeys(item); const leafMaps = this.getLeafMaps(keys, true); const lastIndex = keys.length - 1; const lastKeys = keys[lastIndex]; let added = false; const isUsingSet = this.option.nature === CollectionNature.Set; for (const leafMap of leafMaps) for (const key of lastKeys) { const result = addItemToMap(key, item, leafMap, isUsingSet); added = added || result; } return added; } /** * Remove an item from the index for all of its associated keys. * * @param item The item to unindex * @returns True if the item existed for at least one key */ removeFromIndex(item) { const keys = this.getKeys(item); const leafMaps = this.getLeafMaps(keys, false); const lastKeys = keys[keys.length - 1]; let removed = false; for (const leafMap of leafMaps) for (const key of lastKeys) { const result = removeItemFromMap(key, item, leafMap); removed = removed || result; } return removed; } /** * Retrieve items stored under the specified sequence of keys. * * @param keys Keys identifying the value within the index * @returns A readonly array of matched items */ getValueInternal(keys) { const convertedKeys = keys.map((k) => [k]); const leafMaps = this.getLeafMaps(convertedKeys, false); const lastKey = keys[keys.length - 1]; const values = leafMaps[0]?.get(lastKey); if (values == null) return emptyArray; return values.output; } /** * Extract the key values for the given item using all key extractor * functions. * * Single-value extractors are wrapped in an array so all returned values are * iterable. * * @param item The item whose keys are being generated */ getKeys(item) { return this._keyFns.map((keyFn) => { return keyFn.isMultiple ? keyFn(item) : [keyFn(item)]; }); } /** * Walk or create the nested map structure for the provided keys and return * the leaf maps corresponding to the last key segment. * * @param keys Array of key values for each level of the index */ getLeafMaps(keys, createIfMissing) { if (keys.length === 1) return [this.internalMap]; let currentLevelMap = [this.internalMap]; for (let i = 0; i < keys.length - 1; i++) { const maps = []; for (const key of keys[i]) for (const map of currentLevelMap) { let next = map.get(key); if (next == null) { if (!createIfMissing) continue; next = /* @__PURE__ */ new Map(); map.set(key, next); } maps.push(next); } if (maps.length === 0) return []; currentLevelMap = maps; } return currentLevelMap; } /** * Clear all stored keys and values from the index. */ reset() { this.internalMap = /* @__PURE__ */ new Map(); } }; /** * Create a new internal list depending on collection nature. * * @param isUsingSet When true a set backed list will be created */ function getNewInternalList(isUsingSet) { return isUsingSet ? new InternalSetList(/* @__PURE__ */ new Set()) : new InternalList([]); } /** * Add an item to a map under a specific key, creating the key if needed. * * @returns True if the item was inserted */ function addItemToMap(key, item, map, isUsingSet) { if (!map.has(key)) { const content = getNewInternalList(isUsingSet); content.add(item); map.set(key, content); return true; } const items = map.get(key); if (!items.exists(item)) { items.add(item); return true; } return false; } /** * Remove an item from a map for the given key if it exists. */ function removeItemFromMap(key, item, map) { const items = map.get(key); if (items != null && items.exists(item)) { items.remove(item); return true; } return false; } //#endregion //#region src/indexes/CollectionIndex.ts var CollectionIndex = class extends IndexBase { constructor(keyFn, option = defaultCollectionOption) { super(keyFn, option); } getValue(...keys) { return super.getValueInternal(keys); } }; //#endregion //#region src/collections/PrimaryKeyCollection.ts /** * A collection where every item contains a unique identifier key (aka primary key) */ var PrimaryKeyCollection = class extends IndexedCollectionBase { idIndex; constructor(primaryKeyExtract, initialValues, additionalIndexes = [], option = defaultCollectionOption) { super(void 0, void 0, option); this.primaryKeyExtract = primaryKeyExtract; this.idIndex = new CollectionIndex([primaryKeyExtract]); this.buildIndexes([this.idIndex, ...additionalIndexes]); if (initialValues) this.addRange(initialValues); } buildIndexes(indexes, autoReindex) { const combinedIndex = []; if (this.idIndex != null) combinedIndex.push(this.idIndex); if (indexes != null && indexes.length > 0) combinedIndex.push(...indexes); super.buildIndexes(combinedIndex, autoReindex); } exists(item) { const key = this.primaryKeyExtract(item); return Boolean(this.byPrimaryKey(key)); } /** * Get the item by its primary key * @param keyValue * @returns */ byPrimaryKey(keyValue) { return this.idIndex.getValue(keyValue)[0]; } update(newItem) { const key = this.primaryKeyExtract(newItem); const oldItem = this.byPrimaryKey(key); if (oldItem) return super.update(newItem, oldItem); return false; } }; //#endregion exports.CollectionAddSignal = CollectionAddSignal; exports.CollectionChangeSignal = CollectionChangeSignal; exports.CollectionIndex = CollectionIndex; exports.CollectionNature = CollectionNature; exports.CollectionRemoveSignal = CollectionRemoveSignal; exports.CollectionUpdateSignal = CollectionUpdateSignal; exports.CollectionViewBase = CollectionViewBase; exports.IndexBase = IndexBase; exports.IndexedCollectionBase = IndexedCollectionBase; exports.PrimaryKeyCollection = PrimaryKeyCollection; exports.Signal = Signal; exports.SignalObserver = SignalObserver; exports.buildMultipleKeyExtract = buildMultipleKeyExtract; exports.defaultCollectionOption = defaultCollectionOption; exports.defaultCollectionViewFilter = defaultFilter; exports.defaultCollectionViewOption = defaultCollectionViewOption; exports.defaultCollectionViewSort = defaultSort;