Fork me on GitHub

src/base/collection.js

// Base Collection
// ---------------

// All exernal dependencies required in this scope.
import _, { invokeMap, noop, negate, isNull } from 'lodash';
import inherits from 'inherits';

// All components that need to be referenced in this scope.
import Events from './events';
import Promise from './promise';
import ModelBase from './model';
import extend from '../extend';

const { splice, slice } = Array.prototype;

/**
 * @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));
}

/**
 * @method CollectionBase#on
 * @example
 *
 * const ships = new bookshelf.Collection;
 * ships.on('fetched', function(collection, response) {
 *   // 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);

// 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'
];

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

/**
 * @method CollectionBase#initialize
 * @description
 * Custom initialization function.
 * @see Collection
 */
CollectionBase.prototype.initialize = noop;

/**
 * @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');
};

/**
 * @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;
};

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 {bool}    [options.shallow=false]   Exclude relations.
 * @param {bool}    [options.omitPivot=false] Exclude pivot values.
 * @param {bool}    [options.omitNew=false]   Exclude models that return true for isNew.
 * @returns {Object} Serialized model as a plain object.
 */
CollectionBase.prototype.serialize = function(options) {
  return invokeMap(this.models, '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. 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; and 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 disable it with options: `{add: false}`,
 * `{remove: false}`, or `{merge: false}`.
 *
 * @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 See description.
 * @returns {Collection} Self, this method is chainable.
 */
CollectionBase.prototype.set = function(models, options) {
  options = _.defaults({}, options, setOptions);
  if (!_.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 = {};
  const { add, merge, remove } = options;
  let order = add && 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) {
      if (remove) {
        modelMap[existing.cid] = true;
      }
      if (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 (add) {
      if (!(model = this._prepareModel(attrs, options))) continue;
      toAdd.push(model);
      this._byId[model.cid] = model;
      if (model.id != null) this._byId[model.id] = model;
    }
    if (order) order.push(existing || model);
  }

  // Remove nonexistent models if appropriate.
  if (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) {
      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));
};

/**
 * @method
 * @description
 * Run "reduce" over the models in the collection.
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce | MDN `Array.prototype.reduce` reference.}
 * @param {Function} iterator
 * @param {mixed} initialValue
 * @param {Object} context Bound to `this` in the `iterator` callback.
 * @returns {Promise<mixed[]>}
 *   Promise resolving to array of results from invocation.
 *
 */
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 attributes objects, and have them be vivified as instances of
 * the model. Pass `{at: index}` to splice the model into the collection at the
 * specified `index`. If you're adding models to the collection that are already
 * in the collection, they'll be ignored, unless you pass `{merge: true}`, in
 * which case their {@link Model#attributes attributes} will be merged into the
 * corresponding models.
 *
 * *Note that adding the same model (a model with the same id) to a collection
 * more than once is a no-op.*
 *
 * @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 See description.
 * @returns {Collection} Self, this method is chainable.
 */
CollectionBase.prototype.add = function(models, options) {
  return this.set(models, _.extend({merge: false}, options, addOptions));
};

/**
 * @method
 * @description
 *
 * Remove a {@link Model model} (or an array of models) from the collection,
 * but does not remove the model from the database, use the model's {@link
 * Model#destroy destroy} method for this.
 *
 * @param {Model|Model[]} models The model, or models, to be removed.
 * @param {Object} options
 * @returns {Model|Model[]} The same value passed as `models` argument.
 */
CollectionBase.prototype.remove = function(models, options) {
  const singular = !_.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[model.id];
    delete this._byId[model.cid];
    const index = this.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.add(models, _.extend({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 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[obj.id] || this._byId[obj.cid] || this._byId[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
 * @description
 * Return models with matching attributes. Useful for simple cases of `filter`.
 * @returns {Model[]} Array of matching models.
 */
CollectionBase.prototype.where = function(attrs, first) {
  if (_.isEmpty(attrs)) return first ? void 0 : [];
  return this[first ? 'find' : 'filter'](function(model) {
    for (const key in attrs) {
      if (attrs[key] !== model.get(key)) return false;
    }
    return true;
  });
};

/**
 * @method
 * @description
 * Return the first model with matching attributes. Useful for simple cases of
 * `find`.
 * @returns {Model} The first matching model.
 */
CollectionBase.prototype.findWhere = function(attrs) {
  return this.where(attrs, true);
};

/**
 * @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() {
  return new this.constructor(this.models, _.pick(this, collectionProps));
};

/**
 * @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);
};

/**
 * @method CollectionBase#forEach
 * @see http://lodash.com/docs/#forEach
 */
/**
 * @method CollectionBase#each
 * @see http://lodash.com/docs/#each
 */
/**
 * @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#max
 * @see http://lodash.com/docs/#max
 */
/**
 * @method CollectionBase#maxBy
 * @see http://lodash.com/docs/#maxBy
 */
/**
 * @method CollectionBase#min
 * @see http://lodash.com/docs/#min
 */
/**
 * @method CollectionBase#minBy
 * @see http://lodash.com/docs/#minBy
 */
/**
 * @method CollectionBase#toArray
 * @see http://lodash.com/docs/#toArray
 */
/**
 * @method CollectionBase#size
 * @see http://lodash.com/docs/#size
 */
/**
 * @method CollectionBase#first
 * @see http://lodash.com/docs/#first
 */
/**
 * @method CollectionBase#head
 * @see http://lodash.com/docs/#head
 */
/**
 * @method CollectionBase#take
 * @see http://lodash.com/docs/#take
 */
/**
 * @method CollectionBase#initial
 * @see http://lodash.com/docs/#initial
 */
/**
 * @method CollectionBase#tail
 * @see http://lodash.com/docs/#tail
 */
/**
 * @method CollectionBase#drop
 * @see http://lodash.com/docs/#drop
 */
/**
 * @method CollectionBase#last
 * @see http://lodash.com/docs/#last
 */
/**
 * @method CollectionBase#without
 * @see http://lodash.com/docs/#without
 */
/**
 * @method CollectionBase#difference
 * @see http://lodash.com/docs/#difference
 */
/**
 * @method CollectionBase#indexOf
 * @see http://lodash.com/docs/#indexOf
 */
/**
 * @method CollectionBase#shuffle
 * @see http://lodash.com/docs/#shuffle
 */
/**
 * @method CollectionBase#lastIndexOf
 * @see http://lodash.com/docs/#lastIndexOf
 */
/**
 * @method CollectionBase#isEmpty
 * @see http://lodash.com/docs/#isEmpty
 */
/**
 * @method CollectionBase#chain
 * @see http://lodash.com/docs/#chain
 */
// 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', 'each', 'map', 'reduce', 'reduceRight',
  'find', 'filter', 'every', 'some', 'includes', 'invokeMap',
  'max', 'min', 'maxBy', 'minBy', 'toArray', 'size', 'first', 'head', 'take', 'initial',
  'tail', 'drop', 'last', 'without', 'difference', 'indexOf', 'shuffle',
  'lastIndexOf', 'isEmpty', 'chain'];

// Mix in each Lodash method as a proxy to `Collection#models`.
_.each(methods, function(method) {
  CollectionBase.prototype[method] = function() {
    return _[method](this.models, ...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;

/*
 * NOTE: For some reason `export` is failing in the version of Babel I'm
 * currently using. At some stage it should be corrected to:
 *
 *     export default CollectionBase;
 */
module.exports = CollectionBase;