lib/base/collection.js

// Base Collection
// ---------------
'use strict';

const _ = require('lodash');
const inherits = require('util').inherits;

const Events = require('./events');
const Promise = require('bluebird');
const ModelBase = require('./model');
const extend = require('../extend');

// List of attributes attached directly from the constructor's options object.
//
// RE: 'relatedData'
// It's okay for two `Collection`s to share a `Relation` instance.
// `relatedData` does not mutate itself after declaration. This is only
// here because `clone` needs to duplicate this property. It should not
// be documented as a valid argument for consumer code.
//
// RE: 'attach', 'detach', 'updatePivot', 'withPivot', '_processPivot', '_processPlainPivot', '_processModelPivot'
// It's okay to whitelist also given method references to be copied when cloning
// a collection. These methods are present only when `relatedData` is present and
// its `type` is 'belongsToMany'. So it is safe to put them in the list and use them
// without any additional verification.
// These should not be documented as a valid arguments for consumer code.
const collectionProps = [
  'model',
  'comparator',
  'relatedData',
  // `belongsToMany` pivotal collection properties
  'attach',
  'detach',
  'updatePivot',
  'withPivot',
  '_processPivot',
  '_processPlainPivot',
  '_processModelPivot'
];

/**
 * @class CollectionBase
 * @extends Events
 * @inheritdoc
 */
function CollectionBase(models, options) {
  if (options) _.extend(this, _.pick(options, collectionProps));
  this._reset();
  this.initialize.apply(this, arguments);
  if (!_.isFunction(this.model)) {
    throw new Error('A valid `model` constructor must be defined for all collections.');
  }
  if (models) this.reset(models, _.extend({silent: true}, options));
}

/**
 * Registers an event listener.
 *
 * @method CollectionBase#on
 * @example
 * const ships = new bookshelf.Collection
 * ships.on('fetched', function(collection) {
 *   // Do something after the data has been fetched from the database
 * })
 * @see Events#on
 */

/**
 * @method CollectionBase#off
 * @example
 *
 * ships.off('fetched') // Remove the 'fetched' event listener
 *
 * @see Events#off
 */

/**
 * @method CollectionBase#trigger
 * @example
 *
 * ships.trigger('fetched')
 *
 * @see Events#trigger
 */
inherits(CollectionBase, Events);

// Copied over from Backbone.
const setOptions = {add: true, remove: true, merge: true};
const addOptions = {add: true, remove: false};

/**
 * @member {Number}
 * @default 0
 * @description
 *
 * This is the total number of models in the collection. Note that this may not represent how many
 * models there are in total in the database.
 *
 * @example
 *
 * var vanHalen = new bookshelf.Collection([eddie, alex, stone, roth]);
 * console.log(vanHalen.length) // 4
 */
CollectionBase.prototype.length = 0;

/**
 * @method CollectionBase#initialize
 * @description
 * Called by the {@link Collection Collection constructor} when creating a new instance.
 * Override this function to add custom initialization, such as event listeners.
 * Because plugins may override this method in subclasses, make sure to call
 * your super (extended) class. e.g.
 *
 *     initialize: function() {
 *       this.constructor.__super__.initialize.apply(this, arguments);
 *       // Your initialization code ...
 *     }
 *
 * @see Collection
 */
CollectionBase.prototype.initialize = function() {};

/**
 * @method
 * @private
 * @description
 * The `tableName` on the associated Model, used in relation building.
 * @returns {string} The {@link Model#tableName tableName} of the associated model.
 */
CollectionBase.prototype.tableName = function() {
  return _.result(this.model.prototype, 'tableName');
};

/**
 * Returns the first model in the collection or `undefined` if the collection is empty.
 *
 * @return {Model|undefined} The first model or `undefined`.
 */
CollectionBase.prototype.first = function() {
  return this.at(0);
};

/**
 * Returns the last model in the collection or `undefined` if the collection is empty.
 *
 * @return {Model|undefined} The last model or `undefined`.
 */
CollectionBase.prototype.last = function() {
  return this.slice(-1)[0];
};

/**
 * @method
 * @private
 * @description
 * The `idAttribute` on the associated Model, used in relation building.
 * @returns {string} The {@link Model#idAttribute idAttribute} of the associated model.
 */
CollectionBase.prototype.idAttribute = function() {
  return this.model.prototype.idAttribute;
};

/**
 * @method
 * @private
 * @description
 * When keying a collection by ID, ensure that it is safe to use as a key
 * @param {any} id
 * @return {string|number} The id safe for using as a key in a collection
 */
CollectionBase.prototype.idKey = function(id) {
  return _.isBuffer(id) ? id.toString('hex') : id;
};

CollectionBase.prototype.toString = function() {
  return '[Object Collection]';
};

/**
 * @method
 * @description
 *
 * Return a raw array of the collection's {@link Model#attributes
 * attributes} for JSON stringification. If the {@link Model models} have any
 * relations defined, this will also call {@link Model#toJSON toJSON} on
 * each of the related objects, and include them on the object unless
 * `{shallow: true}` is passed as an option.
 *
 * `serialize` is called internally by {@link Collection#toJSON toJSON}.
 * Override this function if you want to customize its output.
 *
 * @param {Object=} options
 * @param {Boolean}    [options.shallow=false]   Exclude relations.
 * @param {Boolean}    [options.omitPivot=false] Exclude pivot values.
 * @param {Boolean}    [options.omitNew=false]   Exclude models that return true for isNew.
 * @returns {Object} Serialized model as a plain object.
 */
CollectionBase.prototype.serialize = function(options) {
  return this.invokeMap('toJSON', options).filter(_.negate(_.isNull));
};

/**
 * @method
 * @description
 *
 * Called automatically by {@link
 * https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior
 * `JSON.stringify`}. To customize serialization, override {@link
 * Collection#serialize serialize}.
 *
 * @param {options} Options passed to {@link Collection#serialize}.
 */
CollectionBase.prototype.toJSON = function(options) {
  return this.serialize(options);
};

/**
 * @method
 * @description
 *
 * The set method performs a smart update of the collection with the passed
 * model or list of models by following the following rules:
 * - If a model in the list isn't yet in the collection it will be added
 * - if the model is already in the collection its attributes will be merged
 * - if the collection contains any models that aren't present in the list,
 * they'll be removed.
 *
 * If you'd like to customize the behavior, you can do so with the `add`,
 * `merge` and `remove` options.
 *
 * Since version 0.14.0 if both `remove` and `merge` options are set to
 * `false`, then any duplicate models present will be added to the collection,
 * otherwise they will either be removed or merged, according to the chosen
 * option.
 *
 * @example
 *
 * var vanHalen = new bookshelf.Collection([eddie, alex, stone, roth]);
 * vanHalen.set([eddie, alex, stone, hagar]);
 *
 * @param {Object[]|Model[]|Object|Model} models One or more models or raw
 * attribute objects.
 * @param {Object=} options
 *   Options for controlling how models are added or removed.
 * @param {Boolean=} options.add=true
 *   If set to `true` it will add any new models to the collection, otherwise
 *   any new models will be ignored.
 * @param {Boolean=} options.merge=true
 *   If set to `true` it will merge the attributes of duplicate models with the
 *   attributes of existing models in the collection, otherwise duplicate
 *   models in the list will be ignored.
 * @param {Boolean=} options.remove=true
 *   If set to `true` any models in the collection that are not in the list
 *   will be removed from the collection, otherwise they will be kept.
 * @returns {Collection} Self, this method is chainable.
 */
CollectionBase.prototype.set = function(models, options) {
  options = _.defaults({}, options, setOptions);
  if (!Array.isArray(models)) models = models ? [models] : [];
  if (options.parse) models = this.parse(models, options);
  let i, l, id, model, attrs;
  const at = options.at;
  const targetModel = this.model;
  const toAdd = [];
  const toRemove = [];
  const modelMap = {};
  let order = options.add && options.remove ? [] : false;

  // Turn bare objects into model references, and prevent invalid models
  // from being added.
  for (i = 0, l = models.length; i < l; i++) {
    attrs = models[i];
    if (attrs instanceof ModelBase) {
      id = model = attrs;
    } else {
      id = attrs[targetModel.prototype.idAttribute];
    }

    // If a duplicate is found, prevent it from being added and
    // optionally merge it into the existing model.
    const existing = this.get(id);
    if (existing && (options.merge || options.remove)) {
      if (options.remove) {
        modelMap[existing.cid] = true;
      }
      if (options.merge) {
        attrs = attrs === model ? model.attributes : attrs;
        if (options.parse) attrs = existing.parse(attrs, options);
        existing.set(attrs, options);
      }

      // This is a new model, push it to the `toAdd` list.
    } else if (options.add) {
      if (!(model = this._prepareModel(attrs, options))) continue;
      toAdd.push(model);
      this._byId[this.idKey(model.cid)] = model;
      if (model.id != null) this._byId[this.idKey(model.id)] = model;
    }

    if (order && !(existing && order.indexOf(existing) > -1)) order.push(existing || model);
  }

  // Remove nonexistent models if appropriate.
  if (options.remove) {
    for (i = 0, l = this.length; i < l; ++i) {
      if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model);
    }
    if (toRemove.length) this.remove(toRemove, options);
  }

  // See if sorting is needed, update `length` and splice in new models.
  if (toAdd.length || (order && order.length)) {
    this.length += toAdd.length;
    if (at != null) {
      Array.prototype.splice.apply(this.models, [at, 0].concat(toAdd));
    } else {
      if (order) {
        this.models.length = 0;
      } else {
        order = toAdd;
      }
      for (i = 0, l = order.length; i < l; ++i) {
        this.models.push(order[i]);
      }
    }
  }

  if (options.silent) return this;

  // Trigger `add` events.
  for (i = 0, l = toAdd.length; i < l; i++) {
    (model = toAdd[i]).trigger('add', model, this, options);
  }
  return this;
};

/**
 * @method
 * @private
 * @description
 * Prepare a model or hash of attributes to be added to this collection.
 */
CollectionBase.prototype._prepareModel = function(attrs, options) {
  if (attrs instanceof ModelBase) return attrs;
  return new this.model(attrs, options);
};

/**
 * @method
 * @private
 * @description
 * Run "Promise.map" over the models
 */
CollectionBase.prototype.mapThen = function(iterator, context) {
  return Promise.bind(context)
    .thenReturn(this.models)
    .map(iterator);
};

/**
 * @method
 * @description
 * Shortcut for calling `Promise.all` around a {@link Collection#invoke}, this
 * will delegate to the collection's `invoke` method, resolving the promise with
 * an array of responses all async (and sync) behavior has settled. Useful for
 * bulk saving or deleting models:
 *
 *     collection.invokeThen('save', null, options).then(function() {
 *       // ... all models in the collection have been saved
 *     });
 *
 *     collection.invokeThen('destroy', options).then(function() {
 *       // ... all models in the collection have been destroyed
 *     });
 *
 * @param {string} method The {@link Model model} method to invoke.
 * @param {...mixed} arguments Arguments to `method`.
 * @returns {Promise<mixed[]>}
 *   Promise resolving to array of results from invocation.
 */
CollectionBase.prototype.invokeThen = function() {
  return Promise.all(this.invokeMap.apply(this, arguments));
};

/**
 * This iterator is used by the reduceThen method to ietrate over all models in the collection.
 *
 * @callback Collection~reduceThenIterator
 * @param {mixed} acumulator
 * @param {Model} model The current model being iterated over.
 * @param {Number} index
 * @param {Number} length Total number of models being iterated over.
 */

/**
 * @method
 * @description
 * Iterate over all the models in the collection and reduce this array to a single value using the
 * given iterator function.
 * @see {@link http://bluebirdjs.com/docs/api/promise.reduce.html|Bluebird `Promise.reduce` reference}.
 * @param {Collection~reduceThenIterator} iterator
 * @param {mixed} initialValue
 * @param {Object} context Bound to `this` in the `iterator` callback.
 * @returns {Promise<mixed>}
 *   Promise resolving to the single result from the reduction.
 *
 */
CollectionBase.prototype.reduceThen = function(iterator, initialValue, context) {
  return Promise.bind(context)
    .thenReturn(this.models)
    .reduce(iterator, initialValue)
    .bind();
};

CollectionBase.prototype.fetch = function() {
  return Promise.rejected('The fetch method has not been implemented');
};

/**
 * @method
 * @description
 *
 * Add a {@link Model model}, or an array of models, to the collection. You may
 * also pass raw attribute objects, which will be converted to proper models
 * when being added to the collection.
 *
 * You can pass the `{at: index}` option to splice the model into the
 * collection at the specified `index`.
 *
 * By default if you're adding models to the collection that are already
 * present, they'll be ignored, unless you pass `{merge: true}`, in
 * which case their {@link Model#attributes attributes} will be merged with the
 * corresponding models.
 *
 * @example
 *
 * const ships = new bookshelf.Collection;
 *
 * ships.add([
 *   {name: "Flying Dutchman"},
 *   {name: "Black Pearl"}
 * ]);
 *
 * @param {Object[]|Model[]|Object|Model} models
 *   One or more models or raw attribute objects.
 * @param {Object=} options Options for controlling how models are added.
 * @param {Boolean=} options.merge=false
 *   If set to `true` it will merge the attributes of duplicate models with the
 *   attributes of existing models in the collection.
 * @param {Number=} options.at
 *   If set to a number equal to or greater than 0 it will splice the model
 *   into the collection at the specified index number.
 * @returns {Collection} Self, this method is chainable.
 */
CollectionBase.prototype.add = function(models, options) {
  return this.set(models, Object.assign({merge: false}, options, addOptions));
};

/**
 * Remove a {@link Model model}, or an array of models, from the collection. Note that this does not remove the affected
 * models from the database. For that purpose you have to use the model's {@link Model#destroy destroy} method.
 *
 * If you wish to actually remove all the models in a collection from the database you can use this method:
 *
 *     myCollection.invokeThen('destroy').then(() => {
 *       // models have been destroyed
 *     })
 *
 * @param {Model|Model[]} models The model, or models, to be removed.
 * @param {Object} [options] Set of options for the operation.
 * @param {Boolean} [options.silent] If set to `true` will not trigger a `remove` event on the removed model.
 * @returns {Model|Model[]} The same value passed in the `models` argument.
 */
CollectionBase.prototype.remove = function(models, options) {
  const singular = !Array.isArray(models);
  models = singular ? [models] : _.clone(models);
  options = options || {};
  for (let i = 0; i < models.length; i++) {
    const model = (models[i] = this.get(models[i]));
    if (!model) continue;
    delete this._byId[this.idKey(model.id)];
    delete this._byId[model.cid];
    const index = this.models.indexOf(model);
    this.models.splice(index, 1);
    this.length = this.length - 1;
    if (!options.silent) {
      options.index = index;
      model.trigger('remove', model, this, options);
    }
  }
  return singular ? models[0] : models;
};

/**
 * @method
 * @description
 *
 * Adding and removing models one at a time is all well and good, but sometimes
 * you have so many models to change that you'd rather just update the
 * collection in bulk. Use `reset` to replace a collection with a new list of
 * models (or attribute hashes). Calling `collection.reset()` without passing
 * any models as arguments will empty the entire collection.
 *
 * @param {Object[]|Model[]|Object|Model} models One or more models or raw
 * attribute objects.
 * @param {Object} options See {@link Collection#add add}.
 * @returns {Model[]} Array of models.
 */
CollectionBase.prototype.reset = function(models, options) {
  options = options || {};
  options.previousModels = this.models;
  this._reset();
  models = this.set(models, Object.assign({silent: true}, options));
  if (!options.silent) this.trigger('reset', this, options);
  return models;
};

/**
 * @method
 * @description
 * Add a model to the end of the collection.
 * @param {Object[]|Model[]|Object|Model} model One or more models or raw
 * attribute objects.
 * @returns {Collection} Self, this method is chainable.
 */
CollectionBase.prototype.push = function(model, options) {
  return this.add(model, _.extend({at: this.length}, options));
};

/**
 * @method
 * @description
 * Remove a model from the end of the collection.
 */
CollectionBase.prototype.pop = function(options) {
  const model = this.at(this.length - 1);
  this.remove(model, options);
  return model;
};

/**
 * @method
 * @description
 * Add a model to the beginning of the collection.
 */
CollectionBase.prototype.unshift = function(model, options) {
  return this.add(model, _.extend({at: 0}, options));
};

/**
 * @method
 * @description
 * Remove a model from the beginning of the collection.
 */
CollectionBase.prototype.shift = function(options) {
  const model = this.at(0);
  this.remove(model, options);
  return model;
};

/**
 * @method
 * @description
 * Slice out a sub-array of models from the collection.
 */
CollectionBase.prototype.slice = function() {
  return Array.prototype.slice.apply(this.models, arguments);
};

/**
 * @method
 * @description
 *
 * Get a model from a collection, specified by an {@link Model#id id}, a {@link
 * Model#cid cid}, or by passing in a {@link Model model}.
 *
 * @example
 *
 * const book = library.get(110);
 *
 * @returns {Model} The model, or `undefined` if it is not in the collection.
 */
CollectionBase.prototype.get = function(obj) {
  if (obj == null) return void 0;
  return this._byId[this.idKey(obj.id)] || this._byId[obj.cid] || this._byId[this.idKey(obj)];
};

/**
 * @method
 * @description
 * Get a model from a collection, specified by index. Useful if your collection
 * is sorted, and if your collection isn't sorted, `at` will still retrieve
 * models in insertion order.
 */
CollectionBase.prototype.at = function(index) {
  return this.models[index];
};

/**
 * @method
 * @private
 * @description
 * Force the collection to re-sort itself, based on a comporator defined on the model.
 */
CollectionBase.prototype.sort = function(options) {
  if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
  options = options || {};

  // Run sort based on type of `comparator`.
  if (_.isString(this.comparator) || this.comparator.length === 1) {
    this.models = this.sortBy(this.comparator, this);
  } else {
    this.models.sort(_.bind(this.comparator, this));
  }

  if (!options.silent) this.trigger('sort', this, options);
  return this;
};

/**
 * @method
 * @description
 * Pluck an attribute from each model in the collection.
 * @returns {mixed[]} An array of attribute values.
 */
CollectionBase.prototype.pluck = function(attr) {
  return this.invokeMap('get', attr);
};

/**
 * @method
 * @description
 * The `parse` method is called whenever a collection's data is returned in a
 * {@link Collection#fetch fetch} call. The function is passed the raw
 * database `response` array, and should return an array to be set on the
 * collection. The default implementation is a no-op, simply passing through
 * the JSON response.
 *
 * @param {Object[]} resp Raw database response array.
 */
CollectionBase.prototype.parse = function(resp) {
  return resp;
};

/**
 * @method
 * @description
 * Create a new collection with an identical list of models as this one.
 */
CollectionBase.prototype.clone = function() {
  // Iterate over the selected list of collection properties and invoke `clone` for
  // each property that has a method for that porpose.
  const clonedProps = _(this)
    .pick(collectionProps)
    .mapValues((val) => {
      return val && typeof val.clone === 'function' ? val.clone() : val;
    })
    .value();
  return new this.constructor(this.models, clonedProps);
};

/**
 * @method
 * @private
 * @description
 * Reset all internal state. Called when the collection is first initialized or reset.
 */
CollectionBase.prototype._reset = function() {
  this.length = 0;
  this.models = [];
  this._byId = Object.create(null);
};

// Make collection iterable in for of loops
CollectionBase.prototype[Symbol.iterator] = function*() {
  yield* this.models;
};

/**
 * @method CollectionBase#forEach
 * @see http://lodash.com/docs/#forEach
 */
/**
 * @method CollectionBase#map
 * @see http://lodash.com/docs/#map
 */
/**
 * @method CollectionBase#reduce
 * @see http://lodash.com/docs/#reduce
 */
/**
 * @method CollectionBase#reduceRight
 * @see http://lodash.com/docs/#reduceRight
 */
/**
 * @method CollectionBase#find
 * @see http://lodash.com/docs/#find
 */
/**
 * @method CollectionBase#filter
 * @see http://lodash.com/docs/#filter
 */
/**
 * @method CollectionBase#reject
 * @see http://lodash.com/docs/#reject
 */
/**
 * @method CollectionBase#every
 * @see http://lodash.com/docs/#every
 */
/**
 * @method CollectionBase#some
 * @see http://lodash.com/docs/#some
 */
/**
 * @method CollectionBase#includes
 * @see http://lodash.com/docs/#includes
 */
/**
 * @method CollectionBase#invokeMap
 * @see http://lodash.com/docs/#invokeMap
 */
/**
 * @method CollectionBase#toArray
 * @see http://lodash.com/docs/#toArray
 */
/**
 * @method CollectionBase#isEmpty
 * @see http://lodash.com/docs/#isEmpty
 */
// Lodash methods that we want to implement on the Collection.
// 90% of the core usefulness of Backbone Collections is actually implemented
// right here:
const methods = [
  'forEach',
  'map',
  'reduce',
  'reduceRight',
  'find',
  'filter',
  'every',
  'some',
  'includes',
  'invokeMap',
  'toArray',
  'isEmpty'
];

// Mix in each Lodash method as a proxy to `Collection#models`.
_.each(methods, function(method) {
  CollectionBase.prototype[method] = function() {
    return _[method].apply(_, [this.models].concat(Array.from(arguments)));
  };
});

/**
 * @method CollectionBase#groupBy
 * @see http://lodash.com/docs/#groupBy
 */
// Underscore methods that we want to implement on the Collection.
/**
 * @method CollectionBase#countBy
 * @see http://lodash.com/docs/#countBy
 */
// Underscore methods that we want to implement on the Collection.
/**
 * @method CollectionBase#sortBy
 * @see http://lodash.com/docs/#sortBy
 */
// Lodash methods that take a property name as an argument.
const attributeMethods = ['groupBy', 'countBy', 'sortBy'];

// Use attributes instead of properties.
_.each(attributeMethods, function(method) {
  CollectionBase.prototype[method] = function(value, context) {
    const iterator = _.isFunction(value)
      ? value
      : function(model) {
          return model.get(value);
        };
    return _[method](this.models, _.bind(iterator, context));
  };
});

/**
 * @method Collection.extend
 * @description
 *
 * To create a {@link Collection} class of your own, extend
 * `Bookshelf.Collection`.
 *
 * @param {Object=} prototypeProperties
 *   Instance methods and properties to be attached to instances of the new
 *   class.
 * @param {Object=} classProperties
 *   Class (ie. static) functions and properties to be attached to the
 *   constructor of the new class.
 * @returns {Function} Constructor for new `Collection` subclass.
 */
CollectionBase.extend = extend;

module.exports = CollectionBase;