Merge pull request #1220 from shackbarth/22390-email
[xtuple] / lib / backbone-x / source / model.js
1 /*jshint unused:false */
2 /* global XG:true */
3
4 (function () {
5   'use strict';
6
7   XM.Tuplespace = _.clone(Backbone.Events);
8
9   /**
10     @class `XM.Model` is an abstract class designed to operate with `XT.DataSource`.
11     It should be subclassed for any specific implementation. Subclasses should
12     include a `recordType` the data source will use to retrieve the record.
13
14     To create a new model include `isNew` in the options:
15     <pre><code>
16       // Create a new class
17       XM.MyModel = XM.Model.extend({
18         recordType: 'XM.MyModel'
19       });
20
21       // Instantiate a new model object
22       m = new XM.MyModel(null, {isNew: true});
23    </code></pre>
24     To load an existing record use the `findOrCreate` method and include an id in the attributes:
25     <pre><code>
26       m = XM.MyModel.findOrCreate({id: 1});
27       m.fetch();
28     </code></pre>
29
30     @name XM.Model
31     @description To create a new model include `isNew` in the options:
32     @param {Object} Attributes
33     @param {Object} Options
34     @extends XM.ModelMixin
35     @extends Backbone.RelationalModel
36   */
37   XM.Model = Backbone.RelationalModel.extend(XM.ModelMixin);
38
39   XM.Model = XM.Model.extend(/** @lends XM.Model# */{
40
41     autoFetchId: false,
42
43     /**
44      * Attempt to obtain lock.
45      * @fires lock:obtain
46      */
47     obtainLock: function (options) {
48       if (this.getLockKey() && _.isFunction(options.success)) {
49         this.trigger('lock:obtain', this, this.lock);
50         return options.success();
51       }
52       var params = [
53         this.recordType.prefix(),
54         this.recordType.suffix(),
55         this.id,
56         this.etag
57       ];
58       this._reentrantLockHelper('obtain', params, options);
59     },
60
61     /**
62      * Attempt to renew lock.
63      * @fires lock:renew
64      */
65     renewLock: function (options) {
66       this._reentrantLockHelper('renew', [ this.lock.key ], options);
67     },
68
69     /**
70      * Release lock.
71      * @fires lock:release
72      */
73     releaseLock: function (options) {
74       options = options || { };
75       var callback = options.success;
76
77       if (this.getLockKey()) {
78         this._reentrantLockHelper('release', { key: this.getLockKey() }, options);
79         this.lockDidChange(this, _.omit(this.lock, 'key'));
80       }
81       else if (_.isFunction(callback)) {
82         callback();
83       }
84     },
85
86     /**
87       A function that binds events to functions. It can and should only be called
88       once by initialize. Any attempt to call it a second time will throw an error.
89     */
90     bindEvents: function () {
91       var that = this;
92       // Bind events, but only if we haven't already been here before.
93       // We could silently skip, but then that means any overload done
94       // by anyone else has to do that check too. That's too error prone
95       // and dangerous because the problems caused by duplicate bindings
96       // are not immediatley apparent and insidiously hard to pin down.
97       if (this._eventsBound) { throw new Error("Events have already been bound."); }
98
99       /**
100        * Bind all events in the optional 'handlers' hash
101        */
102       _.each(this.handlers, function (handler, event) {
103         if (!_.isFunction(that[handler])) {
104           console.warn('Handler '+ handler + ' not found: not binding');
105           return;
106         }
107         that.on(event, that[handler]);
108       });
109
110       this.on('change', this.didChange);
111       this.on('error', this.didError);
112       this.on('destroy', this.didDestroy);
113
114       _.each(
115         _.where(this.relations, { type: Backbone.HasMany, includeInJSON: true }),
116         function (relation) {
117           that.on('add:' + relation.key, that.didChange);
118           if (!that.isReadOnly()) {
119             that.on('add:' + relation.key, that.relationAdded);
120           }
121         });
122
123       this._idx = {};
124       this._eventsBound = true;
125     },
126
127     /**
128       Reimplemented to handle state change and parent child relationships. Calling
129       `destroy` on a parent will cause the model to commit to the server
130       immediately. Calling destroy on a child relation will simply mark it for
131       deletion on the next save of the parent.
132
133       @returns {Object|Boolean}
134     */
135     destroy: function (options) {
136       options = options ? _.clone(options) : {};
137       var klass = this.getClass(),
138         canDelete = klass.canDelete(this),
139         success = options.success,
140         isNew = this.isNew(),
141         model = this,
142         result,
143         K = XM.Model,
144         parent = this.getParent(true),
145         children = [],
146         findChildren = function (model) {
147           _.each(model.relations, function (relation) {
148             var i, attr = model.attributes[relation.key];
149             if (attr && attr.models &&
150                 relation.type === Backbone.HasMany) {
151               for (i = 0; i < attr.models.length; i += 1) {
152                 findChildren(attr.models[i]);
153               }
154               children = _.union(children, attr.models);
155             }
156           });
157         };
158       if ((parent && parent.canUpdate(this)) ||
159           (!parent && canDelete) ||
160            this.getStatus() === K.READY_NEW) {
161         this._wasNew = isNew; // Hack so prototype call will still work
162         this.setStatus(K.DESTROYED_DIRTY, {cascade: true});
163
164         // If it's top level commit to the server now.
165         if ((!parent && canDelete) || isNew) {
166           findChildren(this); // Lord Vader ... rise
167           this.setStatus(K.BUSY_DESTROYING, {cascade: true});
168           options.wait = true;
169           options.success = function (resp) {
170             var i;
171             // Do not hesitate, show no mercy!
172             for (i = 0; i < children.length; i += 1) {
173               children[i].didDestroy();
174             }
175             if (XT.session.config.debugging) {
176               XT.log('Destroy successful');
177             }
178             if (success) { success(model, resp, options); }
179           };
180           result = Backbone.Model.prototype.destroy.call(this, options);
181           delete this._wasNew;
182           return result;
183
184         }
185
186         // Otherwise just marked for deletion.
187         if (success) {
188           success(this, null, options);
189         }
190         return true;
191       }
192       XT.log('Insufficient privileges to destroy');
193       return false;
194     },
195
196     doEmail: function () {
197       // TODO: a way for an unwatched model to set the scrim
198       XT.dataSource.callRoute("generate-report", this.getReportPayload("email"), {
199         error: function (error) {
200           // TODO: a way for an unwatched model to trigger the notify popup
201           console.log("email error", error);
202         },
203         success: function () {
204           console.log("email success");
205         }
206       });
207     },
208
209     doPrint: function () {
210       XT.dataSource.callRoute("generate-report", this.getReportPayload("print"), {
211         success: function () {
212           console.log("print success");
213         }
214       });
215     },
216
217     /**
218      * @protected
219      *
220      * Prepare fetch.
221      *
222      * TODO sync() alone should handle all of this stuff. fetch() is not a
223      * Backbone customization point by design.
224      */
225     _fetchHelper: function (_options) {
226       if (!this.getClass().canRead()) {
227         XT.log('Error: insufficient privileges to fetch');
228         return false;
229       }
230
231       var that = this,
232         options = _.extend({ }, _options),
233         callback = options.success,
234
235         /**
236          * @callback
237          */
238         done = function (resp) {
239           that.setStatus(XM.Model.READY_CLEAN, options);
240
241           if (_.isFunction(callback)) {
242             callback(that, resp, options);
243           }
244         },
245
246         /**
247          * @callback
248          * Handle successful fetch response. Obtain lock if necessary, and invoke
249          * the optional callback.
250          */
251         afterFetch = function (resp) {
252           var schema = XT.session.getSchemas()[that.recordType.prefix()],
253             lockable = schema.get(that.recordType.suffix()).lockable;
254
255           done = _.partial(done, resp);
256
257           if (lockable && options.obtainLock !== false) {
258             that.obtainLock({ success: done });
259           }
260           else {
261             done();
262           }
263         };
264
265       return _.extend(options, {
266         propagate: true,
267         success: afterFetch
268       });
269     },
270
271     /**
272      * @override
273      * Reimplemented to handle status changes and automatically obtain
274      * a pessimistic lock on the record.
275      *
276      * @param {Object} Options
277      * @returns {Object} Request
278      */
279     fetch: function (_options) {
280       var options = this._fetchHelper(_options);
281       if (!_.isObject(options)) {
282         return false;
283       }
284
285       this.setStatus(XM.Model.BUSY_FETCHING, { cascade: true });
286       return Backbone.Model.prototype.fetch.call(this, options);
287     },
288
289     /**
290       Set the id on this record an id from the server. Including the `cascade`
291       option will call ids to be fetched recursively for `HasMany` relations.
292
293       @returns {Object} Request
294     */
295     fetchId: function (options) {
296       options = _.defaults(options ? _.clone(options) : {});
297       var that = this, attr;
298       if (!this.id) {
299         options.success = function (resp) {
300           that.set(that.idAttribute, resp, options);
301         };
302         this.dispatch('XM.Model', 'fetchId', this.recordType, options);
303       }
304
305       // Cascade through `HasMany` relations if specified.
306       if (options && options.cascade) {
307         _.each(this.relations, function (relation) {
308           attr = that.attributes[relation.key];
309           if (attr) {
310             if (relation.type === Backbone.HasMany) {
311               if (attr.models) {
312                 _.each(attr.models, function (model) {
313                   if (model.fetchId) { model.fetchId(options); }
314                 });
315               }
316             }
317           }
318         });
319       }
320     },
321
322     /**
323      * Retrieve related objects.
324      * @param {String} key The relation key to fetch models for.
325      * @param {Object} options Options for 'Backbone.Model.fetch' and 'Backbone.sync'.
326      * @param {Boolean} update  Whether to force a fetch from the server (updating existing models).
327      * @returns {Array} An array of request objects.
328      */
329     fetchRelated: function (key, options, refresh) {
330       options = options || {};
331       var requests = [],
332         models,
333         created = [],
334         status = this.getStatus(),
335         K = XM.Model,
336         rel = this.getRelation(key),
337                                 keys = rel && (rel.keyIds || [rel.keyId]),
338         toFetch = keys && _.select(keys || [], function (id) {
339           return (id || id === 0) && (refresh || !Backbone.Relational.store.find(rel.relatedModel, id));
340         }, this);
341
342       if (toFetch && toFetch.length) {
343         if (options.max && toFetch.length > options.max) {
344           toFetch.length = options.max;
345         }
346
347         models = _.map(toFetch, function (id) {
348           var model = Backbone.Relational.store.find(rel.relatedModel, id),
349             attrs;
350
351           if (!model) {
352             attrs = {};
353             attrs[rel.relatedModel.prototype.idAttribute] = id;
354             model = rel.relatedModel.findOrCreate(attrs, options);
355             created.push(model);
356           }
357
358           return model;
359         }, this);
360
361         requests = _.map(models, function (model) {
362           var opts = _.defaults(
363             {
364               error: function () {
365                 if (_.contains(created, model)) {
366                   model.trigger('destroy', model, model.collection, options);
367                   if (options.error) { options.error.apply(model, arguments); }
368                 }
369               }
370             },
371             options
372           );
373           // Context option means server will check privilege access of the parent
374           // and the existence of relation on the parent to determine whether user
375           // can see this record instead of usual privilege check on the model.
376           if (status === K.READY_CLEAN || status === K.READY_DIRTY) {
377             opts.context = {
378               recordType: this.recordType,
379               value: this.id,
380               relation: key
381             };
382           }
383           return model.fetch(opts);
384         }, this);
385       } else {
386         if (options.success) { options.success(); }
387       }
388
389       return requests;
390     },
391
392     /**
393       Overload: Delete objects marked as destroyed from arrays and
394       convert dates to strings.
395
396       Add support for 'includeNested' option that will
397       output JSON with nested toOne objects when specified.
398     */
399     toJSON: function (options) {
400       var includeNested = options && options.includeNested,
401         that = this,
402         old = {},
403         nested,
404         json,
405         Klass,
406         idx,
407         idAttr,
408         prop,
409         relations,
410
411         // Sort based on the exact order in which items were added.
412         // This is an absolute must for `generatePatch` to work correctly
413         byIdx = function (a, b) {
414           return idx.indexOf(a[idAttr]) - idx.indexOf(b[idAttr]);
415         },
416         byIdx2 = function (a, b) {
417           return idx.indexOf(a.attributes[idAttr]) - idx.indexOf(b.attributes[idAttr]);
418         };
419
420       // If include nested, preprocess relations so they will show up nested
421       if (includeNested) {
422         nested = _.filter(this._relations, function (rel) {
423           return rel.options.isNested;
424         });
425         _.each(nested, function (rel) {
426           old[rel.key] = rel.options.includeInJSON;
427           rel.options.includeInJSON = true;
428         });
429       }
430
431       json = Backbone.RelationalModel.prototype.toJSON.apply(this, arguments);
432
433       // Convert dates to strings to avoid conflicts with jsonpatch
434       for (prop in json) {
435         if (json.hasOwnProperty(prop) && json[prop] instanceof Date) {
436           json[prop] = json[prop].toJSON();
437         }
438       }
439
440       // If this Model has already been fully serialized in this branch once, return to avoid loops
441       if (this.isLocked()) {
442         return this.id;
443       }
444       this.acquire();
445
446       // Exclude relations that by definition don't need to be processed.
447       relations = _.filter(this.relations, function (relation) {
448         return relation.includeInJSON;
449       });
450
451       // Handle "destroyed" records
452       _.each(relations, function (relation) {
453         var K = XM.ModelClassMixin,
454           key = relation.key,
455           oldComparator,
456           status,
457           attr,
458           i;
459
460         if (relation.type === Backbone.HasMany) {
461           attr = that.get(key);
462           if (attr && attr.length) {
463             // Sort by create order
464             idx = that._idx[relation.relatedModel.suffix()];
465             if (idx) {
466               Klass = Backbone.Relational.store.getObjectByName(relation.relatedModel);
467               idAttr = Klass.prototype.idAttribute;
468               json[key].sort(byIdx);
469
470               // We need to sort by index, but we'll change back later.
471               oldComparator = attr.comparator;
472               attr.comparator = byIdx2;
473               attr.sort();
474             }
475
476             for (i = 0; i < attr.models.length; i++) {
477               status = attr.models[i].getStatus();
478               if (status === K.BUSY_COMMITTING) {
479                 status = attr.models[i]._prevStatus;
480               }
481
482               // If dirty record has changed from server version.
483               // Deleting will leave a "hole" in the array picked up
484               // by the patch algorithm that signals a change
485               if (status === K.DESTROYED_DIRTY) {
486                 delete json[key][i];
487
488               // If clean the server never knew about it so remove entirely
489               } else  if (status === K.DESTROYED_CLEAN) {
490                 json[key].splice(i, 1);
491
492               // Delete the relation parent data if applicable. Don't need it here.
493               } else if (relation.isNested) {
494                 delete json[key][i][relation.reverseRelation.key];
495               }
496             }
497           }
498
499           if (idx) { attr.comparator = oldComparator; }
500         }
501       });
502
503       this.release();
504
505       // Revert relations to previous settings if applicable
506       if (includeNested) {
507         _.each(nested, function (rel) {
508           rel.options.includeInJSON = old[rel.key];
509           delete rel.options.oldIncludeInJSON;
510         });
511       }
512
513       return json;
514     },
515
516     /**
517       Returns the current model prototype class.
518
519       @returns {XM.Model}
520     */
521     getClass: function () {
522       return Backbone.Relational.store.getObjectByName(this.recordType);
523     },
524
525     /**
526       Return the parent model if one exists. If the `getRoot` parameter is
527       passed, it will return the top level parent of the model hierarchy.
528
529       @param {Boolean} Get Root
530       @returns {XM.Model}
531     */
532     getParent: function (getRoot) {
533       var parent,
534         root,
535         relation = _.find(this.relations, function (rel) {
536           if (rel.reverseRelation && rel.isAutoRelation) {
537             return true;
538           }
539         });
540       parent = relation && relation.key ? this.get(relation.key) : false;
541       if (parent && getRoot) {
542         root = parent.getParent(getRoot);
543       }
544       return root || parent;
545     },
546
547     getReportUrl: function (action) {
548       var modelName = this.editableModel || this.recordType,
549         reportUrl = "/generate-report?nameSpace=%@&type=%@&id=%@".f(
550           modelName.prefix(), modelName.suffix(), this.id);
551
552       if (action) {
553         reportUrl = reportUrl + "&action=" + action;
554       }
555       return reportUrl;
556     },
557
558     getReportPayload: function (action) {
559       var modelName = this.editableModel || this.recordType;
560       return {
561         nameSpace: modelName.prefix(),
562         type: modelName.suffix(),
563         id: this.id,
564         action: action
565       };
566     },
567
568     /**
569       Called when model is instantiated.
570     */
571     initialize: function (attributes, options) {
572       attributes = attributes || {};
573       options = options || {};
574       var that = this,
575         klass,
576         K = XM.Model,
577         status = this.getStatus(),
578         idAttribute = this.idAttribute;
579
580       // Set defaults if not provided
581       this.privileges = this.privileges || {};
582       this.readOnlyAttributes = this.readOnlyAttributes ?
583         this.readOnlyAttributes.slice(0) : [];
584       this.requiredAttributes = this.requiredAttributes ?
585         this.requiredAttributes.slice(0) : [];
586
587       // Validate
588       if (_.isEmpty(this.recordType)) { throw new Error('No record type defined'); }
589       if (options.status) {
590         this.setStatus(options.status);
591       } else if (_.isNull(status)) {
592         this.setStatus(K.EMPTY);
593         this.bindEvents();
594       }
595
596       // Handle options
597       if (options.isNew) {
598         klass = this.getClass();
599         if (!klass.canCreate()) {
600           throw new Error('Insufficient privileges to create a record of class ' +
601             this.recordType);
602         }
603         this.setStatus(K.READY_NEW, {cascade: true});
604
605         // Key generator (client based)
606         if (idAttribute === 'uuid' &&
607             !this.get(idAttribute) &&
608             !attributes[idAttribute]) {
609           this.set(idAttribute, XT.generateUUID());
610         }
611
612         // Deprecated key generator (server based)
613         if (this.autoFetchId) { this.fetchId(); }
614       }
615
616       // Set attributes that should be required and read only
617       if (this.idAttribute &&
618           !_.contains(this.requiredAttributes, idAttribute)) {
619         this.requiredAttributes.push(this.idAttribute);
620       }
621
622       /**
623        * Enable ability to listen for events in a global tuple-space, if
624        * required.
625        */
626       XM.Tuplespace.listenTo(this, 'all', XM.Tuplespace.trigger);
627     },
628
629     /**
630       Return whether the model is in a read-only state. If an attribute name
631       is passed, returns whether that attribute is read-only. It is also
632       capable of checking the read only status of child objects via a search path string.
633
634       <pre><code>
635         // Inquire on the whole model
636         var readOnly = this.isReadOnly();
637
638         // Inquire on a single attribute
639         var readOnly = this.isReadOnly("name");
640
641         // Inquire using a search path
642         var readOnly = this.isReadOnly("contact.firstName");
643       </code></pre>
644
645       @seealso `setReadOnly`
646       @seealso `readOnlyAttributes`
647       @param {String} attribute
648       @returns {Boolean}
649     */
650     isReadOnly: function (value) {
651       var parent = this.getParent(true),
652         isLockedOut = parent ? !parent.hasLockKey() : !this.hasLockKey(),
653         result,
654         parts,
655         part,
656         i;
657
658       // Search path
659       if (_.isString(value) && value.indexOf('.') !== -1) {
660         parts = value.split('.');
661         result = this;
662         for (i = 0; i < parts.length; i++) {
663           part = parts[i];
664           if (result instanceof Backbone.Model && i + 1 < parts.length) {
665             result = result.getValue(part);
666           } else if (_.isNull(result)) {
667             return result;
668           } else if (!_.isUndefined(result)) {
669             result = result.isReadOnly(part) || !result.hasLockKey();
670           }
671         }
672         return result;
673       }
674
675       if ((!_.isString(value) && !_.isObject(value)) || this.readOnly) {
676         result = this.readOnly;
677       } else {
678         result = _.contains(this.readOnlyAttributes, value);
679       }
680       return result || isLockedOut;
681     },
682
683     /**
684       Recursively checks the object against the schema and converts date strings to
685       date objects.
686
687       @param {Object} Response
688     */
689     parse: function (resp, options) {
690       var K = XT.Session,
691         schemas = XT.session.getSchemas(),
692         parse;
693
694       // A hack to undo damage done by Backbone inside
695       // save function. For use with options that have
696       // collections. See XT.DataSource for the other
697       // side of this.
698       if (options && options.fixAttributes) {
699         this.attributes = options.fixAttributes;
700       }
701
702       // The normal business.
703       parse = function (namespace, typeName, obj) {
704         var type = schemas[namespace].get(typeName),
705           column,
706           rel,
707           attr,
708           i;
709         if (!type) { throw new Error(typeName + " not found in schema."); }
710         for (attr in obj) {
711           if (obj.hasOwnProperty(attr) && obj[attr] !== null) {
712             if (_.isObject(obj[attr])) {
713               rel = _.findWhere(type.relations, {key: attr});
714               typeName = rel ? rel.relatedModel.suffix() : false;
715               if (typeName) {
716                 if (_.isArray(obj[attr])) {
717                   for (i = 0; i < obj[attr].length; i++) {
718                     obj[attr][i] = parse(namespace, typeName, obj[attr][i]);
719                   }
720                 } else {
721                   obj[attr] = parse(namespace, typeName, obj[attr]);
722                 }
723               }
724             } else {
725               column = _.findWhere(type.columns, {name: attr}) || {};
726               if (column.category === K.DB_DATE) {
727                 obj[attr] = new Date(obj[attr]);
728               }
729             }
730           }
731         }
732
733         return obj;
734       };
735
736       this._lastParse = parse(this.recordType.prefix(), this.recordType.suffix(), resp);
737       return this._lastParse;
738     },
739
740     relationAdded: function (model, related, options) {
741       var type = model.recordType.suffix(),
742         id = model.id,
743         evt,
744         idx,
745         replaceId = function (model) {
746           idx.splice(idx.indexOf(id), 1, model.id);
747           model.off(evt, replaceId);
748         };
749       if (!this._idx[type]) { this._idx[type] = []; }
750       idx = this._idx[type];
751
752       if (id) {
753         if (!_.contains(idx, id)) { idx.push(id); }
754         return;
755       }
756
757       // If no model id, then use a placeholder until we have one
758       id = XT.generateUUID();
759       idx.push(model.id);
760       evt = "change:" + model.idAttribute;
761       model.on(evt, replaceId);
762     },
763
764     /**
765       Revert a model back to its original state the last time it was fetched.
766     */
767     revert: function () {
768       var K = XM.Model;
769
770       this.clear({silent: true});
771       this.setStatus(K.BUSY_FETCHING);
772       this.set(this._lastParse, {silent: true});
773       this.setStatus(K.READY_CLEAN, {cascade: true});
774     },
775
776     /**
777       Revert the model to the previous status. Useful for reseting status
778       after a failed validation.
779
780       param {Boolean} - cascade
781     */
782     revertStatus: function (cascade) {
783       var K = XM.Model,
784         prev = this._prevStatus,
785         that = this,
786         attr;
787       this.setStatus(this._prevStatus || K.EMPTY);
788       this._prevStatus = prev;
789
790       // Cascade changes through relations if specified
791       if (cascade) {
792         _.each(this.relations, function (relation) {
793           attr = that.attributes[relation.key];
794           if (attr && attr.models &&
795               relation.type === Backbone.HasMany) {
796             _.each(attr.models, function (model) {
797               if (model.revertStatus) {
798                 model.revertStatus(cascade);
799               }
800             });
801           }
802         });
803       }
804     },
805
806     /**
807       Overload: Don't allow setting when model is in error or destroyed status, or
808       updating a `READY_CLEAN` record without update privileges.
809
810       @param {String|Object} Key
811       @param {String|Object} Value or Options
812       @param {Objecw} Options
813     */
814     set: function (key, val, options) {
815       var K = XM.Model,
816         keyIsObject = _.isObject(key),
817         status = this.getStatus(),
818         err;
819
820       // Handle both `"key", value` and `{key: value}` -style arguments.
821       if (keyIsObject) { options = val; }
822       options = options ? options : {};
823
824       switch (status)
825       {
826       case K.READY_CLEAN:
827         // Set error if no update privileges
828         if (!this.canUpdate()) { err = XT.Error.clone('xt1010'); }
829         break;
830       case K.READY_DIRTY:
831       case K.READY_NEW:
832         break;
833       case K.ERROR:
834       case K.DESTROYED_CLEAN:
835       case K.DESTROYED_DIRTY:
836         // Set error if attempting to edit a record that is ineligable
837         err = XT.Error.clone('xt1009', { params: { status: status } });
838         break;
839       default:
840         // If we're not in a `READY` state, silence all events
841         if (!_.isBoolean(options.silent)) {
842           options.silent = true;
843         }
844       }
845
846       // Raise error, if any
847       if (err) {
848         this.trigger('invalid', this, err, options);
849         return false;
850       }
851
852       // Handle both `"key", value` and `{key: value}` -style arguments.
853       if (keyIsObject) { val = options; }
854       return Backbone.RelationalModel.prototype.set.call(this, key, val, options);
855     },
856
857     /**
858       Set the status on the model. Triggers `statusChange` event. Option set to
859       `cascade` will propagate status recursively to all HasMany children.
860
861       @param {Number} Status
862       @params {Object} Options
863       @params {Boolean} [cascade=false] Cascade changes only through toMany relations
864       @params {Boolean} [propagate=false] Propagate changes through both toMany and toOne relations
865     */
866     setStatus: function (status, options) {
867       var K = XM.Model,
868         attr,
869         that = this,
870         parent,
871         parentRelation;
872
873       // Prevent recursion
874       if (this.isLocked() || this.status === status) { return; }
875
876       // Reset patch cache if applicable
877       if (status === K.READY_CLEAN && !this.readOnly) {
878         this._idx = {};
879         this._cache = this.toJSON();
880       }
881
882       this.acquire();
883       this._prevStatus = this.status;
884       this.status = status;
885       parent = this.getParent();
886
887       // Cascade changes through relations if specified
888       if (options && (options.cascade || options.propagate)) {
889         _.each(this.relations, function (relation) {
890           attr = that.attributes[relation.key];
891           if (attr && attr.models &&
892               relation.type === Backbone.HasMany) {
893             _.each(attr.models, function (model) {
894               if (model.setStatus) {
895                 model.setStatus(status, options);
896               }
897             });
898           } else if (attr && options.propagate &&
899               relation.type === Backbone.HasOne) {
900             attr.setStatus(status, options);
901           }
902         });
903       }
904
905       // Percolate changes up to parent when applicable
906       if (parent && (this.isDirty() ||
907           status === K.DESTROYED_DIRTY)) {
908         parentRelation = _.find(this.relations, function (relation) {
909           return relation.isAutoRelation;
910         });
911         // #refactor XXX if this is a bona fide Backbone Relational relation,
912         // events will propagate automatically from child to parent.
913         if (parentRelation) {
914           parent.changed[parentRelation.reverseRelation.key] = true;
915           parent.trigger('change', parent, options);
916         }
917       }
918       this.release();
919
920       // Work around for problem where backbone relational doesn't handle
921       // events consistently on loading.
922       if (status === K.READY_CLEAN) {
923         _handleAddRelated(this);
924       }
925
926       this.trigger('statusChange', this, status, options);
927       /**
928        * Fire event specifically for the new status.
929        */
930       this.trigger('status:' + K._status[status], this, status, options);
931
932       return this;
933     },
934
935     /**
936       Reimplemented.
937
938       @retuns {Object} Request
939     */
940     save: function (key, value, options) {
941       options = options ? _.clone(options) : {};
942       var attrs = {},
943         K = XM.ModelClassMixin,
944         success,
945         result;
946
947       // Can't save unless root
948       if (this.getParent()) {
949         XT.log('You must save on the root level model of this relation');
950         return false;
951       }
952
953       // Handle both `"key", value` and `{key: value}` -style arguments.
954       if (_.isObject(key) || _.isEmpty(key)) {
955         attrs = key;
956         options = value ? _.clone(value) : {};
957       } else if (_.isString(key)) {
958         attrs[key] = value;
959       }
960
961       // Only save if we should.
962       if (this.isDirty() || attrs) {
963         this._wasNew = this.isNew();
964         success = options.success;
965         options.wait = true;
966         options.cascade = true; // Cascade status to children
967         options.success = function (model, resp, options) {
968           var namespace = model.recordType.prefix(),
969             schema = XT.session.getSchemas()[namespace],
970             type = model.recordType.suffix(),
971             stype = schema.get(type),
972             params,
973             lockOpts = {};
974
975           if (stype.lockable) {
976             params = [namespace, type, model.id, model.etag];
977             lockOpts.success = function (lock) {
978               model.lock = lock;
979               model.lockDidChange(model, lock);
980               model.setStatus(K.READY_CLEAN, options);
981               if (success) { success(model, resp, options); }
982             };
983
984             model.dispatch("XM.Model", "obtainLock", params, lockOpts);
985             if (XT.session.config.debugging) { XT.log('Save successful'); }
986           } else {
987             model.setStatus(K.READY_CLEAN, options);
988             if (success) { success(model, resp, options); }
989           }
990         };
991
992         // Handle both `"key", value` and `{key: value}` -style arguments.
993         if (_.isObject(key) || _.isEmpty(key)) { value = options; }
994
995         if (options.collection) {
996           options.collection.each(function (model) {
997             model.setStatus(K.BUSY_COMMITTING, options);
998           });
999         } else {
1000           this.setStatus(K.BUSY_COMMITTING, options);
1001         }
1002
1003         // allow the caller to pass in a different save function to call
1004         result = options.prototypeSave ?
1005         options.prototypeSave.call(this, key, value, options) :
1006         Backbone.Model.prototype.save.call(this, key, value, options);
1007         delete this._wasNew;
1008         if (!result) { this.revertStatus(true); }
1009         return result;
1010       }
1011
1012       XT.log('No changes to save');
1013       return false;
1014     },
1015
1016     /**
1017       Default validation checks `attributes` for:<br />
1018         &#42; Data type integrity.<br />
1019         &#42; Required fields.<br />
1020       <br />
1021       Returns `undefined` if the validation succeeded, or some value, usually
1022       an error message, if it fails.<br />
1023       <br />
1024
1025       @param {Object} Attributes
1026       @param {Object} Options
1027     */
1028     validate: function (attributes, options) {
1029       attributes = attributes || {};
1030       options = options || {};
1031       var that = this,
1032         i,
1033         result,
1034         S = XT.Session,
1035         attr, value, category, column,
1036         params = {recordType: this.recordType},
1037         namespace = this.recordType.prefix(),
1038         type = this.recordType.suffix(),
1039         columns = XT.session.getSchemas()[namespace].get(type).columns,
1040         coll = options.collection,
1041         isRel,
1042         model,
1043
1044         // Helper functions
1045         isRelation = function (attr, value, type, options) {
1046           options = options || {};
1047
1048           var rel;
1049           rel = _.find(that.relations, function (relation) {
1050             return relation.key === attr &&
1051               relation.type === type &&
1052               (!options.nestedOnly || relation.isNested);
1053           });
1054           return rel ? _.isObject(value) : false;
1055         },
1056         getColumn = function (attr) {
1057           return _.find(columns, function (column) {
1058             return column.name === attr;
1059           });
1060         };
1061
1062       // If we're dealing with a collection, validate each model
1063       if (coll) {
1064         for (i = 0; i < coll.length; i++) {
1065           model = coll.at(i);
1066           result = model.validate(model.attributes);
1067           if (result) { return result; }
1068         }
1069         return;
1070       }
1071
1072       // Check data type integrity
1073       for (attr in attributes) {
1074         if (attributes.hasOwnProperty(attr) &&
1075             !_.isNull(attributes[attr]) &&
1076             !_.isUndefined(attributes[attr])) {
1077           params.attr = ("_" + attr).loc() || "_" + attr;
1078
1079           value = attributes[attr];
1080           column = getColumn(attr);
1081           category = column ? column.category : false;
1082           switch (category) {
1083           case S.DB_BYTEA:
1084             if (!_.isObject(value) && !_.isString(value)) { // XXX unscientific
1085               params.type = "_binary".loc();
1086               return XT.Error.clone('xt1003', { params: params });
1087             }
1088             break;
1089           case S.DB_UNKNOWN:
1090           case S.DB_STRING:
1091             if (!_.isString(value) && !_.isNumber(value) &&
1092                 !isRelation(attr, value, Backbone.HasOne)) {
1093               params.type = "_string".loc();
1094               return XT.Error.clone('xt1003', { params: params });
1095             }
1096             break;
1097           case S.DB_NUMBER:
1098             if (!_.isNumber(value) &&
1099                 !isRelation(attr, value, Backbone.HasOne)) {
1100               params.type = "_number".loc();
1101               return XT.Error.clone('xt1003', { params: params });
1102             }
1103             break;
1104           case S.DB_DATE:
1105             if (!_.isDate(value)) {
1106               params.type = "_date".loc();
1107               return XT.Error.clone('xt1003', { params: params });
1108             }
1109             break;
1110           case S.DB_BOOLEAN:
1111             if (!_.isBoolean(value)) {
1112               params.type = "_boolean".loc();
1113               return XT.Error.clone('xt1003', { params: params });
1114             }
1115             break;
1116           case S.DB_ARRAY:
1117             isRel = isRelation(attr, value, Backbone.HasMany);
1118             if (!_.isArray(value) && !isRel) {
1119               params.type = "_array".loc();
1120               return XT.Error.clone('xt1003', { params: params });
1121             }
1122             // Validate children if they're nested, but not if they're not
1123             isRel = isRelation(attr, value, Backbone.HasMany, {nestedOnly: true});
1124             if (isRel && value.models) {
1125               for (i = 0; i < value.models.length; i++) {
1126                 model = value.models[i];
1127                 if (!(model._prevStatus & XM.Model.DESTROYED)) {
1128                   result = model.validate(model.attributes, options);
1129                   if (result) { return result; }
1130                 }
1131               }
1132             }
1133             break;
1134           case S.DB_COMPOUND:
1135             if (!_.isObject(value) && !_.isNumber(value)) {
1136               params.type = "_object".loc();
1137               return XT.Error.clone('xt1003', { params: params });
1138             }
1139             break;
1140           default:
1141             // attribute not in schema
1142             return XT.Error.clone('xt1002', { params: params });
1143           }
1144         }
1145       }
1146
1147       // Check required.
1148       for (i = 0; i < this.requiredAttributes.length; i += 1) {
1149         value = attributes[this.requiredAttributes[i]];
1150         if (value === undefined || value === null || value === "") {
1151           params.attr = ("_" + this.requiredAttributes[i]).loc() ||
1152             "_" + this.requiredAttributes[i];
1153           return XT.Error.clone('xt1004', { params: params });
1154         }
1155       }
1156
1157       return;
1158     }
1159
1160   });
1161
1162   // ..........................................................
1163   // CLASS METHODS
1164   //
1165
1166   _.extend(XM.Model, XM.ModelClassMixin);
1167   _.extend(XM.Model, /** @lends XM.Model# */{
1168
1169     /**
1170       Overload: Need to handle status here
1171     */
1172     findOrCreate: function (attributes, options) {
1173       options = options ? options : {};
1174       var parsedAttributes = (_.isObject(attributes) && options.parse && this.prototype.parse) ?
1175                                 this.prototype.parse(attributes) : attributes;
1176
1177       // Try to find an instance of 'this' model type in the store
1178       var model = Backbone.Relational.store.find(this, parsedAttributes);
1179
1180       // If we found an instance, update it with the data in 'item'; if not, create an instance
1181       // (unless 'options.create' is false).
1182       if (_.isObject(attributes)) {
1183         if (model && options.merge !== false) {
1184           model.setStatus(XM.Model.BUSY_FETCHING);
1185           model.set(attributes, options);
1186         } else if (!model && options.create !== false) {
1187           model = this.build(attributes, options);
1188         }
1189       }
1190
1191       return model;
1192     },
1193
1194     /**
1195       Overload: assume that anything calling this function is doing so because it
1196       is building a model for a relation. In that case set the `isFetching` option
1197       is true which will set it in a `BUSY_FETCHING` state when it is created.
1198     */
1199     build: function (attributes, options) {
1200       options = options ? _.clone(options) : {};
1201       options.isFetching = options.isFetching !== false ? true : false;
1202       options.validate = false;
1203       return Backbone.RelationalModel.build.call(this, attributes, options);
1204     }
1205
1206   });
1207
1208   /**
1209     We need to handle the ``isFetching` option at the constructor to make
1210     *sure* the status of the model will be `BUSY_FETCHING` if it needs to be.
1211   */
1212   var ctor = Backbone.RelationalModel.constructor;
1213   Backbone.RelationalModel.constructor = function (attributes, options) {
1214     ctor.apply(this, arguments);
1215     if (options && options.isFetching) { this.status = XM.Model.BUSY_FETCHING; }
1216   };
1217
1218   /** @private
1219     Hack because `add` events don't fire normally when models loaded from the db.
1220     Recursively look for toMany relationships and set sort indexes needed
1221     for `patch` processing.
1222   */
1223   var _handleAddRelated = function (model) {
1224
1225     // Loop through each model's relation
1226     _.each(model.relations, function (relation) {
1227       var coll;
1228
1229       // If HasMany, get models and call `relationAdded` on each
1230       // then dive recursively
1231       if (relation.type === Backbone.HasMany) {
1232         coll = model.get(relation.key);
1233         if (coll && coll.length) {
1234           _.each(coll.models, function (item) {
1235             model.relationAdded(item);
1236             _handleAddRelated(item);
1237           });
1238         }
1239       }
1240     });
1241   };
1242
1243 })();