You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/util/collection/Collection.ts

1399 lines
40 KiB

import {AsyncPipe, Pipeline} from '../support/Pipe'
import {Unsubscribe, Subscription} from '../support/BehaviorSubject'
type CollectionItem<T> = T
type MaybeCollectionItem<T> = CollectionItem<T> | undefined
type KeyFunction<T, T2> = (item: CollectionItem<T>, index: number) => CollectionItem<T2>
type KeyReducerFunction<T, T2> = (current: any, item: CollectionItem<T>, index: number) => T2
type CollectionFunction<T, T2> = (items: Collection<T>) => T2
type KeyOperator<T, T2> = keyof T | KeyFunction<T, T2>
type AssociatedCollectionItem<T2, T> = { key: T2, item: CollectionItem<T> }
type CollectionComparable<T> = CollectionItem<T>[] | Collection<T>
type DeterminesEquality<T> = (item: CollectionItem<T>, other: any) => boolean
type CollectionIndex = number
type MaybeCollectionIndex = CollectionIndex | undefined
type ComparisonFunction<T> = (item: CollectionItem<T>, otherItem: CollectionItem<T>) => number
type Collectable<T> = CollectionItem<T>[] | Collection<T>
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 = <T>(items: CollectionItem<T>[] = []): Collection<T> => 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<T> {
private storedItems: CollectionItem<T>[] = []
private pushSubscribers: Subscription<T>[] = []
/**
* Create a new collection from an array of items.
* @param items
*/
public static collect<T2>(items: CollectionItem<T2>[]): Collection<T2> {
return new Collection(items)
}
/**
* Create a new collection from an item or array of items.
* Filters out undefined items.
* @param itemOrItems
*/
public static normalize<T2>(itemOrItems: Collection<T2> | (CollectionItem<T2>)[] | CollectionItem<T2>): Collection<T2> {
if ( itemOrItems instanceof Collection ) {
return itemOrItems
}
if ( !Array.isArray(itemOrItems) ) {
itemOrItems = [itemOrItems]
}
return new Collection<T2>(itemOrItems)
}
/**
* Create a collection of "undefined" elements of a given size.
* @param size
*/
public static size(size: number): Collection<undefined> {
const arr = Array(size).fill(undefined)
return new Collection<undefined>(arr)
}
/**
* Fill a new collection of the given size with the given item.
* @param size
* @param item
*/
public static fill<T2>(size: number, item: T2): Collection<T2> {
const arr = Array(size).fill(item)
return new Collection<T2>(arr)
}
constructor(
/**
* The items to base the collection on.
*/
items?: CollectionItem<T>[],
) {
if ( items ) {
this.storedItems = items
}
}
private allOperator<T2>(key: KeyOperator<T, T2>): CollectionItem<T2>[] {
let items: CollectionItem<T2>[] = []
if ( typeof key === 'function' ) {
items = this.storedItems.map(key)
} else {
items = this.storedItems.map((item: CollectionItem<T>) => (<any>item)[key])
}
return items
}
private allAsNumbers<T2>(key: KeyOperator<T, T2>): number[] {
return this.allOperator(key).map(value => Number(value))
}
private allAssociated<T2>(key: KeyOperator<T, T2>): AssociatedCollectionItem<T2, T>[] {
const associatedItems: AssociatedCollectionItem<T2, T>[] = []
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: (<any>item)[key],
item,
})
})
}
return associatedItems
}
/**
* Cast the collection to an array.
*/
all(): CollectionItem<T>[] {
return [...this.storedItems]
}
/**
* Get the average value of the items or one of their keys.
* @param key
*/
average<T2>(key?: KeyOperator<T, T2>): 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<T2>(key?: KeyOperator<T, T2>): 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<T2>(key?: KeyOperator<T, T2>): 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<any> {
const newItems: CollectionItem<T>[] = []
const items = [...this.storedItems]
const getLayer = (current: CollectionItem<T>|CollectionItem<T>[]) => {
if ( typeof (<any>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<T2>(key: KeyOperator<T, T2>, 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<T> {
return new Collection<T>(this.storedItems)
}
/**
* Return a collection of items that ARE in this collection, but NOT in the `items` collection.
* @param items
*/
diff<T2>(items: CollectionComparable<T|T2>): Collection<T> {
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<T2>(items: CollectionComparable<T|T2>): 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<T2>(items: CollectionComparable<T|T2>, compare: DeterminesEquality<T>): Collection<T> {
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>): boolean {
return this.storedItems.some(func)
}
/**
* Execute the function for every item in the collection.
* @param func
*/
each<T2>(func: KeyFunction<T, T2>): void {
this.storedItems.map(func)
}
/**
* Create a new collection by mapping the items in this collection using the given function.
* @param func
*/
map<T2>(func: KeyFunction<T, T2>): Collection<T2> {
const newItems: CollectionItem<T2>[] = []
this.each(((item, index) => {
newItems.push(func(item, index))
}))
return new Collection<T2>(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<TLeft, TRight>(func: KeyFunction<T, Either<TLeft, TRight>>): Either<TLeft, Collection<TRight>> {
const newItems: CollectionItem<TRight>[] = []
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<TRight>(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<TLeft, TRight>(func: KeyFunction<T, Awaitable<Either<TLeft, TRight>>>): Promise<Either<TLeft, Collection<TRight>>> {
const newItems: CollectionItem<TRight>[] = []
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<TRight>(newItems))
}
/**
* Get the collection as an AsyncCollection.
*/
toAsync(): AsyncCollection<T> {
const iter = new ArrayIterable([...this.storedItems])
return new AsyncCollection<T>(iter)
}
/**
* Map a method on the underlying type, passing it any required parameters.
* This is delightfully type-safe.
* @param method
* @param params
*/
mapCall<T2 extends MethodsOf<T>>(method: T2, ...params: Parameters<MethodType<T, T2>>): Collection<ReturnType<MethodType<T, T2>>> {
// 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<T, T2>, 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<T2 extends MethodsOf<T>>(method: T2, ...params: Parameters<MethodType<T, T2>>): Promise<Collection<Awaited<ReturnType<MethodType<T, T2>>>>> {
return this.mapCall(method, ...params).awaitAll()
}
/**
* Await all values in the collection.
*/
async awaitAll(): Promise<Collection<Awaited<T>>> {
return this.promiseMap(async x => x as Awaited<T>)
}
/**
* Map each element in the collection to a string.
*/
strings(): Collection<string> {
return this.map<string>(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<T2>(func: KeyFunction<T, T2 | undefined>): Collection<NonNullable<T2>> {
const newItems: CollectionItem<NonNullable<T2>>[] = []
this.each(((item, index) => {
const result = func(item, index)
if ( typeof result !== 'undefined' ) {
newItems.push(result as NonNullable<T2>)
}
}))
return new Collection<NonNullable<T2>>(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<T, string>): {[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<T2>(key: KeyOperator<T, string>, value: KeyOperator<T, T2>): {[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<T2>(func: KeyFunction<T, T2>): boolean {
return this.storedItems.every(func)
}
everyWhere<T2>(key: KeyOperator<T, T2>, 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<T2>(func?: KeyFunction<T, T2>): Collection<T> {
return new Collection(this.storedItems.filter(func ?? Boolean))
}
/**
* Like filter, but inverted. That is, removes items that DO match the criterion.
* @param func
*/
filterOut<T2>(func?: KeyFunction<T, T2>): Collection<T> {
return this.filter((...args) => !(func ?? Boolean)(...args))
}
whereDefined(): Collection<NonNullable<T>> {
return this.filter() as unknown as Collection<NonNullable<T>>
}
/**
* Returns the index of the record for which the given function returns true, if such an index exists.
* @param func
*/
find<T2>(func: KeyFunction<T, T2>): 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<T2>(bool: boolean, then: CollectionFunction<T, T2>): Collection<T> {
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<T2>(bool: boolean, then: CollectionFunction<T, T2>): Collection<T> {
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<T2>(key: KeyOperator<T, T2>, operator: WhereOperator, operand?: unknown): Collection<T> {
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<T2>(key: KeyOperator<T, T2>, operator: WhereOperator, operand?: unknown): Collection<T> {
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<T2>(key: KeyOperator<T, T2>, items: CollectionComparable<T2>): Collection<T> {
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<T2>(key: KeyOperator<T, T2>, items: CollectionComparable<T2>): Collection<T> {
return this.diff(this.whereIn(key, items))
}
/**
* Return the first item in the collection, if it exists.
*/
first(): MaybeCollectionItem<T> {
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<T2>(key: KeyOperator<T, T2>, operator: WhereOperator = '=', operand: any = true): MaybeCollectionItem<T> {
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<T2>(key: KeyOperator<T, T2>, operator: WhereOperator, operand?: unknown): MaybeCollectionItem<T> {
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<T> {
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<T> {
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<T2>(key: KeyOperator<T, T2>): 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<T2>(key: KeyOperator<T, T2>): 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<T>, key?: KeyOperator<T, T>): Collection<T> {
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<T> {
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<T2>(key: KeyOperator<T, T2>, operator: WhereOperator, operand?: unknown): MaybeCollectionItem<T> {
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<T2>(key: KeyOperator<T, T2>, operator: WhereOperator, operand?: unknown): MaybeCollectionItem<T> {
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<T2 extends keyof T>(key: T2): Collection<T[T2]> {
return new Collection<T[T2]>(this.allOperator(key))
}
/**
* Return the max value of the given key.
* @param key
*/
max<T2 = T>(key: KeyOperator<T, T2>): number {
const values = this.allAsNumbers(key)
return Math.max(...values)
}
/**
* Return the item with the max value of the given key.
* @param key
*/
whereMax<T2>(key: KeyOperator<T, T2>): Collection<T> {
return this.where(key, '=', this.max(key))
}
/**
* Return the min value of the given key.
* @param key
*/
min<T2>(key: KeyOperator<T, T2>): number {
const values = this.allAsNumbers(key)
return Math.min(...values)
}
/**
* Return the item with the min value of the given key.
* @param key
*/
whereMin<T2>(key: KeyOperator<T, T2>): Collection<T> {
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<T2>(items: CollectionComparable<T2>): Collection<T|T2> {
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<T> {
const matches: CollectionItem<T>[] = []
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<T> {
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<TOut>(pipeline: Pipeline<this, TOut>): TOut {
return pipeline.apply(this)
}
/** Build and apply a pipeline. */
pipe<TOut>(builder: (pipeline: Pipeline<this, this>) => Pipeline<this, TOut>): TOut {
return builder(Pipeline.id()).apply(this)
}
/**
* Return a new AsyncPipe of this collection.
*/
asyncPipe(): AsyncPipe<Collection<T>> {
return AsyncPipe.wrap(this)
}
/**
* Remove the last item from this collection.
*/
pop(): MaybeCollectionItem<T> {
if ( this.length > 0 ) {
return this.storedItems.pop()
}
}
/**
* Add the given item to the beginning of this collection.
* @param item
*/
prepend(item: CollectionItem<T>): Collection<T> {
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<T>): Collection<T> {
this.storedItems.push(item)
this.callPushSubscribers(item)
return this
}
/**
* Subscribe to listen for items being added to the collection.
* @param sub
*/
push$(sub: Subscription<T>): 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<T>): Collection<T> {
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<T>): Collection<T> {
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<T> {
const randomItems: CollectionItem<T>[] = []
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<T2>(reducer: KeyReducerFunction<T, T2>, initialValue: T2): T2
reduce<T2>(reducer: KeyReducerFunction<T, T2>, 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<T2>(truthTestFunction: KeyFunction<T, T2>): Collection<T> {
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<T> {
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<T>): 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<T> {
if ( this.length > 0 ) {
return this.storedItems.shift()
}
}
/**
* Shuffle the items in this collection to a random order.
*/
shuffle(): Collection<T> {
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<T> {
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<T>): Collection<T> {
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<T2>(key?: KeyOperator<T, T2>): Collection<T> {
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<T2, T>) => item.item))
}
/**
* Identical to `sort()`, but in reverse order.
* @param comparisonFunction
*/
sortDesc(comparisonFunction?: ComparisonFunction<T>): Collection<T> {
return this.sort(comparisonFunction).reverse()
}
/**
* Identical to `sortBy()`, but in reverse order.
* @param key
*/
sortByDesc<T2>(key?: KeyOperator<T, T2>): Collection<T> {
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<T> {
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<T2>(key?: KeyOperator<T, T2>): 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<T> {
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<T2>(func: CollectionFunction<T, T2>): 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<T2>(key?: KeyOperator<T, T2>): Collection<T|T2> {
const has: CollectionItem<T|T2>[] = []
let items
if ( key ) {
items = this.allOperator<T2>(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<T>): 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<T>): Collection<T> {
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<T> {
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<Collection[2, 3, 4]>
* ```
*
* @param func
*/
async promiseMap<T2>(func: KeyFunction<T, T2 | Promise<T2>>): Promise<Collection<T2>> {
return new Collection<T2>(await Promise.all(
this.map(func).toArray(),
))
}
}