Home Manual ReferenceGraphs Source Repository

backbone-es6/src/Collection.js

'use strict';

import _             from 'underscore';
import BackboneProxy from './BackboneProxy.js';
import Events        from 'typhonjs-core-backbone-events/src/Events.js';
import Model         from './Model.js';
import Utils         from './Utils.js';

import Debug         from './Debug.js';

// Private / internal methods ---------------------------------------------------------------------------------------

/**
 * Default options for `Collection#add`.
 * @type {{add: boolean, remove: boolean}}
 */
const s_ADD_OPTIONS = { add: true, remove: false };

/**
 * Default options for `Collection#set`.
 * @type {{add: boolean, remove: boolean}}
 */
const s_SET_OPTIONS = { add: true, remove: true, merge: true };

/**
 * Internal method to create a model's ties to a collection.
 *
 * @param {Collection}  collection  - A collection instance
 * @param {Model}       model       - A model instance
 */
const s_ADD_REFERENCE = (collection, model) =>
{
   collection._byId[model.cid] = model;
   const id = collection.modelId(model.attributes);

Debug.log(`Collection - s_ADD_REFERENCE - id: ${id}; model.cid: ${model.cid}`, true);

   if (!Utils.isNullOrUndef(id)) { collection._byId[id] = model; }
   model.on('all', s_ON_MODEL_EVENT, collection);
};

/**
 * Internal method called every time a model in the set fires an event. Sets need to update their indexes when models
 * change ids. All other events simply proxy through. "add" and "remove" events that originate in other collections
 * are ignored.
 *
 * Note: Because this is the callback added to the model via Events the "this" context is associated with the model.
 *
 * @param {string}      event       - Event name
 * @param {Model}       model       - A model instance
 * @param {Collection}  collection  - A collection instance
 * @param {object}      options     - Optional parameters
 */
const s_ON_MODEL_EVENT = function(event, model, collection, options)
{
Debug.log(`Collection - s_ON_MODEL_EVENT - 0 - event: ${event}`, true);

   if ((event === 'add' || event === 'remove') && collection !== this) { return; }
   if (event === 'destroy') { this.remove(model, options); }
   if (event === 'change')
   {
      const prevId = this.modelId(model.previousAttributes());
      const id = this.modelId(model.attributes);

Debug.log(`Collection - s_ON_MODEL_EVENT - 1 - change - id: ${id}; prevId: ${prevId}`);

      if (prevId !== id)
      {
         if (!Utils.isNullOrUndef(prevId)) { delete this._byId[prevId]; }
         if (!Utils.isNullOrUndef(id)) { this._byId[id] = model; }
      }
   }

   this.trigger(...arguments);
};

/**
 * Internal method called by both remove and set.
 *
 * @param {Collection}     collection  - A collection instance
 * @param {Array<Model>}   models      - A model instance
 * @param {object}         options     - Optional parameters
 * @returns {*}
 */
const s_REMOVE_MODELS = (collection, models, options) =>
{
   const removed = [];

   for (let i = 0; i < models.length; i++)
   {
      const model = collection.get(models[i]);

Debug.log(`Collection - s_REMOVE_MODELS - 0 - model: ${model}`, true);

      if (!model) { continue; }

Debug.log(`Collection - s_REMOVE_MODELS - 1 - model: ${model.toJSON()}`);

      const index = collection.indexOf(model);

Debug.log(`Collection - s_REMOVE_MODELS - 2 - index: ${index}`);

      collection.models.splice(index, 1);
      collection.length--;

      if (!options.silent)
      {
         options.index = index;
         model.trigger('remove', model, collection, options);
      }

      removed.push(model);
      s_REMOVE_REFERENCE(collection, model, options);
   }

   return removed.length ? removed : false;
};

/**
 * Internal method to sever a model's ties to a collection.
 *
 * @param {Collection}  collection  - A collection instance
 * @param {Model}       model       - A model instance
 */
const s_REMOVE_REFERENCE = (collection, model) =>
{
   delete collection._byId[model.cid];
   const id = collection.modelId(model.attributes);

Debug.log(`Collection - s_REMOVE_REFERENCE - id: ${id}; model.cid: ${model.cid}`);

   if (!Utils.isNullOrUndef(id)) { delete collection._byId[id]; }
   if (collection === model.collection) { delete model.collection; }
   model.off('all', s_ON_MODEL_EVENT, collection);
};

/**
 * Splices `insert` into `array` at index `at`.
 *
 * @param {Array}    array    - Target array to splice into
 * @param {Array}    insert   - Array to insert
 * @param {number}   at       - Index to splice at
 */
const s_SPLICE = (array, insert, at) =>
{
   at = Math.min(Math.max(at, 0), array.length);
   const tail = new Array(array.length - at);
   const length = insert.length;

   for (let i = 0; i < tail.length; i++) { tail[i] = array[i + at]; }
   for (let i = 0; i < length; i++) { array[i + at] = insert[i]; }
   for (let i = 0; i < tail.length; i++) { array[i + length + at] = tail[i]; }
};

/**
 * Backbone.Collection - Collections are ordered sets of models. (http://backbonejs.org/#Collection)
 * -------------------
 *
 * You can bind "change" events to be notified when any model in the collection has been modified, listen for "add"
 * and "remove" events, fetch the collection from the server, and use a full suite of Underscore.js methods.
 *
 * Any event that is triggered on a model in a collection will also be triggered on the collection directly, for
 * convenience. This allows you to listen for changes to specific attributes in any model in a collection, for
 * example: documents.on("change:selected", ...)
 *
 * ---------
 *
 * Underscore methods available to Collection (including aliases):
 *
 * @see http://underscorejs.org/#chain
 * @see http://underscorejs.org/#contains
 * @see http://underscorejs.org/#countBy
 * @see http://underscorejs.org/#difference
 * @see http://underscorejs.org/#each
 * @see http://underscorejs.org/#every
 * @see http://underscorejs.org/#filter
 * @see http://underscorejs.org/#find
 * @see http://underscorejs.org/#first
 * @see http://underscorejs.org/#groupBy
 * @see http://underscorejs.org/#indexBy
 * @see http://underscorejs.org/#indexOf
 * @see http://underscorejs.org/#initial
 * @see http://underscorejs.org/#invoke
 * @see http://underscorejs.org/#isEmpty
 * @see http://underscorejs.org/#last
 * @see http://underscorejs.org/#lastIndexOf
 * @see http://underscorejs.org/#map
 * @see http://underscorejs.org/#max
 * @see http://underscorejs.org/#min
 * @see http://underscorejs.org/#partition
 * @see http://underscorejs.org/#reduce
 * @see http://underscorejs.org/#reduceRight
 * @see http://underscorejs.org/#reject
 * @see http://underscorejs.org/#rest
 * @see http://underscorejs.org/#sample
 * @see http://underscorejs.org/#shuffle
 * @see http://underscorejs.org/#some
 * @see http://underscorejs.org/#sortBy
 * @see http://underscorejs.org/#size
 * @see http://underscorejs.org/#toArray
 * @see http://underscorejs.org/#without
 *
 * @example
 *
 * If using Backbone-ES6 by ES6 source one can create a module for a Backbone.Collection:
 *
 * export default new Backbone.Collection(null,
 * {
 *    model: Backbone.Model.extend(...)
 * });
 *
 * or if importing a specific model class
 *
 * import Model from '<MY-BACKBONE-MODEL>'
 *
 * export default new Backbone.Collection(null,
 * {
 *    model: Model
 * });
 *
 * or use full ES6 style by using a getter for "model":
 *
 * import Model from '<MY-BACKBONE-MODEL>'
 *
 * class MyCollection extends Backbone.Collection
 * {
 *    get model() { return Model; }
 * }
 *
 * export default new MyCollection();   // If desired drop "new" to export the class itself and not an instance.
 */
class Collection extends Events
{
   /**
    * When creating a Collection, you may choose to pass in the initial array of models. The collection's comparator
    * may be included as an option. Passing false as the comparator option will prevent sorting. If you define an
    * initialize function, it will be invoked when the collection is created. There are a couple of options that, if
    * provided, are attached to the collection directly: model and comparator.
    *
    * Pass null for models to create an empty Collection with options.
    *
    * @see http://backbonejs.org/#Collection-constructor
    *
    * @param {Array<Model>}   models   - An optional array of models to set.
    * @param {object}         options  - Optional parameters
    */
   constructor(models = [], options = {})
   {
      super();

      // Allows child classes to abort constructor execution.
      if (_.isBoolean(options.abortCtor) && options.abortCtor) { return; }

      // Must detect if there are any getters defined in order to skip setting these values.
      const hasModelGetter = !_.isUndefined(this.model);
      const hasComparatorGetter = !_.isUndefined(this.comparator);

      // The default model for a collection is just a **Backbone.Model**. This should be overridden in most cases.
      if (!hasModelGetter)
      {
         /**
          * The default Backbone.Model class to use as a prototype for this collection.
          * @type {Model}
          */
         this.model = Model;
      }

      if (options.model && !hasModelGetter) { this.model = options.model; }

      if (options.comparator !== void 0 && !hasComparatorGetter)
      {
         /**
          * A comparator string indicating the attribute to sort.
          * @type {string}
          */
         this.comparator = options.comparator;
      }

      // Allows child classes to postpone initialization.
      if (_.isBoolean(options.abortCtorInit) && options.abortCtorInit) { return; }

      this._reset();

      this.initialize(...arguments);

      if (models) { this.reset(models, _.extend({ silent: true }, options)); }
   }

   /**
    * Add a model (or an array of models) to the collection, firing an "add" event for each model, and an "update"
    * event afterwards. If a model property is defined, you may also pass raw attributes objects, and have them be
    * vivified as instances of the model. Returns the added (or preexisting, if duplicate) models. 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 attributes will
    * be merged into the corresponding models, firing any appropriate "change" events.
    *
    * Note that adding the same model (a model with the same id) to a collection more than once is a no-op.
    *
    * @example
    * var ships = new Backbone.Collection;
    *
    * ships.on("add", function(ship) {
    *    alert("Ahoy " + ship.get("name") + "!");
    * });
    *
    * ships.add([
    *    {name: "Flying Dutchman"},
    *    {name: "Black Pearl"}
    * ]);
    *
    * @see http://backbonejs.org/#Collection-add
    *
    * @param {Model|Array<Model>}   models   - A single model or an array of models to add.
    * @param {object}               options  - Optional parameters
    * @returns {*}
    */
   add(models, options)
   {
      return this.set(models, _.extend({ merge: false }, options, s_ADD_OPTIONS));
   }

   /**
    * 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.
    *
    * @see http://backbonejs.org/#Collection-at
    *
    * @param {number}   index - Index for model to retrieve.
    * @returns {*}
    */
   at(index)
   {
      if (index < 0) { index += this.length; }
      return this.models[index];
   }

   /**
    * Returns a new instance of the collection with an identical list of models.
    *
    * @see http://backbonejs.org/#Collection-clone
    *
    * @returns {Collection} Returns a new collection with shared models.
    */
   clone()
   {
      return new this.constructor(this.models, {
         model: this.model,
         comparator: this.comparator
      });
   }

   /**
    * Convenience to create a new instance of a model within a collection. Equivalent to instantiating a model with a
    * hash of attributes, saving the model to the server, and adding the model to the set after being successfully
    * created. Returns the new model. If client-side validation failed, the model will be unsaved, with validation
    * errors. In order for this to work, you should set the model property of the collection. The create method can
    * accept either an attributes hash or an existing, unsaved model object.
    *
    * Creating a model will cause an immediate "add" event to be triggered on the collection, a "request" event as the
    * new model is sent to the server, as well as a "sync" event, once the server has responded with the successful
    * creation of the model. Pass {wait: true} if you'd like to wait for the server before adding the new model to the
    * collection.
    *
    * @example
    * var Library = Backbone.Collection.extend({
    *     model: Book
    * });
    *
    * var nypl = new Library;
    *
    * var othello = nypl.create({
    *    title: "Othello",
    *    author: "William Shakespeare"
    * });
    *
    * @see http://backbonejs.org/#Collection-create
    *
    * @param {Model}    attrs    - Attributes hash for the new model
    * @param {object}   options  - Optional parameters
    * @returns {*}
    */
   create(attrs, options)
   {
      options = options ? _.clone(options) : {};
      const wait = options.wait;
      const model = this._prepareModel(attrs, options);

      if (!model) { return false; }
      if (!wait) { this.add(model, options); }

      const collection = this;
      const success = options.success;

      options.success = function(model, resp, callbackOpts)
      {
         if (wait) { collection.add(model, callbackOpts); }
         if (success) { success.call(callbackOpts.context, model, resp, callbackOpts); }
      };

      model.save(null, options);

      return model;
   }

   /**
    * Fetch the default set of models for this collection from the server, setting them on the collection when they
    * arrive. The options hash takes success and error callbacks which will both be passed (collection, response,
    * options) as arguments. When the model data returns from the server, it uses set to (intelligently) merge the
    * fetched models, unless you pass {reset: true}, in which case the collection will be (efficiently) reset.
    * Delegates to Backbone.sync under the covers for custom persistence strategies and returns a jqXHR. The server
    * handler for fetch requests should return a JSON array of models.
    *
    * The behavior of fetch can be customized by using the available set options. For example, to fetch a collection,
    * getting an "add" event for every new model, and a "change" event for every changed existing model, without
    * removing anything: collection.fetch({remove: false})
    *
    * jQuery.ajax options can also be passed directly as fetch options, so to fetch a specific page of a paginated
    * collection: Documents.fetch({data: {page: 3}})
    *
    * Note that fetch should not be used to populate collections on page load — all models needed at load time should
    * already be bootstrapped in to place. fetch is intended for lazily-loading models for interfaces that are not
    * needed immediately: for example, documents with collections of notes that may be toggled open and closed.
    *
    * @example
    * Backbone.sync = function(method, model) {
    *    alert(method + ": " + model.url);
    * };
    *
    * var accounts = new Backbone.Collection;
    * accounts.url = '/accounts';
    *
    * accounts.fetch();
    *
    * @see http://backbonejs.org/#Collection-fetch
    *
    * @param {object}   options  - Optional parameters
    * @returns {*}
    */
   fetch(options)
   {
      options = _.extend({ parse: true }, options);
      const success = options.success;

      options.success = (resp) =>
      {
         const method = options.reset ? 'reset' : 'set';
Debug.log(`Collection - fetch - success callback - method: ${method}`, true);
         this[method](resp, options);

         if (success) { success.call(options.context, this, resp, options); }

         this.trigger('sync', this, resp, options);
      };

      Utils.wrapError(this, options);

      return this.sync('read', this, options);
   }

   /**
    * Just like `where`, but directly returns only the first model in the collection that matches the passed
    * attributes.
    *
    * @see http://backbonejs.org/#Collection-findWhere
    *
    * @param {object}   attrs - Attribute hash to match.
    * @returns {*}
    */
   findWhere(attrs)
   {
      return this.where(attrs, true);
   }

   /**
    * Get a model from a collection, specified by an id, a cid, or by passing in a model.
    *
    * @example
    * var book = library.get(110);
    *
    * @see http://backbonejs.org/#Collection-get
    *
    * @param {Model} obj   - An instance of a model to search for by object, id, or cid.
    * @returns {*}
    */
   get(obj)
   {
      if (Utils.isNullOrUndef(obj)) { return void 0; }

      const id = this.modelId(Utils.isModel(obj) ? obj.attributes : obj);

Debug.log(`Collection - get - id: ${id}`);

      return this._byId[obj] || this._byId[id] || this._byId[obj.cid];
   }

   /**
    * Initialize is an empty function by default. Override it with your own initialization logic.
    *
    * @see http://backbonejs.org/#Collection-constructor
    * @abstract
    */
   initialize()
   {
   }

   /**
    * Override this method to specify the attribute the collection will use to refer to its models in collection.get.
    * By default returns the idAttribute of the collection's model class or failing that, 'id'. If your collection uses
    * polymorphic models and those models have an idAttribute other than id you must override this method with your own
    * custom logic.
    *
    * @example
    * var Library = Backbone.Collection.extend({
    *    model: function(attrs, options) {
    *       if (condition) {
    *          return new PublicDocument(attrs, options);
    *       } else {
    *          return new PrivateDocument(attrs, options);
    *       }
    *    },
    *
    *    modelId: function(attrs) {
    *       return attrs.private ? 'private_id' : 'public_id';
    *    }
    * });
    *
    * @see http://backbonejs.org/#Collection-modelId
    *
    * @param {object}   attrs - Attributes hash
    * @returns {*}
    */
   modelId(attrs)
   {
Debug.log(`Collection - modelId - 0 - this.model.prototype.idAttribute: ${this.model.prototype.idAttribute}`, true);
Debug.log(`Collection - modelId - 1 - attrs: ${JSON.stringify(attrs)}`);

      return attrs[this.model.prototype.idAttribute || 'id'];
   }

   /* eslint-disable no-unused-vars */
   /**
    * `parse` is called by Backbone whenever a collection's models are returned by the server, in fetch. The function is
    * passed the raw response object, and should return the array of model attributes to be added to the collection.
    * The default implementation is a no-op, simply passing through the JSON response. Override this if you need to
    * work with a preexisting API, or better namespace your responses.
    *
    * @example
    * var Tweets = Backbone.Collection.extend({
    *    // The Twitter Search API returns tweets under "results".
    *    parse: function(response) {
    *       return response.results;
    *    }
    * });
    *
    * @see http://backbonejs.org/#Collection-parse
    *
    * @param {object}   resp - Usually a JSON object.
    * @param {object}   options - Unused optional parameters.
    * @returns {object} Pass through to set the attributes hash on the model.
    */
   parse(resp, options)
   {
      /* eslint-enable no-unused-vars */
      return resp;
   }

   /**
    * Pluck an attribute from each model in the collection. Equivalent to calling map and returning a single attribute
    * from the iterator.
    *
    * @example
    * var stooges = new Backbone.Collection([
    *    {name: "Curly"},
    *    {name: "Larry"},
    *    {name: "Moe"}
    * ]);
    *
    * var names = stooges.pluck("name");
    *
    * alert(JSON.stringify(names));
    *
    * @see http://backbonejs.org/#Collection-pluck
    *
    * @param {string}   attr  - Attribute key
    * @returns {*}
    */
   pluck(attr)
   {
      return _.invoke(this.models, 'get', attr);
   }

   /**
    * Remove and return the last model from a collection. Takes the same options as remove.
    *
    * @see http://backbonejs.org/#Collection-pop
    *
    * @param {object}   options  - Optional parameters
    * @returns {*}
    */
   pop(options)
   {
      const model = this.at(this.length - 1);
      return this.remove(model, options);
   }

   /**
    * Prepare a hash of attributes (or other model) to be added to this collection.
    *
    * @protected
    * @param {object}         attrs       - Attribute hash
    * @param {object}         options     - Optional parameters
    * @returns {*}
    */
   _prepareModel(attrs, options)
   {
      if (Utils.isModel(attrs))
      {
Debug.log(`Collection - _prepareModel - 0`, true);
         if (!attrs.collection) { attrs.collection = this; }
         return attrs;
      }

      options = options ? _.clone(options) : {};
      options.collection = this;

Debug.log(`Collection - _prepareModel - 1 - attrs.parseObject: ${attrs.parseObject}`);

      const model = new this.model(attrs, options);

      if (!model.validationError) { return model; }

      this.trigger('invalid', this, model.validationError, options);

      return false;
   }

   /**
    * Add a model at the end of a collection. Takes the same options as `add`.
    *
    * @see http://backbonejs.org/#Collection-push
    *
    * @param {Model}    model    - A Model instance
    * @param {object}   options  - Optional parameters
    * @returns {*}
    */
   push(model, options)
   {
      return this.add(model, _.extend({ at: this.length }, options));
   }

   /**
    * Remove a model (or an array of models) from the collection, and return them. Each model can be a Model instance,
    * an id string or a JS object, any value acceptable as the id argument of collection.get. Fires a "remove" event
    * for each model, and a single "update" event afterwards. The model's index before removal is available to
    * listeners as options.index.
    *
    * @see http://backbonejs.org/#Collection-remove
    *
    * @param {Model|Array<Model>}   models   - An single model or an array of models to remove.
    * @param {object}               options  - Optional parameters
    * @returns {*}
    */
   remove(models, options)
   {
      options = _.extend({}, options);
      const singular = !_.isArray(models);
      models = singular ? [models] : _.clone(models);
      const removed = s_REMOVE_MODELS(this, models, options);

      if (!options.silent && removed) { this.trigger('update', this, options); }

      return singular ? removed[0] : removed;
   }

   /**
    * Resets all internal state. Called when the collection is first initialized or reset.
    * @protected
    */
   _reset()
   {
      /**
       * The length of the models array.
       * @type {number}
       */
      this.length = 0;

      /**
       * An array of models in the collection.
       * @type {Array<Model>}
       */
      this.models = [];

      this._byId = {};
   }

   /**
    * 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), triggering a single "reset" event at the end. Returns the newly-set models. For
    * convenience, within a "reset" event, the list of any previous models is available as options.previousModels.
    * Pass null for models to empty your Collection with options.
    *
    * Calling collection.reset() without passing any models as arguments will empty the entire collection.
    *
    * Here's an example using reset to bootstrap a collection during initial page load, in a Rails application:
    * @example
    * <script>
    *    var accounts = new Backbone.Collection;
    *    accounts.reset(<%= @accounts.to_json %>);
    * </script>
    *
    * @see http://backbonejs.org/#Collection-reset
    *
    * @param {Array<Model>}   models   - An array of models to add silently after resetting.
    * @param {object}         options  - Optional parameters
    * @returns {*}
    */
   reset(models, options)
   {
      options = options ? _.clone(options) : {};

      for (let i = 0; i < this.models.length; i++) { s_REMOVE_REFERENCE(this, this.models[i]); }

      options.previousModels = this.models;

      this._reset();

      models = this.add(models, _.extend({ silent: true }, options));

      if (!options.silent) { this.trigger('reset', this, options); }

      return models;
   }

   /**
    * The set method performs a "smart" update of the collection with the passed 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. All of the
    * appropriate "add", "remove", and "change" events are fired as this happens. Returns the touched models in the
    * collection. 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 Backbone.Collection([eddie, alex, stone, roth]);
    *
    * vanHalen.set([eddie, alex, stone, hagar]);
    *
    * // Fires a "remove" event for roth, and an "add" event for "hagar".
    * // Updates any of stone, alex, and eddie's attributes that may have
    * // changed over the years.
    *
    * @see http://backbonejs.org/#Collection-set
    *
    * @param {Array<Model>}   models   - An array of models to set.
    * @param {object}         options  - Optional parameters
    * @returns {*}
    */
   set(models, options)
   {
Debug.log(`Collection - set - 0`, true);
      if (Utils.isNullOrUndef(models)) { return; }

      options = _.defaults({}, options, s_SET_OPTIONS);
      if (options.parse && !Utils.isModel(models)) { models = this.parse(models, options); }

      const singular = !_.isArray(models);
      models = singular ? [models] : models.slice();

      let at = options.at;
      if (!Utils.isNullOrUndef(at)) { at = +at; }
      if (at < 0) { at += this.length + 1; }

Debug.log(`Collection - set - 1 - at: ${at}; models.length: ${models.length}`);

      const set = [];
      const toAdd = [];
      const toRemove = [];
      const modelMap = {};

      const add = options.add;
      const merge = options.merge;
      const remove = options.remove;

      let sort = false;
      const sortable = this.comparator && Utils.isNullOrUndef(at) && options.sort !== false;
      const sortAttr = _.isString(this.comparator) ? this.comparator : null;

      // Turn bare objects into model references, and prevent invalid models from being added.
      let model;

      for (let i = 0; i < models.length; i++)
      {
         model = models[i];

         // If a duplicate is found, prevent it from being added and optionally merge it into the existing model.
         const existing = this.get(model);
         if (existing)
         {
Debug.log(`Collection - set - 2 - existing`);

            if (merge && model !== existing)
            {
Debug.log(`Collection - set - 3 - merge && model !== existing`);

               let attrs = Utils.isModel(model) ? model.attributes : model;
               if (options.parse) { attrs = existing.parse(attrs, options); }
               existing.set(attrs, options);
               if (sortable && !sort) { sort = existing.hasChanged(sortAttr); }
            }

            if (!modelMap[existing.cid])
            {
Debug.log(`Collection - set - 4 - !modelMap[existing.cid]`);

               modelMap[existing.cid] = true;
               set.push(existing);
            }

            models[i] = existing;

            // If this is a new, valid model, push it to the `toAdd` list.
         }
         else if (add)
         {
Debug.log(`Collection - set - 5 - add`);

            model = models[i] = this._prepareModel(model, options);

            if (model)
            {
Debug.log(`Collection - set - 6 - toAdd`);

               toAdd.push(model);
               s_ADD_REFERENCE(this, model);
               modelMap[model.cid] = true;
               set.push(model);
            }
         }
      }

      // Remove stale models.
      if (remove)
      {
         for (let i = 0; i < this.length; i++)
         {
            model = this.models[i];
            if (!modelMap[model.cid])
            {
Debug.log(`Collection - set - 7 - toRemove push`);
               toRemove.push(model);
            }
         }

         if (toRemove.length)
         {
Debug.log(`Collection - set - 8 - before invoking s_REMOVE_MODELS`);
            s_REMOVE_MODELS(this, toRemove, options);
         }
      }

      // See if sorting is needed, update `length` and splice in new models.
      let orderChanged = false;
      const replace = !sortable && add && remove;

      if (set.length && replace)
      {
         orderChanged = this.length !== set.length || _.some(this.models, (model, index) =>
         {
            return model !== set[index];
         });

Debug.log(`Collection - set - 9 - set.length > 0 && replace - orderChanged: ${orderChanged}`);

         this.models.length = 0;

         s_SPLICE(this.models, set, 0);

         this.length = this.models.length;
      }
      else if (toAdd.length)
      {
         if (sortable) { sort = true; }

Debug.log(`Collection - set - 10 - toAdd.length > 0 - sort: ${sort}; at: ${at}`);

         s_SPLICE(this.models, toAdd, Utils.isNullOrUndef(at) ? this.length : at);

         this.length = this.models.length;
      }

      // Silently sort the collection if appropriate.
      if (sort)
      {
Debug.log(`Collection - set - 11 - sorting silent`);

         this.sort({ silent: true });
      }

      // Unless silenced, it's time to fire all appropriate add/sort events.
      if (!options.silent)
      {
Debug.log(`Collection - set - 12 - !options.silent: ${!options.silent}`);

         for (let i = 0; i < toAdd.length; i++)
         {
            if (!Utils.isNullOrUndef(at)) { options.index = at + i; }

            model = toAdd[i];
            model.trigger('add', model, this, options);
         }

         if (sort || orderChanged) { this.trigger('sort', this, options); }
         if (toAdd.length || toRemove.length) { this.trigger('update', this, options); }
      }

      // Return the added (or merged) model (or models).
      return singular ? models[0] : models;
   }

   /**
    * Remove and return the first model from a collection. Takes the same options as `remove`.
    *
    * @see http://backbonejs.org/#Collection-shift
    *
    * @param {object}   options  - Optional parameters
    * @returns {*}
    */
   shift(options)
   {
      const model = this.at(0);
      return this.remove(model, options);
   }

   /**
    * Return a shallow copy of this collection's models, using the same options as native `Array#slice`.
    *
    * @see http://backbonejs.org/#Collection-slice
    *
    * @returns {*}
    */
   slice()
   {
      return Array.prototype.slice.apply(this.models, arguments);
   }

   /**
    * Force a collection to re-sort itself. You don't need to call this under normal circumstances, as a collection
    * with a comparator will sort itself whenever a model is added. To disable sorting when adding a model, pass
    * {sort: false} to add. Calling sort triggers a "sort" event on the collection.
    *
    * @see http://backbonejs.org/#Collection-sort
    *
    * @param {object}   options  - Optional parameters
    * @returns {Collection}
    */
   sort(options = {})
   {
      let comparator = this.comparator;

      if (!comparator) { throw new Error('Cannot sort a set without a comparator'); }

      const length = comparator.length;

      if (_.isFunction(comparator)) { comparator = _.bind(comparator, this); }

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

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

      return this;
   }

   /**
    * Uses Backbone.sync to persist the state of a collection to the server. Can be overridden for custom behavior.
    *
    * @see http://backbonejs.org/#Collection-sync
    *
    * @returns {*}
    */
   sync()
   {
Debug.log("Collection - sync", true);
      return BackboneProxy.backbone.sync.apply(this, arguments);
   }

   /**
    * Return an array containing the attributes hash of each model (via toJSON) in the collection. This can be used to
    * serialize and persist the collection as a whole. The name of this method is a bit confusing, because it conforms
    * to JavaScript's JSON API.
    *
    * @example
    * var collection = new Backbone.Collection([
    *    {name: "Tim", age: 5},
    *    {name: "Ida", age: 26},
    *    {name: "Rob", age: 55}
    * ]);
    *
    * alert(JSON.stringify(collection));
    *
    * @see http://backbonejs.org/#Collection-toJSON
    *
    * @param {object}   options  - Optional parameters
    * @returns {object} JSON
    */
   toJSON(options)
   {
      return this.map((model) => { return model.toJSON(options); });
   }

   /**
    * Add a model at the beginning of a collection. Takes the same options as `add`.
    *
    * @see http://backbonejs.org/#Collection-unshift
    *
    * @param {Model}    model    - A Model instance
    * @param {object}   options  - Optional parameters
    * @returns {*}
    */
   unshift(model, options)
   {
      return this.add(model, _.extend({ at: 0 }, options));
   }

   /**
    * Return an array of all the models in a collection that match the passed attributes. Useful for simple cases of
    * filter.
    *
    * @example
    * var friends = new Backbone.Collection([
    *    {name: "Athos",      job: "Musketeer"},
    *    {name: "Porthos",    job: "Musketeer"},
    *    {name: "Aramis",     job: "Musketeer"},
    *    {name: "d'Artagnan", job: "Guard"},
    * ]);
    *
    * var musketeers = friends.where({job: "Musketeer"});
    *
    * alert(musketeers.length);
    *
    * @see http://backbonejs.org/#Collection-where
    *
    * @param {object}   attrs - Attribute hash to match.
    * @param {boolean}  first - Retrieve first match or all matches.
    * @returns {*}
    */
   where(attrs, first)
   {
      return this[first ? 'find' : 'filter'](attrs);
   }
}

// Underscore methods that we want to implement on the Collection. 90% of the core usefulness of Backbone Collections
// is actually implemented right here:
const collectionMethods =
{
   forEach: 3, each: 3, map: 3, collect: 3, reduce: 4,
   foldl: 4, inject: 4, reduceRight: 4, foldr: 4, find: 3, detect: 3, filter: 3,
   select: 3, reject: 3, every: 3, all: 3, some: 3, any: 3, include: 3, includes: 3,
   contains: 3, invoke: 0, max: 3, min: 3, toArray: 1, size: 1, first: 3,
   head: 3, take: 3, initial: 3, rest: 3, tail: 3, drop: 3, last: 3,
   without: 0, difference: 0, indexOf: 3, shuffle: 1, lastIndexOf: 3,
   isEmpty: 1, chain: 1, sample: 3, partition: 3, groupBy: 3, countBy: 3,
   sortBy: 3, indexBy: 3
};

// Mix in each Underscore method as a proxy to `Collection#models`.
Utils.addUnderscoreMethods(Collection, collectionMethods, 'models');

/**
 * Exports the Collection class.
 */
export default Collection;