UNPKG

indexable-array

Version:

Extended native JavaScript Array which provides indexed lookup similar to native Map.

595 lines 27.7 kB
"use strict"; /* eslint-disable @typescript-eslint/no-explicit-any, class-methods-use-this, no-dupe-class-members, no-use-before-define, @typescript-eslint/explicit-module-boundary-types */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const sorted_array_functions_1 = tslib_1.__importDefault(require("sorted-array-functions")); const dot_prop_1 = require("dot-prop"); function isDefaultKey(value) { return typeof value === "string" || value === undefined; } const nonEnumerableProps = new Set([ "primitiveLookups", "objectLookups", "indexedKeys", "_defaultKey", "indexEnabled", "operationAtEnd", "builtIndexKeys", "_throwUnknown", ]); /** * Extended native array class to access array elements by fast key lookups using binary search. Used for storing objects. * * @example * import IndexableArray, { Self } from "indexable-array"; * const users = new IndexableArray({ id: 23, name: "Geroge" }, { id: 96, name: "Lisa" }).addIndex("name"); * Array.isArray(users); // true * users.get("George"); // { id: 23, name: "George"} * const user = { id: 21, name: "Henry" }; * users[0] = user; * users.getIndex(user); // 0 - It is possible to index whole object by { selfIndex: true } option. * users.splice(1, 1, { id: 34, name: "Henry" }); * users.getAllIndexes("Henry"); // [0, 1]; * * users[0].name = "DON'T DO THIS"; // WRONG: Sub fields (i.e. [0]."name") of the array is not watched, so index does not get updated. * users.set(0, "name", "OK"); // Index updated. * users.disableIndex(); * users[0].name = "THIS IS OK NOW"; * users.enableIndex(); // Index is recreated from scratch. */ class IndexableArray extends Array { /** * Creates an `IndexableArray` instance from given items. * * @param items are items to create `IndexableArray` from. */ constructor(...items) { super(...items); this.primitiveLookups = new Map(); this.objectLookups = new Map(); this.builtIndexKeys = new Set(); this.indexEnabled = false; this.operationAtEnd = false; this._throwUnknown = false; /** * Set of the indexed key names. `$$self` is used for the whole value. * * @example * const users = new IndexableArray({ id: 23, name: "Geroge" }, { id: 96, name: "Lisa" }).addSelfIndex().addIndex("name"); * users.indexedArray; // ["$$self", "name"] */ this.indexedKeys = new Set(); nonEnumerableProps.forEach((property) => Object.defineProperty(this, property, { writable: true, enumerable: false })); // Make added fields non-enumerable. return new Proxy(this, { set: (target, property, value) => target.setProperty(property, value), deleteProperty: (target, property) => target.deleteProperty(property), }); } /** * Creates a new, shallow-copied `IndexableArray` instance from an array-like or iterable object. If source is also `IndexableArray`, * returned `IndexableArray` will have same indexed keys. * * @param arrayLike is an array-like or iterable object to convert to an array. * @param defaultKey is default key to be used with `get()` method if no key is provided. * @param indexKeys are keys to be indexed. * @returns a new `IndexableArray` instance. */ static from(arrayLike, defaultKey, ...indexKeys) { const array = Array.isArray(arrayLike) ? arrayLike : Array.from(arrayLike); const indexableArray = new IndexableArray(...array); if (arrayLike instanceof IndexableArray) { indexableArray._throwUnknown = arrayLike._throwUnknown; } if (defaultKey) { indexableArray.addIndex(defaultKey, ...indexKeys); } if (arrayLike instanceof IndexableArray && !defaultKey) { indexableArray._copyMeta(arrayLike); } return indexableArray; } /** * Creates a new, shallow-copied `IndexableArray` instance from an array-like or iterable object. If source is also `IndexableArray`, * returned `IndexableArray` will have same indexed keys. Returned instance throws exception if `get()` methods cannot find given value. * * @param arrayLike is an array-like or iterable object to convert to an array. * @param defaultKey is default key to be used with `get()` method if no key is provided. * @param indexKeys are keys to be indexed. * @returns a new `IndexableArray` instance. */ static throwingFrom(arrayLike, defaultKey, ...indexKeys) { const indexableArray = IndexableArray.from(arrayLike, defaultKey, ...indexKeys); indexableArray._throwUnknown = true; return indexableArray; } get defaultKey() { const firstIndexKey = this.builtIndexKeys.values().next().value; /* istanbul ignore next */ return this._defaultKey === undefined ? firstIndexKey : this._defaultKey; } /** * Clears index by emptying related index fields. * * @ignore */ clearIndex() { this.indexedKeys.forEach((key) => { this.primitiveLookups.set(key, new Map()); this.objectLookups.set(key, new WeakMap()); this.builtIndexKeys.delete(key); }); } /** * Adds given keys to the index. * * @ignore * @param keys are list of keys to add to index. * @returns this object. */ addIndex(...indexKeys) { const keys = indexKeys.length === 0 ? Array.from(this.indexedKeys) : indexKeys; const addedIndexKeys = []; this.indexEnabled = true; this.operationAtEnd = true; keys .filter((key) => !this.builtIndexKeys.has(key)) .forEach((key) => { if (!this._defaultKey) { this._defaultKey = key; } this.primitiveLookups.set(key, new Map()); this.objectLookups.set(key, new WeakMap()); this.indexedKeys.add(key); this.builtIndexKeys.add(key); addedIndexKeys.push(key); }); this.forEach((item, position) => this.addToIndex(position, item, addedIndexKeys)); this.operationAtEnd = false; return this; } /** * Adds same index types from another IndexableArray. * * @ignore * @param source is `IndexableArray` to get index keys from. * @returns this object. * @example * const users = new IndexableArray({ id: 23, name: "Geroge" }, { id: 96, name: "Lisa" }).addIndex("name"); * const other = new IndexableArray().addIndexFrom(users); // Indexes "name". */ _copyMeta(source) { this._throwUnknown = source._throwUnknown; return this.addIndex(...source.indexedKeys); } /** * Returns either `objectLookup` or `primitiveLookup` based on given `field` and `value` * * @ignore * @param key is array item's field to get lookup for. * @param value is value stored in that field. (Used for selecting primitve or object lookup) * @returns map with keys are lookup-values, values are indexes of those values. */ getLookup(key, value) { if (!this.indexedKeys.has(key)) { throw new Error(`Key is not indexed: ${key}`); } const lookups = typeof value === "object" ? this.objectLookups : this.primitiveLookups; const lookup = lookups.get(key); return lookup; } /** * Adds index into lookup for given field. Also creates lookup if it does not exists. * * @ignore * @param position is position of item which holds the value for the given field. * @param item is item to add to index. * @param keys are fields to add lookup. (i.e. "name") */ addToIndex(position, item, keys = Array.from(this.indexedKeys)) { keys.forEach((key) => { const value = item[key]; const lookup = this.getLookup(key, value); const sortedIndex = lookup.get(value); if (sortedIndex) { if (position === 0) { sortedIndex.unshift(position); } else if (this.operationAtEnd || position >= this.length - 1) { sortedIndex.push(position); } else { sorted_array_functions_1.default.add(sortedIndex, position); } } else { lookup.set(value, [position]); } }); } /** * Removes index from lookup for given field. Also deletes lookup if no index remains for this value. * * @ignore * @param item is item to add to index. * @param position is position of item which holds the value for the given field. * @param keys are fields to add lookup. (i.e. "name") */ removeFromIndex(position, keys = Array.from(this.indexedKeys)) { const item = this[position]; keys.forEach((key) => { const value = item[key]; const lookup = this.getLookup(key, value); const sortedIndex = lookup.get(value); // Cannot be undefined, otherwise value should not be there and therefore should not have executed a delete operation. if (sortedIndex.length === 1) { lookup.delete(value); } else if (position === 0) { sortedIndex.shift(); } else if (this.operationAtEnd || position >= this.length - 1) { sortedIndex.pop(); } else { sorted_array_functions_1.default.remove(sortedIndex, position); } }); } /** * The `handler.set()` method is a trap for setting a property value of array item. * * @ignore * @param property is the name or Symbol of the property to set. * @param newItem is the new value of the property to set. * @returns whether that assignment succeeded */ setProperty(property, newItem) { if (this.indexEnabled && !nonEnumerableProps.has(property)) { if (property === "length") { if (newItem < this.length) { const oldLength = this.length; const newLength = newItem; this.operationAtEnd = true; for (let position = newLength; position < oldLength; position += 1) { if (this[position] !== undefined) { this.removeFromIndex(position); } } this.operationAtEnd = false; } } else { const position = parseInt(property, 10); const oldItem = this[property]; if (oldItem) { this.removeFromIndex(position); } this.addToIndex(position, newItem); } } this[property] = newItem; return true; } /** * The `handler.deleteProperty()` method is a trap for setting a property value of array item. * * @ignore * @param property is the name or Symbol of the property to delete. * @returns whether delete operation succeeded */ deleteProperty(property) { const position = parseInt(property, 10); if (position !== undefined && this.indexEnabled) { this.removeFromIndex(position); } delete this[property]; return true; } /** * Returns positive index from start for given positive or negative index. Negative index is used for indexing from the end of array. * * @ignore * @param index is index to get positive for. * @returns positive index for given index. * @example * const indexedArray = IndexedArray.create({ id: 98 }, { id: 43 }, { id: 34 }, { id: 23 }); * indexedArray.positiveIndexOf(-1); // 3 * indexedArray.positiveIndexOf(1); // 1 */ positiveIndexOf(index) { return index >= 0 ? index : Math.max(0, this.length + index); } /** * Throws error if an index based operation is accessed when index is disabled. * * @private */ assertIndexEnabled() { if (!this.indexEnabled) { throw new Error("Index based operations cannot be used when index is disabled or there isn't any indexed fields."); } } push(...items) { this.operationAtEnd = true; const result = super.push(...items); this.operationAtEnd = false; return result; } splice(start, deleteCount = this.length, ...items) { if (start < this.length * 0.8) { this.disableIndex(); const result = super.splice(start, deleteCount, ...items); this.indexEnabled = true; this.enableIndex(); return result; } return super.splice(start, deleteCount, ...items); } sort(compareFn) { this.disableIndex(); const result = super.sort(compareFn); this.enableIndex(); return result; } /** * Sorts the elements of an array by given key in place and returns the sorted array. * * @param key is the key to sort array by. * @returns this instance. */ sortBy(key = this.defaultKey) { return this.sort((aObject, bObject) => { const [a, b] = [aObject[key], bObject[key]]; if (typeof a === "number" && typeof b === "number") return a - b; const textA = a.toUpperCase(); const textB = b.toUpperCase(); // eslint-disable-next-line no-nested-ternary return textA < textB ? -1 : textA > textB ? 1 : 0; }); } filter(callbackfn, thisArg) { const array = super.filter(callbackfn, thisArg); return array._copyMeta(this); } /** * Creates a new `IndexableArray` with the results of calling a provided function on every element in the calling array. * Returned `IndexedArray` does not have any indexes, because callback function may return different kind of elements from source array. * To have same indexes as source `IndexedArray`, use `mapWithIndex()` instead. * * @param callbackFn is function that produces an element of the new Array, taking three arguments: `value`, `index` and `indexableArray`. * @param defaultKeyOrThisArg is key to be used as a default index on returned `IndexableArray` instance or value to use as `this` when executing callback. * @param keys are the keys to be indexed. * @returns a new `IndexableArray` with each element being the result of the callback function. * * @example * const usersWithName = new IndexableArray({ id: 23, name: "Geroge" }, { id: 96, name: "Lisa" }).addIndex("name"); * const usersWithNick = usersWithName.map(user => ({ id: user.id, nick: name.substring(0,2) })).addIndex("nick"); // Has only "nick" index. */ map(callbackFn, defaultKeyOrThisArg, ...keys) { const [thisArg, defaultKey, indexKeys] = isDefaultKey(defaultKeyOrThisArg) ? [undefined, defaultKeyOrThisArg, keys] : [defaultKeyOrThisArg, keys[0], keys.slice(1)]; const indexableArray = super.map(callbackFn, thisArg); if (defaultKey) { indexableArray._throwUnknown = this._throwUnknown; indexableArray._defaultKey = defaultKey; indexableArray.addIndex(defaultKey, ...indexKeys); } else { indexableArray._copyMeta(this); } return indexableArray; } /** * Calls a defined callback function on each element of an indexable array. Then, flattens the result into * a new indexable array. * This is identical to a map followed by flat with depth 1. * * @param callbackFn is a function that accepts up to three arguments. The flatMap method calls the callback function one time for each element in the array. * @param defaultKeyOrThisArg is key to be used as a default index on returned `IndexableArray` instance or an object to which the this keyword can refer in the callback function. If thisArg is omitted, undefined is used as the this value. * @param keys are the keys to be indexed. * @returns a new `IndexableArray` of dept 1. */ flatMap(callbackFn, defaultKeyOrThisArg, ...keys) { const [thisArg, defaultKey, indexKeys] = isDefaultKey(defaultKeyOrThisArg) ? [undefined, defaultKeyOrThisArg, keys] : [defaultKeyOrThisArg, keys[0], keys.slice(1)]; const indexableArray = super.flatMap(callbackFn, thisArg); if (defaultKey) { indexableArray._defaultKey = defaultKey; indexableArray._throwUnknown = this._throwUnknown; indexableArray.addIndex(defaultKey, ...indexKeys); } else { indexableArray._copyMeta(this); } return indexableArray; } /** * Creates a new base Array (not IndexableArray) with the results of calling a provided function on every element in the calling array. * * @param callbackfn is function that produces an element of the new Array, taking three arguments: `value`, `index` and `indexableArray`. * @param thisArg is value to use as this when executing callback. * @returns a new `Array` with each element being the result of the callback function. * @see {@link IndexableArray#map} to get an `IndexableArray`. * @example * const usersWithName = new IndexableArray({ id: 23, name: "Geroge" }, { id: 96, name: "Lisa" }).addIndex("name"); * const baseArray = usersWithName.mapToArray(user => ({ id: user.id, nick: name.substring(0,2) })); // Normal base array. */ mapToArray(callbackfn, thisArg) { return Array.from(this).map(callbackfn, thisArg); } slice(start, end) { return super.slice(start, end)._copyMeta(this); } concat(...items) { const indexableArray = super.concat(...items); indexableArray._copyMeta(this); return indexableArray; } /** * Sets default index key to be used with lookup functions. Returns same instance. * * @param key is key to be used as default index with lookup functions. * @returns this object. * @example * const input = [{ id: 23, name: "Geroge" }, { id: 96, name: "Lisa" }]; * let users = new IndexableArray(...input).addIndex("name", "id"); // "name" is default index * users = users.withDefaultIndex("id"); // "id" is default index. Assignment is used for TypeScript to assign right type to variable. */ withDefaultIndex(key) { this._defaultKey = key; return this; } /** * Returns the first index at which a given indexed value can be found in the array, or -1 if it is not present. * * @param value indexed value to search for. * @param options are option to modify behaviour. * @param options.key is index field to look for value. Default lookup field is used if no key is provided. * @param options.fromIndex is the index to start the search at. If the index is greater than or equal to the array's length, -1 is returned, which means the array will not be searched. If the provided index value is a negative number, it is taken as the offset from the end of the array * @returns the first index of the element in the array; -1 if not found. */ getIndex(value, { key = this.defaultKey, fromIndex = 0 } = {}) { this.assertIndexEnabled(); const sortedIndex = this.getLookup(key, value).get(value); let index; if (sortedIndex) { const positiveFromIndex = this.positiveIndexOf(fromIndex); index = positiveFromIndex === 0 ? sortedIndex[0] : sortedIndex[sorted_array_functions_1.default.gte(sortedIndex, positiveFromIndex)]; } return index === undefined ? -1 : index; } /** * Returns all indexes at which a given indexed value can be found in the array, or empty array if it is not present. * * @param value indexed value to search for. * @param options are option to modify behaviour. * @param options.key is index field to look for value. Default lookup field is used if no key is provided. * @returns all indexes of the element in the array; Empty array if not found. */ getAllIndexes(value, { key = this.defaultKey } = {}) { this.assertIndexEnabled(); return this.getLookup(key, value).get(value) || []; } /** * Returns the first item at which a given indexed value can be found in the array. According to construction option or `throwUnknown` option, * returns `undefined` or throws exception if value cannot be found. * * @param value is indexed value to search for. * @param options are options to modify behaviour. * @param options.key is index field to look for value. Default lookup field is used if no key is provided. * @param options.fromIndex is the index to start the search at. If the index is greater than or equal to the array's length, -1 is returned, which means the array will not be searched. If the provided index value is a negative number, it is taken as the offset from the end of the array * @param options.throwUnknown is whether to throw exception if value cannot be found in index. * @returns the first item with given indexed value in the array; `undefined` if not found. */ get(value, { key = this.defaultKey, fromIndex = 0, throwUnknown = this._throwUnknown, } = {}) { const index = this.getIndex(value, { key, fromIndex }); if (throwUnknown && index === -1) { const firstObjectElement = this.find((e) => typeof e === "object"); const possibleType = firstObjectElement && `${firstObjectElement.constructor.name}'s `; throw new Error(`'${value}' cannot be found in ${possibleType || ""}${key}.`); } return (index > -1 ? this[index] : undefined); } /** * Returns the first item at which a given indexed value can be found in the array, or throws exception if it is not present. * * @param value is indexed value to search for. * @param options are options to modify behaviour. * @param options.key is index field to look for value. Default lookup field is used if no key is provided. * @param options.fromIndex is the index to start the search at. If the index is greater than or equal to the array's length, -1 is returned, which means the array will not be searched. If the provided index value is a negative number, it is taken as the offset from the end of the array * @returns the first item with given indexed value in the array; `undefined` if not found. */ getSure(value, { key = this.defaultKey, fromIndex = 0 } = {}) { return this.get(value, { key, fromIndex, throwUnknown: true }); } /** * Returns the first item at which a given indexed value can be found in the array. Returns `undefined` if value cannot be found. * * @param value is indexed value to search for. * @param options are options to modify behaviour. * @param options.key is index field to look for value. Default lookup field is used if no key is provided. * @param options.fromIndex is the index to start the search at. If the index is greater than or equal to the array's length, -1 is returned, which means the array will not be searched. If the provided index value is a negative number, it is taken as the offset from the end of the array * @returns is the first item with given indexed value in the array; `undefined` if not found. */ getMaybe(value, { key = this.defaultKey, fromIndex = 0 } = {}) { return this.get(value, { key, fromIndex, throwUnknown: false }); } /** * Returns all items at which a given indexed value can be found in the array, or empty array if it is not present. * * @param value is indexed value to search for. * @param options are options to modify behaviour. * @param options.key is index field to look for value. Default lookup field is used if no key is provided. * @returns all items with given indexed value in the array; Empty array if not found. */ getAll(value, { key = this.defaultKey } = {}) { this.assertIndexEnabled(); const allIndexes = this.getAllIndexes(value, { key }); return allIndexes.map((index) => this[index]); } /** * Determines whether an array includes a certain indexed value among its entries' keys, returning true or false as appropriate. * * @param value is indexed value to search for. * @param options are options to modify behaviour. * @param options.key is index field to look for value. Default lookup field is used if no key is provided. * @param options.fromIndex is the index to start the search at. If the index is greater than or equal to the array's length, -1 is returned, which means the array will not be searched. If the provided index value is a negative number, it is taken as the offset from the end of the array * @returns true if indexed value is found among array's entries' keys. */ has(value, { key = this.defaultKey, fromIndex = 0 } = {}) { this.assertIndexEnabled(); return this.getIndex(value, { key, fromIndex }) > -1; } /** * Sets value at path of the object, which is one of the entires of array. To update fields of the objects, this method should be used. Otherwise * index cannot be updated, because sub fileds are not tracked for chage detection. * * @param position is index of the item to be changed. * @param path is item's path where value to be changed at. * @param value is new value to be assigned. * @example * indexedArray[0].name = "DON'T DO THIS"; // WRONG: Sub fields (i.e. [0]."name") of the array is not watched, so index does not get updated. * indexedArray.set(0, "name", "OK"); // Index updated. * */ set(position, path, value) { this.assertIndexEnabled(); const oldItem = this[position]; if (!oldItem || typeof oldItem !== "object") { throw new Error("Cannot set field value of undefined"); } this.removeFromIndex(position); const newItem = dot_prop_1.set(this[position], path, value); this.addToIndex(position, newItem); } /** * Disables indexing of the array. It may be used to disable temporarily * - to do heavy updates for performance reasons, * - to do operations in sub fields. * If indexing is not needed anymore, it is suggested to create a new native non-extended array and copy values into it * for avoiding performance penalty of proxy array used in this library. * * @see {IndexedArray#enableIndex} method. * @example * indexedArray.disableIndex(); * indexedArray[0].name = "THIS IS OK NOW"; * indexedArray.enableIndex(); // Index is recreated from scratch. */ disableIndex() { this.indexEnabled = false; } /** * Enables indexing and recreates index from scratch. * * @see {IndexedArray#disableIndex} method. */ enableIndex() { this.indexEnabled = true; this.clearIndex(); this.addIndex(); } } exports.default = IndexableArray; //# sourceMappingURL=index.js.map