indexable-array
Version:
Extended native JavaScript Array which provides indexed lookup similar to native Map.
595 lines • 27.7 kB
JavaScript
;
/* 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