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
JavaScript
//#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;