Home Reference Source Repository

src/collection.js

/**
 * The Collection object.
 *
 * @example
 * let collection = new Collection([1, 2, 3]);
 */
export default class Collection
{
    /**
     * The collection constructor.
     *
     * @param  {Array} [items=[]] the array to collect.
     * @return {Collection} A Collection object.
     */
    constructor(items = []) {
        /**
         * The internal array.
         * @type {Array|Object}
         */
        this.items = items;
    }

    /**
     * Adds an item to the collection.
     *
     * @param {*} item the item to be added.
     * @return {Collection} the collection object.
     * @example
     * const collection = new Collection();
     * collection.add('Arya');
     * console.log(collection.first()); //outputs 'Arya'
     */
    add(item) {
        this.items.push(item);

        return this;
    }

    /**
     * Gets the collected elements in an array.
     *
     * @return {Array} the internal array.
     * @example
     * const collection = new Collection([1, 2, 3]);
     * console.log(collection.all()); // [1, 2, 3]
     */
    all() {
        return this.items;
    }

    /**
     * Gets the average value of the array or a property or a callback return value.
     * If no property is provided: it will calculate the average value of the array (Numeric array).
     * If property is a string: it will calculate the average value of that property for all
     *  objects in the array.
     * If Property is a callback: the the averaging will use the value returned instead.
     *
     * @param  {function|string} [property=null] The property name or the callback function.
     * defaults to null.
     * @return {number} The average value.
     * @example <caption>Averaging elements</caption>
     * const collection = new Collection([1, 2, 3]);
     * console.log(collection.average()); // 2
     * @example <caption>Averaging a property</caption>
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]);
     * console.log(collection.average('age')); // 10
     * @example <caption>Averaging using a callback</caption>
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]);
     * console.log(collection.average(i => i.age)); // 10
     */
    average(property = null) {
        return this.sum(property) / this.count();
    }

    /**
     * Chunks the collection into a new collection with equal length arrays as its items.
     *
     * @param  {number} size the size of each chunk.
     * @return {Collection} the new collection.
     * @example
     * const collection = new Collection([1, 2, 3, 4, 5]).chunk(2);
     * console.log(collection.all()); // [[1, 2], [3, 4], [5]]
     */
    chunk(size) {
        if (size <= 0) {
            return new Collection();
        }

        const items = [];

        for (let i = 0; i < this.count(); i += size) {
            items.push(this.items.slice(i, i + size));
        }

        return new Collection(items);
    }

    /**
     * Static constructor.
     * cool if you don't like using the 'new' keyword.
     *
     * @param  {Array} collectable the array or the string to wrapped in a collection.
     * @return {Collection} A collection that wraps the collectable items.
     * @example
     * const collection = Collection.collect([1, 2, 3]);
     * console.log(collection.all()); // [1, 2, 3]
     */
    static collect(collectable) {
        return new Collection(collectable);
    }

    /**
     * Concatnates the collection with an array or another collection.
     *
     * @param {Array|Collection} collection the array or the collection to be concatenated with.
     * @return {Collection} concatenated collection.
     * @example
     * const collection = new Collection([1, 2, 3]);
     * const array = [4, 5, 6]; // or another collection.
     * const newCollection = collection.concat(array);
     * console.log(newCollection.all()); // [1, 2, 3, 4, 5, 6]
     */
    concat(collection) {
        if (Array.isArray(collection)) {
            return new Collection(this.items.concat(collection));
        }

        return new Collection(this.items.concat(collection.all()));
    }

    /**
     * Checks if there is at least one occurance of an element using a closure.
     * @param  {function} closure The closure the be used on each element.
     * @return {boolean} true if at least one occurance exist, false otherwise.
     * @example
     * const collection = new Collection([
     *     { name: 'John Snow', age: 14 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Arya Stark', age: 9 }
     * ]);
     *
     * collection.contains(stark => stark.name === 'John Snow'); // true
     * collection.contains(stark => stark.name === 'Eddard Stark'); // false
     */
    contains(closure) {
        return !! this.first(closure);
    }

    /**
     * Gets the number of items in the collection.
     *
     * @return {number} Number of items in the collection.
     * @example
     * const collection = new Collection([1, 2, 3]);
     * console.log(collection.count()); // 3
     */
    count() {
        return this.items.length;
    }


    /**
     * Executes a callback for each element in the collection.
     *
     * @param  {function} callback the callback to be excuted for each item.
     * @return {Collection} The collection object.
     * @example
     * const collection = new Collection(['this', 'is', 'collectionjs']);
     * collection.each(t => console.log(t)); // this is collectionjs
     */
    each(callback) {
        this.items.forEach(callback);

        return this;
    }

    /**
     * Filters the collection using a predicate (callback that returns a boolean).
     *
     * @param  {function} callback A function that returns a boolean expression.
     * @return {Collection} Filtered collection.
     * @example
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).filter(stark => stark.age === 14);
     * console.log(collection.all()); // [{ name: 'Jon Snow', age: 14 }]
     */
    filter(callback) {
        return new Collection(this.items.filter(callback));
    }

    /**
     * Returns the index of an element.
     *
     * @param  {*} item The item to be searched.
     * @return {number} The index of the item. -1 means it wasn't found.
     * @example
     * const collection = new Collection(['jon', 'arya', 'bran']);
     * console.log(collection.find('bran')); // 2
     * console.log(collection.find('ed')); // -1
     */
    find(item) {
        return this.items.indexOf(item);
    }

    /**
     * Gets the first element satisfying a critera.
     *
     * @param  {function} [callback=null] the predicate (callback) that will be applied on items.
     * @return {*} the first item to satisfy the critera.
     * @example <caption>Using a callback</caption>
     * const first = new Collection([
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).first(item => item.age > 7);
     *
     * console.log(first); // { name: 'Arya Stark', age: 9 }
     * @example <caption>No Arguments</caption>
     * const first = new Collection([
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).first();
     *
     * console.log(first); // { name: 'Bran Stark', age: 7 }
     */
    first(callback = null) {
        if (! this.count()) {
            return null;
        }

        if (callback && typeof(callback) === 'function') {
            for (let i = 0; i < this.count(); i++) {
                if (callback(this.items[i])) {
                    return this.items[i];
                }
            }

            return null;
        }

        return this.items[0];
    }

    /**
     * Flattens the collection elements.
     *
     * @param  {Boolean} [deep=false] recursively flatten the items (multi-level).
     * @return {Collection} the flattened collection.
     * @example <caption>Just one level</caption>
     * const collection = new Collection([1, [2, [3, [4]], 5]]).flatten();
     * console.log(collection.all()); // [1, 2, [3, [4]], 5]
     *
     * @example <caption>Deep</caption>
     * const collection = new Collection([1, [2, [3, [4]], 5]]).flatten(true);
     * console.log(collection.all()); // [1, 2, 3, 4, 5]
     */
    flatten(deep = false) {
        const flattened = new Collection([].concat(...this.items));

        if (! deep || ! flattened.contains(Array.isArray)) {
            return flattened;
        }

        return flattened.flatten(true);
    }

    /**
     * Gets an element at a specified index.
     *
     * @param  {number} index the index of the item.
     * @return {*} the item at that index.
     * @example
     * const collection = new Collection([1, 2, 3]);
     * console.log(collection.get(2)); // 3
     */
    get(index) {
        return this.items[index];
    }

    /**
     * Checks if a collection has a specific item.
     *
     * @param  {*}  item The item to be searched.
     * @return {boolean} true if exists, false otherwise.
     * @example
     * const collection = new Collection([1, 2, 3, 4]);
     *
     * console.log(collection.has(2)); // true
     * console.log(collection.has(5)); // false
     */
    has(item) {
        return !! ~this.find(item);
    }

    /**
     * Joins the collection elements into a string.
     *
     * @param  {string} [seperator=','] The seperator between each element and the next.
     * @return {string} The joined string.
     *
     * @example
     * const collection = new Collection(['Wind', 'Rain', 'Fire']);
     * console.log(collection.join()); // 'Wind,Rain,Fire'
     * console.log(collection.join(', ')); 'Wind, Rain, Fire'
     */
    join(seperator = ',') {
        return this.items.join(seperator);
    }

    /**
     * Gets the collection elements keys in a new collection.
     *
     * @return {Collection} The keys collection.
     * @example <caption>Objects</caption>
     * const keys = new Collection({
     *     arya: 10,
     *     john: 20,
     *     potato: 30
     * }).keys();
     * console.log(keys); // ['arya', 'john', 'potato']
     *
     * @example <caption>Regular Array</caption>
     * const keys = new Collection(['arya', 'john', 'potato']).keys();
     * console.log(keys); // ['0', '1', '2']
     */
    keys() {
        return new Collection(Object.keys(this.items));
    }

    /**
     * Gets the last element to satisfy a callback.
     *
     * @param  {function} [callback=null] the predicate to be checked on all elements.
     * @return {*} The last element in the collection that satisfies the predicate.
     * @example <caption>Using a callback</caption>
     * const last = new Collection([
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).last(item => item.age > 7);
     *
     * console.log(last); // { name: 'Jon Snow', age: 14 }
     * @example <caption>No Arguments</caption>
     * const last = new Collection([
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).last();
     *
     * console.log(last); // { name: 'Jon Snow', age: 14 }
     */
    last(callback = null) {
        if (! this.count()) {
            return null;
        }

        if (callback && typeof(callback) === 'function') {
            return this.filter(callback).last();
        }

        return this.items[this.count() - 1];
    }

    /**
     * Maps each element using a mapping function and collects the mapped items.
     * @param  {function} callback the mapping function.
     * @return {Collection} collection containing the mapped items.
     * @example
     * const collection = new Collection([
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).map(stark => stark.name);
     * console.log(collection.all()); ['Bran Stark', 'Arya Stark', 'Jon Snow']
     */
    map(callback) {
        return new Collection(this.items.map(callback));
    }

    /**
     * Extracts a property from the objects in the collection.
     *
     * @param  {string} property the name of the property to be extracted.
     * @return {Collection} A collection with the extracted items.
     * @example
     * const collection = new Collection([
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).pluck('name');
     * console.log(collection.all()); ['Bran Stark', 'Arya Stark', 'Jon Snow']
     */
    pluck(property) {
        return this.map((item) => item[property]);
    }

    /**
     * Adds an element to the collection.
     *
     * @param  {*} item the item to be added.
     * @return {Collection} The collection object.
     * @example
     * const collection = new Collection().push('First');
     * console.log(collection.first()); // "First"
     */
    push(item) {
        return this.add(item);
    }

    /**
     * Reduces the collection to a single value using a reducing function.
     *
     * @param  {function} callback the reducing function.
     * @param  {*} initial  initial value.
     * @return {*} The reduced results.
     * @example
     * const value = new Collection([1, 2, 3]).reduce(
     *     (previous, current) => previous + current,
     *      0
     *  );
     *  console.log(value); // 6
     */
    reduce(callback, initial) {
        return this.items.reduce(callback, initial);
    }

    /**
     * Removes the elements that do not satisfy the predicate.
     *
     * @param  {function} callback the predicate used on each item.
     * @return {Collection} A collection without the rejected elements.
     * @example
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).reject(stark => stark.age < 14);
     * console.log(collection.all()); // [{ name: 'Jon Snow', age: 14 }]
     */
    reject(callback) {
        const items = [];
        this.items.forEach((item) => {
            if (! callback(item)) {
                items.push(item);
            }
        });

        return new Collection(items);
    }

    /**
     * Removes an item from the collection.
     *
     * @param  {*} item the item to be searched and removed, first occurance will be removed.
     * @return {boolean} True if the element was removed, false otherwise.
     * @example
     * const collection = new Collection(['john', 'arya', 'bran']);
     * collection.remove('john');
     * console.log(collection.all()); // ['arya', 'bran']
     */
    remove(item) {
        const index = this.find(item);
        if (~index) {
            this.items.splice(index, 1);

            return true;
        }

        return false;
    }

    /**
     * Reverses the collection order.
     *
     * @return {Collection} A new collection with the reversed order.
     * @example
     * const collection = new Collection(['one', 'two', 'three']).reverse();
     * console.log(collection.all()); // ['three', 'two', 'one']
     */
    reverse() {
        return new Collection(this.items.reverse());
    }

    /**
     * Skips a specified number of elements.
     *
     * @param  {number} count the number of items to be skipped.
     * @return {Collection} A collection without the skipped items.
     * @example
     * const collection = new Collection(['John', 'Arya', 'Bran', 'Sansa']).skip(2);
     * console.log(collection.all()); // ['Bran', 'Sansa']
     */
    skip(count) {
        return this.slice(count);
    }

    /**
     * Slices the collection starting from a specific index and ending at a specified index.
     *
     * @param  {number} start The zero-based starting index.
     * @param  {number} [end=length] The zero-based ending index.
     * @return {Collection} A collection with the sliced items.
     * @example <caption>start and end are specified</caption>
     * const collection = new Collection([0, 1, 2, 3, 4, 5, 6]).slice(2, 4);
     * console.log(collection.all()); // [2, 3]
     *
     * @example <caption>only start is specified</caption>
     * const collection = new Collection([0, 1, 2, 3, 4, 5, 6]).slice(2);
     * console.log(collection.all()); // [2, 3, 4, 5, 6]
     */
    slice(start, end = this.items.length) {
        return new Collection(this.items.slice(start, end));
    }

    /**
     * Sorts the elements of a collection and returns a new sorted collection.
     * note that it doesn't change the orignal collection and it creates a
     * shallow copy.
     *
     * @param  {function} [compare=undefined] the compare function.
     * @return {Collection} A new collection with the sorted items.
     *
     * @example
     * const collection = new Collection([5, 3, 4, 1, 2]);
     * const sorted = collection.sort();
     * // original collection is intact.
     * console.log(collection.all()); // [5, 3, 4, 1, 2]
     * console.log(sorted.all()); // [1, 2, 3, 4, 5]
     */
    sort(compare = undefined) {
        return new Collection(this.items.slice().sort(compare));
    }

    /**
     * Sorts the collection by key value comaprison, given that the items are objects.
     * It creates a shallow copy and retains the order of the orignal collection.
     *
     * @param  {string} property the key or the property to be compared.
     * @param  {string} [order='asc'] The sorting order.
     * use 'asc' for ascending or 'desc' for descending, case insensitive.
     * @return {Collection} A new Collection with the sorted items.
     *
     * @example
     * const collection = new Collection([
     *     { name: 'Jon Snow', age: 14 },
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     * ]).sortBy('age');
     *
     * console.log(collection.pluck('name').all()); // ['Brand Stark', 'Arya Stark', 'Jon Snow']
     */
    sortBy(property, order = 'asc') {
        const isAscending = order.toLowerCase() === 'asc';

        return this.sort((a, b) => {
            if (a[property] > b[property]) {
                return isAscending ? 1 : -1;
            }

            if (a[property] < b[property]) {
                return isAscending ? -1 : 1;
            }

            return 0;
        });
    }

    /**
     * {stringifies the collection using JSON.stringify API.
     *
     * @return {string} The stringified value.
     * @example
     * const collection = new Collection([1, 2, 3]);
     * console.log(collection.stringify()); // "[1,2,3]"
     */
    stringify() {
        return JSON.stringify(this.items);
    }

    /**
     * Sums the values of the array, or the properties, or the result of the callback.
     *
     * @param  {undefined|string|function} [property=null] the property to be summed.
     * @return {*} The sum of values used in the summation.
     * @example <caption>Summing elements</caption>
     * const collection = new Collection([1, 2, 3]);
     * console.log(collection.sum()); // 6
     *
     * @example <caption>Summing a property</caption>
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]);
     * console.log(collection.sum('age')); // 30
     *
     * @example <caption>Summing using a callback</caption>
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]);
     * console.log(collection.sum(i => i.age + 1)); // 33
     */
    sum(property = null) {
        if (typeof property === 'string') {
            return this.reduce((previous, current) =>
                previous + current[property]
            , 0);
        }

        if (typeof property === 'function') {
            return this.reduce((previous, current) =>
                previous + property(current)
            , 0);
        }

        return this.reduce((previous, current) =>
            previous + current
        , 0);
    }

    /**
     * Gets a new collection with the number of specified items from the begining or the end.
     *
     * @param  {number} count the number of items to take. Takes from end if negative.
     * @return {Collection} A collection with the taken items.
     * @example <caption>From the beginning</caption>
     * const collection = new Collection([1, 2, 3, 4, 5]).take(3);
     * console.log(collection.all()); // [1, 2, 3]
     *
     * @example <caption>From the end</caption>
     * const collection = new Collection([1, 2, 3, 4, 5]).take(-3);
     * console.log(collection.all()); // [5, 4 ,3]
     */
    take(count) {
        if (! count) {
            return new Collection([]);
        }

        if (count < 0) {
            return new Collection(this.items.reverse()).take(-count);
        }

        return new Collection(this.items.slice(0, count));
    }

    /**
     * Registers a new method on the collection prototype for future use.
     * The closure gets the collection object passed as the first parameter then
     * other parameters gets passed after.
     *
     * @param  {string} name The name of the macro function.
     * @param  {function} callback A closure containing the behavior of the macro.
     * @return {*} returns your callback result.
     *
     * @example
     * Collection.macro('addToMembers', (collection, n) => collection.map(item => item + n));
     * const col2 = new Collection([1, 2, 3, 4]).addToMembers(3);
     * console.log(col2.all()); // [4, 5, 6, 7]
     */
    static macro(name, callback) {
        if (Collection.prototype[name] !== undefined) {
            throw new Error('Collection.macro(): This macro name is already defined.');
        }

        Collection.prototype[name] = function collectionMacroWrapper(...args) {
            const collection = this;

            return callback(collection, ...args);
        };
    }

    /**
     * Remove duplicate values from the collection.
     *
     * @param {function|string} [callback=null] The predicate that returns a value
     * which will be checked for uniqueness, or a string that has the name of the property.
     * @return {Collection} A collection containing ue values.
     * @example <caption>No Arguments</caption>
     * const unique = new Collection([2, 1, 2, 3, 3, 4, 5, 1, 2]).unique();
     * console.log(unique); // [2, 1, 3, 4, 5]
     * @example <caption>Property Name</caption>
     * const students = new Collection([
     * 		{ name: 'Rick', grade: 'A'},
     * 		{ name: 'Mick', grade: 'B'},
     * 		{ name: 'Richard', grade: 'A'},
     * ]);
     * // Students with unique grades.
     * students.unique('grade'); // [{ name: 'Rick', grade: 'A'}, { name: 'Mick', grade: 'B'}]
     * @example <caption>With Callback</caption>
     * const students = new Collection([
     * 		{ name: 'Rick', grade: 'A'},
     * 		{ name: 'Mick', grade: 'B'},
     * 		{ name: 'Richard', grade: 'A'},
     * ]);
     * // Students with unique grades.
     * students.unique(s => s.grade); // [{ name: 'Rick', grade: 'A'}, { name: 'Mick', grade: 'B'}]
     */
    unique(callback = null) {
        if (typeof callback === 'string') {
            return this.unique(item => item[callback]);
        }

        if (callback) {
            const mappedCollection = new Collection();

            return this.reduce((collection, item) => {
                const mappedItem = callback(item);
                if (! mappedCollection.has(mappedItem)) {
                    collection.add(item);
                    mappedCollection.add(mappedItem);
                }

                return collection;
            }, new Collection);
        }

        return this.reduce((collection, item) => {
            if (! collection.has(item)) {
                collection.add(item);
            }

            return collection;
        }, new Collection);
    }

    /**
     * Gets the values without preserving the keys.
     *
     * @return {Collection} A Collection containing the values.
     * @example
     * const collection = new Collection({
     *     1: 2,
     *     2: 3,
     *     4: 5
     * }).values();
     *
     * console.log(collection.all()); / /[2, 3, 5]
     */
    values() {
        return this.keys().map(key => this.items[key]);
    }

    /**
     * Filters the collection using a callback or equality comparison to a property in each item.
     *
     * @param  {function|string} callback The callback to be used to filter the collection.
     * @param  {*} [value=null] The value to be compared.
     * @return {Collection} A collection with the filtered items.
     * @example <caption>Using a property name</caption>
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).where('age', 14);
     * console.log(collection.all()); // [{ name: 'Jon Snow', age: 14 }]
     *
     * @example <caption>Using a callback</caption>
     * const collection = new Collection([
     *     { name: 'Arya Stark', age: 9 },
     *     { name: 'Bran Stark', age: 7 },
     *     { name: 'Jon Snow', age: 14 }
     * ]).where(stark => stark.age === 14);
     * console.log(collection.all()); // [{ name: 'Jon Snow', age: 14 }]
     */
    where(callback, value = null) {
        if (typeof(callback) === 'string') {
            return this.filter(item => item[callback] === value);
        }

        return this.filter(callback);
    }

    /**
     * Pairs each item in the collection with another array item in the same index.
     *
     * @param  {Array|Collection} array the array to be paired with.
     * @return {Collection} A collection with the paired items.
     * @example
     * const array = ['a', 'b', 'c']; // or a collection.
     * const collection = new Collection([1, 2, 3]).zip(array);
     * console.log(collection.all()); // [[1, 'a'], [2, 'b'], [3, 'c']]
     */
    zip(array) {
        if (array instanceof Collection) {
            return this.map((item, index) => [item, array.get(index)]);
        }

        return this.map((item, index) => [item, array[index]]);
    }
}