FormModel.js

(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    define(['underscore', 'backbone', './NestedModel', './validation'], factory);
  } else if (typeof exports === 'object') {
    module.exports = factory(require('underscore'), require('backbone'), require('./NestedModel'), require('./validation'));
  } else {
    root.Torso = root.Torso || {};
    root.Torso.FormModel = factory(root._, root.Backbone, root.Torso.NestedModel, root.Torso.validation);
  }
}(this, function(_, Backbone, NestedModel, validation) {
  'use strict';

  var $ = Backbone.$;

  /**
   * Generic Form Model
   *
   * @class FormModel
   * @extends NestedModel
   * @mixes validationMixin
   *
   * @author kent.willis@vecna.com
   *
   * @see <a href="../annotated/modules/FormModel.html">FormModel Annotated Source</a>
   */
  var FormModel = NestedModel.extend(/** @lends FormModel# */{
    /**
     * @private
     * @property __currentMappings
     * @type Object
     */
    /**
     * @private
     * @property __cache
     * @type Object
     */
    /**
     * @private
     * @property __currentObjectModels
     * @type Object
     */
    /**
     * @private
     * @property __currentUpdateEvents
     * @type Array
     */
    /**
     * @property validation
     * @type Object
     */
    /**
     * @property labels
     * @type Object
     */
    /**
     * Map from aliases (either model names or computed value names) to mappings.
     * Please refer to the documentation on the constructor about the form and options for this field.
     * @property mapping
     * @type Object
     */
    mapping: undefined,

    /**
     * Map from model aliases to model instances.
     * Please refer to the documentation on the constructor about the form and options for this field.
     * @property models
     * @type Object
     */
    models: undefined,

    /**
     * Constructor the form model. Can take in attributes to set initially. These will override any pulled values from object models
     * on initialization. On initialization the object model's values will be pulled once.
     * For the options, here are needed definitions:
     * mapping: {
     *   modelName: 'foo bar baz' // track a model by providing an alias for a name and a space seperated list of fields to track as a String
     *   modelName2: true          // to track all fields
     *   ...                      // can have many model mappings
     *   computedName: {
     *     modelName: 'taz raz',  // mappings for models that will be used for this computed mapping.
     *     ...                    // can have many model mappings for a computed
     *     pull: function(models) {}, // a callback that will be invoked when pulling data from the Object model. Passes in a map of model alias/name to shallow copies of fields being tracked on that model.
     *     push: function(models) {}  // a callback that will be invoked when pushing data to the Object model. Passes in a map of model alias/name to object model being tracked under that alias.
     *   }
     * },
     * models: {
     *   modelName: modelInstance,  // optionally, provide a set of model instance to model name (aliases) to start tracking
     *   modelName2: modelInstance2 // provide as many aliases to model instances as you'd like
     * }
     * @param {Object} [options]
     *   @param {Object} [options.mapping] map from aliases (either model names or computed value names) to mappings.
     *     A model mapping can bind an alias to a space seperated list of fields to track as a String  r the boolean true if it is mapping all the
     *     fields. A computed mapping can bind an alias to a set of model mappings required for this computed value and both a pull and/or push method
     *     that are used to compute different values to or from object model(s).
     *   @param {Object} [options.models] Because the options.mapping parameter only allows you to define the mappings to aliases, this options allows
     *     you to bind model instances to aliases. Setting model instances to aliases are required to actually begin pulling/pushing values.
     *   @param {boolean} [options.startUpdating=false] set to true if you want to immediately set up listeners to update this form
     *     model as the object model updates. You can always toggle this state with startUpdating() and stopUpdating().
     *   @param {Object} [options.validation] A Backbone.Validation plugin hash to dictate the validation rules
     *   @param {Object} [options.labels] A Backbone.Validation plugin hash to dictate the attribute labels
     */
    constructor: function(attributes, options) {
      options = options || {};
      this.__cache = {};
      this.__currentUpdateEvents = [];
      this.__currentMappings = {};
      this.__currentObjectModels = {};

      // override + extend the validation and labels hashes
      this.validation = _.extend({}, this.validation, options.validation);
      this.labels = _.extend({}, this.labels, options.labels);

      NestedModel.apply(this, arguments);

      this.__initMappings(options);

      // Do an initial pull
      this.pull();

      // The pull may have overridden default attributes
      if (attributes) {
        this.set(attributes);
      }

      // Begin updating if requested
      if (options.startUpdating) {
        this.startUpdating();
      }
      this.trigger('initialization-complete');
    },

    /**
     * @param {string} alias the alias of the mapping - either a model mapping or a computed mapping
     * @returns the mapping config for that alias
     */
    getMapping: function(alias) {
      return this.__currentMappings[alias];
    },

    /**
     * @returns all the current mapping configs
     */
    getMappings: function() {
      return this.__currentMappings;
    },

    /**
     * Define or redefine how the form model pull/pushes or otherwise tracks properties between an object model(s).
     * Examples:
     * this.setMapping('modelAlias', true, optional model instance);
     * this.setMapping('modelAlias, 'foo bar baz', optional model instance);
     * this.setMapping('computedAlias', {
     *   model1: 'foo',
     *   model2: 'bar',
     *   push: function(models) {
     *     models.model1.set('foo', this.get('foobar')[0]);
     *     models.model2.set('bar', this.get('foobar')[1]);
     *   },
     *   pull: function(models) {
     *     this.set('foobar', [models.model1.foo, models.model2.bar]);
     *   },
     * }, optional model map)
     * @param {string} alias the name for the mapping - either a model mapping or a computed mapping
     * @param {(string|boolean|Object)} mapping Provides the mapping for this alias. If trying to map to a model, then either provide
     *  a space delimited list of fields to track as a String or the boolean true to track all the model's fields. If the mapping is for
     *  a computed value, then provide a map from model alias to model mapping for all the fields needed for the computed and a pull method
     *  if you want to change/combine/split object model properties before bringing them into the form model and a push method if you want to
     *  change/combine/split form model properties before pushing them to the object models.
     * @param {Object|external:Backbone-Model} [models] Provides instances to use for this mapping. If mapping is a computed,
     *   provide a map from alias to model instance. If mapping is for a single model, just provide the model instance for that alias.
     * @param [copy=false] if true, will pull values definined by this mapping after setting the mapping. Requires models to be passed in.
     */
    setMapping: function(alias, mapping, models, copy) {
      var computed, fields,
        config = {};
      if (_.isString(mapping)) {
        fields = mapping.split(' ');
      } else if (mapping === true) {
        fields = undefined;
      } else if (_.isObject(mapping)) {
        mapping = _.clone(mapping);
        computed = true;
      }
      config.computed = computed;
      if (computed) {
        config.mapping = mapping;
        _.each(this.__getModelAliases(config), function(modelAlias) {
          var configMappingForAlias = config.mapping[modelAlias];
          if (_.isString(configMappingForAlias)) {
            configMappingForAlias = configMappingForAlias.split(' ');
          } else if (configMappingForAlias === true) {
            configMappingForAlias = undefined;
          }
          config.mapping[modelAlias] = configMappingForAlias;
        });
      } else {
        config.mapping = fields;
      }
      this.__currentMappings[alias] = config;
      if (models) {
        if (computed) {
          this.trackModels(models, copy);
        } else {
          this.trackModel(alias, models, copy);
        }
      }
    },

    /**
     * Sets multiple mappings (both model mappings and computed value mappings) with one call.
     * Uses the same style of mapping syntax as the constructor. Please refer to the documentation on the constructor.
     * Here is an example:
     * this.setMappings({
     *   model1: 'foo bar',
     *   model2: 'baz',
     *   ssn: {
     *     model1: 'ssn',
     *     model2: 'lastssn'
     *     push: function(models) {},
     *     pull: function(models) {},
     *   }
     * }, optional model map)
     * @param {Object} mappings Uses the same style of mapping syntax as the constructor. Please refer to the documentation on the constructor.
     * @param {Object} [models] this parameter allows you to immediately bind model instances to aliases. Keys are aliases and values are external:Backbone-Models.
     * @param [copy=false] if true, will pull values definined by this mapping after setting the mapping. Requires models to be passed in.
     */
    setMappings: function(mappings, models, copy) {
      _.each(mappings, function(mapping, alias) {
        this.setMapping(alias, mapping);
      }, this);
      if (models) {
        this.trackModels(models, copy);
      }
    },

    /**
     * Remove a mapping (model or computed) by alias
     * @param {string|external:Backbone-Model} aliasOrModel if a String is provided, it will unset the mapping with that alias.
     *   If a external:Backbone-Model is provided, it will remove the model mapping that was bound to that model.
     * @param {boolean} [removeModelIfUntracked=false] If true, after the mapping is removed, the model will also be unset but only if
     *   no other mappings reference it. Note, setting this to true will not remove any computed mappings that also use that model.
     */
    unsetMapping: function(aliasOrModel, removeModelIfUntracked) {
      var alias = this.__findAlias(aliasOrModel);
      if (alias) {
        delete this.__currentMappings[alias];
      }
      var model = this.getTrackedModel(alias);
      if (removeModelIfUntracked && model && _.isEmpty(this.__getTrackedModelFields(model))) {
        this.untrackModel(model);
      }
    },

    /**
     * Removes all current mappings
     * Does NOT remove current model being tracked. Call this.untrackModels afterwards if you wish this behavior.
     */
    unsetMappings: function() {
      this.__currentMappings = {};
      this.resetUpdating();
    },

    /**
     * Returns the object model currently bound to the given name/alias.
     * @param {string} alias the name/alias used by the mappings.
     * @returns {external:Backbone-Model} the model currently bound to the alias
     */
    getTrackedModel: function(alias) {
      return this.__currentObjectModels[alias];
    },

    /**
     * Returns all the currently tracked object models
     * @returns all the currently tracked object models
     */
    getTrackedModels: function() {
      return _.values(this.__currentObjectModels);
    },

    /**
     * Use {@link FormModel#trackModel} instead.
     * @see {@link FormModel#trackModel}
     * @deprecated
     */
    setTrackedModel: function() {
      this.trackModel.apply(this, arguments);
    },

    /**
     * Update or create a binding between an object model and an alias.
     * @param {string} alias the alias/name to bind to.
     * @param {external:Backbone-Model} model the model to be bound. Mappings referencing this alias will start applying to this model.
     * @param {boolean} [copy=false] if true, the form model will perform a pull on any mappings using this alias.
     */
    trackModel: function(alias, model, copy) {
      this.__currentObjectModels[alias] = model;
      this.__updateCache(model);
      this.resetUpdating();
      if (copy) {
        _.each(this.getMappings(), function(config, mappingAlias) {
          var modelAliases;
          if (alias === mappingAlias) {
            this.__pull(mappingAlias);
          }
          if (config.computed) {
            modelAliases = this.__getModelAliases(mappingAlias);
            if (_.contains(modelAliases, alias)) {
              this.__pull(mappingAlias);
            }
          }
        }, this);
      }
    },

    /**
     * Use {@link FormModel#trackModels} instead.
     * @see {@link FormModel#trackModels}
     * @deprecated
     */
    setTrackedModels: function() {
      this.trackModels.apply(this, arguments);
    },

    /**
     * Binds multiple models to their aliases.
     * @param {Object.<string, external:Backbone-Model>} models A map from alias/name to model to be bound to that alias.
     * @param {boolean} [copy=false] if true, the form model will perform a pull on any mapping using these models.
     */
    trackModels: function(models, copy) {
      _.each(models, function(instance, alias) {
        this.trackModel(alias, instance, copy);
      }, this);
    },

    /**
     * Use {@link FormModel#untrackModel} instead.
     * @see {@link FormModel#untrackModel}
     * @deprecated
     */
    unsetTrackedModel: function() {
      this.untrackModel.apply(this, arguments);
    },

    /**
     * Removes the binding between a model alias and a model instance. Effectively stops tracking that model.
     * @param {string|external:Backbone-Model} aliasOrModel If a string is given, it will unset the model using that alias. If a model instance
     *   is given, it will unbind whatever alias is currently bound to it.
     */
    untrackModel: function(aliasOrModel) {
      var model,
        alias = this.__findAlias(aliasOrModel);
      if (alias) {
        model = this.__currentObjectModels[alias];
        delete this.__currentObjectModels[alias];
        this.__updateCache(model);
      }
      this.resetUpdating();
    },

    /**
     * Use {@link FormModel#untrackModels} instead.
     * @see {@link FormModel#untrackModels}
     * @deprecated
     */
    unsetTrackedModels: function() {
      this.untrackModels.apply(this, arguments);
    },

    /**
     * Removes all the bindings between model aliases and model instances. Effectively stops tracking the current models.
     */
    untrackModels: function() {
      this.__currentObjectModels = [];
      this.__updateCache();
      this.resetUpdating();
    },

    /**
     * Pushes values from this form model back to the object models it is tracking. This includes invoking the push callbacks from
     * computed values
     */
    push: function() {
      _.each(this.getMappings(), function(config, alias) {
        this.__push(alias);
      }, this);
    },

    /**
     * Pulls the most recent values of every object model that this form model tracks including computed values
     * NOTE: using this method can override user-submitted data from an HTML form. Use caution.
     */
    pull: function() {
      _.each(this.getMappings(), function(config, alias) {
        this.__pull(alias);
      }, this);
      this.__updateCache();
    },

    /**
     * If FormModel has a "url" property defined, it will invoke a save on the form model, and after successfully
     * saving, will perform a push.
     * If no "url" property is defined then the following behavior is used:
     * Pushes the form model values to the object models it is tracking and invokes save on each one. Returns a promise.
     * NOTE: if no url is specified and no models are being tracked, it will instead trigger a 'save-fail' event and reject the returned promise
     * with a payload that mimics a server response: {none: { success: false, response: [{ responseJSON: { generalReasons: [{messageKey: 'no.models.were.bound.to.form'}] }}] }}
     * @param {Object} [options]
     *   @param {boolean} [options.rollback=true] if true, when any object model fails to save, it will revert the object
     *     model attributes to the state they were before calling save. NOTE: if there are updates that happen
     *     to object models within the timing of this save method, the updates could be lost.
     *   @param {boolean} [options.force=true] if false, the form model will check to see if an update has been made
     *     to any object models it is tracking since it's last pull. If any stale data is found, save with throw an exception
     *     with attributes: {name: 'Stale data', staleModels: [Array of model cid's]}
     * @returns when using a "url", a promise is returned for the save on this form model.
         If not using a "url", a promise that will either resolve when all the models have successfully saved in which case the context returned
     *   is an array of the responses (order determined by first the array of models and then the array of models used by
     *   the computed values, normalized), or if any of the saves fail, the promise will be rejected with an array of responses.
     *   Note: the size of the failure array will always be one - the first model that failed. This is a side-effect of $.when
     */
    save: function(options) {
      var notTrackingResponse, url,
        deferred = new $.Deferred(),
        formModel = this;
      options = options || {};
      _.defaults(options, {
        rollback: true,
        force: true
      });
      try {
        url = _.result(formModel, 'url');
      } catch (e) {
        // no url attached to this form model. Continue by pushing to models.
      }
      if (url) {
        return NestedModel.prototype.save.apply(formModel, arguments).done(function() {
          formModel.push();
        });
      } else if (this.isTrackingAnyObjectModel()) {
        this.__saveToModels(deferred, options);
        return deferred.promise();
      } else {
        // Return a response that is generated when this form model is not tracking an object model
        notTrackingResponse = {
          'none': {
            success: false,
            response: [{
              responseJSON: {
                generalReasons: [{messageKey: 'no.models.were.bound.to.form'}]
              }
            }]
          }
        };
        this.trigger('save-fail', notTrackingResponse);
        return (new $.Deferred()).reject(notTrackingResponse).promise();
      }
    },

    /**
     * @returns true if this form model is backed by an Object model. That means that at least one object model was bound to an mapping alias.
     */
    isTrackingAnyObjectModel: function() {
      return _.size(this.__currentObjectModels) > 0;
    },

    /**
     * @returns true if any updates to an object model will immediately copy new values into this form model.
     */
    isUpdating: function() {
      return this.__currentUpdateEvents.length > 0;
    },

    /**
     * Will add listeners that will automatically pull new updates from this form's object models.
     * @param {boolean} [pullFirst=false] if true, the form model will pull most recent values then start listening
     */
    startUpdating: function(pullFirst) {
      if (this.isTrackingAnyObjectModel() && !this.isUpdating()) {
        if (pullFirst) {
          this.pull();
        }
        this.__setupListeners();
      }
    },

    /**
     * This will stop the form model from listening to its object models.
     */
    stopUpdating: function() {
      _.each(this.__currentUpdateEvents, function(eventConfig) {
        this.stopListening(eventConfig.model, eventConfig.eventName);
      }, this);
      this.__currentUpdateEvents = [];
    },

    /**
     * If updating, it will reset the updating events to match the current mappings.
     */
    resetUpdating: function() {
      if (this.isUpdating()) {
        this.stopUpdating();
        this.startUpdating();
      }
    },

    /**
     * @param {Backbone.Model} model the backbone model that is being checked
     * @param {Object} [staleModels] a hash that will be updated to contain this model if it is stale in the form: cid -> model.
     * @param {Object} [currentHashValues] If passed an object, it will look in this cache for the current value of the object model
     *   instead of calculating it. It should be key'ed by the model's cid
     * @returns {boolean} true if the model passed in has been changed since the last pull from the object model.
     */
    isModelStale: function(model, staleModels, currentHashValues) {
      var hashValue;
      currentHashValues = currentHashValues || {};
      if (!currentHashValues[model.cid]) {
        currentHashValues[model.cid] = this.__generateHashValue(model);
      }
      hashValue = currentHashValues[model.cid];
      var isStaleModel = this.__cache[model.cid] !== hashValue;
      if (staleModels) {
        if (isStaleModel) {
          staleModels[model.cid] = model;
        } else if (staleModels[model.cid]) {
          delete staleModels[model.cid];
        }
      }
      return isStaleModel;
    },

    /**
     * @returns {Array} an array of the object models that have been updated since the last pull from this form model
     */
    checkIfModelsAreStale: function() {
      var staleModels = {},
        currentHashValues = this.__generateAllHashValues();
      _.each(this.getTrackedModels(), function(model) {
        this.isModelStale(model, staleModels, currentHashValues);
      }, this);
      return _.values(staleModels);
    },

    //************** Private methods **************//

    /**
     * Sets up a listener to update the form model if the model's field (or any field) changes.
     * @param {Backbone.Model} model the object model from which this form model will start listen to changes
     * @param {string} [field] the field name that it will start listening to. If no field is given, it will listen to the general 'change' event.
     * @private
     */
    __listenToModelField: function(model, field) {
      var callback, eventName;
      if (field) {
        eventName = 'change:' + field;
        callback = _.bind(this.__updateFormField, {
          formModel: this,
          field: field
        });
      } else {
        eventName = 'change';
        callback = this.__updateFormModel;
      }
      this.listenTo(model, eventName, callback);
      this.__currentUpdateEvents.push({model: model, eventName: eventName});
    },

    /**
     * Sets up a listener on one (or all) of the fields that is needed to update a computed value
     * @param {Backbone.Model} model the object model from which this form model will start listen to changes
     * @param {string} [field] the field name that it will start listening to. If no field is given, it will listen to the general 'change' event.
     * @param {string} computedAlias the name/alias of the computed mapping being used.
     * @private
     */
    __listenToComputedValuesDependency: function(model, field, computedAlias) {
      var callback, eventName;
      if (field) {
        eventName = 'change:' + field;
      } else {
        eventName = 'change';
      }
      callback = _.bind(this.__invokeComputedPull, {
        formModel: this,
        alias: computedAlias
      });
      this.listenTo(model, eventName, callback);
      this.__currentUpdateEvents.push({model: model, eventName: eventName});
    },

    /**
     * Returns the models that a currently being tracked that are part of a computed mapping
     * If there is a missing model (a model alias is referenced but no model instance is bound to that alias), then it will return undefined.
     * @param {string} computedAlias the name/alias of the computed mapping
     * @returns {Object} a map from model name/alias to model instance. If there is a missing model (an model alias is referenced but no model
     *   instance is bound to that alias), then it will return undefined.
     * @private
     */
    __getComputedModels: function(computedAlias) {
      var hasAllModels = !_.isUndefined(this.getMapping(computedAlias)),
        models = {};
      _.each(this.__getModelAliases(computedAlias), function(modelAlias) {
        var model = this.getTrackedModel(modelAlias);
        if (model) {
          models[modelAlias] = model;
        } else {
          hasAllModels = false;
        }
      }, this);
      return hasAllModels ? models : undefined;
    },

    /**
     * Returns the aliases/names of models referenced in the computed mapping with the given alias
     * @param {(string|Object)} computedAliasOrConfig the name/alias of the computed mapping or the computed mapping itself as
     *   an object if it hasn't been added as a mapping yet.
     * @returns {string[]} an array of the model names/aliases referenced inside the computed mapping
     * @private
     */
    __getModelAliases: function(computedAliasOrConfig) {
      var config,
        modelAliases = [];
      if (_.isString(computedAliasOrConfig)) {
        config = this.getMapping(computedAliasOrConfig);
      } else {
        config = computedAliasOrConfig;
      }
      return _.filter(_.keys(config.mapping), function(key) {
        return key != 'pull' && key != 'push';
      });
    },

    /**
     * Repackages a computed mapping to be easier consumed by methods wanting the model mappings tied to the model instances.
     * Returns a list of objects that contain the model instance and the mapping for that model.
     *
     * @private
     * @param {string} computedAlias the name/alias used for this computed
     * @returns {object[]} a list of objects that contain the model instance under "model" and the mapping for that model under "fields".
     */
    __getComputedModelConfigs: function(computedAlias) {
      var hasAllModels = true,
        config = this.getMapping(computedAlias),
        modelConfigs = [];
      _.each(this.__getModelAliases(computedAlias), function(modelAlias) {
        var modelConfig = this.__createModelConfig(modelAlias, config.mapping[modelAlias]);
        if (modelConfig) {
          modelConfigs.push(modelConfig);
        } else {
          hasAllModels = false;
        }
      }, this);
      return hasAllModels ? modelConfigs : undefined;
    },

    /**
     * Pushes the form model values to the object models it is tracking and invokes save on each one. Returns a promise.
     * @param {Object} [options]
     *   @param {boolean} [options.rollback=true] if true, when any object model fails to save, it will revert the object
     *     model attributes to the state they were before calling save. NOTE: if there are updates that happen
     *     to object models within the timing of this save method, the updates could be lost.
     *   @param {boolean} [options.force=true] if false, the form model will check to see if an update has been made
     *     to any object models it is tracking since it's last pull. If any stale data is found, save with throw an exception
     *     with attributes: {name: 'Stale data', staleModels: [Array of model cid's]}
     * @returns a promise that will either resolve when all the models have successfully saved in which case the context returned
     *   is an array of the responses (order determined by first the array of models and then the array of models used by
     *   the computed values, normalized), or if any of the saves fail, the promise will be rejected with an array of responses.
     *   Note: the size of the failure array will always be one - the first model that failed. This is a side-effect of $.when
     * @private
     */
    __saveToModels: function(deferred, options) {
      var staleModels,
        formModel = this,
        responsesSucceeded = 0,
        responsesFailed = 0,
        responses = {},
        oldValues = {},
        models = formModel.getTrackedModels(),
        numberOfSaves = models.length;
      // If we're not forcing a save, then throw an error if the models are stale
      if (!options.force) {
        staleModels = formModel.checkIfModelsAreStale();
        if (staleModels.length > 0) {
          throw {
            name: 'Stale data',
            staleModels: staleModels
          };
        }
      }
      // Callback for each response
      function responseCallback(response, model, success) {
        // Add response to a hash that will eventually be returned through the promise
        responses[model.cid] = {
            success: success,
            response: response
          };
        // If we have reached the total of number of expected responses, then resolve or reject the promise
        if (responsesFailed + responsesSucceeded === numberOfSaves) {
          if (responsesFailed > 0) {
            // Rollback if any responses have failed
            if (options.rollback) {
              _.each(formModel.getTrackedModels(), function(model) {
                model.set(oldValues[model.cid]);
                if (responses[model.cid].success) {
                  model.save();
                }
              });
            }
            formModel.trigger('save-fail', responses);
            deferred.reject(responses);
          } else {
            formModel.trigger('save-success', responses);
            deferred.resolve(responses);
          }
        }
      }
      // Grab the current values of the object models
      _.each(models, function(model) {
        oldValues[model.cid] = formModel.__getTrackedModelFields(model);
      });
      // Push the form model values to the object models
      formModel.push();
      // Call save on each object model
      _.each(models, function(model) {
        model.save().fail(function() {
          responsesFailed++;
          responseCallback(arguments, model, false);
        }).done(function() {
          responsesSucceeded++;
          responseCallback(arguments, model, true);
        });
      });
    },

    /**
     * Pulls in new information from tracked models using the mapping defined by the given alias.
     * This works for both model mappings and computed value mappings
     * @param {string} alias the name of the mapping that will be used during the pull
     * @private
     */
    __pull: function(alias) {
      var config = this.getMapping(alias);
      if (config.computed && config.mapping.pull) {
        this.__invokeComputedPull.call({formModel: this, alias: alias});
      } else if (config.computed) {
        var modelAliases = this.__getModelAliases(alias);
        _.each(modelAliases, function(modelAlias) {
          var model = this.getTrackedModel(modelAlias);
          if (model) {
            this.__copyFields(config.mapping[modelAlias], this, model);
          }
        }, this);
      } else {
        var model = this.getTrackedModel(alias);
        if (model) {
          this.__copyFields(config.mapping, this, model);
        }
      }
    },

    /**
     * Pushes form model information to tracked models using the mapping defined by the given alias.
     * This works for both model mappings and computed value mappings
     * @param {string} alias the name of the mapping that will be used during the push
     * @private
     */
    __push: function(alias) {
      var config = this.getMapping(alias);
      if (config.computed && config.mapping.push) {
        var models = this.__getComputedModels(alias);
        if (models) {
          config.mapping.push.call(this, models);
        }
      } else if (config.computed) {
        var modelAliases = this.__getModelAliases(alias);
        _.each(modelAliases, function(modelAlias) {
          var model = this.getTrackedModel(modelAlias);
          if (model) {
            this.__copyFields(config.mapping[modelAlias], model, this);
          }
        }, this);
      } else {
        var model = this.getTrackedModel(alias);
        if (model) {
          this.__copyFields(config.mapping, model, this);
        }
      }
    },

    /**
     * Updates a single attribute in this form model.
     * NOTE: requires the context of this function to be:
     * {
     *  formModel: <this form model>,
     *  field: <the field being updated>
     * }
     * NOT the form model itself like if you called this.__updateFormField.
     * @private
     */
    __updateFormField: function(model, value) {
      this.formModel.set(this.field, value);
      this.formModel.__updateCache(model);
    },

    /**
     * NOTE: When looking to update the form model manually, call this.pull().
     * Updates this form model with the changed attributes of a given object model
     * @param {external:Backbone-Model} model the object model that has been changed
     * @private
     */
    __updateFormModel: function(model) {
      _.each(model.changedAttributes(), function(value, fieldName) {
        this.set(fieldName, this.__cloneVal(value));
      }, this);
      this.__updateCache(model);
    },

    /**
     * Updates the form model's snapshot of the model's attributes to use later
     * @param {external:Backbone-Model} model the object model
     * @param {Object} [cache=this.__cache] if passed an object (can be empty), this method will fill
     *   this cache object instead of this form model's __cache field
     * @private
     */
    __updateCache: function(model) {
      if (!model) {
        this.__cache = {};
        _.each(this.getTrackedModels(), function(model) {
          if (model) {
            this.__updateCache(model);
          }
        }, this);
      } else {
        this.__cache[model.cid] = this.__generateHashValue(model);
      }
    },

    /**
     * Create a hash value of a simple object
     * @param {Object} obj simple object with no functions
     * @returns a hash value of the object
     * @private
     */
    __hashValue: function(obj) {
      return JSON.stringify(obj);
    },

    /**
     * Returns the alias/name bound to the model passed in. If a string is passed in, it will just return this string.
     * @param {string|external:Backbone-Model} aliasOrModel If string, just returns this string. If a model instance, then the alias
     *   that is bound to the tracked model passed in will be found and returned.
     * @returns {string} the alias
     * @private
     */
    __findAlias: function(aliasOrModel) {
      var alias, objectModel;
      if (_.isString(aliasOrModel)) {
        alias = aliasOrModel;
      } else {
        objectModel = aliasOrModel;
        alias = _.find(this.__currentObjectModels, function(model) {
          return model == objectModel;
        });
      }
      return alias;
    },

    /**
     * @param {external:Backbone-Model} model the model to create the hash value from
     * @returns {string} the hash value of the model making sure to only use the tracked fields
     * @private
     */
    __generateHashValue: function(model) {
      var modelFields = this.__getTrackedModelFields(model);
      return this.__hashValue(modelFields);
    },

    /**
     * @returns {Object} a map of model's cid to the hash value of the model making sure to only use the tracked fields
     * @private
     */
    __generateAllHashValues: function() {
      var currentHashValues = {};
      _.each(this.getTrackedModels(), function(model) {
        currentHashValues[model.cid] = this.__generateHashValue(model);
      }, this);
      return currentHashValues;
    },

    /**
     * Deep clones the attributes. There should be no functions in the attributes
     * @param {(Object|Array|string|number|boolean)} val a non-function value
     * @returns the clone
     * @private
     */
    __cloneVal: function(val) {
      var seed;
      if (_.isArray(val)) {
        seed = [];
      } else if (_.isObject(val)) {
        seed = {};
      } else {
        return val;
      }
      return $.extend(true, seed, val);
    },

    /**
     * Attaches listeners to the tracked object models with callbacks that will copy new properties into this form model.
     * @private
     */
    __setupListeners: function() {
      var model, modelConfigs,
        formModel = this;
      _.each(formModel.getMappings(), function(config, alias) {
        if (config.computed) {
          modelConfigs = formModel.__getComputedModelConfigs(alias);
          _.each(modelConfigs, function(modelConfig) {
            var model = modelConfig.model;
            if (modelConfig.fields) {
              _.each(modelConfig.fields, function(field) {
                formModel.__listenToComputedValuesDependency(model, field, alias);
              });
            } else {
              formModel.__listenToComputedValuesDependency(model, '', alias);
            }
          });
        } else {
          model = formModel.getTrackedModel(alias);
          if (model) {
            if (config.mapping) {
              _.each(config.mapping, function(field) {
                formModel.__listenToModelField(model, field);
              });
            } else {
              formModel.__listenToModelField(model);
            }
          }
        }
      });
    },

    /**
     * Copies fields from one backbone model to another. Is useful during a pull or push to/from Object models. The values will
     * be deep cloned from the origin to the destination.
     * @param {Array} [fields] a string of attribute names on the origin model that will be copied. Leave null if all attributes
     *   are to be copied
     * @param {Backbone.Model} destination the backbone model that will have values copied into
     * @param {Backbone.Model} origin the backbone model that will be used to grab values.
     * @private
     */
    __copyFields: function(fields, destination, origin) {
      if ((!fields || fields === true) && this === origin && _.size(this.getTrackedModels()) > 1) {
        // only copy attributes that exist on object model when the form model is tracking all the properties
        // of that object model, but is also tracking other models as well.
        fields = _.keys(destination.attributes);
      }
      if (fields) {
        _.each(fields, function(field) {
          destination.set(field, this.__cloneVal(origin.get(field)));
        }, this);
      } else {
        destination.set(this.__cloneVal(origin.attributes));
      }
    },

    /**
     * Sets the mapping using the form model's default mapping or the options.mappings if available.
     * Also sets the tracked models if the form model's default models or the options.models is provided.
     * @param {Object} [options] See initialize options: 'mapping' and 'models'.
     * @private
     */
    __initMappings: function(options) {
      var mapping,
        models,
        defaultMapping = _.result(this, 'mapping'),
        defaultModels = _.result(this, 'models');
      mapping = options.mapping || defaultMapping;
      models = options.models || defaultModels;
      if (mapping) {
        this.setMappings(mapping, models);
      }
    },

    /**
     * Returns a map where the keys are the fields that are being tracked on tracked model and values are
     * the with current values of those fields.
     * @param {external:Backbone-Model} model the object model
     * @returns {Object} aa map where the keys are the fields that are being tracked on tracked model and
     *   values are the with current values of those fields.
     * @private
     */
    __getTrackedModelFields: function(model) {
      var allFields,
        fieldsUsed = {},
        modelFields = {},
        modelConfigs = [];
      _.each(this.__getAllModelConfigs(), function(modelConfig) {
        if (modelConfig.model && modelConfig.model.cid === model.cid) {
          modelConfigs.push(modelConfig);
        }
      });
      allFields = _.reduce(modelConfigs, function(result, modelConfig) {
        return result || !modelConfig.fields;
      }, false);
      if (allFields) {
        modelFields = this.__cloneVal(model.attributes);
      } else {
        _.each(modelConfigs, function(modelConfig) {
          _.each(modelConfig.fields, function(field) {
            if (!fieldsUsed[field]) {
              fieldsUsed[field] = true;
              modelFields[field] = this.__cloneVal(model.get(field));
            }
          }, this);
        }, this);
      }
      return modelFields;
    },

    /**
     * Returns a useful data structure that binds a tracked model to the fields being tracked on a mapping.
     * @param modelAlias
     * @param {(string[]|undefined)} fields the fields that the model is tracking. Can be undefined if tracking all fields.
     *   When creating a model config for a computed mapping, the fields refers to the fields being tracked only for that computed value.
     * @returns {Object} a binding between a tracked model and the fields its tracking for a mapping. If no tracked model is bound to the modelAlias,
     *   it will return undefined.
     * @private
     */
    __createModelConfig: function(modelAlias, fields) {
      var model = this.getTrackedModel(modelAlias);
      if (model) {
        return {
          fields: fields,
          model: model
        };
      }
    },

    /**
     * Returns an array of convenience data structures that bind tracked models to the fields they are tracking for each mapping,
     * including model mappings inside computed mappings. There will be a model config for each tracked model on a computed mapping
     * meaning there can be multiple model configs for the same tracked model.
     * @returns {Array} array of convenience data structures that bind tracked models to the fields they are tracking for each mapping,
     *   including model mappings inside computed mappings.
     * @private
     */
    __getAllModelConfigs: function() {
      var modelConfigs = [];
      _.each(this.getMappings(), function(config, alias) {
        if (config.computed) {
          var computedModelConfigs = this.__getComputedModelConfigs(alias);
          if (computedModelConfigs) {
            modelConfigs = modelConfigs.concat(computedModelConfigs);
          }
        } else {
          var modelConfig = this.__createModelConfig(alias, config.mapping);
          if (modelConfig) {
            modelConfigs.push(modelConfig);
          }
        }
      }, this);
      return modelConfigs;
    },

    /**
     * A wrapper function that can invoke the pull callback on a computed mapping during an event callback.
     * Because an event callback predetermines the argument list, this method assumes the necessary computed configuration is
     * bound as the part of the function context.
     * When invoking the pull callback, it will pass in a object map from model alias to shallow copy of the tracked fields the
     * computed value uses. It is NOT just the model, but a  copy of its attributes - feel free to change the properties.
     * Example:
     * fooBar: {
     *   myModel: 'foo bar',
     *   pull: function(models) {
     *     console.log(models.myModel.foo, models.myModel.bar)
     *   }
     * }
     * If any model mapping is tracking all fields by passing true as its config, a copy of all the attributes for that model will be provided.
     * @param {Backbone.Model} [model] the model that was updated. If provided, the cache will be updated
     * NOTE: requires the context of this function to be:
     * {
     *  formModel: <this form model>,
     *  alias: <the computed alias>,
     * }
     * @private
     */
    __invokeComputedPull: function(model) {
      if (model) {
        this.formModel.__updateCache(model);
      }
      (function(formModel, alias) {
        var hasAllModels = true,
          config = formModel.getMapping(alias),
          modelAliases = formModel.__getModelAliases(alias),
          models = {};
        if (!config.mapping.pull) {
          if (console && _.isFunction(console.log)) {
            console.log('Not pulling the computed: ' + alias + ', because no pull method was defined for this computed.');
          }
          return;
        }
        _.each(modelAliases, function(modelAlias) {
          var fields = config.mapping[modelAlias],
            model = formModel.getTrackedModel(modelAlias),
            modelCopy = {};
          if (!model) {
            hasAllModels = false;
          } else {
            if (fields) {
              _.each(fields, function(field) {
                modelCopy[field] = formModel.__cloneVal(model.get(field));
              });
            } else {
              modelCopy = formModel.__cloneVal(model.attributes);
            }
            models[modelAlias] = modelCopy;
          }
        });
        if (hasAllModels) {
          config.mapping.pull.call(formModel, models);
        }
      })(this.formModel, this.alias);
    }
  });

  _.extend(FormModel.prototype, validation.mixin);

  return FormModel;
}));