UNPKG

backbone-rel

Version:

Relationships between Backbone models in the flavor of MongoDB's document references and embeddings

1,116 lines (927 loc) 47.1 kB
(function(root, factory) { if(typeof define === 'function' && define.amd) { // AMD define(['underscore', 'backbone', 'exports'], function(_, Backbone, exports) { // Export global even in AMD case in case this script is loaded with // others that may still expect a global Backbone. root.Backbone = factory(root, exports, _, Backbone); }); } else if(typeof exports !== 'undefined') { // for Node.js or CommonJS var _ = require('underscore'), Backbone = require('backbone'); factory(root, exports, _, Backbone); } else { // as a browser global root.Backbone = factory(root, {}, root._, root.Backbone); } }(this, function(root, exports, _, BackboneBase) { var Backbone = _.extend({}, BackboneBase); var modelOptions = ['url', 'urlRoot', 'collection']; Backbone.Model = BackboneBase.Model.extend({ references: {}, embeddings: {}, // Property to control whether a related object shall be inlined in this model's JSON representation. // Useful when the related object shall be saved to the server together with its parent/referencing object. // If a relationship key is added as a string to this array, the result of #toJSON() will have // a property of that key, under which the related object's JSON representation is nested. inlineJSON: [], // Property to control whether referenced objects shall be fetched automcatically when set. // - `true` (default) will cause all referenced objects to be fetched automatically // - `false` will cause that referenced objects are never fetched automatically // - Setting an array of reference key strings, allows to explicitly specify which references // shall be auto-fetched. autoFetchRelated: true, constructor: function(attributes, options) { var attrs = attributes || {}; this.cid = _.uniqueId('c'); options || (options = {}); this.attributes = {}; _.extend(this, _.pick(options, modelOptions)); this.relatedObjects = {}; this._relatedObjectsToFetch = []; this._updateIdRefFor = {}; // handle default values for relations var defaults, references = this.references, referenceAttributeName = this.referenceAttributeName.bind(this); if(options.parse) attrs = this.parse(attrs, options) || {}; if(defaults = _.result(this, 'defaults')) { defaults = _.extend({}, defaults); // clone _.each(_.keys(references), function(refKey) { // do not set default value for referenced object attribute // if attrs contain a corresponding ID reference if(referenceAttributeName(refKey) in attrs && refKey in defaults) { delete defaults[refKey]; } }); attrs = _.defaults({}, attrs, defaults); } this.set(attrs, options); this.changed = {}; if(!this.isNew()) { this._autoFetchEmbeddings(true); } this.initialize.apply(this, arguments); }, // Returns the URL for this model // // url: function() { var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url'); var suffix = _.result(this, 'urlSuffix'); if(base) { if(this.isNew()) return base; return base.replace(/([^\/])$/, '$1/') + encodeURIComponent(this.id); } else if(this.parent) { if(this.parent.isNew() && !this.parent.parent) { throw new Error("Could not get the parent model's URL as it has not been saved yet."); } base = _.result(this.parent, 'url'); if(base && suffix) { return base.replace(/([^\/])$/, '$1/') + suffix.replace(/(\/?)(.*)/, '$2'); } } throw new Error('Could not build url for the model with ID "' + this.id + '" (URL suffix: "' + suffix + '")'); }, // For embedded models, returns the suffix to append to the parent model's URL // for building the URL for the embedded model instance. urlSuffix: function() { var self = this, parent = this.parent; return parent && _.find(_.keys(parent.embeddings), function(key) { return parent.get(key) === self; }); }, // For models with references, returns the attribute name under which the IDs of referenced // objects for the given reference key are stored. // Per default, the attribute name is built by using the reference key + the ID attribute of the // referenced model. E.g: "userId" for reference key "user" to a model with ID attribute "id", // "userIds" for a to-many reference. // Override this method to customize the reference attribute naming pattern. referenceAttributeName: function(referenceKey) { var referencedModel = resolveRelClass(this.references[referenceKey]); return refKeyToIdRefKey(referencedModel, referenceKey); }, get: function(attr) { if(this.embeddings[attr] || this.references[attr]) { // return related object if the key corresponds to a reference or embedding return this.relatedObjects[attr]; } else { // otherwise return the regular attribute return BackboneBase.Model.prototype.get.apply(this, arguments); } }, // Set a hash of model attributes and relations on the object, firing `"change"`. This is // the core primitive operation of a model, updating the data and notifying // anyone who needs to know about the change in state. The heart of the beast. // ATTENTION: This is a full override of Backbone's default implementation meaning that // it will not call the base class method. If you are using third Backbone extensions that // override #set, make sure that these extend backbone-rel' Model class. set: function(key, val, options) { var attr, attrs, unset, changes, silent, changing, prev, current, referenceKey; if(key === null) return this; // Handle both `"key", value` and `{key: value}` -style arguments. if(typeof key === 'object') { attrs = key; options = val; } else { (attrs = {})[key] = val; } options || (options = {}); // Pass the setOriginId down to the nested set calls via options. // Also support `nestedSetOptions` to define options that shall only be applied to nested // #set calls for related object. var nestedOptions = _.extend( { setOriginId: _.uniqueId() }, options, { clear: undefined }, // `clear` option should not propagate to nested set calls { nestedSetOptions: undefined }, options.nestedSetOptions ); if(nestedOptions.collection) { // `collection` option should not propagate to nested set calls delete nestedOptions.collection; } this._deepChangePropagatedFor = []; // Run validation. if(!this._validate(attrs, options)) return false; // Extract attributes and options. unset = options.unset; silent = options.silent; changes = []; changing = this._changing; this._changing = true; if(!changing) { this._previousAttributes = _.clone(this.attributes); this._previousRelatedObjects = _.clone(this.relatedObjects); this.changed = {}; } current = this.attributes, prev = this._previousAttributes; // Check for changes of `id`. if(this.idAttribute in attrs) this.id = attrs[this.idAttribute]; // Precalculate the idRefKeys for all references to improve performance of the lookups var refKeys = _.keys(this.references); var refAndIdRefKeys = {}; var i; for(i=0; i<refKeys.length; i++) { refAndIdRefKeys[refKeys[i]] = refKeys[i]; refAndIdRefKeys[this.referenceAttributeName(refKeys[i])] = refKeys[i]; } var findReferenceKey = function(key) { return refAndIdRefKeys[key]; }; // If `clear` is set, calculate the keys to be unset and add those keys to attrs var keysToUnset = []; if(options.clear) { var defaults = _.result(this, 'defaults') || {}; keysToUnset = _.difference( _.union(_.keys(this.attributes), _.keys(this.relatedObjects)), _.keys(attrs), _.map(_.keys(attrs), findReferenceKey) ); // clone because the keysToUnset array is modified from within the loop var keysToUnsetCopy = _.clone(keysToUnset); for(i=0, l=keysToUnsetCopy.length; i<l; ++i) { var keyToUnset = keysToUnsetCopy[i]; if(defaults.hasOwnProperty(keyToUnset)) { // reset to default value instead of deleting var defVal = defaults[keyToUnset]; // ensure that the default for a reference/embedding is an actual Backbone // model/collection and not just a plain JSON hash or array (which would be // applied to the current referenced objects instead of replacing them) if(defVal && !defVal._representsToMany && !defVal._representsToOne) { var relationship = this.embeddings[keyToUnset] || this.references[keyToUnset]; var RelClass = relationship && resolveRelClass(relationship); if(RelClass) defVal = new RelClass(defVal, nestedOptions); } attrs[keyToUnset] = defVal; keysToUnset = _.without(keysToUnset, keyToUnset); } else { // add to attrs just to make sure the key will be traversed in the for loop attrs[keyToUnset] = void 0; } } } // For each `set` attribute, update or delete the current value. for (attr in attrs) { val = attrs[attr]; if(this.embeddings[attr]) { var opts = _.extend({}, nestedOptions, { clear: options.clear }, { unset: unset || _.includes(keysToUnset, attr) }); this._setEmbedding(attr, val, opts, changes); } else if(referenceKey = findReferenceKey(attr)) { // side-loaded JSON structures take precedence over ID references if(attr !== referenceKey && attrs[referenceKey]) { // is ID ref, but also side-loaded data is present in attrs continue; // ignore attr } var opts = _.extend({}, nestedOptions, { unset: unset || _.includes(keysToUnset, referenceKey) }); this._setReference(referenceKey, val, opts, changes); } else { // default Backbone behavior for plain attribute set if(!_.isEqual(current[attr], val)) changes.push(attr); if(!_.isEqual(prev[attr], val)) { this.changed[attr] = val; } else { delete this.changed[attr]; } unset || _.includes(keysToUnset, attr) ? delete current[attr] : current[attr] = val; } } var currentAll = _.extend({}, current, this.relatedObjects); // Trigger all relevant attribute changes. if(!silent) { if(changes.length) this._pending = true; var l; for (i = 0, l = changes.length; i < l; i++) { this.trigger('change:' + changes[i], this, currentAll[changes[i]], options); } } // You might be wondering why there's a `while` loop here. Changes can // be recursively nested within `"change"` events. if(changing) return this; if(!silent) { while (this._pending) { this._pending = false; this.trigger('change', this, options); } } this._pending = false; this._changing = false; // Trigger original 'deepchange' event, which will be propagated through the related object graph if(!silent && changes.length && !_.includes(this._deepChangePropagatedFor, nestedOptions.setOriginId)) { this._deepChangePropagatedFor.push(nestedOptions.setOriginId); this.trigger('deepchange', this, _.extend({ setOriginId: nestedOptions.setOriginId }, options)); this.trigger('deepchange_propagated', this, _.extend({ setOriginId: nestedOptions.setOriginId }, options)); } // finally, fetch all related objects that need a fetch this._fetchRelatedObjects(); return this; }, // Fetches the related object for each key in the provided keys array // If no keys array is provided, it fetches the related objects for all // relations that have not been synced before fetchRelated: function(keys) { if(!keys) { var embeddingKeys = _.filter(_.keys(this.embeddings), function(key) { return !this.get(key) || (!this.get(key).isSyncing && !this.get(key).isSynced); }.bind(this)); var referencesKeys = _.filter(_.keys(this.references), function(key) { return this.get(key) && (!this.get(key).isSyncing && !this.get(key).isSynced); }.bind(this)); keys = _.union(embeddingKeys, referencesKeys); } if(_.isString(keys)) { keys = [keys]; } for(var i=0; i<keys.length; i++) { var key = keys[i]; if(!this.embeddings[key] && !this.references[key]) { throw new Error("Invalid relationship key '" + key + "'"); } // init embeddings if(!this.get(key) && this.embeddings[key]) { var RelClass = resolveRelClass(this.embeddings[key]); this.set(key, new RelClass()); } var relatedObject = this.get(key); if(relatedObject && !relatedObject.isSyncing && !_.includes(this._relatedObjectsToFetch, relatedObject)) { this._relatedObjectsToFetch.push(relatedObject); } } this._fetchRelatedObjects(); }, // Sets the parent of an embedded object // If the optional keyInParent parameter is omitted, is is automatically detected setParent: function(parent, keyInParent) { var self = this; this.keyInParent = keyInParent || _.find(_.keys(parent.embeddings), function(key) { return parent.get(key) === self; }); if(!this.keyInParent) { throw new Error("A key for the embedding in the parent must be specified as it could not be detected automatically."); } this.parent = parent; if(this.parent.get(this.keyInParent) !== this) { this.parent.set(this.keyInParent, this); } this.trigger("embedded", this, parent, keyInParent); }, // Override #previous to add support for getting previous values of references and embeddings // in "change" events previous: function(attr) { var result = BackboneBase.Model.prototype.previous.apply(this, arguments); if(result) return result; if (attr === null || !this._previousRelatedObjects) return null; return this._previousRelatedObjects[attr]; }, // Override #toJSON to add support for inlining JSON representations of related objects // in the JSON of this model. The related objects to be inlined can be specified via the // `inlineJSON` property or option. toJSON: function(options) { options = options || {}; var self = this; var json = BackboneBase.Model.prototype.toJSON.apply(this, arguments); var inlineJSON = _.uniq(_.compact(_.flatten( _.union([options.inlineJSON], [_.result(this, "inlineJSON")]) ))); _.each(inlineJSON, function(key) { var obj = self; var path = key.split("."), nestedJson = json; while(obj && path.length > 0 && _.isFunction(obj.toJSON)) { key = path.shift(); obj = obj.get(key); if(obj && _.isFunction(obj.toJSON)) { // nest JSON represention ob embedded object into the hierarchy nestedJson[key] = obj.toJSON(); nestedJson = nestedJson[key]; } else if(obj===null) { // if an embedded object was unset, i.e., set to null, we have to // notify the server by nesting a null value into the JSON hierarchy nestedJson[key] = null; } } }); return json; }, // Override #fetch to add support for auto-fetching of embedded objects fetch: function() { var result = BackboneBase.Model.prototype.fetch.apply(this, arguments); this._autoFetchEmbeddings(); return result; }, // Override #sync to force PUT method when creating embedded models sync: function(method, obj, options) { this._beforeSync(); options = wrapOptionsCallbacks(this._afterSyncBeforeSet.bind(this), options); if(this.parent && method === "create") method = "update"; // always PUT embedded models if(options.forceMethod) { method = options.forceMethod; } return BackboneBase.Model.prototype.sync.apply(this, arguments); }, // // PRIVATE METHODS // _setEmbedding: function(key, value, options, changes) { var RelClass = resolveRelClass(this.embeddings[key]); var current = this.relatedObjects[key]; if(options.unset || options.clear) { delete this.relatedObjects[key]; } if(!options.unset && value && value !== current) { if(value._representsToMany || value._representsToOne) { // a model object is directly assigned // set its parent this.relatedObjects[key] = value; this.relatedObjects[key].setParent(this, key); } else if(!this.relatedObjects[key]) { // || (!_.isArray(value) && !this.relatedObjects[key].isNew() && this.relatedObjects[key].id !== value[this.relatedObjects[key].idAttribute])) { // first assignment of an embedded model //or assignment of an embedded model with a different ID // create embedded model and set its parent this.relatedObjects[key] = new RelClass(value, options); this.relatedObjects[key].setParent(this, key); } else { // update embedded model's attributes if(this.relatedObjects[key]._representsToMany) { this.relatedObjects[key][options.reset ? 'reset' : 'set'](value, options); } else { if(options.parse) { value = this.relatedObjects[key].parse(value, options); } this.relatedObjects[key].set(value, options); } } } else if(!options.unset) { // set new embedded object or null/undefined this.relatedObjects[key] = value; } if(current !== this.relatedObjects[key]) { changes.push(key); this._listenToRelatedObject(key, current); // unset current's parent property if(current) { current.parent = null; } } if(this._previousRelatedObjects[key] !== this.relatedObjects[key]) { this.changed[key] = this.relatedObjects[key]; } else { delete this.changed[key]; } }, _setReference: function(key, value, options, changes) { var RelClass = resolveRelClass(this.references[key]), idRef = this.referenceAttributeName(key); var current = this.relatedObjects[key], currentId = this.attributes[idRef]; if(options.unset || options.clear) { delete this.relatedObjects[key]; delete this.attributes[idRef]; } if(!options.unset && value!==undefined && value!==null) { if(RelClass.prototype._representsToOne) { // handling to-one relation this._setToOneReference(key, RelClass, value, options); } else if(RelClass.prototype._representsToMany) { // handling to-many relation this._setToManyReference(key, RelClass, value, options); } this._ensureIdReference(idRef, key); } else if(!options.unset) { // set `undefined` or `null` this.relatedObjects[key] = value; this.attributes[idRef] = value; } if(!_.isEqual(currentId, this.attributes[idRef])) { changes.push(idRef); } if(current !== this.relatedObjects[key]) { changes.push(key); this._listenToRelatedObject(key, current); } if(this._previousRelatedObjects[key] !== this.relatedObjects[key]) { this.changed[key] = this.relatedObjects[key]; } else { delete this.changed[key]; } if(!_.isEqual(this._previousAttributes[idRef], this.attributes[idRef])) { this.changed[idRef] = this.attributes[idRef]; } else { delete this.changed[idRef]; } }, _ensureIdReference: function(idRef, refKey) { var relatedObject = this.relatedObjects[refKey]; if(relatedObject._representsToOne) { // if the relatedObject is new, i.e., it doesn't have an ID yet // we need to update the reference as soon as the referenced objects // got assigned an ID if(relatedObject.isNew()) { if(this.attributes[idRef]) { delete this.attributes[idRef]; } relatedObject.once("change:" + (relatedObject.idAttribute||"id"), function() { this.set(idRef, relatedObject.id); }, this); } else { this.attributes[idRef] = relatedObject.id; } } else { // if any one of the referenced objects is new, // we need to update the ID ref array as soon as that item // got assigned an ID var atLeastOneItemIsNew = false, idAttr; this.attributes[idRef] = _.compact(relatedObject.map(function(m) { if(m.isNew()) { atLeastOneItemIsNew = true; idAttr = m.idAttribute || "id"; return undefined; } else { return m.id; } })); if(atLeastOneItemIsNew) { relatedObject.once("change:" + idAttr, this._ensureIdReference.bind(this, idRef, refKey)); } } }, _setToOneReference: function(key, RelClass, value, options) { var relatedObject = this.relatedObjects[key]; var id = value[RelClass.prototype.idAttribute||"id"] || value; // reset relatedObject if the ID reference changed // if the current related object does not yet have an id and the new value is side-loaded // data, do not reset, but assign the new id to the current related object if(relatedObject && relatedObject.id !== id && (relatedObject[relatedObject.idAttribute||"id"] || !(value instanceof Object))) { relatedObject = undefined; } if(value._representsToOne) { // directly assign a model if(value===relatedObject) return; relatedObject = value; this.relatedObjects[key] = relatedObject; return; } if(value instanceof Object) { // if the related model data is side-loaded, // create/update the related model instance if(relatedObject) { if(options.parse) { value = relatedObject.parse(value, options); } relatedObject.set(value, options); } else { relatedObject = new RelClass(value, options); } relatedObject.isSynced = true; // remove side-loaded object from the models to fetch if(relatedObject !== this) this._relatedObjectsToFetch = _.without(this._relatedObjectsToFetch, relatedObject); } else { // if only an ID reference is provided, // instantiate the model if(!relatedObject) { var attrs = {}; attrs[RelClass.prototype.idAttribute||"id"] = id; relatedObject = new RelClass(attrs, options); // auto-fetch related model if its url can be built var autoFetch = this.autoFetchRelated === true || (_.isArray(this.autoFetchRelated) && _.includes(this.autoFetchRelated, key)); var url; try { url = _.result(relatedObject, "url"); } catch(e) { if(autoFetch && console && _.isFunction(console.warn)) { console.warn("Could not build url to auto-fetch referenced model for key '" + key +"'", e.stack); } } if(autoFetch && url && !relatedObject.isSynced && !relatedObject.isSyncing && !_.includes(this._relatedObjectsToFetch, relatedObject)) { this._relatedObjectsToFetch.push(relatedObject); } } } this.relatedObjects[key] = relatedObject; }, _setToManyReference: function(key, RelClass, value, options) { var ItemModel = RelClass.prototype.model; var relatedObject = this.relatedObjects[key]; if(value._representsToMany) { // a collection model is directly assigned if(value===relatedObject) return; // teardown relation to old collection if(relatedObject) { relatedObject.parent = undefined; // TODO get rid of this here!!! } // setup relation to the new collection relatedObject = value; relatedObject.parent = this; // TODO get rid of this here!!! this.relatedObjects[key] = relatedObject; return; } // expect an array of IDs or model json objects if(!_.isArray(value)) { throw new Error("Got an unexpected value to set reference '" + key + "'"); } if(!relatedObject) { relatedObject = new RelClass([], {parent: this}); } // iterate all related items and get/initialize/fetch the model objects var modelArray = _.map(value, function(itemData) { var id = itemData.id || itemData; // try to get the related model from the current relatedObject collection var item = relatedObject.get(id); if(itemData instanceof Backbone.Model) { return itemData; } if(itemData instanceof Object) { // if the related model data is sideloaded, // create/update the related model instance if(item) { if(options.parse) { itemData = item.parse(itemData, options); } item.set(itemData, options); } else { item = new ItemModel(itemData, options); } item.isSynced = true; // remove side-loaded object from the models to fetch if(item !== this) { this._relatedObjectsToFetch = _.without(this._relatedObjectsToFetch, item); } } else { // if only an ID reference is provided // and the relation could not be resolved to an already loaded model, // instantiate the model if(!item) { var attrs = {}; attrs[ItemModel.prototype.idAttribute||"id"] = id; item = new ItemModel(attrs, _.extend({}, options, { parse: undefined })); // auto-fetch related model if its url can be built var autoFetch = this.autoFetchRelated === true || (_.isArray(this.autoFetchRelated) && _.includes(this.autoFetchRelated, key)); var url; try { url = _.result(item, "url"); } catch(e) { if(autoFetch && console && _.isFunction(console.warn)) { console.warn("Could not build url to auto-fetch referenced model for key '" + key + "'", e); } } if(autoFetch && url && !item.isSynced && !item.isSyncing && !_.includes(this._relatedObjectsToFetch, item)) { this._relatedObjectsToFetch.push(item); } } } return item; }.bind(this)); // important: do not merge into existing models as this might cause running into endless set loops for circular relations // merging of related model items' attributes is already done in the _.map() above relatedObject.set(modelArray, {merge:false}); this.relatedObjects[key] = relatedObject; }, _listenToRelatedObject: function(key, current) { if(current) { // stop propagating 'deepchange' of current related object this.stopListening(current, 'deepchange', this._propagateDeepChange); // stop listening to destroy and ID change events if(current._representsToOne) { this.stopListening(current, 'destroy', this._relatedObjectDestroyHandler); this.stopListening(current, 'change:' + (current.idAttribute || "id"), this._updateIdRefFor[key]); } else { this.stopListening(current, 'add remove reset change:' + (current.idAttribute || "id"), this._updateIdRefFor[key]); } } // start propagating 'deepchange' of new related object if(this.relatedObjects[key]) { this.listenTo(this.relatedObjects[key], 'deepchange', this._propagateDeepChange); if(this.relatedObjects[key]._representsToOne) { // listen to destroy to unset references this.listenTo(this.relatedObjects[key], 'destroy', this._relatedObjectDestroyHandler); // listen to changes of the ID to update ref this._updateIdRefFor[key] = this._updateIdRefFor[key] || this._updateIdRef.bind(this, key); this.listenTo(this.relatedObjects[key], 'change:' + (this.relatedObjects[key].idAttribute || "id"), this._updateIdRefFor[key]); } else { // listen to changes in the of item IDs and collection manipulations to update ID ref array this._updateIdRefFor[key] = this._updateIdRefFor[key] || this._updateIdRef.bind(this, key); this.listenTo(this.relatedObjects[key], 'add remove reset change:' + (this.relatedObjects[key].idAttribute || "id"), this._updateIdRefFor[key]); } } }, _updateIdRef: function(key) { if(this.references[key]) { var idRef = this.referenceAttributeName(key); this._ensureIdReference(idRef, key); this.trigger("change:" + idRef, this, this.get(idRef), {}); this.trigger("change", this, {}); } }, _autoFetchEmbeddings: function(onlyUndefinedEmbeddings) { var embeddingsKeys = _.keys(this.embeddings); for(var i=0; i<embeddingsKeys.length; i++) { var key = embeddingsKeys[i]; var autoFetch = this.autoFetchRelated === true || (_.isArray(this.autoFetchRelated) && _.includes(this.autoFetchRelated, key)); if(autoFetch) { if(!this.get(key)) { var RelClass = resolveRelClass(this.embeddings[key]); this.set(key, new RelClass()); } else if(onlyUndefinedEmbeddings) { continue; } var relatedObject = this.get(key); if(!relatedObject.isSyncing && !_.includes(this._relatedObjectsToFetch, relatedObject)) { this._relatedObjectsToFetch.push(relatedObject); } } } this._fetchRelatedObjects(); }, _beforeSync: function() { this.isSyncing = true; // make sure that "deepsync" is always triggered after "sync" this._relatedObjectsToFetch.push(this); var self = this; var syncCb = function() { self._relatedObjectFetchSuccessHandler(self); self.off("error", errorCb); }; var errorCb = function() { self._relatedObjectsToFetch.splice(self._relatedObjectsToFetch.indexOf(self), 1); self.off("sync", syncCb); }; this.once("sync", syncCb); this.once("error", errorCb); }, _afterSyncBeforeSet: function() { this.isSynced = true; delete this.isSyncing; }, _propagateDeepChange: function(changedModelOrCollection, opts) { // make sure that 'deepchange' is only triggered once, also when set operations are nested if(_.includes(this._deepChangePropagatedFor, opts.setOriginId)) { return; } this._deepChangePropagatedFor.push(opts.setOriginId); this.trigger('deepchange', changedModelOrCollection, opts); changedModelOrCollection.once('deepchange_propagated', function() { this.trigger('deepchange_propagated', changedModelOrCollection, opts); }, this); }, _fetchRelatedObjects: function() { for (var i=0; i<this._relatedObjectsToFetch.length; i++) { var model = this._relatedObjectsToFetch[i]; if(model===this) continue; // do not fetch again while setting // test whether fetching has already been triggered by another relation if(model.isSyncing) { model.once("sync", this._relatedObjectFetchSuccessHandler.bind(this, model)); continue; } else if(model.isSynced) { this._relatedObjectFetchSuccessHandler(model); continue; } model.fetch({ success: this._relatedObjectFetchSuccessHandler.bind(this), error: this._relatedObjectFetchErrorHandler.bind(this), isAutoFetch: true }); } }, // This callback is executed after every successful fetch of related objects after // these have been set as a reference auto-fetched as an embedding. It is responsible // for eventually triggering the 'deepsync' event. _relatedObjectFetchSuccessHandler: function(obj) { this._relatedObjectsToFetch.splice(this._relatedObjectsToFetch.indexOf(obj), 1); if(this._relatedObjectsToFetch.length === 0) { this.trigger("deepsync", this); } }, // propagate errors when automatically fetching related models _relatedObjectFetchErrorHandler: function(obj, resp, options) { this._relatedObjectsToFetch.splice(this._relatedObjectsToFetch.indexOf(obj), 1); this.trigger('error', obj, resp, options); }, // This callback ensures that relations are unset, when a related object is destroyed _relatedObjectDestroyHandler: function(destroyedObject) { _.each(this.relatedObjects, function(relObj, key) { if(relObj === destroyedObject) { this.unset(key); } }.bind(this)); }, _representsToOne: true }); Backbone.Collection = BackboneBase.Collection.extend({ constructor: function() { var triggerOriginalDeepChange = function(options) { options = options || {}; // Trigger original 'deepchange' event, which will be propagated through the related object graph var originId = options.setOriginId || _.uniqueId(); this._deepChangePropagatedFor.push(originId); this.trigger('deepchange', this, _.extend({ setOriginId: originId }, options)); this.trigger('deepchange_propagated', this, _.extend({ setOriginId: originId }, options)); }.bind(this); this.on('add remove', function(model, collection, options) { triggerOriginalDeepChange(options); }); this.on('reset', function(collection, options) { triggerOriginalDeepChange(options); }); this.on('sort', function(collection, options) { triggerOriginalDeepChange(options); }); return BackboneBase.Collection.prototype.constructor.apply(this, arguments); }, url: function() { var base = _.result(this, 'urlRoot'); if(base) { return base; } else if(this.parent) { if(this.parent.isNew() && !this.parent.parent) { throw new Error("Could not get the parent model's URL as it has not been saved yet."); } base = _.result(this.parent, 'url'); var suffix = _.result(this, 'urlSuffix'); if(base && suffix) { return base.replace(/([^\/])$/, '$1/') + suffix.replace(/(\/?)(.*)/, '$2'); } } throw new Error('Could not build url for the collection'); }, urlSuffix: function() { var self = this, parent = this.parent; return parent && _.find(_.keys(parent.embeddings), function(key) { return parent.get(key) === self; }); }, set: function() { this._deepChangePropagatedFor = []; return BackboneBase.Collection.prototype.set.apply(this, arguments); }, // Sets the parent for an embedded collection // If the optional keyInParent parameter is omitted, is is automatically detected setParent: function(parent, keyInParent) { var self = this; this.keyInParent = keyInParent || _.find(_.keys(parent.embeddings), function(key) { return parent.get(key) === self; }); if(!this.keyInParent) { throw new Error("A key for the embedding in the parent must be specified as it could not be detected automatically."); } this.parent = parent; if(this.parent.get(this.keyInParent) !== this) { this.parent.set(this.keyInParent, this); } this.trigger("embedded", this, parent, keyInParent); }, sync: function() { this._beforeSync(); //options = wrapOptionsCallbacks(this._afterSetBeforeTrigger, options); return BackboneBase.Collection.prototype.sync.apply(this, arguments); }, fetch: function(options) { options = wrapOptionsCallbacks(this._afterSetBeforeTrigger.bind(this), options); // auto-fetch embeddings of items //this.once("sync", function() { // this.each(function(item) { // item._autoFetchEmbeddings(); // }); //}, this); return BackboneBase.Collection.prototype.fetch.apply(this, [options]); }, // // PRIVATE METHODS // _beforeSync: function() { this.isSyncing = true; }, _prepareModel: function() { // set isSynced flag on each item model // before the the "add" event is triggered var model = BackboneBase.Collection.prototype._prepareModel.apply(this, arguments); if(model && this.isSyncing) { model.isSynced = true; } return model; }, _afterSetBeforeTrigger: function() { this.isSynced = true; delete this.isSyncing; var triggerDeepSync = function() { this.trigger("deepsync", this); }.bind(this); var afterAllItemsDeepSync, itemsToSyncCount = 0, itemSyncedCb = function() { afterAllItemsDeepSync(); }; this.each(function(item) { if(item.isSyncing) { itemsToSyncCount++; item.once("deepsync", itemSyncedCb); } }); if(itemsToSyncCount > 0) { afterAllItemsDeepSync = _.after(itemsToSyncCount, triggerDeepSync); } else { triggerDeepSync(); } }, _representsToMany: true }); // Resolve the model/collection class that is specified for a relation. // Supports lazy resolution when passing in a function. function resolveRelClass(cls) { if(_.isFunction(cls)) { if(cls.prototype._representsToOne || cls.prototype._representsToMany) { return cls; } else { var resolvedClass = cls(); if(!resolvedClass.prototype._representsToOne && !resolvedClass.prototype._representsToMany) { throw new Error("The model class for the relation could not be resolved. " + "It must extend either Backbone.Model or Backbone.Collection and the " + "backbone-rel extension must be loaded"); } return resolvedClass; } } else { throw new Error("Cannot resolve relation class from " + cls); } } function capitalize(string) { return string.charAt(0).toUpperCase() + string.substring(1); }; // Returns the ID reference attribute key for the given reference key // e.g.: "userId" for reference with key "user" with idAttribute "id", // "typeKey" for a reference "type" to a model with idAttribute "key", // "taskIds" for a reference "tasks" to a collection function refKeyToIdRefKey(referencedModel, key) { var idAttribute = referencedModel.prototype.idAttribute || "id"; if(referencedModel.prototype._representsToMany) { return key.replace(/s{0,1}$/, capitalize(idAttribute)+"s"); } else { return key + capitalize(idAttribute); } } // Wraps the xhr success and error callbacks to hook in additional method invocation. function wrapOptionsCallbacks(method, options) { options = options || {}; var success = options.success; var error = options.error; options.success = function(resp) { method(options); if(success) success(resp); }; options.error = function(resp) { method(options); if(error) error(resp); }; return options; } _.extend(exports, Backbone); return Backbone; }));