Source: backbone-x/source/model.js

/*jshint unused:false */
/* global XG:true */

(function () {
  'use strict';

  XM.Tuplespace = _.clone(Backbone.Events);

  /**
    @class `XM.Model` is an abstract class designed to operate with `XT.DataSource`.
    It should be subclassed for any specific implementation. Subclasses should
    include a `recordType` the data source will use to retrieve the record.

    To create a new model include `isNew` in the options:
    <pre><code>
      // Create a new class
      XM.MyModel = XM.Model.extend({
        recordType: 'XM.MyModel'
      });

      // Instantiate a new model object
      m = new XM.MyModel(null, {isNew: true});
   </code></pre>
    To load an existing record use the `findOrCreate` method and include an id in the attributes:
    <pre><code>
      m = XM.MyModel.findOrCreate({id: 1});
      m.fetch();
    </code></pre>

    @name XM.Model
    @description To create a new model include `isNew` in the options:
    @param {Object} Attributes
    @param {Object} Options
    @extends XM.ModelMixin
    @extends Backbone.RelationalModel
  */
  XM.Model = Backbone.RelationalModel.extend(XM.ModelMixin);

  XM.Model = XM.Model.extend(/** @lends XM.Model# */{

    autoFetchId: false,

    /**
     * Attempt to obtain lock.
     * @fires lock:obtain
     */
    obtainLock: function (options) {
      if (this.getLockKey() && _.isFunction(options.success)) {
        this.trigger('lock:obtain', this, this.lock);
        return options.success();
      }
      var params = [
        this.recordType.prefix(),
        this.recordType.suffix(),
        this.id,
        this.etag
      ];
      this._reentrantLockHelper('obtain', params, options);
    },

    /**
     * Attempt to renew lock.
     * @fires lock:renew
     */
    renewLock: function (options) {
      this._reentrantLockHelper('renew', [ this.lock.key ], options);
    },

    /**
     * Release lock.
     * @fires lock:release
     */
    releaseLock: function (options) {
      options = options || { };
      var callback = options.success;

      if (this.getLockKey()) {
        this._reentrantLockHelper('release', { key: this.getLockKey() }, options);
        this.lockDidChange(this, _.omit(this.lock, 'key'));
      }
      else if (_.isFunction(callback)) {
        callback();
      }
    },

    /**
      A function that binds events to functions. It can and should only be called
      once by initialize. Any attempt to call it a second time will throw an error.
    */
    bindEvents: function () {
      var that = this;
      // Bind events, but only if we haven't already been here before.
      // We could silently skip, but then that means any overload done
      // by anyone else has to do that check too. That's too error prone
      // and dangerous because the problems caused by duplicate bindings
      // are not immediatley apparent and insidiously hard to pin down.
      if (this._eventsBound) { throw new Error("Events have already been bound."); }

      /**
       * Bind all events in the optional 'handlers' hash
       */
      _.each(this.handlers, function (handler, event) {
        if (!_.isFunction(that[handler])) {
          console.warn('Handler '+ handler + ' not found: not binding');
          return;
        }
        that.on(event, that[handler]);
      });

      this.on('change', this.didChange);
      this.on('error', this.didError);
      this.on('destroy', this.didDestroy);

      _.each(
        _.where(this.relations, { type: Backbone.HasMany, includeInJSON: true }),
        function (relation) {
          that.on('add:' + relation.key, that.didChange);
          if (!that.isReadOnly()) {
            that.on('add:' + relation.key, that.relationAdded);
          }
        });

      this._idx = {};
      this._eventsBound = true;
    },

    /**
      Reimplemented to handle state change and parent child relationships. Calling
      `destroy` on a parent will cause the model to commit to the server
      immediately. Calling destroy on a child relation will simply mark it for
      deletion on the next save of the parent.

      @returns {Object|Boolean}
    */
    destroy: function (options) {
      options = options ? _.clone(options) : {};
      var klass = this.getClass(),
        canDelete = klass.canDelete(this),
        success = options.success,
        isNew = this.isNew(),
        model = this,
        result,
        K = XM.Model,
        parent = this.getParent(true),
        children = [],
        findChildren = function (model) {
          _.each(model.relations, function (relation) {
            var i, attr = model.attributes[relation.key];
            if (attr && attr.models &&
                relation.type === Backbone.HasMany) {
              for (i = 0; i < attr.models.length; i += 1) {
                findChildren(attr.models[i]);
              }
              children = _.union(children, attr.models);
            }
          });
        };
      if ((parent && parent.canUpdate(this)) ||
          (!parent && canDelete) ||
           this.getStatus() === K.READY_NEW) {
        this._wasNew = isNew; // Hack so prototype call will still work
        this.setStatus(K.DESTROYED_DIRTY, {cascade: true});

        // If it's top level commit to the server now.
        if ((!parent && canDelete) || isNew) {
          findChildren(this); // Lord Vader ... rise
          this.setStatus(K.BUSY_DESTROYING, {cascade: true});
          options.wait = true;
          options.success = function (resp) {
            var i;
            // Do not hesitate, show no mercy!
            for (i = 0; i < children.length; i += 1) {
              children[i].didDestroy();
            }
            if (XT.session.config.debugging) {
              XT.log('Destroy successful');
            }
            if (success) { success(model, resp, options); }
          };
          result = Backbone.Model.prototype.destroy.call(this, options);
          delete this._wasNew;
          return result;

        }

        // Otherwise just marked for deletion.
        if (success) {
          success(this, null, options);
        }
        return true;
      }
      XT.log('Insufficient privileges to destroy');
      return false;
    },

    doEmail: function () {
      // TODO: a way for an unwatched model to set the scrim
      XT.dataSource.callRoute("generate-report", this.getReportPayload("email"), {
        error: function (error) {
          // TODO: a way for an unwatched model to trigger the notify popup
          console.log("email error", error);
        },
        success: function () {
          console.log("email success");
        }
      });
    },

    /**
     *
     * Prepare fetch.
     *
     * TODO sync() alone should handle all of this stuff. fetch() is not a
     * Backbone customization point by design.
     */
    _fetchHelper: function (_options) {
      if (!this.getClass().canRead()) {
        XT.log('Error: insufficient privileges to fetch');
        return false;
      }

      var that = this,
        options = _.extend({ }, _options),
        callback = options.success,

        /**
         * @callback
         */
        done = function (resp) {
          that.setStatus(XM.Model.READY_CLEAN, options);

          if (_.isFunction(callback)) {
            callback(that, resp, options);
          }
        },

        // Handle successful fetch response. Obtain lock if necessary, and invoke
        // the optional callback.
        afterFetch = function (resp) {
          var schema = XT.session.getSchemas()[that.recordType.prefix()],
            lockable = schema.get(that.recordType.suffix()).lockable;

          done = _.partial(done, resp);

          if (lockable && options.obtainLock !== false) {
            that.obtainLock({ success: done });
          }
          else {
            done();
          }
        };

      return _.extend(options, {
        propagate: true,
        success: afterFetch
      });
    },

    /**
     * @override
     * Reimplemented to handle status changes and automatically obtain
     * a pessimistic lock on the record.
     *
     * @param {Object} Options
     * @returns {Object} Request
     */
    fetch: function (_options) {
      var options = this._fetchHelper(_options);
      if (!_.isObject(options)) {
        return false;
      }

      this.setStatus(XM.Model.BUSY_FETCHING, { cascade: true });
      return Backbone.Model.prototype.fetch.call(this, options);
    },

    /**
      Set the id on this record an id from the server. Including the `cascade`
      option will call ids to be fetched recursively for `HasMany` relations.

      @returns {Object} Request
    */
    fetchId: function (options) {
      options = _.defaults(options ? _.clone(options) : {});
      var that = this, attr;
      if (!this.id) {
        options.success = function (resp) {
          that.set(that.idAttribute, resp, options);
        };
        this.dispatch('XM.Model', 'fetchId', this.recordType, options);
      }

      // Cascade through `HasMany` relations if specified.
      if (options && options.cascade) {
        _.each(this.relations, function (relation) {
          attr = that.attributes[relation.key];
          if (attr) {
            if (relation.type === Backbone.HasMany) {
              if (attr.models) {
                _.each(attr.models, function (model) {
                  if (model.fetchId) { model.fetchId(options); }
                });
              }
            }
          }
        });
      }
    },

    /**
     * Retrieve related objects.
     * @param {String} key The relation key to fetch models for.
     * @param {Object} options Options for 'Backbone.Model.fetch' and 'Backbone.sync'.
     * @param {Boolean} update  Whether to force a fetch from the server (updating existing models).
     * @returns {Array} An array of request objects.
     */
    fetchRelated: function (key, options, refresh) {
      options = options || {};
      var requests = [],
        models,
        created = [],
        status = this.getStatus(),
        K = XM.Model,
        rel = this.getRelation(key),
				keys = rel && (rel.keyIds || [rel.keyId]),
        toFetch = keys && _.select(keys || [], function (id) {
          return (id || id === 0) && (refresh || !Backbone.Relational.store.find(rel.relatedModel, id));
        }, this);

      if (toFetch && toFetch.length) {
        if (options.max && toFetch.length > options.max) {
          toFetch.length = options.max;
        }

        models = _.map(toFetch, function (id) {
          var model = Backbone.Relational.store.find(rel.relatedModel, id),
            attrs;

          if (!model) {
            attrs = {};
            attrs[rel.relatedModel.prototype.idAttribute] = id;
            model = rel.relatedModel.findOrCreate(attrs, options);
            created.push(model);
          }

          return model;
        }, this);

        requests = _.map(models, function (model) {
          var opts = _.defaults(
            {
              error: function () {
                if (_.contains(created, model)) {
                  model.trigger('destroy', model, model.collection, options);
                  if (options.error) { options.error.apply(model, arguments); }
                }
              }
            },
            options
          );
          // Context option means server will check privilege access of the parent
          // and the existence of relation on the parent to determine whether user
          // can see this record instead of usual privilege check on the model.
          if (status === K.READY_CLEAN || status === K.READY_DIRTY) {
            opts.context = {
              recordType: this.recordType,
              value: this.id,
              relation: key
            };
          }
          return model.fetch(opts);
        }, this);
      } else {
        if (options.success) { options.success(); }
      }

      return requests;
    },

    /**
      Overload: Delete objects marked as destroyed from arrays and
      convert dates to strings.

      Add support for 'includeNested' option that will
      output JSON with nested toOne objects when specified.
    */
    toJSON: function (options) {
      var includeNested = options && options.includeNested,
        that = this,
        old = {},
        nested,
        json,
        Klass,
        idx,
        idAttr,
        prop,
        relations,

        // Sort based on the exact order in which items were added.
        // This is an absolute must for `generatePatch` to work correctly
        byIdx = function (a, b) {
          return idx.indexOf(a[idAttr]) - idx.indexOf(b[idAttr]);
        },
        byIdx2 = function (a, b) {
          return idx.indexOf(a.attributes[idAttr]) - idx.indexOf(b.attributes[idAttr]);
        };

      // If include nested, preprocess relations so they will show up nested
      if (includeNested) {
        nested = _.filter(this._relations, function (rel) {
          return rel.options.isNested;
        });
        _.each(nested, function (rel) {
          old[rel.key] = rel.options.includeInJSON;
          rel.options.includeInJSON = true;
        });
      }

      json = Backbone.RelationalModel.prototype.toJSON.apply(this, arguments);

      // Convert dates to strings to avoid conflicts with jsonpatch
      for (prop in json) {
        if (json.hasOwnProperty(prop) && json[prop] instanceof Date) {
          json[prop] = json[prop].toJSON();
        }
      }

      // If this Model has already been fully serialized in this branch once, return to avoid loops
      if (this.isLocked()) {
        return this.id;
      }
      this.acquire();

      // Exclude relations that by definition don't need to be processed.
      relations = _.filter(this.relations, function (relation) {
        return relation.includeInJSON;
      });

      // Handle "destroyed" records
      _.each(relations, function (relation) {
        var K = XM.ModelClassMixin,
          key = relation.key,
          oldComparator,
          status,
          attr,
          i;

        if (relation.type === Backbone.HasMany) {
          attr = that.get(key);
          if (attr && attr.length) {
            // Sort by create order
            idx = that._idx[relation.relatedModel.suffix()];
            if (idx) {
              Klass = Backbone.Relational.store.getObjectByName(relation.relatedModel);
              idAttr = Klass.prototype.idAttribute;
              json[key].sort(byIdx);

              // We need to sort by index, but we'll change back later.
              oldComparator = attr.comparator;
              attr.comparator = byIdx2;
              attr.sort();
            }

            for (i = 0; i < attr.models.length; i++) {
              status = attr.models[i].getStatus();
              if (status === K.BUSY_COMMITTING) {
                status = attr.models[i]._prevStatus;
              }

              // If dirty record has changed from server version.
              // Deleting will leave a "hole" in the array picked up
              // by the patch algorithm that signals a change
              if (status === K.DESTROYED_DIRTY) {
                delete json[key][i];

              // If clean the server never knew about it so remove entirely
              } else  if (status === K.DESTROYED_CLEAN) {
                json[key].splice(i, 1);

              // Delete the relation parent data if applicable. Don't need it here.
              } else if (relation.isNested) {
                delete json[key][i][relation.reverseRelation.key];
              }
            }
          }

          if (idx) { attr.comparator = oldComparator; }
        }
      });

      this.release();

      // Revert relations to previous settings if applicable
      if (includeNested) {
        _.each(nested, function (rel) {
          rel.options.includeInJSON = old[rel.key];
          delete rel.options.oldIncludeInJSON;
        });
      }

      return json;
    },

    /**
      Returns the current model prototype class.

      @returns {XM.Model}
    */
    getClass: function () {
      return Backbone.Relational.store.getObjectByName(this.recordType);
    },

    /**
      Return the parent model if one exists. If the `getRoot` parameter is
      passed, it will return the top level parent of the model hierarchy.

      @param {Boolean} Get Root
      @returns {XM.Model}
    */
    getParent: function (getRoot) {
      var parent,
        root,
        relation = _.find(this.relations, function (rel) {
          if (rel.reverseRelation && rel.isAutoRelation) {
            return true;
          }
        });
      parent = relation && relation.key ? this.get(relation.key) : false;
      if (parent && getRoot) {
        root = parent.getParent(getRoot);
      }
      return root || parent;
    },

    /**
      Called when model is instantiated.
    */
    initialize: function (attributes, options) {
      attributes = attributes || {};
      options = options || {};
      var that = this,
        klass,
        K = XM.Model,
        status = this.getStatus(),
        idAttribute = this.idAttribute;

      // Set defaults if not provided
      this.privileges = this.privileges || {};
      this.readOnlyAttributes = this.readOnlyAttributes ?
        this.readOnlyAttributes.slice(0) : [];
      this.requiredAttributes = this.requiredAttributes ?
        this.requiredAttributes.slice(0) : [];

      // Validate
      if (_.isEmpty(this.recordType)) { throw new Error('No record type defined'); }
      if (options.status) {
        this.setStatus(options.status);
      } else if (_.isNull(status)) {
        this.setStatus(K.EMPTY);
        this.bindEvents();
      }

      // Handle options
      if (options.isNew) {
        klass = this.getClass();
        if (!klass.canCreate()) {
          throw new Error('Insufficient privileges to create a record of class ' +
            this.recordType);
        }
        this.setStatus(K.READY_NEW, {cascade: true});

        // Key generator (client based)
        if (idAttribute === 'uuid' &&
            !this.get(idAttribute) &&
            !attributes[idAttribute]) {
          this.set(idAttribute, XT.generateUUID());
        }

        // Deprecated key generator (server based)
        if (this.autoFetchId) { this.fetchId(); }
      }

      // Set attributes that should be required and read only
      if (this.idAttribute &&
          !_.contains(this.requiredAttributes, idAttribute)) {
        this.requiredAttributes.push(this.idAttribute);
      }

      /**
       * Enable ability to listen for events in a global tuple-space, if
       * required.
       */
      XM.Tuplespace.listenTo(this, 'all', XM.Tuplespace.trigger);
    },

    /**
      Return whether the model is in a read-only state. If an attribute name
      is passed, returns whether that attribute is read-only. It is also
      capable of checking the read only status of child objects via a search path string.

      <pre><code>
        // Inquire on the whole model
        var readOnly = this.isReadOnly();

        // Inquire on a single attribute
        var readOnly = this.isReadOnly("name");

        // Inquire using a search path
        var readOnly = this.isReadOnly("contact.firstName");
      </code></pre>

      @seealso `setReadOnly`
      @seealso `readOnlyAttributes`
      @param {String} attribute
      @returns {Boolean}
    */
    isReadOnly: function (value) {
      var parent = this.getParent(true),
        isLockedOut = parent ? !parent.hasLockKey() : !this.hasLockKey(),
        result,
        parts,
        part,
        i;

      // Search path
      if (_.isString(value) && value.indexOf('.') !== -1) {
        parts = value.split('.');
        result = this;
        for (i = 0; i < parts.length; i++) {
          part = parts[i];
          if (_.isObject(result) && result.readOnlyAttributes &&
            _.contains(result.readOnlyAttributes, part)) {
            // Stop working down the path
            result = true;
            i = parts.length;
          } else if (result instanceof Backbone.Model && i + 1 < parts.length) {
            result = result.getValue(part);
          } else if (_.isNull(result)) {
            return result;
          } else if (!_.isUndefined(result)) {
            result = result.isReadOnly(part) || !result.hasLockKey();
          }
        }
        return result;
      }

      if ((!_.isString(value) && !_.isObject(value)) || this.readOnly) {
        result = this.readOnly;
      } else {
        result = _.contains(this.readOnlyAttributes, value);
      }
      return result || isLockedOut;
    },

    /**
      Recursively checks the object against the schema and converts date strings to
      date objects.

      @param {Object} Response
    */
    parse: function (resp, options) {
      var K = XT.Session,
        schemas = XT.session.getSchemas(),
        parse;

      // A hack to undo damage done by Backbone inside
      // save function. For use with options that have
      // collections. See XT.DataSource for the other
      // side of this.
      if (options && options.fixAttributes) {
        this.attributes = options.fixAttributes;
      }

      // The normal business.
      parse = function (namespace, typeName, obj) {
        var type = schemas[namespace].get(typeName),
          column,
          rel,
          attr,
          i;
        if (!type) { throw new Error(typeName + " not found in schema."); }
        for (attr in obj) {
          if (obj.hasOwnProperty(attr) && obj[attr] !== null) {
            if (_.isObject(obj[attr])) {
              rel = _.findWhere(type.relations, {key: attr});
              typeName = rel ? rel.relatedModel.suffix() : false;
              if (typeName) {
                if (_.isArray(obj[attr])) {
                  for (i = 0; i < obj[attr].length; i++) {
                    obj[attr][i] = parse(namespace, typeName, obj[attr][i]);
                  }
                } else {
                  obj[attr] = parse(namespace, typeName, obj[attr]);
                }
              }
            } else {
              column = _.findWhere(type.columns, {name: attr}) || {};
              if (column.category === K.DB_DATE) {
                obj[attr] = new Date(obj[attr]);
              }
            }
          }
        }

        return obj;
      };

      this._lastParse = parse(this.recordType.prefix(), this.recordType.suffix(), resp);
      return this._lastParse;
    },

    relationAdded: function (model, related, options) {
      var type = model.recordType.suffix(),
        id = model.id,
        evt,
        idx,
        replaceId = function (model) {
          idx.splice(idx.indexOf(id), 1, model.id);
          model.off(evt, replaceId);
        };
      if (!this._idx[type]) { this._idx[type] = []; }
      idx = this._idx[type];

      if (id) {
        if (!_.contains(idx, id)) { idx.push(id); }
        return;
      }

      // If no model id, then use a placeholder until we have one
      id = XT.generateUUID();
      idx.push(model.id);
      evt = "change:" + model.idAttribute;
      model.on(evt, replaceId);
    },

    /** 
      Used on inventory transaction models. 
      Default to false to ease printing handling of (inv. trans.) distribution detail.
    */
    requiresDetail: function () {
      return false;
    },

    /**
      Revert a model back to its original state the last time it was fetched.
    */
    revert: function () {
      var K = XM.Model;

      this.clear({silent: true});
      this.setStatus(K.BUSY_FETCHING);
      this.set(this._lastParse, {silent: true});
      this.setStatus(K.READY_CLEAN, {cascade: true});
    },

    /**
      Revert the model to the previous status. Useful for reseting status
      after a failed validation.

      param {Boolean} - cascade
    */
    revertStatus: function (cascade) {
      var K = XM.Model,
        prev = this._prevStatus,
        that = this,
        attr;
      this.setStatus(this._prevStatus || K.EMPTY);
      this._prevStatus = prev;

      // Cascade changes through relations if specified
      if (cascade) {
        _.each(this.relations, function (relation) {
          attr = that.attributes[relation.key];
          if (attr && attr.models &&
              relation.type === Backbone.HasMany) {
            _.each(attr.models, function (model) {
              if (model.revertStatus) {
                model.revertStatus(cascade);
              }
            });
          }
        });
      }
    },

    /**
      Overload: Don't allow setting when model is in error or destroyed status, or
      updating a `READY_CLEAN` record without update privileges.

      @param {String|Object} Key
      @param {String|Object} Value or Options
      @param {Objecw} Options
    */
    set: function (key, val, options) {
      var K = XM.Model,
        keyIsObject = _.isObject(key),
        status = this.getStatus(),
        err;

      // Handle both `"key", value` and `{key: value}` -style arguments.
      if (keyIsObject) { options = val; }
      options = options ? options : {};

      switch (status)
      {
      case K.READY_CLEAN:
        // Set error if no update privileges
        if (!this.canUpdate()) { err = XT.Error.clone('xt1010'); }
        break;
      case K.READY_DIRTY:
      case K.READY_NEW:
        break;
      case K.ERROR:
      case K.DESTROYED_CLEAN:
      case K.DESTROYED_DIRTY:
        // Set error if attempting to edit a record that is ineligable
        err = XT.Error.clone('xt1009', { params: { status: status } });
        break;
      default:
        // If we're not in a `READY` state, silence all events
        if (!_.isBoolean(options.silent)) {
          options.silent = true;
        }
      }

      // Raise error, if any
      if (err) {
        this.trigger('invalid', this, err, options);
        return false;
      }

      // Handle both `"key", value` and `{key: value}` -style arguments.
      if (keyIsObject) { val = options; }
      return Backbone.RelationalModel.prototype.set.call(this, key, val, options);
    },

    /**
      Set the status on the model. Triggers `statusChange` event. Option set to
      `cascade` will propagate status recursively to all HasMany children.

      @param {Number} Status
      @params {Object} Options
      @params {Boolean} [cascade=false] Cascade changes only through toMany relations
      @params {Boolean} [propagate=false] Propagate changes through both toMany and toOne relations
    */
    setStatus: function (status, options) {
      var K = XM.Model,
        attr,
        that = this,
        parent,
        parentRelation;

      options = options || {};

      // Prevent recursion
      if (this.isLocked() || this.status === status) { return; }

      // Reset patch cache if applicable
      if (status === K.READY_CLEAN && !this.readOnly) {
        this._idx = {};
        this._cache = this.toJSON();
      }

      this.acquire();
      this._prevStatus = this.status;
      this.status = status;
      parent = this.getParent();

      // Cascade changes through relations if specified
      if (options && (options.cascade || options.propagate)) {
        _.each(this.relations, function (relation) {
          attr = that.attributes[relation.key];
          if (attr && attr.models &&
              relation.type === Backbone.HasMany) {
            _.each(attr.models, function (model) {
              if (model.setStatus) {
                model.setStatus(status, options);
              }
            });
          } else if (attr && options.propagate &&
              relation.type === Backbone.HasOne) {
            attr.setStatus(status, options);
          }
        });
      }

      // Percolate changes up to parent when applicable
      if (parent && (this.isDirty() ||
          status === K.DESTROYED_DIRTY)) {
        parentRelation = _.find(this.relations, function (relation) {
          return relation.isAutoRelation;
        });
        // #refactor XXX if this is a bona fide Backbone Relational relation,
        // events will propagate automatically from child to parent.
        if (parentRelation) {
          parent.changed[parentRelation.reverseRelation.key] = true;
          parent.trigger('change', parent, options);
        }
      }
      this.release();

      // Work around for problem where backbone relational doesn't handle
      // events consistently on loading.
      if (status === K.READY_CLEAN) {
        _handleAddRelated(this);
      }

      if ((options || { }).ignoreStatusChange !== true) {
        this.trigger('statusChange', this, status, options);
        this.trigger('status:' + K._status[status], this, status, options);
      }
      return this;
    },

    /**
      Set a value(s) on attributes if key(s) is/are in schema, otherwise set on
      `meta`. If `meta` is null then behaves the same as `setIfExists`.

      Supports path strings.
    */
    setValue: function (key, val, options) {
      var K = XM.ModelMixin,
        keyIsObject = _.isObject(key),
        that = this,
        obj = {},
        processPath = function (k, v, o) {
          var model,
            parts,
            part,
            i;

          // Anything on search path is handled separately
          if (k.indexOf('.') !== -1) {
            parts = k.split('.');
            model = that;
            for (i = 0; i < parts.length; i++) {
              part = parts[i];
              if (i + 1 < parts.length) {
                model = model.getValue(part);
              } else {
                model.setValue(part, v, options);
              }
            }
            return true;
          }
          return false;
        };


      if (keyIsObject) {
        // Handle both `"key", value` and `{key: value}` -style arguments.
        if (keyIsObject) { options = val; }
        options = options ? options : {};

        // Handle anything with a path separately
        _.each(_.keys(key), function (prop) {
          if (!processPath(prop, key[prop], options)) {
            obj[prop] = key[prop];
          }
        });

        // Set the rest normally
        return K.setValue.call(this, obj, options);
      }

      // Key is string...
      if (!processPath(key, val, options)) {
         // Just do the normal thing
        return K.setValue.apply(this, arguments);
      }
    },

    /**
      Reimplemented.

      @retuns {Object} Request
    */
    save: function (key, value, options) {
      options = options ? _.clone(options) : {};
      var attrs = {},
        K = XM.ModelClassMixin,
        success,
        result;

      // Can't save unless root
      if (this.getParent()) {
        XT.log('You must save on the root level model of this relation');
        return false;
      }

      // Handle both `"key", value` and `{key: value}` -style arguments.
      if (_.isObject(key) || _.isEmpty(key)) {
        attrs = key;
        options = value ? _.clone(value) : {};
      } else if (_.isString(key)) {
        attrs[key] = value;
      }

      // Only save if we should.
      if (this.isDirty() || attrs) {
        this._wasNew = this.isNew();
        success = options.success;
        options.wait = true;
        options.propagate = true;
        options.success = function (model, resp, options) {
          var namespace = model.recordType.prefix(),
            schema = XT.session.getSchemas()[namespace],
            type = model.recordType.suffix(),
            stype = schema.get(type),
            params,
            lockOpts = {};

          if (stype.lockable) {
            params = [namespace, type, model.id, model.etag];
            lockOpts.success = function (lock) {
              model.lock = lock;
              model.lockDidChange(model, lock);
              model.setStatus(K.READY_CLEAN, options);
              if (success) { success(model, resp, options); }
            };

            model.dispatch("XM.Model", "obtainLock", params, lockOpts);
            if (XT.session.config.debugging) { XT.log('Save successful'); }
          } else {
            model.setStatus(K.READY_CLEAN, options);
            if (success) { success(model, resp, options); }
          }
        };

        // Handle both `"key", value` and `{key: value}` -style arguments.
        if (_.isObject(key) || _.isEmpty(key)) { value = options; }

        if (options.collection) {
          options.collection.each(function (model) {
            model.setStatus(K.BUSY_COMMITTING, options);
          });
        } else {
          this.setStatus(K.BUSY_COMMITTING, options);
        }

        // allow the caller to pass in a different save function to call
        result = options.prototypeSave ?
        options.prototypeSave.call(this, key, value, options) :
        Backbone.Model.prototype.save.call(this, key, value, options);
        delete this._wasNew;
        if (!result) { this.revertStatus(true); }
        return result;
      }

      XT.log('No changes to save');
      return false;
    },

    /**
      Default validation checks `attributes` for:<br />
        * Data type integrity.<br />
        * Required fields.<br />
      <br />
      Returns `undefined` if the validation succeeded, or some value, usually
      an error message, if it fails.<br />
      <br />

      @param {Object} Attributes
      @param {Object} Options
    */
    validate: function (attributes, options) {
      attributes = attributes || {};
      options = options || {};
      var that = this,
        i,
        result,
        S = XT.Session,
        attr, value, category, column,
        params = {recordType: this.recordType},
        namespace = this.recordType.prefix(),
        type = this.recordType.suffix(),
        columns = XT.session.getSchemas()[namespace].get(type).columns,
        coll = options.collection,
        isRel,
        model,

        // Helper functions
        isRelation = function (attr, value, type, options) {
          options = options || {};

          var rel;
          rel = _.find(that.relations, function (relation) {
            return relation.key === attr &&
              relation.type === type &&
              (!options.nestedOnly || relation.isNested);
          });
          return rel ? _.isObject(value) : false;
        },
        getColumn = function (attr) {
          return _.find(columns, function (column) {
            return column.name === attr;
          });
        };

      // If we're dealing with a collection, validate each model
      if (coll) {
        for (i = 0; i < coll.length; i++) {
          model = coll.at(i);
          result = model.validate(model.attributes);
          if (result) { return result; }
        }
        return;
      }

      // Check data type integrity
      for (attr in attributes) {
        if (attributes.hasOwnProperty(attr) &&
            !_.isNull(attributes[attr]) &&
            !_.isUndefined(attributes[attr])) {
          params.attr = ("_" + attr).loc() || "_" + attr;

          value = attributes[attr];
          column = getColumn(attr);
          category = column ? column.category : false;
          switch (category) {
          case S.DB_BYTEA:
            if (!_.isObject(value) && !_.isString(value)) { // XXX unscientific
              params.type = "_binary".loc();
              return XT.Error.clone('xt1003', { params: params });
            }
            break;
          case S.DB_UNKNOWN:
          case S.DB_STRING:
            if (!_.isString(value) && !_.isNumber(value) &&
                !isRelation(attr, value, Backbone.HasOne)) {
              params.type = "_string".loc();
              return XT.Error.clone('xt1003', { params: params });
            }
            break;
          case S.DB_NUMBER:
            if (!_.isNumber(value) &&
                !isRelation(attr, value, Backbone.HasOne)) {
              params.type = "_number".loc();
              return XT.Error.clone('xt1003', { params: params });
            }
            break;
          case S.DB_DATE:
            if (!_.isDate(value)) {
              params.type = "_date".loc();
              return XT.Error.clone('xt1003', { params: params });
            }
            break;
          case S.DB_BOOLEAN:
            if (!_.isBoolean(value)) {
              params.type = "_boolean".loc();
              return XT.Error.clone('xt1003', { params: params });
            }
            break;
          case S.DB_ARRAY:
            isRel = isRelation(attr, value, Backbone.HasMany);
            if (!_.isArray(value) && !isRel) {
              params.type = "_array".loc();
              return XT.Error.clone('xt1003', { params: params });
            }
            // Validate children if they're nested, but not if they're not
            isRel = isRelation(attr, value, Backbone.HasMany, {nestedOnly: true});
            if (isRel && value.models) {
              for (i = 0; i < value.models.length; i++) {
                model = value.models[i];
                /*
                  https://github.com/xtuple/xtuple/pull/1964
                  Document associations are stored "wrong" on the client. Don't bother validating them.
                  `data.sql` will clean them up.
                */
                if (!(model._prevStatus & XM.Model.DESTROYED) && model.recordType !== "XM.DocumentAssociation") {
                  result = model.validate(model.attributes, options);
                  if (result) { return result; }
                }
              }
            }
            break;
          case S.DB_COMPOUND:
            if (!_.isObject(value) && !_.isNumber(value)) {
              params.type = "_object".loc();
              return XT.Error.clone('xt1003', { params: params });
            }
            break;
          default:
            // attribute not in schema
            return XT.Error.clone('xt1002', { params: params });
          }
        }
      }

      // Check required.
      for (i = 0; i < this.requiredAttributes.length; i += 1) {
        value = attributes[this.requiredAttributes[i]];
        if (value === undefined || value === null || value === "") {
          params.attr = ("_" + this.requiredAttributes[i]).loc() ||
            "_" + this.requiredAttributes[i];
          return XT.Error.clone('xt1004', { params: params });
        }
      }

      return;
    }

  });

  // ..........................................................
  // PATCH
  //

  // Patch in fix https://github.com/PaulUithol/Backbone-relational/commit/7e47f1cc750cd0925ac65837ba1522288505e122
  // Fixes a problem where nested triggers don't work
  // Remove once we get caught up on backbone relational

  _.extend(XM.Model.prototype, {
    _attributeChangeFired: false,

    trigger: function ( eventName ) {
            if ( eventName.length > 5 && eventName.indexOf( 'change' ) === 0 ) {
                    var dit = this,
                            args = arguments;

                    Backbone.Relational.eventQueue.add( function () {
                            if ( !dit._isInitialized ) {
                                    return;
                            }

                            // Determine if the `change` event is still valid, now that all relations are populated
                            var changed = true;
                            if ( eventName === 'change' ) {
                                    // `hasChanged` may have gotten reset by nested calls to `set`.
                                    changed = dit.hasChanged() || dit._attributeChangeFired;
                                    dit._attributeChangeFired = false;
                            }
                            else {
                                    var attr = eventName.slice( 7 ),
                                            rel = dit.getRelation( attr );

                                    if ( rel ) {
                                            // If `attr` is a relation, `change:attr` get triggered from `Relation.onChange`.
                                            // These take precedence over `change:attr` events triggered by `Model.set`.
                                            // The relation sets a fourth attribute to `true`. If this attribute is present,
                                            // continue triggering this event; otherwise, it's from `Model.set` and should be stopped.
                                            changed = ( args[ 4 ] === true );

                                            // If this event was triggered by a relation, set the right value in `this.changed`
                                            // (a Collection or Model instead of raw data).
                                            if ( changed ) {
                                                    dit.changed[ attr ] = args[ 2 ];
                                            }
                                            // Otherwise, this event is from `Model.set`. If the relation doesn't report a change,
                                            // remove attr from `dit.changed` so `hasChanged` doesn't take it into account.
                                            else if ( !rel.changed ) {
                                                    delete dit.changed[ attr ];
                                            }
                                    }
                                    else if ( changed ) {
                                            dit._attributeChangeFired = true;
                                    }
                            }

                            if (changed) {
                              Backbone.Model.prototype.trigger.apply( dit, args );
                            }
                    });
            }
            else {
                    Backbone.Model.prototype.trigger.apply( this, arguments );
            }

            return this;
    }
  });

  // ..........................................................
  // CLASS METHODS
  //

  _.extend(XM.Model, XM.ModelClassMixin);
  _.extend(XM.Model, /** @lends XM.Model# */{

    /**
      Overload: Need to handle status here
    */
    findOrCreate: function (attributes, options) {
      options = options ? options : {};
      var parsedAttributes = (_.isObject(attributes) && options.parse && this.prototype.parse) ?
				this.prototype.parse(attributes) : attributes;

      // Try to find an instance of 'this' model type in the store
      var model = Backbone.Relational.store.find(this, parsedAttributes);

      // If we found an instance, update it with the data in 'item'; if not, create an instance
      // (unless 'options.create' is false).
      if (_.isObject(attributes)) {
        if (model && options.merge !== false) {
          model.setStatus(XM.Model.BUSY_FETCHING);
          model.set(attributes, options);
        } else if (!model && options.create !== false) {
          model = this.build(attributes, options);
        }
      }

      return model;
    },

    /**
      Overload: assume that anything calling this function is doing so because it
      is building a model for a relation. In that case set the `isFetching` option
      is true which will set it in a `BUSY_FETCHING` state when it is created.
    */
    build: function (attributes, options) {
      options = options ? _.clone(options) : {};
      options.isFetching = options.isFetching !== false ? true : false;
      options.validate = false;
      return Backbone.RelationalModel.build.call(this, attributes, options);
    }

  });

  /**
    We need to handle the ``isFetching` option at the constructor to make
    *sure* the status of the model will be `BUSY_FETCHING` if it needs to be.
  */
  var ctor = Backbone.RelationalModel.constructor;
  Backbone.RelationalModel.constructor = function (attributes, options) {
    ctor.apply(this, arguments);
    if (options && options.isFetching) { this.status = XM.Model.BUSY_FETCHING; }
  };

  //  Hack because `add` events don't fire normally when models loaded from the db.
  //  Recursively look for toMany relationships and set sort indexes needed
  //  for `patch` processing.
  /** @private */
  var _handleAddRelated = function (model) {

    // Loop through each model's relation
    _.each(model.relations, function (relation) {
      var coll;

      // If HasMany, get models and call `relationAdded` on each
      // then dive recursively
      if (relation.type === Backbone.HasMany) {
        coll = model.get(relation.key);
        if (coll && coll.length) {
          _.each(coll.models, function (item) {
            model.relationAdded(item);
            _handleAddRelated(item);
          });
        }
      }
    });
  };

})();