behaviors/DataBehavior.js

(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    define(['underscore', 'backbone', '../Behavior', '../Collection', '../Events'], factory);
  } else if (typeof exports === 'object') {
    var _ = require('underscore');
    var Backbone = require('backbone');
    var Behavior = require('../Behavior');
    var Collection = require('../Collection');
    var Events = require('../Events');
    module.exports = factory(_, Backbone, Behavior, Collection, Events);
  } else {
    root.Torso = root.Torso || {};
    root.Torso.behaviors = root.Torso.behaviors || {};
    root.Torso.behaviors.DataBehavior = factory(root._, root.Backbone, root.Torso.Behavior, root.Torso.Collection, root.Torso.Events);
  }
}(this, function(_, Backbone, Behavior, Collection, Events) {
  'use strict';

  var $ = Backbone.$;

  var PROPERTY_SEPARATOR = '.';
  var CONTAINER_SEPARATOR = ':';

  var FETCHED_STATUSES = {
    SUCCESS: 'success',
    FAILURE: 'failed'
  };

  /**
   * Converts string or number values into an array with a single string or number item.
   * If the input is not a string, number, array, or info about the ids then undefined is returned.
   * This is a private helper method used internally by this behavior and is not exposed in any way.
   * @param {(string|number|string[]|number[]|Object)} ids the ids to convert.
   *   @param {boolean} [ids.skipObjectRetrieval] set if this is a meta-info object about the ids.
   * @returns {(string[]|number[]|Object)} an array of strings or numbers.
   * @private
   */
  function normalizeIds(ids) {
    if (_.isArray(ids)) {
      // remove any nesting of arrays - it is assumed that the resulting ids will be simple string or number values.
      ids = _.flatten(ids);
      // remove any duplicate ids.
      return _.uniq(ids);
    } else if (_.isString(ids) || _.isNumber(ids)) {
      // individual id - convert to array for consistency.
      return [ids];
    } else if (ids && ids.skipObjectRetrieval) {
      return ids;
    }
  }

  /**
   * Converts any undefined or null values to an empty array.  All other values are left unchanged.
   * @param valueToConvert the value to check for null or undefined.
   * @returns {Array|*} either the original value or [] if the valueToConvert is null or undefined.
   */
  function undefinedOrNullToEmptyArray(valueToConvert) {
    if (_.isUndefined(valueToConvert) || _.isNull(valueToConvert)) {
      valueToConvert = [];
    }
    return valueToConvert;
  }

  /**
   * Gets a nested property from an object, returning undefined if it doesn't exist on any level.
   * @param {Object} rootObject object containing the property to get.
   * @param {string} propertyString string identifying the nested object to retrieve.
   * @returns {*} either undefined or the property referenced from the rootObject.
   */
  function getNestedProperty(rootObject, propertyString) {
    propertyString = propertyString.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
    propertyString = propertyString.replace(/^\./, '');           // strip a leading dot
    var propertyStringParts = propertyString.split(PROPERTY_SEPARATOR);
    return _.reduce(propertyStringParts, function(currentBaseObject, currentPropertyName) {
      return _.isUndefined(currentBaseObject) ? undefined : currentBaseObject[currentPropertyName];
    }, rootObject);
  }

  /**
   * Determines if the stringDefinition contains a container definition (i.e. has a ':' in it).
   * @param stringDefinition to test.
   * @returns {boolean} true if the string definition contains a container definition, false otherwise.
   */
  function containsContainerDefinition(stringDefinition) {
    return !!stringDefinition && stringDefinition.indexOf(CONTAINER_SEPARATOR) > -1;
  }

  /**
   * This behavior implements simplified interaction with data sources (i.e. TorsoCollection).
   * This behavior manages re-rendering when data changes and automatically adding the returned data to the view's context.
   * This behavior also manages dependencies between data and other objects to allow intelligent re-fetching when data changes.
   *
   * @class DataBehavior
   * @extends Behavior
   *
   * @author  jyoung@vecna.com
   *
   * @see <a href="../annotated/modules/behaviors/DataBehavior.html">DataBehavior Annotated Source</a>
   * @see <a href="../modules/behaviors/DATA_BEHAVIOR.html">Detailed docs</a> for more in-depth documentation and details.
   */
  var DataBehavior = Behavior.extend(/** @lends DataBehavior */{
    /**
     * The torso collection that is acting as a cache used to create the private collections.
     * This property/option is required.  Instantiation will fail if it is not set.
     * @property {Collection} cache
     */
    cache: undefined,

    /**
     * Adds a listener on the Behavior for the `fetched` event that triggers a render on the view.
     * true - A listener is added to the behavior that re-renders the view when a 'fetched' event is triggered.
     * false (default) - no listeners are added.
     * @property {boolean} renderOnFetch
     * @default false
     */
    renderOnFetch: false,

    /**
     * Skip triggering a load of this data behavior when the view completes initializing.
     * true - no load after the view is initialized.
     * false (default) - trigger a .retrieve() on this data behavior when the view completes initialization.
     * @property {boolean} skipInitialLoad
     * @default false
     */
    skipInitialLoad: false,

    /**
     * Determines the result of `view.getBehavior('thisBehaviorAlias').toJSON()`.
     * true - a single model result is returned.
     * false (default) - an array of model results are returned.
     * @property {boolean} returnSingleResult
     * @default false
     */
    returnSingleResult: false,

    /**
     * Determines whether `pull()` or `fetch()` is called when using `retrieve()`.
     * true - Use fetch() by default on the private collection.
     * false (default) - Use pull() by default on the private collection.
     * True will query the server more often, but will provide more up-to-date data.
     * False will only query the server if the model hasn't already been retrieved.
     * This property will be ignored if `fetch()` or `pull()` is called directly.
     * @property {boolean} alwaysFetch
     * @default false
     */
    alwaysFetch: false,

    /**
     * Duck-typed property that identifies the ids to use. id or ids is required (either by behavior options or as properties).
     *   - {(string|number)} - the id to use directly (equivalent to an array of a single id).
     *   - {(string[]|number[])} - the ids to use directly.
     *   - {Object} - more complex configuration that identifies a model-like object that fires a change event and the
     *                property on that object to use. The object can fire the change event for the given property
     *                and have a .get('propertyName') method, or it can define the property directly on the idContainer.
     *                Only one property can be identified as supplying the id for this data model.
     *                If the identified object does not fire a change event then the id(s) will never be refreshed for this behavior.
     *                The idContainer can also fire a 'fetched:ids' event on itself to signal to this data behavior that the ids
     *                have been fetched for the first time.  Then a 'change:<propertyName>' event can be used to notify this
     *                data behavior that the property has been modified.
     *     - property {string} - the name of the property that defines the ids. The root object is assumed to be the view unless
     *                           idContainer is defined. The idContainer is the object that fires a change event for the given property name.
     *                           Uses the view or the idContainer as the root to get the identified property (i.e. 'viewState.', 'model.', etc).
     *                           Will get the property before the first '.' from the view and if it is an object will try to use a
     *                           .get('<propertyName>') on it and set a 'change:<propertyName>' listener on it.
     *                           If it is a string/number or array of string/number, then it will use that as the ids.
     *                           Triggering a 'id-container-updated' event on the behavior will cause it to stop listing to the
     *                           old idContainer and start listening to the new one defined by this property.
     *     - idContainer {Cell|Backbone.Model|Function} - object (or a function that returns an object) that fires change
     *                           events and has a .get('propertyName') function. It isn't required to fire events -
     *                           the change event is only required if it needs to re-fetch when the id property value changes.
     *     Examples:
     *       - { property: '_patientId' }
     *       - { property: 'viewState.appointmentId' }
     *       - { property: 'model.type' }
     *       - { property: 'behaviors.demographics.data.appointments' }
     *       - { property: 'id', idContainer: userService }
     *       - { property: 'username', idContainer: function() { application.getCurrentUser() } }
     *   - {Function(cache)} - expected to return the ids (either array, jquery deferred that resolves to the ids or single primitive)
     *                         to track with the private collection. Cache is passed in as the first argument so that the behavior
     *                         can be defined and the cache can be overridden later.
     *                         'this' is the behavior (from which you can get the view if needed).
     *                         What was criteria should use this instead:
     *
     *         function(cache) {
     *           var thisBehaviorInstance = this;
     *           var view = this.view;
     *           var criteria = { ... some criteria ... };
     *           return cache.fetchIdsByCriteria(criteria);
     *         }
     * @property {(string|number|string[]|number[]|Object|Function)} ids
     */
    ids: undefined,

    /**
     * cause this behavior to re-calculate its ids and refetch them from the server if the given events are triggered
     * (space separated if string, single item is equivalent to array of single item).
     *   - 'view:eventName' - arbitrary event triggered on the view (eventName can be a change:propertyName event).
     *   - 'viewState:eventName' - arbitrary event triggered on the viewState (eventName can be a change:propertyName event).
     *   - 'model:eventName' - arbitrary even triggered on the view's model (eventName can be a change:propertyName event).
     *   - 'this:eventName' - arbitrary event triggered by this behavior (eventName can be a change:propertyName event).
     *   - 'behaviorAlias:eventName' - arbitrary event triggered by another data behavior on this view (eventName can be a change:propertyName event).
     *   - 'behaviorAlias.data:eventName' - arbitrary event triggered by the data of another DataBehavior on this view (eventName can be a change:propertyName event).
     *   - { '<eventName>': < object (or function returning an object) that the event is triggered on > } - arbitrary ('<eventName>') triggered on the supplied object.
     * @property {(string|string[]|Object|Object[])} updateEvents
     */
    updateEvents: undefined,

    /**
     * Object that manages interaction with the data.  Contains the privateCollection, proxies all events from the privateCollection,
     * and has get('...') and .toJSON() methods that access the private collection data.
     * @property {Torso.behaviors.DataBehavior.Data} data
     */
    data: undefined,

    /**
     * The possible fetched statuses.  This is the status value of the fetched event payload.
     * @property {Object} { SUCCESS: 'SUCCESS', FAILURE: 'FAILURE' } FETCHED_STATUSES
     */
    FETCHED_STATUSES: FETCHED_STATUSES,

    /**
     * @override
     * @param {Object} [behaviorState] the initial state of the behavior.
     * @param {Object} behaviorOptions
     *   @param {Collection} behaviorOptions.cache see cache property.
     *   @param {boolean} [behaviorOptions.renderOnFetch=false] see renderOnFetch property.
     *   @param {boolean} [behaviorOptions.skipInitialLoad=false] see skipInitialLoad property.
     *   @param {boolean} [behaviorOptions.returnSingleResult=false] see returnSingleResult property.
     *   @param {boolean} [behaviorOptions.alwaysFetch=false] see alwaysFetch property.
     *   @param {string|number|string[]|number[]|{property: String, idContainer: Object}|Function} [behaviorOptions.id=behaviorOptions.ids] see id property.
     *   @param {string|number|string[]|number[]|{property: String, idContainer: Object}|Function} [behaviorOptions.ids=behaviorOptions.id] see ids property.
     *   @param {(string|string[]|Object|Object[])} [behaviorOptions.updateEvents] see updateEvents property.
     * @param {Object} [viewOptions] options passed to View's initialize
     */
    constructor: function(behaviorState, behaviorOptions, viewOptions) {
      _.bindAll(this, '__skipRetrieveOnEmptyTrackedIdsAndNewIds', '__completeLoadingIds', '__fetchSuccess', '__fetchFailed', '__abortIfDisposed');
      behaviorOptions = behaviorOptions || {};
      behaviorOptions = _.defaults(behaviorOptions, {
        alwaysFetch: false
      });
      _.extend(this, _.pick(behaviorOptions, 'cache', 'id', 'ids', 'renderOnFetch', 'skipInitialLoad', 'returnSingleResult', 'alwaysFetch', 'updateEvents'));

      this.__validateCache();
      this.__normalizeAndValidateIds();
      this.__normalizeAndValidateUpdateEvents();

      this.cid = this.cid || _.uniqueId(this.cidPrefix);
      this.data = new this.Data({
        parentBehavior: this,
        privateCollection: this.cache.createPrivateCollection(this.cid)
      });

      Behavior.apply(this, arguments);

      this.set('loadingIds', 0);

      this.on('id-container-updated', this.listenToIdsPropertyChangeEvent);
      this.on('id-container-updated', this.retrieve);
      this.listenTo(this.view, 'initialize:complete', this.listenToIdsPropertyChangeEvent);
      this.listenTo(this.view, 'initialize:complete', this._delegateUpdateEvents);
      if (!this.skipInitialLoad) {
        this.listenTo(this.view, 'initialize:complete', this.retrieve);
      }

      // This allows 'renderOnFetch' to be changed at runtime after the constructor is executed.
      this.on('fetched', function() {
        // If a view isn't active then it shouldn't be rendering.
        // When a view is attached it is rendered anyway.
        if (this.renderOnFetch && this.view.isActive() && this.view.isAttached()) {
          this.view.render();
        }
      });
      this.listenTo(this.view, 'before-dispose-callback', this.data.dispose);
    },

    /**
     * Retrieves the ids for this data object and passes them off to the private collection to track and then does a
     * pull or a fetch based on the alwaysFetch property.  (pull is default if always fetch is true then it fetches instead).
     * @returns {$.Deferred.Promise} a jquery deferred promise that resolves to the retrieved models.
     */
    retrieve: function() {
      if (this.alwaysFetch) {
        return this.fetch();
      } else {
        return this.pull();
      }
    },

    /**
     * Retrieves the ids for this data object and passes them off to the private collection's trackAndPull() method.
     * @returns {$.Deferred.Promise} a jquery deferred promise that resolves to the retrieved models.
     */
    pull: function() {
      var thisDataBehavior = this;
      return this.__getIds()
        .then(this.__skipRetrieveOnEmptyTrackedIdsAndNewIds)
        .then(this.__abortIfDisposed)
        .then(function(idsResult) {
          if (idsResult && !idsResult.skipObjectRetrieval) {
            return thisDataBehavior.data.privateCollection.trackAndPull(idsResult);
          }
          return idsResult;
        }, function(errorResponse) {
          errorResponse.failedOnIds = true;
          // Deferred reject is more reliable than throwing an exception since it works in both jQuery 2 and 3.
          return new $.Deferred().reject(errorResponse).promise();
        })
        .then(this.__fetchSuccess, this.__fetchFailed);
    },

    /**
     * Retrieves the ids for this data object and passes them off to the private collection's trackAndFetch() method.
     * @returns {$.Deferred.Promise} a jquery deferred promise that resolves to the retrieved models.
     */
    fetch: function() {
      var thisDataBehavior = this;
      return this.__getIds()
        .then(this.__skipRetrieveOnEmptyTrackedIdsAndNewIds)
        .then(this.__abortIfDisposed)
        .then(function(idsResult) {
          if (idsResult && !idsResult.skipObjectRetrieval) {
            return thisDataBehavior.data.privateCollection.trackAndFetch(idsResult);
          }
          return idsResult;
        }, function(errorResponse) {
          errorResponse.failedOnIds = true;
          // Deferred reject is more reliable than throwing an exception since it works in both jQuery 2 and 3.
          return new $.Deferred().reject(errorResponse).promise();
        })
        .then(this.__fetchSuccess, this.__fetchFailed);
    },

    /**
     * Adds the toJSON of the data represented by this behavior to the context.
     * @override
     */
    prepare: function() {
      var behaviorContext = Behavior.prototype.prepare.apply(this) || {};
      behaviorContext.data = this.data.toJSON();
      behaviorContext.loading = this.isLoading();
      behaviorContext.loadingIds = this.isLoadingIds();
      behaviorContext.loadingObjects = this.isLoadingObjects();
      return behaviorContext;
    },

    /**
     * Determine if the behavior is loading objects or ids.
     * @returns {boolean} true - the behavior is currently loading objects or ids.
     *                   false - the behavior is not currently loading objects or ids.
     */
    isLoading: function() {
      return this.isLoadingIds() || this.isLoadingObjects();
    },

    /**
     * Determine if the behavior is loading ids.
     * @returns {boolean} true - the behavior is currently loading ids.
     *                   false - the behavior is not currently loading ids.
     */
    isLoadingIds: function() {
      return this.get('loadingIds') > 0;
    },

    /**
     * Determine if the behavior is loading objects.
     * @returns {boolean} true - the behavior is currently loading objects.
     *                   false - the behavior is not currently loading objects.
     */
    isLoadingObjects: function() {
      return this.data.privateCollection.isLoading();
    },

    /**
     * Listens for the change event on the ids property and, if triggered, re-fetches the data based on the new ids.
     */
    listenToIdsPropertyChangeEvent: function() {
      if (!_.isUndefined(this.ids.property)) {
        this.stopListeningToIdsPropertyChangeEvent();
        var idsPropertyNameAndContext = this.__parseIdsPropertyNameAndIdContainer();
        var idContainer = idsPropertyNameAndContext.idContainer;
        var canListenToEvents = idContainer && _.isFunction(idContainer.on);
        if (canListenToEvents) {
          this.__currentContextWithListener = idContainer;
          this.__currentContextEventName = 'change:' + idsPropertyNameAndContext.idsPropertyName;
          this.listenTo(this.__currentContextWithListener, this.__currentContextEventName, this.retrieve);
          this.listenTo(this.__currentContextWithListener, 'fetched:ids', this.retrieve);
        }
      }
    },

    /**
     * Removes the listener added by listenToIdsPropertyChangeEvent().
     */
    stopListeningToIdsPropertyChangeEvent: function() {
      if (this.__currentContextWithListener) {
        this.stopListening(this.__currentContextWithListener, this.__currentContextEventName, this.retrieve);
        this.stopListening(this.__currentContextWithListener, 'fetched:ids', this.retrieve);
        delete this.__currentContextWithListener;
        delete this.__currentContextEventName;
      }
    },

    /**
     * This is a good way to have something be called after at least one retrieve (pull or fetch) has completed.
     * This is especially useful if you don't care if the fetch has already happen you just want to do something once
     * the data is loaded.
     *
     * This can also be done purely by listening for the 'fetched' event, but you might miss the event if it is fired
     * before you start listening.  This gives a structure for handling that case so that your methods are called
     * if the event is fired and if it is not fired.
     *
     * This also gives the ability to distinguish between a successful and failed fetch easily using the promises
     * resolve/reject handlers.
     *
     * Usage:
     *
     * someDataBehavior.retrieveOncePromise()
     *   .then(view.doSomethingWithTheData, view.handleFiledFetch);
     *
     * @returns {external:jQuery-Deferred} that resolves when the data is successfully fetched and rejects when the fetch fails.
     */
    retrieveOncePromise: function() {
      var retrieveOnceDeferred = $.Deferred();
      var demographicsFetchSuccess = this.get('fetchSuccess');
      if (demographicsFetchSuccess) {
        retrieveOnceDeferred.resolve();
      } else if (demographicsFetchSuccess === false) {
        retrieveOnceDeferred.reject();
      } else {
        this.once('fetched', function() {
          if (this.get('fetchSuccess')) {
            retrieveOnceDeferred.resolve();
          } else {
            retrieveOnceDeferred.reject();
          }
        });
      }
      return retrieveOnceDeferred.promise();
    },

    /**
     * Removes existing listeners and adds new ones for all of the updateEvents configured.
     * @private
     */
    _delegateUpdateEvents: function() {
      this._undelegateUpdateEvents();
      var updateEvents = this.__parseUpdateEvents();
      _.each(updateEvents, function(parsedUpdateEvent) {
        this.listenTo(parsedUpdateEvent.idContainer, parsedUpdateEvent.eventName, this.retrieve);
      }, this);
    },

    /**
     * Removes existing event listeners.
     * @private
     */
    _undelegateUpdateEvents: function() {
      var updateEvents = this.__parseUpdateEvents();
      _.each(updateEvents, function(parsedUpdateEvent) {
        this.stopListening(parsedUpdateEvent.idContainer, parsedUpdateEvent.eventName, this.retrieve);
      }, this);
    },

    /**
     * Parses this.updateEvents configuration.
     * @returns {Object[]} {[{ eventName: String, idContainer: Object }]} an array of objects with the event name and idContainer included.
     * @private
     */
    __parseUpdateEvents: function() {
      this.__normalizeAndValidateUpdateEvents();
      var updateEvents = _.flatten(_.map(this.updateEvents, this.__parseUpdateEvent, this));
      return _.compact(updateEvents);
    },

    /**
     * Parses an individual event configuration.
     * Note: events defined using objects can have more than one event defined w/in the object.
     * @param {string | Object} updateEventConfiguration the configuration for an individual event configuration.
     * @returns {(Object[]|undefined)} {[{ eventName: String, idContainer: Object }] | undefined} an array of objects with the event name and idContainer included.
     *                                                                If the event could not be parsed, undefined is returned.
     * @private
     */
    __parseUpdateEvent: function(updateEventConfiguration) {
      if (_.isUndefined(updateEventConfiguration)) {
        return undefined;
      }
      var parsedUpdateEvents = [];
      if (_.isString(updateEventConfiguration)) {
        var updateEvent = this.__parseStringUpdateEvent(updateEventConfiguration);
        if (!_.isUndefined(updateEvent)) {
          parsedUpdateEvents.push(updateEvent);
        }
      } else if (_.isObject(updateEventConfiguration)) {
        parsedUpdateEvents = _.map(updateEventConfiguration, function(idContainer, eventName) {
          return {
            idContainer: idContainer,
            eventName: eventName
          };
        });
      }
      return parsedUpdateEvents;
    },

    /**
     * Validates that the cache property is valid and if not throws an error describing why its not valid.
     * @private
     */
    __validateCache: function() {
      if (!this.cache) {
        throw new Error('Torso Data Behavior constructed without a cache');
      }
      if (!(this.cache instanceof Collection)) {
        throw new Error('Torso Data Behavior\'s cache is not of type Torso.Collection');
      }
    },

    /**
     * Validates that the ids property is valid and if not throws an error describing why its not valid.
     * A side effect of this method is copying id into the ids location (if id is set).  Ids is what is used by the rest of the code.
     * This is done as part of validation because we first validate that both are not set.
     * @private
     */
    __normalizeAndValidateIds: function() {
      if (!_.isUndefined(this.ids) && !_.isUndefined(this.id)) {
        throw new Error('Torso Data Behavior constructed with both id and ids.  Please define only one.');
      }
      this.ids = this.id || this.ids;
      this.__validateIds();
    },

    /**
     * Validates that the ids property is valid and if not throws an error describing why its not valid.
     * A side effect of this method is copying id into the ids location (if id is set).  Ids is what is used by the rest of the code.
     * This is done as part of validation because we first validate that both are not set.
     * @private
     */
    __validateIds: function() {
      if (_.isUndefined(this.ids)) {
        throw new Error('Torso Data Behavior constructed without a way to identify the ids for this data.  Please define either id, ids.');
      }

      var idsIsArray = _.isArray(this.ids);
      var idsIsSingleId = _.isString(this.ids) || _.isNumber(this.ids);
      var idsIsFunction = _.isFunction(this.ids);
      var idsIsObjectWithStringProperty = _.isString(this.ids.property);
      var idsIsObject = _.isObject(this.ids);
      var idsIsValid = idsIsArray || idsIsSingleId || idsIsFunction || idsIsObjectWithStringProperty;
      if (!idsIsValid && idsIsObject) {
        throw new Error('Data Behavior ids invalid definition.  It is an object, but the property field is not defined or is not a string: ' + JSON.stringify(this.ids));
      } else if (!idsIsValid) {
        throw new Error('Data Behavior ids invalid definition.  Not a string, number, object, array or function: ' + JSON.stringify(this.ids));
      }

      if (idsIsObjectWithStringProperty) {
        var propertyNameContainsIdContainer = containsContainerDefinition(this.ids.property);
        var hasIdContainerProperty = !_.isUndefined(this.ids.idContainer);

        if (hasIdContainerProperty && propertyNameContainsIdContainer) {
          throw new Error('Data Behavior ids invalid definition.  Id container defined on both ids.property and ids.idContainer: ', JSON.stringify(this.ids));
        }
      }
    },

    /**
     * Validates that the updateEvents property is valid and if not throws an error describing why its not valid.
     * @private
     */
    __normalizeAndValidateUpdateEvents: function() {
      var updateEventsIsArray = _.isArray(this.updateEvents);
      var updateEventsIsSingleValue = !updateEventsIsArray && (_.isObject(this.updateEvents) || _.isString(this.updateEvents));
      var updateEventsIsUndefined = _.isUndefined(this.updateEvents);
      var updateEventsIsValidType = updateEventsIsArray || updateEventsIsSingleValue || updateEventsIsUndefined;

      if (updateEventsIsSingleValue) {
        this.updateEvents = [this.updateEvents];
      }

      if (!updateEventsIsValidType) {
        throw new Error('Update events are not an array, string or object.  Please see parameters for examples of how to define updateEvents.  Configured UpdateEvents: ', this.updateEvents);
      }

      // Remove any random falsey values (mostly to get rid of undefined events).
      this.updateEvents = _.compact(this.updateEvents);
      _.each(this.updateEvents, this.__validUpdateEvent);
    },

    /**
     * Validates that the updateEventConfiguration is valid and if not throws an error describing why its not valid.
     * @private
     */
    __validUpdateEvent: function(updateEventConfiguration) {
      var validStringConfig = _.isString(updateEventConfiguration);
      var validObjectConfig = _.isObject(updateEventConfiguration) && _.keys(updateEventConfiguration).length > 0;
      if (!validStringConfig && !validObjectConfig) {
        throw new Error('Not a valid updateEvent configuration.  Update events need to either be strings or objects with a single property: ' + JSON.stringify(updateEventConfiguration));
      }
    },

    /**
     * @returns {$.Deferred.Promise} A jquery deferred promise that resolves to the ids to track in the private collection
     *                              or rejects with the error message.
     *                              It can also return an object that prevents object retrieval ({ skipObjectRetrieval: true }).
     * @private
     */
    __getIds: function() {
      this.set('loadingIds', this.get('loadingIds') + 1);
      this.__validateIds(); // validate ids enforces a fast-fail that guarantees that one of the if statements below will work.

      var idsDeferred = $.Deferred();
      var ids = this.ids;
      var normalizedIds = normalizeIds(ids);
      if (!_.isUndefined(normalizedIds)) {
        idsDeferred.resolve(normalizedIds);
      } else if (_.isFunction(this.ids)) {
        ids = this.ids(this.cache);
        normalizedIds = normalizeIds(ids);
        if (!_.isUndefined(normalizedIds)) {
          idsDeferred.resolve(normalizedIds);
        } else if (ids && _.isFunction(ids.then)) {
          idsDeferred = ids.then(normalizeIds);
        } else {
          idsDeferred.resolve([]);
        }
      } else if (!_.isUndefined(this.ids.property)) {
        var parsedContextDefinition = this.__parseIdsPropertyNameAndIdContainer();
        var propertyName = parsedContextDefinition.idsPropertyName;
        var idContainer = parsedContextDefinition.idContainer;

        ids = getNestedProperty(idContainer, propertyName);
        var propertyOnContextIsUndefined = idContainer && _.isUndefined(ids);
        var idContainerHasGetMethod = idContainer && _.isFunction(idContainer.get);

        if (propertyOnContextIsUndefined && idContainerHasGetMethod) {
          ids = idContainer.get(propertyName);
        }
        normalizedIds = normalizeIds(ids);

        idsDeferred.resolve(normalizedIds);
      }
      return idsDeferred.promise()
        .then(undefinedOrNullToEmptyArray)
        .always(this.__completeLoadingIds);
    },

    /**
     * Sets the loading ids property to false (loading completed).
     * @private
     */
    __completeLoadingIds: function() {
      this.set('loadingIds', this.get('loadingIds') - 1);
    },

    /**
     * Converts the definition into the actual idContainer object and property name to retrieve off of that idContainer.
     * @returns {{idsPropertyName: String, idContainer: Object}} the name of the ids property and the actual object to use as the idContainer.
     * @private
     */
    __parseIdsPropertyNameAndIdContainer: function() {
      var propertyName = this.ids.property;
      var propertyNameContainsIdContainer = containsContainerDefinition(propertyName);
      var hasIdContainerProperty = !_.isUndefined(this.ids.idContainer);

      var idContainer;
      if (hasIdContainerProperty) {
        idContainer = this.__parseIdContainer();
      }

      if (propertyNameContainsIdContainer) {
        var containerAndDetail = this.__parseContainerDetailString(propertyName);
        propertyName = containerAndDetail.detail;
        idContainer = containerAndDetail.idContainer;
      }

      if (_.isUndefined(idContainer)) {
        idContainer = this.view;
      }

      return {
        idsPropertyName: propertyName,
        idContainer: idContainer
      };
    },

    /**
     * Parses the idContainer property of ids.
     * @returns {Object} the idContainer object to apply the properties value to (may not be the final idContainer depending on the property definition).
     * @private
     */
    __parseIdContainer: function() {
      var idContainerDefinition = this.ids.idContainer;
      var idContainer;
      if (_.isUndefined(idContainerDefinition)) {
        idContainer = undefined;
      } else if (_.isFunction(idContainerDefinition)) {
        var idContainerFxn = _.bind(idContainerDefinition, this);
        idContainer = idContainerFxn();
      } else if (_.isObject(idContainerDefinition)) {
        idContainer = idContainerDefinition;
      } else {
        throw new Error('Invalid idContainer.  Not an object or function: ' + JSON.stringify(this.ids));
      }
      return idContainer;
    },

    /**
     * Parses a string that contains a container and details for that container (event name or property name).
     * Strings are expected to be of the form '<some container path>:<detail>'.
     * @param fullContainerAndDetail
     * @returns {{detail: String, idContainer: *}} the idContainer is the actual object containing the ids.
     *         details is the rest of the idContainer string without the idContainer definition.
     * @private
     */
    __parseContainerDetailString: function(fullContainerAndDetail) {
      var containerString = '';
      if (containsContainerDefinition(fullContainerAndDetail)) {
        containerString = fullContainerAndDetail.split(CONTAINER_SEPARATOR, 1)[0];
      }
      var containerStringParts = containerString.split(PROPERTY_SEPARATOR);

      var rootContainerString = containerStringParts[0];
      var rootContainer;
      if (rootContainerString === 'this') {
        rootContainer = this;
      } else if (rootContainerString === 'behaviors' || rootContainerString === 'behavior') {
        var behaviorAlias = containerStringParts[1];
        rootContainerString += PROPERTY_SEPARATOR + behaviorAlias; // property track what part of the string has been consumed.
        rootContainer = this.view.getBehavior(behaviorAlias);
      } else {
        if (rootContainerString !== 'view') {
          rootContainerString = ''; // property track what part of the string has been consumed.
        }
        rootContainer = this.view;
      }

      var container = rootContainer;
      var rootContainerPropertyString = containerString.replace(rootContainerString, '');
      if (rootContainerPropertyString) {
        // remove leading .
        if (rootContainerPropertyString[0] === PROPERTY_SEPARATOR) {
          rootContainerPropertyString = rootContainerPropertyString.slice(1);
        }
        container = getNestedProperty(rootContainer, rootContainerPropertyString);
      }

      var detail = fullContainerAndDetail.replace(containerString + CONTAINER_SEPARATOR, '');
      return {
        detail: detail,
        idContainer: container
      };
    },

    /**
     * Parse a string type update event.
     * Context Key (first part of the string up to the first ':') can be one of the following:
     *   this (maps to the behavior),
     *   view or empty (maps to the behavior's view),
     *   viewState (maps to the behavior's view's viewState),
     *   model (maps to the behavior's view's model),
     *   <*> any others are assumed to be the names of behaviors on this behavior's view.
     * @param {string} updateEventConfiguration a string representation of the event.
     * @returns {{eventName: String, idContainer: Backbone.Events}} the parsed configuration with the event name and idContainer object.
     * @private
     */
    __parseStringUpdateEvent: function(updateEventConfiguration) {
      var parsedIdContainerDetails = this.__parseContainerDetailString(updateEventConfiguration);
      if (parsedIdContainerDetails) {
        return {
          idContainer: parsedIdContainerDetails.idContainer,
          eventName: parsedIdContainerDetails.detail
        };
      }
    },

    /**
     * Rejects the promise chain if this behavior is already disposed.
     * @returns {external:jQuery-Deferred} that is resolved if the behavior is not disposed and rejects if the behavior is disposed.
     * @private
     */
    __abortIfDisposed: function() {
      var resultDeferred = $.Deferred();
      if (this.isDisposed()) {
        var rejectArguments = Array.prototype.slice.call(arguments);
        rejectArguments.push('Data Behavior disposed, aborting.');
        resultDeferred.reject.apply(resultDeferred, rejectArguments);
      } else {
        resultDeferred.resolve.apply(resultDeferred, arguments);
      }
      return resultDeferred.promise();
    },

    /**
     * Triggers a 'fetched' event with the payload { status: 'success' } when the fetch completes successfully.
     * @param {Object} response the response from the server.
     *   @param {boolean} [response.skipObjectRetrieval=false] if we retrieved objects, then trigger fetch event.
     *   @param {boolean} [response.forceFetchedEvent=false] if true then trigger fetch no matter what.
     * @returns {Object} response - returns the response to match promise spec that converts returned values into resolves.
     * @private
     */
    __fetchSuccess: function(response) {
      this.set('fetchSuccess', true);
      this.set('fetchedOnce', true);
      if (this.__shouldTriggerFetchedEvent(response)) {
        this.trigger('fetched', {
          status: FETCHED_STATUSES.SUCCESS,
          response: response
        });
        this.data.trigger('fetched', {
          status: FETCHED_STATUSES.SUCCESS,
          response: response
        });
      }
      this.trigger('fetched:ids');
      this.data.trigger('fetched:ids');
      return response;
    },

    /**
     * Triggers a 'fetched' event with the payload { status: 'failed' } when the fetch fails.
     * @param {Object} [response] the response from the server.
     *   @param {boolean} [response.skipObjectRetrieval=false] if we retrieved objects, then trigger fetch event.
     *   @param {boolean} [response.forceFetchedEvent=false] if true then trigger fetch no matter what.
     *   @param {boolean} [response.emptyIds=false] true if were are no ids retrieved.  False otherwise.
     * @throws {Object} response - throws an error to match promise spec that converts thrown errors into rejects.
     * @private
     */
    __fetchFailed: function(response) {
      this.set('fetchSuccess', false);
      this.set('fetchedOnce', true);
      if (this.__shouldTriggerFetchedEvent(response)) {
        this.trigger('fetched', {
          status: FETCHED_STATUSES.FAILURE,
          response: response
        });
        this.data.trigger('fetched', {
          status: FETCHED_STATUSES.FAILURE,
          response: response
        });
        if (response && response.emptyIds) {
          this.__firstEmptyFetchedTriggered = true;
        }
      }
      this.trigger('fetched:ids');
      this.data.trigger('fetched:ids');
      // Deferred reject is more reliable than throwing an exception since it works in both jQuery 2 and 3.
      return new $.Deferred().reject(response).promise();
    },

    /**
     * Determines if the 'fetched' event should be triggered in the __fetchFailed or __fetchSuccess methods.
     * @param {Object} response to use to determine if the fetched event should be triggered.
     *   @param {boolean} [response.skipObjectRetrieval=false] if we retrieved objects, then trigger fetch event.
     *   @param {boolean} [response.forceFetchedEvent=false] if true then trigger fetch no matter what.
     * @returns {boolean} true if the fetched event should be triggered, false otherwise.
     * @private
     */
    __shouldTriggerFetchedEvent: function(response) {
      return !response || !response.skipObjectRetrieval || response.forceFetchedEvent;
    },

    /**
     * Skip retrieving objects if new ids list is empty and existing ids list is empty.
     * @param {(Array|Object)} idsResult
     * @returns {(Array|Object)} either the original idsResult
     *                        or { skipObjectRetrieval: true, forceFetchedEvent: true } if both the ids retrieved
     *                        and the current ids are empty.
     * @private
     */
    __skipRetrieveOnEmptyTrackedIdsAndNewIds: function(idsResult) {
      if (_.isEmpty(idsResult) && _.isEmpty(this.data.privateCollection.getTrackedIds())) {
        return { skipObjectRetrieval: true, forceFetchedEvent: true };
      } else {
        return idsResult;
      }
    },

    /**
     * Adds listeners when the view is activated.
     * @private
     */
    _activate: function() {
      this.listenToIdsPropertyChangeEvent();
      this._delegateUpdateEvents();
      this.data.activate();
    },

    /**
     * Stops listening when the view is deactivated.
     * @private
     */
    _deactivate: function() {
      this.stopListeningToIdsPropertyChangeEvent();
      this._undelegateUpdateEvents();
      this.data.deactivate();
    }
  });

  /**
   * @module Torso.behaviors.DataBehavior
   * @namespace Torso.behaviors.DataBehavior
   */

  /**
   * Data object used to create the .data property of the DataBehavior.
   * @class Data
   * @inner
   * @memberof DataBehavior
   */
  var Data = function(options) {
    this.initialize(options);
  };

  _.extend(Data.prototype, Events, /** @lends Data */ {
    /**
     * Instantiates the data objects and binds it to this behavior instance.
     * @param {Object} options to pass to the initialize methods.
     *   @param {DataBehavior} options.parentBehavior the data behavior instance that this Data object should be bound to.
     *   @param {Collection} options.privateCollection the private collection that this data represents.
     */
    initialize: function(options) {
      /**
       * The dataBehavior instance that owns this data object.
       * @property {DataBehavior} parentBehavior
       */
      this.parentBehavior = options.parentBehavior;
      /**
       * The private collection that this data object manages.
       * @property {Collection} privateCollection
       */
      this.privateCollection = options.privateCollection;

      _.bindAll(this, 'dispose');
    },

    /**
     * Determine if behavior is loading ids or objects.
     * @returns {boolean} true - the behavior is loading objects or ids.
     *                   false - the behavior is not loading objects or ids.
     */
    isLoading: function() {
      return this.parentBehavior.isLoading();
    },

    /**
     * Determine if the behavior is loading ids.
     * @returns {boolean} true - the behavior is currently loading ids.
     *                   false - the behavior is not currently loading ids.
     */
    isLoadingIds: function() {
      return this.parentBehavior.isLoadingIds();
    },

    /**
     * Determine if the behavior is loading objects.
     * @returns {boolean} true - the behavior is currently loading objects.
     *                   false - the behavior is not currently loading objects.
     */
    isLoadingObjects: function() {
      return this.parentBehavior.isLoadingObjects();
    },

    /**
     * Get the full data object contents.  Either an array if returnSingleResult is false or a single object if it is true.
     * @returns {(Object|Object[])} containing the full contents of either the collection or model.
     */
    toJSON: function() {
      var privateCollection = this.privateCollection;
      if (!this.parentBehavior.returnSingleResult) {
        return privateCollection.toJSON();
      }

      if (privateCollection.length === 0) {
        return undefined;
      } else if (privateCollection.length === 1) {
        var singleResultModel = privateCollection.at(0);
        return singleResultModel.toJSON();
      } else {
        throw new Error('Multiple results found, but single result expected: ' + JSON.stringify(privateCollection.toJSON()));
      }
    },

    /**
     * Get the full data object contents (either an array of model attributes or a single model attribute based on the
     * value of propertyName) or the value of a specific property if a single result is expected.
     *
     * If returnSingleResult is true then this will return the given property from the model (if that model exists).
     * If returnSingleResult is false then this will return an array containing that property from all of the retrieved models.
     * @param {string} [propertyName] the property to get from the model(s).
     * @returns {(Object|Object[])} containing the full contents of either the collection or model.
     */
    get: function(propertyName) {
      var privateCollection = this.privateCollection;
      if (!this.parentBehavior.returnSingleResult) {
        if (_.isString(propertyName)) {
          return privateCollection.pluck(propertyName);
        } else {
          return privateCollection.toJSON();
        }
      }

      var singleResultModel = this.getModel();
      if (singleResultModel) {
        if (_.isString(propertyName)) {
          return singleResultModel.get(propertyName);
        }
        return singleResultModel.toJSON();
      }
    },

    /**
     * @param {(number|string)} modelId The id of the model to get from the collection.
     * @returns {Backbone.Model} either the model with the given id or the only model on this behavior (if model id is undefined).
     * @throws an error if there are more than 1 result or the configuration of the behavior specifies returnSingleResult === false.
     */
    getModel: function(modelId) {
      var privateCollection = this.privateCollection;
      if (!this.parentBehavior.returnSingleResult && _.isUndefined(modelId)) {
        throw new Error('data.getModel() of a DataBehavior is only valid if the behavior is set to returnSingleResult === true');
      }

      if (!_.isUndefined(modelId)) {
        return privateCollection.get(modelId);
      }

      if (privateCollection.length === 0) {
        return undefined;
      } else if (privateCollection.length === 1) {
        return privateCollection.at(0);
      } else {
        throw new Error('Multiple results found, but single result expected: ' + JSON.stringify(privateCollection.toJSON()));
      }
    },

    /**
     * @returns {Backbone.Model[]} new array containing all the models in the data's private collection.
     */
    getModels: function() {
      return this.privateCollection.models.slice(0);
    },

    /**
     * Adds the listeners to the private collection.
     */
    activate: function() {
      this.listenTo(this.privateCollection, 'all', this.trigger);
    },

    /**
     * Removes the listeners on the private collection.
     */
    deactivate: function() {
      this.stopListening(this.privateCollection, 'all', this.trigger);
    },

    /**
     * Dispose of the data events.
     */
    dispose: function() {
      this.off();
      this.stopListening();
      this.privateCollection.dispose();
    }
  });

  DataBehavior.prototype.Data = Data;
  DataBehavior.FETCHED_STATUSES = FETCHED_STATUSES;

  return DataBehavior;
}));