util/Collection.js

"use strict";

/**
 * Hold a bunch of something
 * @extends Map
 * @prop {Class} baseObject The base class for all items
 * @prop {Number?} limit Max number of items to hold
 */
class Collection extends Map {
  /**
   * Construct a Collection
   * @arg {Class} baseObject The base class for all items
   * @arg {Number} [limit] Max number of items to hold
   */
  constructor(baseObject, limit) {
    super();
    this.baseObject = baseObject;
    this.limit = limit;
  }

  /**
   * Update an object
   * @arg {Object} obj The updated object data
   * @arg {String} obj.id The ID of the object
   * @arg {Class} [extra] An extra parameter the constructor may need
   * @arg {Boolean} [replace] Whether to replace an existing object with the same ID
   * @returns {Class} The updated object
   */
  update(obj, extra, replace) {
    if (!obj.id && obj.id !== 0) {
      throw new Error("Missing object id");
    }
    const item = this.get(obj.id);
    if (!item) {
      return this.add(obj, extra, replace);
    }
    item.update(obj, extra);
    return item;
  }

  /**
   * Add an object
   * @arg {Object} obj The object data
   * @arg {String} obj.id The ID of the object
   * @arg {Class} [extra] An extra parameter the constructor may need
   * @arg {Boolean} [replace] Whether to replace an existing object with the same ID
   * @returns {Class} The existing or newly created object
   */
  add(obj, extra, replace) {
    if (this.limit === 0) {
      return (obj instanceof this.baseObject || obj.constructor.name === this.baseObject.name) ? obj : new this.baseObject(obj, extra);
    }
    if (obj.id == null) {
      throw new Error("Missing object id");
    }
    const existing = this.get(obj.id);
    if (existing && !replace) {
      return existing;
    }
    if (!(obj instanceof this.baseObject || obj.constructor.name === this.baseObject.name)) {
      obj = new this.baseObject(obj, extra);
    }

    this.set(obj.id, obj);

    if (this.limit && this.size > this.limit) {
      const iter = this.keys();
      while (this.size > this.limit) {
        this.delete(iter.next().value);
      }
    }
    return obj;
  }

  /**
   * Returns true if all elements satisfy the condition
   * @arg {Function} func A function that takes an object and returns true or false
   * @returns {Boolean} Whether or not all elements satisfied the condition
   */
  every(func) {
    for (const item of this.values()) {
      if (!func(item)) {
        return false;
      }
    }
    return true;
  }

  /**
   * Return all the objects that make the function evaluate true
   * @arg {Function} func A function that takes an object and returns true if it matches
   * @returns {Array<Class>} An array containing all the objects that matched
   */
  filter(func) {
    const arr = [];
    for (const item of this.values()) {
      if (func(item)) {
        arr.push(item);
      }
    }
    return arr;
  }

  /**
   * Return the first object to make the function evaluate true
   * @arg {Function} func A function that takes an object and returns true if it matches
   * @returns {Class?} The first matching object, or undefined if no match
   */
  find(func) {
    for (const item of this.values()) {
      if (func(item)) {
        return item;
      }
    }
    return undefined;
  }

  /**
   * Return an array with the results of applying the given function to each element
   * @arg {Function} func A function that takes an object and returns something
   * @returns {Array} An array containing the results
   */
  map(func) {
    const arr = [];
    for (const item of this.values()) {
      arr.push(func(item));
    }
    return arr;
  }

  /**
   * Get a random object from the Collection
   * @returns {Class?} The random object, or undefined if there is no match
   */
  random() {
    const index = Math.floor(Math.random() * this.size);
    const iter = this.values();
    for (let i = 0; i < index; ++i) {
      iter.next();
    }
    return iter.next().value;
  }

  /**
   * Returns a value resulting from applying a function to every element of the collection
   * @arg {Function} func A function that takes the previous value and the next item and returns a new value
   * @arg {any} [initialValue] The initial value passed to the function
   * @returns {any} The final result
   */
  reduce(func, initialValue) {
    const iter = this.values();
    let val;
    let result = initialValue === undefined ? iter.next().value : initialValue;
    while ((val = iter.next().value) !== undefined) {
      result = func(result, val);
    }
    return result;
  }

  /**
   * Remove an object
   * @arg {Object} obj The object
   * @arg {String} obj.id The ID of the object
   * @returns {Class?} The removed object, or null if nothing was removed
   */
  remove(obj) {
    const item = this.get(obj.id);
    if (!item) {
      return null;
    }
    this.delete(obj.id);
    return item;
  }

  /**
   * Returns true if at least one element satisfies the condition
   * @arg {Function} func A function that takes an object and returns true or false
   * @returns {Boolean} Whether or not at least one element satisfied the condition
   */
  some(func) {
    for (const item of this.values()) {
      if (func(item)) {
        return true;
      }
    }
    return false;
  }

  toString() {
    return `[Collection<${this.baseObject.name}>]`;
  }

  toJSON() {
    const json = {};
    for (const item of this.values()) {
      json[item.id] = item;
    }
    return json;
  }
}

module.exports = Collection;