import {AsyncPipe, Pipeline} from '../support/Pipe' import {Unsubscribe, Subscription} from '../support/BehaviorSubject' type CollectionItem = T type MaybeCollectionItem = CollectionItem | undefined type KeyFunction = (item: CollectionItem, index: number) => CollectionItem type KeyReducerFunction = (current: any, item: CollectionItem, index: number) => T2 type CollectionFunction = (items: Collection) => T2 type KeyOperator = keyof T | KeyFunction type AssociatedCollectionItem = { key: T2, item: CollectionItem } type CollectionComparable = CollectionItem[] | Collection type DeterminesEquality = (item: CollectionItem, other: any) => boolean type CollectionIndex = number type MaybeCollectionIndex = CollectionIndex | undefined type ComparisonFunction = (item: CollectionItem, otherItem: CollectionItem) => number type Collectable = CollectionItem[] | Collection import { WhereOperator, applyWhere, whereMatch } from './where' import {Awaitable, Awaited, Either, isLeft, Maybe, MethodsOf, MethodType, right, unright} from '../support/types' import {AsyncCollection} from './AsyncCollection' import {ArrayIterable} from './ArrayIterable' const collect = (items: CollectionItem[] = []): Collection => Collection.collect(items) const toString = (item: unknown): string => String(item) export { collect, toString, Collection, // Types CollectionItem, MaybeCollectionItem, KeyFunction, KeyReducerFunction, CollectionFunction, KeyOperator, AssociatedCollectionItem, CollectionComparable, DeterminesEquality, CollectionIndex, MaybeCollectionIndex, ComparisonFunction, Collectable, } /** * A helper class for working with arrays of items in a more robust fashion. * Provides helpers for accessing sub-keys, filtering, piping, and aggregate functions. */ class Collection { private storedItems: CollectionItem[] = [] private pushSubscribers: Subscription[] = [] /** * Create a new collection from an array of items. * @param items */ public static collect(items: CollectionItem[]): Collection { return new Collection(items) } /** * Create a new collection from an item or array of items. * Filters out undefined items. * @param itemOrItems */ public static normalize(itemOrItems: Collection | (CollectionItem)[] | CollectionItem): Collection { if ( itemOrItems instanceof Collection ) { return itemOrItems } if ( !Array.isArray(itemOrItems) ) { itemOrItems = [itemOrItems] } return new Collection(itemOrItems) } /** * Create a collection of "undefined" elements of a given size. * @param size */ public static size(size: number): Collection { const arr = Array(size).fill(undefined) return new Collection(arr) } /** * Fill a new collection of the given size with the given item. * @param size * @param item */ public static fill(size: number, item: T2): Collection { const arr = Array(size).fill(item) return new Collection(arr) } constructor( /** * The items to base the collection on. */ items?: CollectionItem[], ) { if ( items ) { this.storedItems = items } } private allOperator(key: KeyOperator): CollectionItem[] { let items: CollectionItem[] = [] if ( typeof key === 'function' ) { items = this.storedItems.map(key) } else { items = this.storedItems.map((item: CollectionItem) => (item)[key]) } return items } private allAsNumbers(key: KeyOperator): number[] { return this.allOperator(key).map(value => Number(value)) } private allAssociated(key: KeyOperator): AssociatedCollectionItem[] { const associatedItems: AssociatedCollectionItem[] = [] const items = [...this.storedItems] if ( typeof key === 'function' ) { items.map((item, index) => { const keyItem = key(item, index) associatedItems.push({ key: keyItem, item, }) }) } else { items.map(item => { associatedItems.push({ key: (item)[key], item, }) }) } return associatedItems } /** * Cast the collection to an array. */ all(): CollectionItem[] { return [...this.storedItems] } /** * Get the average value of the items or one of their keys. * @param key */ average(key?: KeyOperator): number { let items if ( key ) { items = this.allAsNumbers(key) } else { items = this.storedItems.map(x => Number(x)) } if ( items.length === 0 ) { return 0 } const sum = items.reduce((prev, curr) => prev + curr) return sum / items.length } /** * Get the median value of the items or one of their keys. * @param key */ median(key?: KeyOperator): number { let items if ( key ) { items = this.allAsNumbers(key).sort((a, b) => a - b) } else { items = this.storedItems.map(x => Number(x)).sort((a, b) => a - b) } const middle = Math.floor((items.length - 1) / 2) if ( items.length % 2 ) { return items[middle] } else { return (items[middle] + items[middle + 1]) / 2 } } /** * Get the mode of the items or one of their keys. * @param key */ mode(key?: KeyOperator): number { let items if ( key ) { items = this.allAsNumbers(key).sort((a, b) => a - b) } else { items = this.storedItems.map(x => Number(x)).sort((a, b) => a - b) } const counts: any = {} for ( const item of items ) { counts[item] = (counts[item] ?? -1) + 1 } return Number(Object.keys(counts).reduce((a, b) => counts[a] > counts[b] ? a : b)[0]) } /** * Collapse a (potentially nested) collection of items down to a single dimension. */ collapse(): Collection { const newItems: CollectionItem[] = [] const items = [...this.storedItems] const getLayer = (current: CollectionItem|CollectionItem[]) => { if ( typeof (current)[Symbol.iterator] === 'function' ) { for (const item of (current as any)) { if (Array.isArray(item)) { getLayer(item) } else { newItems.push(item) } } } } getLayer(items) return new Collection(newItems) } /** * Returns true if the given key matches the given condition for any item in the collection. * * @example * ```typescript * const userExists = users.contains('username', '=', 'jdoe') * ``` * * @param key * @param operator * @param operand */ contains(key: KeyOperator, operator: WhereOperator, operand?: unknown): boolean { const associate = this.allAssociated(key) const matches = applyWhere(associate, operator, operand) return matches.length > 0 } // TODO crossJoin /** * Create a copy of this collection. * Does NOT deep copy the underlying items. */ clone(): Collection { return new Collection(this.storedItems) } /** * Return a collection of items that ARE in this collection, but NOT in the `items` collection. * @param items */ diff(items: CollectionComparable): Collection { const exclude = items instanceof Collection ? items.all() : items const matches = [] for ( const item of [...this.storedItems] ) { if ( !exclude.includes(item) ) { matches.push(item) } } return new Collection(matches) } /** * Like diff, but mutates the current collection. * @param items */ diffInPlace(items: CollectionComparable): this { const exclude = items instanceof Collection ? items.all() : items this.storedItems = this.storedItems.filter(item => !exclude.includes(item)) return this } /** * Return a collection of items that ARE in this collection, but NOT In the `items` collection * using a helper function to determine whether two items are equal. * * @example * ```typescript * potentialUsers.diffUsing(storedUsers, (u1, u2) => u1.username.toLowerCase() === u2.username.toLowerCase()) * ``` * * @param items * @param compare */ diffUsing(items: CollectionComparable, compare: DeterminesEquality): Collection { const exclude = items instanceof Collection ? items.all() : items const matches = [] for ( const item of [...this.storedItems] ) { if ( !exclude.some(exc => compare(item, exc)) ) { matches.push(item) } } return new Collection(matches) } /** * Returns true if the given function returns truthy for any item in the collection. * Stops executing if a single truth case is found. * @param func */ some(func: (item: T) => Maybe): boolean { return this.storedItems.some(func) } /** * Execute the function for every item in the collection. * @param func */ each(func: KeyFunction): void { this.storedItems.map(func) } /** * Create a new collection by mapping the items in this collection using the given function. * @param func */ map(func: KeyFunction): Collection { const newItems: CollectionItem[] = [] this.each(((item, index) => { newItems.push(func(item, index)) })) return new Collection(newItems) } /** * Create a new collection by mapping the items in this collection using the given function * where the function returns an Either. The collection is all Right instances. If a Left * is encountered, that value is returned. * @param func */ mapRight(func: KeyFunction>): Either> { const newItems: CollectionItem[] = [] for ( let i = 0; i < this.length; i += 1 ) { const result = func(this.storedItems[i], i) if ( isLeft(result) ) { return result } newItems.push(unright(result)) } return right(new Collection(newItems)) } /** * Create a new collection by mapping the items in this collection using the given function * where the function returns an Either. The collection is all Right instances. If a Left * is encountered, that value is returned. * @param func */ async asyncMapRight(func: KeyFunction>>): Promise>> { const newItems: CollectionItem[] = [] for ( let i = 0; i < this.length; i += 1 ) { const result = await func(this.storedItems[i], i) if ( isLeft(result) ) { return result } newItems.push(unright(result)) } return right(new Collection(newItems)) } /** * Get the collection as an AsyncCollection. */ toAsync(): AsyncCollection { const iter = new ArrayIterable([...this.storedItems]) return new AsyncCollection(iter) } /** * Map a method on the underlying type, passing it any required parameters. * This is delightfully type-safe. * @param method * @param params */ mapCall>(method: T2, ...params: Parameters>): Collection>> { // This is dumb, but I'm not sure how else to resolve it. The types check out, but TypeScript loses track of the fact that // typeof x[method] === MethodType, so it assumes we're indexing an object incorrectly. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return this.map((x: T) => x[method](...params)) } /** * Shortcut for .mapCall(...).awaitAll(). * @param method * @param params */ async awaitMapCall>(method: T2, ...params: Parameters>): Promise>>>> { return this.mapCall(method, ...params).awaitAll() } /** * Await all values in the collection. */ async awaitAll(): Promise>> { return this.promiseMap(async x => x as Awaited) } /** * Map each element in the collection to a string. */ strings(): Collection { return this.map(x => String(x)) } /** * Create a new collection by mapping the items in this collection using the given function, * excluding any for which the function returns undefined. * @param func */ partialMap(func: KeyFunction): Collection> { const newItems: CollectionItem>[] = [] this.each(((item, index) => { const result = func(item, index) if ( typeof result !== 'undefined' ) { newItems.push(result as NonNullable) } })) return new Collection>(newItems) } /** * Convert this collection to an object keyed by the given field. * * @example * ```typescript * const users = collect([{uid: 1, name: 'John'}, {uid: 2, name: 'Jane'}]) * users.keyBy('name') // => {John: {uid: 1, name: 'John'}, Jane: {uid: 2, name: 'Jane'}} * ``` * * @param key */ keyBy(key: KeyOperator): {[key: string]: T} { const obj: {[key: string]: T} = {} this.allAssociated(key).forEach(assoc => { obj[assoc.key] = assoc.item }) return obj } /** * Convert this collection to an object keyed by the given field, whose values are * the output of the `value` operator. * * @example * ```typescript * const users = collect([{uid: 1, name: 'John'}, {uid: 2, name: 'Jane'}]) * users.keyMap('name', 'uid') // => {John: 1, Jane: 2} * ``` * * @param key * @param value */ keyMap(key: KeyOperator, value: KeyOperator): {[key: string]: T2} { const obj: {[key: string]: T2} = {} let i = -1 this.allAssociated(key).forEach(assoc => { i += 1 if ( typeof value === 'function' ) { obj[assoc.key] = value(assoc.item, i) } else { obj[assoc.key] = (assoc.item[value] as any) as T2 } }) return obj } /** * Returns true if the given function returns true for every item in the collection. * @param func */ every(func: KeyFunction): boolean { return this.storedItems.every(func) } everyWhere(key: KeyOperator, operator: WhereOperator, operand?: unknown): boolean { const items = this.allAssociated(key) return items.every(item => whereMatch(item, operator, operand)) } /** * Return a new collection filtered by the given function. * @param func */ filter(func?: KeyFunction): Collection { return new Collection(this.storedItems.filter(func ?? Boolean)) } /** * Like filter, but inverted. That is, removes items that DO match the criterion. * @param func */ filterOut(func?: KeyFunction): Collection { return this.filter((...args) => !(func ?? Boolean)(...args)) } whereDefined(): Collection> { return this.filter() as unknown as Collection> } /** * Returns the index of the record for which the given function returns true, if such an index exists. * @param func */ find(func: KeyFunction): number | undefined { let foundIndex: number | undefined = undefined this.storedItems.some((item, index) => { if ( func(item, index) ) { foundIndex = index return true } }) return foundIndex } /** * When `bool` is truthy, execute the callback, passing in the collection. * Can be used for functional-style chained calls. * @param bool * @param then */ when(bool: boolean, then: CollectionFunction): Collection { if ( bool ) { then(this) } return this } /** * When `bool` is falsy, execute the callback, passing in the collection. * Can be used for functional-style chained calls. * @param bool * @param then */ unless(bool: boolean, then: CollectionFunction): Collection { if ( !bool ) { then(this) } return this } /** * Filter the collection by the given where-condition. * * @example * ```typescript * const users = collect([ * {uid: 1, name: 'John'}, * {uid: 2, name: 'Jane'}, * {uid: 3, name: 'James'}, * ]) * * users.where('uid', '<', 3) // => Collection[{uid: 1, name: 'John'}, {uid: 2, name: 'Jane'}] * ``` * * @param key * @param operator * @param operand */ where(key: KeyOperator, operator: WhereOperator, operand?: unknown): Collection { const items = this.allAssociated(key) return new Collection(applyWhere(items, operator, operand)) } /** * Filter the collection by the inverse of the given where-condition. * @param key * @param operator * @param operand */ whereNot(key: KeyOperator, operator: WhereOperator, operand?: unknown): Collection { return this.diff(this.where(key, operator, operand)) } /** * Filter the collection for all records where the given key is in a set of items. * @param key * @param items */ whereIn(key: KeyOperator, items: CollectionComparable): Collection { const allowed = items instanceof Collection ? items.all() : items const matches = [] for ( const { key: search, item } of this.allAssociated(key) ) { if ( allowed.includes(search) ) { matches.push(item) } } return new Collection(matches) } /** * Filter the collection for all records where the given key is NOT in a set of items. * @param key * @param items */ whereNotIn(key: KeyOperator, items: CollectionComparable): Collection { return this.diff(this.whereIn(key, items)) } /** * Return the first item in the collection, if it exists. */ first(): MaybeCollectionItem { if ( this.length > 0 ) { return this.storedItems[0] } } /** * Return the first item in the collection that matches the given where-condition. * @param key * @param operator * @param operand */ firstWhere(key: KeyOperator, operator: WhereOperator = '=', operand: any = true): MaybeCollectionItem { const items = this.where(key, operator, operand).all() if ( items.length > 0 ) { return items[0] } } /** * Return the first item in the collection that does NOT match the given where-condition. * @param key * @param operator * @param operand */ firstWhereNot(key: KeyOperator, operator: WhereOperator, operand?: unknown): MaybeCollectionItem { const items = this.whereNot(key, operator, operand).all() if ( items.length > 0 ) { return items[0] } } /** * Get the number of items in the collection. */ get length(): number { return this.storedItems.length } /** * Get the number of items in the collection. */ count(): number { return this.storedItems.length } // TODO flatten - depth /** * Get the item at the given index in the collection. If none exists, * return the fallback. * @param index * @param fallback */ get(index: number, fallback?: T): MaybeCollectionItem { if ( this.length > index ) { return this.storedItems[index] } else { return fallback } } /** * Return the item at the given index in the collection, if it exists. * @param index */ at(index: number): MaybeCollectionItem { return this.get(index) } /** * Return an object mapping key values to arrays of records with that key. * * @example * ```typescript * const users = collect([ * {uid: 1, name: 'John', type: 'admin'}, * {uid: 2, name: 'Jane', type: 'user'}, * {uid: 3, name: 'James', type: 'user'}, * ]) * * users.groupBy('type') // => {admin: [{uid: 1, ...}], user: [{uid: 2, ...}, {uid: 3, ...}]} * ``` * * @param key */ groupBy(key: KeyOperator): any { const items = this.allAssociated(key) const groups: any = {} for ( const item of items ) { const itemKey = String(item.key) if ( !groups[itemKey] ) { groups[itemKey] = [] } groups[itemKey].push(item.item) } return groups } /** * Return an object mapping the given key to the record with that key. * See `keyBy()`. * @param key */ associate(key: KeyOperator): any { const items = this.allAssociated(key) const values: any = {} for ( const item of items ) { values[String(item.key)] = item.item } return values } /** * Join the items in the collection to a string delimited by the given delimiter. * @param delimiter */ join(delimiter: string): string { return this.storedItems.join(delimiter) } /** * Join the items in the collection to a string delimited by the given delimiter. * @param delimiter */ implode(delimiter: string): string { return this.join(delimiter) } /** * Return a collection of the items that exist in both this collection and this collection, * (optionally) using the given key to compare. * @param items * @param key */ intersect(items: CollectionComparable, key?: KeyOperator): Collection { const compare = items instanceof Collection ? items.all() : items const intersect = [] let allItems if ( key ) { allItems = this.allAssociated(key) } else { allItems = this.storedItems.map(item => { return { key: item, item, } }) } for ( const item of allItems ) { if ( compare.includes(item.key) ) { intersect.push(item.item) } } return new Collection(intersect) } /** * True if the collection has no items. */ isEmpty(): boolean { return this.length < 1 } /** * True if the collection has at least one item. */ isNotEmpty(): boolean { return this.length > 0 } /** * Return the last item in the collection. */ last(): MaybeCollectionItem { if ( this.length > 0 ) { return this.storedItems.reverse()[0] } } /** * Return the last item in the collection that matches the given where-condition. * @param key * @param operator * @param operand */ lastWhere(key: KeyOperator, operator: WhereOperator, operand?: unknown): MaybeCollectionItem { const items = this.where(key, operator, operand).all() if ( items.length > 0 ) { return items.reverse()[0] } } /** * Return the last item in the collection that does NOT match the given where-condition. * @param key * @param operator * @param operand */ lastWhereNot(key: KeyOperator, operator: WhereOperator, operand?: unknown): MaybeCollectionItem { const items = this.whereNot(key, operator, operand).all() if ( items.length > 0 ) { return items.reverse()[0] } } /** * Map the collection to a collection of the values of the key. * * @example * ```typescript * const users = collect([ * {uid: 1, name: 'John', type: 'admin'}, * {uid: 2, name: 'Jane', type: 'user'}, * {uid: 3, name: 'James', type: 'user'}, * ]) * * users.pluck('name') // => Collection['John', 'Jane', 'James'] * ``` * * @param key */ pluck(key: T2): Collection { return new Collection(this.allOperator(key)) } /** * Return the max value of the given key. * @param key */ max(key: KeyOperator): number { const values = this.allAsNumbers(key) return Math.max(...values) } /** * Return the item with the max value of the given key. * @param key */ whereMax(key: KeyOperator): Collection { return this.where(key, '=', this.max(key)) } /** * Return the min value of the given key. * @param key */ min(key: KeyOperator): number { const values = this.allAsNumbers(key) return Math.min(...values) } /** * Return the item with the min value of the given key. * @param key */ whereMin(key: KeyOperator): Collection { return this.where(key, '=', this.min(key)) } /** * Get a new collection containing both the items in this collection, and the `items` collection. * @param items */ merge(items: CollectionComparable): Collection { const merge = items instanceof Collection ? items.all() : items return new Collection([...this.storedItems, ...merge]) } /** * Return every nth item in the collection. * * @example * ``` * const items = collect(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) * * items.nth(3) // => Collection['a', 'd', 'g'] * ``` * * @param n */ nth(n: number): Collection { const matches: CollectionItem[] = [] let current = 1 this.storedItems.forEach(item => { if ( current === 1 ) { matches.push(item) } current += 1 if ( current > n ) { current = 1 } }) return new Collection(matches) } /** * Return the items that should exist on the given page, assuming there are `perPage` many items on a single page. * @param page * @param perPage */ forPage(page: number, perPage: number): Collection { const start = page * perPage - perPage const end = page * perPage return new Collection(this.storedItems.slice(start, end)) } /** * Return a new Pipe of this collection. */ pipeTo(pipeline: Pipeline): TOut { return pipeline.apply(this) } /** Build and apply a pipeline. */ pipe(builder: (pipeline: Pipeline) => Pipeline): TOut { return builder(Pipeline.id()).apply(this) } /** * Return a new AsyncPipe of this collection. */ asyncPipe(): AsyncPipe> { return AsyncPipe.wrap(this) } /** * Remove the last item from this collection. */ pop(): MaybeCollectionItem { if ( this.length > 0 ) { return this.storedItems.pop() } } /** * Add the given item to the beginning of this collection. * @param item */ prepend(item: CollectionItem): Collection { this.storedItems = [item, ...this.storedItems] this.callPushSubscribers(item) return this } /** * Add the given item to the end of this collection. * @param item */ push(item: CollectionItem): Collection { this.storedItems.push(item) this.callPushSubscribers(item) return this } /** * Subscribe to listen for items being added to the collection. * @param sub */ push$(sub: Subscription): Unsubscribe { this.pushSubscribers.push(sub) return { unsubscribe: () => this.pushSubscribers = this.pushSubscribers.filter(x => x !== sub), } } /** Helper to notify subscribers that an item has been pushed to the collection. */ private callPushSubscribers(item: T): void { this.pushSubscribers .forEach(sub => { if ( typeof sub === 'object' ) { sub?.next?.(item) } else { sub(item) } }) } /** * Push the given items to the end of this collection. * Unlike `merge()`, this mutates the current collection's items. * @param items */ concat(items: CollectionComparable): Collection { const concats = items instanceof Collection ? items.all() : items for ( const item of concats ) { this.storedItems.push(item) this.callPushSubscribers(item) } return this } /** * Insert the given item into this collection at the specified index. * @param index * @param item */ put(index: number, item: CollectionItem): Collection { const newItems = [] let inserted = false this.storedItems.forEach((existing, existingIndex) => { if ( existingIndex === index ) { newItems.push(item) inserted = true } newItems.push(existing) }) if ( !inserted ) { newItems.push(item) } return new Collection(newItems) } /** * Return `n` many randomly-selected items from this collection. * @param n */ random(n: number): Collection { const randomItems: CollectionItem[] = [] const all = this.storedItems if ( n > this.length ) { n = this.length } while ( randomItems.length < n ) { const item = all[Math.floor(Math.random() * all.length)] if ( !randomItems.includes(item) ) { randomItems.push(item) } } return new Collection(randomItems) } /** * Reduce this collection to a single value using the given reducer function. * * @example * ```typescript * const items = collect([1, 3, 5, 7]) * * items.reduce((sum, item) => sum + item, 0) // => 16 * ``` * * @param reducer * @param initialValue */ reduce(reducer: KeyReducerFunction, initialValue: T2): T2 reduce(reducer: KeyReducerFunction, initialValue?: T2): T2 | undefined { let currentValue = initialValue this.storedItems.forEach((item, index) => { currentValue = reducer(currentValue, item, index) }) return currentValue } /** * Return a new collection of items that fail the given truth-test function. * @param truthTestFunction */ reject(truthTestFunction: KeyFunction): Collection { const rejected = this.storedItems.filter((item, index) => { return !truthTestFunction(item, index) }) return new Collection(rejected) } /** * Return a new collection whose items are in the reverse order of the current one. */ reverse(): Collection { return new Collection([...this.storedItems.reverse()]) } /** * Try to find the given item in the collection. If it exists, return the index. * @param item */ search(item: CollectionItem): MaybeCollectionIndex { let foundIndex this.storedItems.some((possibleItem, index) => { if ( possibleItem === item ) { foundIndex = index return true } }) return foundIndex } /** * Remove and return the first item in the collection, if it exists. */ shift(): MaybeCollectionItem { if ( this.length > 0 ) { return this.storedItems.shift() } } /** * Shuffle the items in this collection to a random order. */ shuffle(): Collection { const items = [...this.storedItems] for ( let i = items.length - 1; i > 0; i-- ) { const j = Math.floor(Math.random() * (i + 1)) ;[items[i], items[j]] = [items[j], items[i]] } return new Collection(items) } /** * Return a sub-set of this collection between the given index ranges. * @param start * @param end */ slice(start: number, end: number): Collection { return new Collection(this.storedItems.slice(start, end)) } // TODO split // TODO chunk /** * Sort the collection (optionally) using the given comparison function. * @param comparisonFunction */ sort(comparisonFunction?: ComparisonFunction): Collection { const items = this.storedItems if ( comparisonFunction ) { items.sort(comparisonFunction) } else { items.sort() } return new Collection(items) } /** * Sort the collection (optionally) using the given key operator. * @param key */ sortBy(key?: KeyOperator): Collection { let items: any[] if ( key ) { items = this.allAssociated(key) } else { items = this.storedItems.map(item => { return { key: item, item } }) } items.sort((a: any, b: any) => { if ( a.key > b.key ) { return 1 } else if ( a.key < b.key ) { return -1 } else { return 0 } }) return new Collection(items.map((item: AssociatedCollectionItem) => item.item)) } /** * Identical to `sort()`, but in reverse order. * @param comparisonFunction */ sortDesc(comparisonFunction?: ComparisonFunction): Collection { return this.sort(comparisonFunction).reverse() } /** * Identical to `sortBy()`, but in reverse order. * @param key */ sortByDesc(key?: KeyOperator): Collection { return this.sortBy(key).reverse() } /** * Remove `deleteCount` many items from the collection, starting at the `start` index. * @param start * @param deleteCount */ splice(start: CollectionIndex, deleteCount?: number): Collection { return new Collection([...this.storedItems].splice(start, deleteCount)) } /** * Return the sum of the items in the collection, optionally by key. * * @example * ```typescript * const items = collect([{k1: 1}, {k1: 3}, {k1: 5}, {k1: 7}]) * * items.sum('k1') // => 16 * ``` * * @param key */ sum(key?: KeyOperator): number { let items if ( key ) { items = this.allAsNumbers(key) } else { items = this.storedItems.map(x => Number(x)) } return items.reduce((prev, curr) => prev + curr) } /** * Return a collection of the first `limit` many items in this collection. * If `limit` is negative, returns the last `limit` many items. * @param limit */ take(limit: number): Collection { if ( limit === 0 ) { return new Collection() } else if ( limit > 0 ) { return new Collection(this.storedItems.slice(0, limit)) } else { return new Collection(this.storedItems.reverse().slice(0, -1 * limit) .reverse()) } } /** * Apply the given function to this collection then return the collection. * This is intended to help with chaining. * * @example * ```typescript * collection.tap(coll => { * coll.push(item) * }) * .where('someKey', '>', 4) * // ... &c. * ``` * * @param func */ tap(func: CollectionFunction): this { func(this) return this } /** * Return all distinct items in this collection. If a key is specified, returns * all unique values of that key. * * @example * ```typescript * const users = collect([ * {uid: 1, name: 'John', type: 'admin'}, * {uid: 2, name: 'Jane', type: 'user'}, * {uid: 3, name: 'James', type: 'user'}, * ]) * * users.unique('type') // => Collection['admin', 'user'] * ``` * * @param key */ unique(key?: KeyOperator): Collection { const has: CollectionItem[] = [] let items if ( key ) { items = this.allOperator(key) } else { items = [...this.storedItems] } for ( const item of items ) { if ( !has.includes(item) ) { has.push(item) } } return new Collection(has) } /** * Returns true if the given item is in this collection. * @param item */ includes(item: CollectionItem): boolean { return this.storedItems.includes(item) } /** * Add on to the end of this collection as many `value` items as necessary until the collection is `length` long. * @param length * @param value */ pad(length: number, value: CollectionItem): Collection { const items = [...this.storedItems] while ( items.length < length ) { items.push(value) } return new Collection(items) } /** * Cast the collection to an array. */ toArray(recursive = true): any[] { const returns: any = [] for ( const item of this.storedItems ) { if ( recursive && item instanceof Collection ) { returns.push(item.toArray()) } else { returns.push(item) } } return returns } /** * Cast the collection to a JSON string, optionally specifying the replacer and indentation. * @param replacer * @param space */ toJSON(replacer = undefined, space = 4): string { return JSON.stringify(this.toArray(), replacer, space) } // TODO getIterator // TODO getCachingIterator [Symbol.iterator](): Iterator { const items = this.storedItems let currentIndex = 0 return { next() { const item = items[currentIndex] currentIndex += 1 return { done: currentIndex > items.length, value: item, } }, } } /** * Like `map()`, but the callback can be async. * * @example * A trivial example, but demonstrative: * * ```typescript * const collection = collect([1, 2, 3]) * * collection.map(async item => item + 1) // => Collection[Promise<1>, Promise<2>, Promise<3>] * * collection.promiseMap(async item => item + 1) // => Promise * ``` * * @param func */ async promiseMap(func: KeyFunction>): Promise> { return new Collection(await Promise.all( this.map(func).toArray(), )) } }