Skip to content

Instantly share code, notes, and snippets.

@toddpi314
Created November 4, 2012 01:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save toddpi314/4009725 to your computer and use it in GitHub Desktop.
Save toddpi314/4009725 to your computer and use it in GitHub Desktop.
Backbone-Relational - AutoFetch support
/**
* Backbone-relational.js 0.5.0
* (c) 2011 Paul Uithol
*
* Backbone-relational may be freely distributed under the MIT license.
* For details and documentation: https://github.com/PaulUithol/Backbone-relational.
* Depends on (as in, compeletely useless without) Backbone: https://github.com/documentcloud/backbone.
*/
(function(undefined) {
/**
* CommonJS shim
**/
var _, Backbone, exports;
if ( typeof window === 'undefined') {
_ = require('underscore');
Backbone = require('backbone');
exports = module.exports = Backbone;
} else {
var _ = window._;
Backbone = window.Backbone;
exports = window;
}
Backbone.Relational = {
showWarnings : true
};
/**
* Semaphore mixin; can be used as both binary and counting.
**/
Backbone.Semaphore = {
_permitsAvailable : null,
_permitsUsed : 0,
acquire : function() {
if (this._permitsAvailable && this._permitsUsed >= this._permitsAvailable) {
throw new Error('Max permits acquired');
} else {
this._permitsUsed++;
}
},
release : function() {
if (this._permitsUsed === 0) {
throw new Error('All permits released');
} else {
this._permitsUsed--;
}
},
isLocked : function() {
return this._permitsUsed > 0;
},
setAvailablePermits : function(amount) {
if (this._permitsUsed > amount) {
throw new Error('Available permits cannot be less than used permits');
}
this._permitsAvailable = amount;
}
};
/**
* A BlockingQueue that accumulates items while blocked (via 'block'),
* and processes them when unblocked (via 'unblock').
* Process can also be called manually (via 'process').
*/
Backbone.BlockingQueue = function() {
this._queue = [];
};
_.extend(Backbone.BlockingQueue.prototype, Backbone.Semaphore, {
_queue : null,
add : function(func) {
if (this.isBlocked()) {
this._queue.push(func);
} else {
func();
}
},
process : function() {
while (this._queue && this._queue.length) {
this._queue.shift()();
}
},
block : function() {
this.acquire();
},
unblock : function() {
this.release();
if (!this.isBlocked()) {
this.process();
}
},
isBlocked : function() {
return this.isLocked();
}
});
/**
* Global event queue. Accumulates external events ('add:<key>', 'remove:<key>' and 'update:<key>')
* until the top-level object is fully initialized (see 'Backbone.RelationalModel').
*/
Backbone.Relational.eventQueue = new Backbone.BlockingQueue();
/**
* Backbone.Store keeps track of all created (and destruction of) Backbone.RelationalModel.
* Handles lookup for relations.
*/
Backbone.Store = function() {
this._collections = [];
this._reverseRelations = [];
};
_.extend(Backbone.Store.prototype, Backbone.Events, {
_collections : null,
_reverseRelations : null,
/**
* Add a reverse relation. Is added to the 'relations' property on model's prototype, and to
* existing instances of 'model' in the store as well.
* @param {object} relation
* @param {Backbone.RelationalModel} relation.model
* @param {String} relation.type
* @param {String} relation.key
* @param {String|object} relation.relatedModel
*/
addReverseRelation : function(relation) {
var exists = _.any(this._reverseRelations, function(rel) {
return _.all(relation, function(val, key) {
return val === rel[key];
});
});
if (!exists && relation.model && relation.type) {
this._reverseRelations.push(relation);
if (!relation.model.prototype.relations) {
relation.model.prototype.relations = [];
}
relation.model.prototype.relations.push(relation);
this.retroFitRelation(relation);
}
},
/**
* Add a 'relation' to all existing instances of 'relation.model' in the store
* @param {object} relation
*/
retroFitRelation : function(relation) {
var coll = this.getCollection(relation.model);
coll.each(function(model) {
new relation.type(model, relation);
}, this);
},
/**
* Find the Store's collection for a certain type of model.
* @param {Backbone.RelationalModel} model
* @return {Backbone.Collection} A collection if found (or applicable for 'model'), or null
*/
getCollection : function(model) {
var coll = _.detect(this._collections, function(c) {
// Check if model is the type itself (a ref to the constructor), or is of type c.model
return model === c.model || model.constructor === c.model;
});
if (!coll) {
coll = this._createCollection(model);
}
return coll;
},
/**
* Find a type on the global object by name. Splits name on dots.
* @param {String} name
* @return {object}
*/
getObjectByName : function(name) {
var type = _.reduce(name.split('.'), function(memo, val) {
return memo[val];
}, exports);
return type !== exports ? type : null;
},
_createCollection : function(type) {
var coll;
// If 'type' is an instance, take it's constructor
if ( type instanceof Backbone.RelationalModel) {
type = type.constructor;
}
// Type should inherit from Backbone.RelationalModel.
if (type.prototype instanceof Backbone.RelationalModel.prototype.constructor) {
coll = new Backbone.Collection();
coll.model = type;
this._collections.push(coll);
}
return coll;
},
find : function(type, id) {
var coll = this.getCollection(type);
return coll && coll.get(id);
},
/**
* Add a 'model' to it's appropriate collection. Retain the original contents of 'model.collection'.
* @param {Backbone.RelationalModel} model
*/
register : function(model) {
var modelColl = model.collection;
var coll = this.getCollection(model);
coll && coll.add(model);
model.bind('destroy', this.unregister, this);
model.collection = modelColl;
},
/**
* Explicitly update a model's id in it's store collection
* @param {Backbone.RelationalModel} model
*/
update : function(model) {
var coll = this.getCollection(model);
coll._onModelEvent('change:' + model.idAttribute, model, coll);
},
/**
* Remove a 'model' from the store.
* @param {Backbone.RelationalModel} model
*/
unregister : function(model) {
model.unbind('destroy', this.unregister);
var coll = this.getCollection(model);
coll && coll.remove(model);
}
});
Backbone.Relational.store = new Backbone.Store();
/**
* The main Relation class, from which 'HasOne' and 'HasMany' inherit. Internally, 'relational:<key>' events
* are used to regulate addition and removal of models from relations.
*
* @param {Backbone.RelationalModel} instance
* @param {object} options
* @param {string} options.key
* @param {Backbone.RelationalModel.constructor} options.relatedModel
* @param {Boolean|String} [options.includeInJSON=true] Serialize the given attribute for related model(s)' in toJSON, or just their ids.
* @param {Boolean} [options.createModels=true] Create objects from the contents of keys if the object is not found in Backbone.store.
* @param {object} [options.reverseRelation] Specify a bi-directional relation. If provided, Relation will reciprocate
* the relation to the 'relatedModel'. Required and optional properties match 'options', except that it also needs
* {Backbone.Relation|String} type ('HasOne' or 'HasMany').
*/
Backbone.Relation = function(instance, options) {
this.instance = instance;
// Make sure 'options' is sane, and fill with defaults from subclasses and this object's prototype
options = ( typeof options === 'object' && options ) || {};
this.reverseRelation = _.defaults(options.reverseRelation || {}, this.options.reverseRelation);
this.reverseRelation.type = !_.isString(this.reverseRelation.type) ? this.reverseRelation.type : Backbone[this.reverseRelation.type] || Backbone.Relational.store.getObjectByName(this.reverseRelation.type);
this.model = options.model || this.instance.constructor;
this.options = _.defaults(options, this.options, Backbone.Relation.prototype.options);
this.key = this.options.key;
// 'exports' should be the global object where 'relatedModel' can be found on if given as a string.
this.relatedModel = this.options.relatedModel;
if (_.isString(this.relatedModel)) {
this.relatedModel = Backbone.Relational.store.getObjectByName(this.relatedModel);
}
if (!this.checkPreconditions()) {
return false;
}
if (instance) {
this.keyContents = this.instance.get(this.key);
// Add this Relation to instance._relations
this.instance._relations.push(this);
}
// Add the reverse relation on 'relatedModel' to the store's reverseRelations
if (!this.options.isAutoRelation && this.reverseRelation.type && this.reverseRelation.key) {
Backbone.Relational.store.addReverseRelation(_.defaults({
isAutoRelation : true,
model : this.relatedModel,
relatedModel : this.model,
reverseRelation : this.options // current relation is the 'reverseRelation' for it's own reverseRelation
}, this.reverseRelation // Take further properties from this.reverseRelation (type, key, etc.)
));
}
_.bindAll(this, '_modelRemovedFromCollection', '_relatedModelAdded', '_relatedModelRemoved');
if (instance) {
this.initialize();
if (options.autoFetch) {
this.instance.fetchRelated(options.key, _.isObject(options.autoFetch) ? options.autoFetch : {});
}
// When a model in the store is destroyed, check if it is 'this.instance'.
Backbone.Relational.store.getCollection(this.instance).bind('relational:remove', this._modelRemovedFromCollection);
// When 'relatedModel' are created or destroyed, check if it affects this relation.
Backbone.Relational.store.getCollection(this.relatedModel).bind('relational:add', this._relatedModelAdded).bind('relational:remove', this._relatedModelRemoved);
}
};
// Fix inheritance :\
Backbone.Relation.extend = Backbone.Model.extend;
// Set up all inheritable **Backbone.Relation** properties and methods.
_.extend(Backbone.Relation.prototype, Backbone.Events, Backbone.Semaphore, {
options : {
createModels : true,
includeInJSON : true,
isAutoRelation : false,
autoFetch : false
},
instance : null,
key : null,
keyContents : null,
relatedModel : null,
reverseRelation : null,
related : null,
_relatedModelAdded : function(model, coll, options) {
// Allow 'model' to set up it's relations, before calling 'tryAddRelated'
// (which can result in a call to 'addRelated' on a relation of 'model')
var dit = this;
model.queue(function() {
dit.tryAddRelated(model, options);
});
},
_relatedModelRemoved : function(model, coll, options) {
this.removeRelated(model, options);
},
_modelRemovedFromCollection : function(model) {
if (model === this.instance) {
this.destroy();
}
},
/**
* Check several pre-conditions.
* @return {Boolean} True if pre-conditions are satisfied, false if they're not.
*/
checkPreconditions : function() {
var i = this.instance, k = this.key, m = this.model, rm = this.relatedModel, warn = Backbone.Relational.showWarnings;
if (!m || !k || !rm) {
warn && typeof console !== 'undefined' && console.warn('Relation=%o; no model, key or relatedModel (%o, %o, %o)', this, k, rm);
return false;
}
// Check if the type in 'relatedModel' inherits from Backbone.RelationalModel
if (!(m.prototype instanceof Backbone.RelationalModel.prototype.constructor )) {
warn && typeof console !== 'undefined' && console.warn('Relation=%o; model does not inherit from Backbone.RelationalModel (%o)', this, i);
return false;
}
// Check if the type in 'relatedModel' inherits from Backbone.RelationalModel
if (!(rm.prototype instanceof Backbone.RelationalModel.prototype.constructor )) {
warn && typeof console !== 'undefined' && console.warn('Relation=%o; relatedModel does not inherit from Backbone.RelationalModel (%o)', this, rm);
return false;
}
// Check if this is not a HasMany, and the reverse relation is HasMany as well
if (this instanceof Backbone.HasMany && this.reverseRelation.type === Backbone.HasMany.prototype.constructor) {
warn && typeof console !== 'undefined' && console.warn('Relation=%o; relation is a HasMany, and the reverseRelation is HasMany as well.', this);
return false;
}
if (i) {
// Check if we're not attempting to create a duplicate relationship
if (i._relations.length) {
var exists = _.any(i._relations, function(rel) {
var hasReverseRelation = this.reverseRelation.key && rel.reverseRelation.key;
return rel.relatedModel === rm && rel.key === k && (!hasReverseRelation || this.reverseRelation.key === rel.reverseRelation.key );
}, this);
if (exists) {
warn && typeof console !== 'undefined' && console.warn('Relation=%o between instance=%o.%s and relatedModel=%o.%s already exists', this, i, k, rm, this.reverseRelation.key);
return false;
}
}
}
return true;
},
setRelated : function(related, options) {
this.related = related;
var value = {};
value[this.key] = related;
this.instance.acquire();
this.instance.set(value, _.defaults(options || {}, {
silent : true
}));
this.instance.release();
},
createModel : function(item) {
if (this.options.createModels && typeof (item ) === 'object') {
return new this.relatedModel(item);
}
},
/**
* Determine if a relation (on a different RelationalModel) is the reverse
* relation of the current one.
*/
_isReverseRelation : function(relation) {
if (relation.instance instanceof this.relatedModel && this.reverseRelation.key === relation.key && this.key === relation.reverseRelation.key) {
return true;
}
return false;
},
/**
* Get the reverse relations (pointing back to 'this.key' on 'this.instance') for the currently related model(s).
* @param {Backbone.RelationalModel} [model] Get the reverse relations for a specific model.
* If not specified, 'this.related' is used.
*/
getReverseRelations : function(model) {
var reverseRelations = [];
// Iterate over 'model', 'this.related.models' (if this.related is a Backbone.Collection), or wrap 'this.related' in an array.
var models = !_.isUndefined(model) ? [model] : this.related && (this.related.models || [this.related] );
_.each(models, function(related) {
_.each(related.getRelations(), function(relation) {
if (this._isReverseRelation(relation)) {
reverseRelations.push(relation);
}
}, this);
}, this);
return reverseRelations;
},
/**
* Rename options.silent, so add/remove events propagate properly.
* (for example in HasMany, from 'addRelated'->'handleAddition')
*/
sanitizeOptions : function(options) {
options || ( options = {} );
if (options.silent) {
options = _.extend({}, options, {
silentChange : true
});
delete options.silent;
}
return options;
},
// Cleanup. Get reverse relation, call removeRelated on each.
destroy : function() {
Backbone.Relational.store.getCollection(this.instance).unbind('relational:remove', this._modelRemovedFromCollection);
Backbone.Relational.store.getCollection(this.relatedModel).unbind('relational:add', this._relatedModelAdded).unbind('relational:remove', this._relatedModelRemoved);
_.each(this.getReverseRelations(), function(relation) {
relation.removeRelated(this.instance);
}, this);
}
});
Backbone.HasOne = Backbone.Relation.extend({
options : {
reverseRelation : {
type : 'HasMany'
}
},
initialize : function() {
_.bindAll(this, 'onChange');
this.instance.bind('relational:change:' + this.key, this.onChange);
var model = this.findRelated({
silent : true
});
this.setRelated(model);
// Notify new 'related' object of the new relation.
var dit = this;
_.each(dit.getReverseRelations(), function(relation) {
relation.addRelated(dit.instance);
});
},
findRelated : function(options) {
var item = this.keyContents;
var model = null;
if ( item instanceof this.relatedModel) {
model = item;
} else if (item && (_.isString(item) || _.isNumber(item) || typeof (item ) === 'object' )) {
// Try to find an instance of the appropriate 'relatedModel' in the store, or create it
var id = _.isString(item) || _.isNumber(item) ? item : item[this.relatedModel.prototype.idAttribute];
model = Backbone.Relational.store.find(this.relatedModel, id);
if (model && _.isObject(item)) {
model.set(item, options);
} else if (!model) {
model = this.createModel(item);
}
}
return model;
},
/**
* If the key is changed, notify old & new reverse relations and initialize the new relation
*/
onChange : function(model, attr, options) {
// Don't accept recursive calls to onChange (like onChange->findRelated->createModel->initializeRelations->addRelated->onChange)
if (this.isLocked()) {
return;
}
this.acquire();
options = this.sanitizeOptions(options);
// 'options._related' is set by 'addRelated'/'removeRelated'. If it is set, the change
// is the result of a call from a relation. If it's not, the change is the result of
// a 'set' call on this.instance.
var changed = _.isUndefined(options._related);
var oldRelated = changed ? this.related : options._related;
if (changed) {
this.keyContents = attr;
// Set new 'related'
if ( attr instanceof this.relatedModel) {
this.related = attr;
} else if (attr) {
var related = this.findRelated(options);
this.setRelated(related);
} else {
this.setRelated(null);
}
}
// Notify old 'related' object of the terminated relation
if (oldRelated && this.related !== oldRelated) {
_.each(this.getReverseRelations(oldRelated), function(relation) {
relation.removeRelated(this.instance, options);
}, this);
}
// Notify new 'related' object of the new relation. Note we do re-apply even if this.related is oldRelated;
// that can be necessary for bi-directional relations if 'this.instance' was created after 'this.related'.
// In that case, 'this.instance' will already know 'this.related', but the reverse might not exist yet.
_.each(this.getReverseRelations(), function(relation) {
relation.addRelated(this.instance, options);
}, this);
// Fire the 'update:<key>' event if 'related' was updated
if (!options.silentChange && this.related !== oldRelated) {
var dit = this;
Backbone.Relational.eventQueue.add(function() {
dit.instance.trigger('update:' + dit.key, dit.instance, dit.related, options);
});
}
this.release();
},
/**
* If a new 'this.relatedModel' appears in the 'store', try to match it to the last set 'keyContents'
*/
tryAddRelated : function(model, options) {
if (this.related) {
return;
}
options = this.sanitizeOptions(options);
var item = this.keyContents;
if (item && (_.isString(item) || _.isNumber(item) || typeof (item ) === 'object' )) {
var id = _.isString(item) || _.isNumber(item) ? item : item[this.relatedModel.prototype.idAttribute];
if (model.id === id) {
this.addRelated(model, options);
}
}
},
addRelated : function(model, options) {
if (model !== this.related) {
var oldRelated = this.related || null;
this.setRelated(model);
this.onChange(this.instance, model, {
_related : oldRelated
});
}
},
removeRelated : function(model, options) {
if (!this.related) {
return;
}
if (model === this.related) {
var oldRelated = this.related || null;
this.setRelated(null);
this.onChange(this.instance, model, {
_related : oldRelated
});
}
}
});
Backbone.HasMany = Backbone.Relation.extend({
collectionType : null,
options : {
reverseRelation : {
type : 'HasOne'
},
collectionType : Backbone.Collection,
collectionKey : true
},
initialize : function() {
_.bindAll(this, 'onChange', 'handleAddition', 'handleRemoval', 'handleReset');
this.instance.bind('relational:change:' + this.key, this.onChange);
// Handle a custom 'collectionType'
this.collectionType = this.options.collectionType;
if (_(this.collectionType).isString()) {
this.collectionType = Backbone.Relational.store.getObjectByName(this.collectionType);
}
if (!this.collectionType.prototype instanceof Backbone.Collection.prototype.constructor) {
throw new Error('collectionType must inherit from Backbone.Collection');
}
this.setRelated(this.prepareCollection(new this.collectionType()));
this.findRelated({
silent : true
});
},
prepareCollection : function(collection) {
if (this.related) {
this.related.unbind('relational:add', this.handleAddition).unbind('relational:remove', this.handleRemoval).unbind('relational:reset', this.handleReset)
}
collection.reset();
collection.model = this.relatedModel;
if (this.options.collectionKey) {
var key = this.options.collectionKey === true ? this.options.reverseRelation.key : this.options.collectionKey;
if (collection[key] && collection[key] !== this.instance) {
if (Backbone.Relational.showWarnings && typeof console !== 'undefined') {
console.warn('Relation=%o; collectionKey=%s already exists on collection=%o', this, key, this.options.collectionKey);
}
} else {
collection[key] = this.instance;
}
}
collection.bind('relational:add', this.handleAddition).bind('relational:remove', this.handleRemoval).bind('relational:reset', this.handleReset);
return collection;
},
findRelated : function(options) {
if (this.keyContents) {
// Handle cases the an API/user supplies just an Object/id instead of an Array
this.keyContents = _.isArray(this.keyContents) ? this.keyContents : [this.keyContents];
// Try to find instances of the appropriate 'relatedModel' in the store
_.each(this.keyContents, function(item) {
var id = _.isString(item) || _.isNumber(item) ? item : item[this.relatedModel.prototype.idAttribute];
var model = Backbone.Relational.store.find(this.relatedModel, id);
if (model && _.isObject(item)) {
model.set(item, options);
} else if (!model) {
model = this.createModel(item);
}
if (model && !this.related.getByCid(model) && !this.related.get(model)) {
this.related.add(model);
}
}, this);
}
},
/**
* If the key is changed, notify old & new reverse relations and initialize the new relation
*/
onChange : function(model, attr, options) {
options = this.sanitizeOptions(options);
this.keyContents = attr;
// Notify old 'related' object of the terminated relation
_.each(this.getReverseRelations(), function(relation) {
relation.removeRelated(this.instance, options);
}, this);
// Replace 'this.related' by 'attr' if it is a Backbone.Collection
if ( attr instanceof Backbone.Collection) {
this.prepareCollection(attr);
this.related = attr;
}
// Otherwise, 'attr' should be an array of related object ids.
// Re-use the current 'this.related' if it is a Backbone.Collection.
else {
var coll = this.related instanceof Backbone.Collection ? this.related : new this.collectionType();
this.setRelated(this.prepareCollection(coll));
this.findRelated(options);
}
// Notify new 'related' object of the new relation
_.each(this.getReverseRelations(), function(relation) {
relation.addRelated(this.instance, options);
}, this);
var dit = this;
Backbone.Relational.eventQueue.add(function() {
!options.silentChange && dit.instance.trigger('update:' + dit.key, dit.instance, dit.related, options);
});
},
tryAddRelated : function(model, options) {
options = this.sanitizeOptions(options);
if (!this.related.getByCid(model) && !this.related.get(model)) {
// Check if this new model was specified in 'this.keyContents'
var item = _.any(this.keyContents, function(item) {
var id = _.isString(item) || _.isNumber(item) ? item : item[this.relatedModel.prototype.idAttribute];
return id && id === model.id;
}, this);
if (item) {
this.related.add(model, options);
}
}
},
/**
* When a model is added to a 'HasMany', trigger 'add' on 'this.instance' and notify reverse relations.
* (should be 'HasOne', must set 'this.instance' as their related).
*/
handleAddition : function(model, coll, options) {
//console.debug('handleAddition called; args=%o', arguments);
// Make sure the model is in fact a valid model before continuing.
// (it can be invalid as a result of failing validation in Backbone.Collection._prepareModel)
if (!( model instanceof Backbone.Model )) {
return;
}
options = this.sanitizeOptions(options);
_.each(this.getReverseRelations(model), function(relation) {
relation.addRelated(this.instance, options);
}, this);
// Only trigger 'add' once the newly added model is initialized (so, has it's relations set up)
var dit = this;
Backbone.Relational.eventQueue.add(function() {
!options.silentChange && dit.instance.trigger('add:' + dit.key, model, dit.related, options);
});
},
/**
* When a model is removed from a 'HasMany', trigger 'remove' on 'this.instance' and notify reverse relations.
* (should be 'HasOne', which should be nullified)
*/
handleRemoval : function(model, coll, options) {
//console.debug('handleRemoval called; args=%o', arguments);
if (!( model instanceof Backbone.Model )) {
return;
}
options = this.sanitizeOptions(options);
_.each(this.getReverseRelations(model), function(relation) {
relation.removeRelated(this.instance, options);
}, this);
var dit = this;
Backbone.Relational.eventQueue.add(function() {
!options.silentChange && dit.instance.trigger('remove:' + dit.key, model, dit.related, options);
});
},
handleReset : function(coll, options) {
options = this.sanitizeOptions(options);
var dit = this;
Backbone.Relational.eventQueue.add(function() {
!options.silentChange && dit.instance.trigger('reset:' + dit.key, dit.related, options);
});
},
addRelated : function(model, options) {
var dit = this;
model.queue(function() {// Queued to avoid errors for adding 'model' to the 'this.related' set twice
if (dit.related && !dit.related.getByCid(model) && !dit.related.get(model)) {
dit.related.add(model, options);
}
});
},
removeRelated : function(model, options) {
if (this.related.getByCid(model) || this.related.get(model)) {
this.related.remove(model, options);
}
}
});
/**
* A type of Backbone.Model that also maintains relations to other models and collections.
* New events when compared to the original:
* - 'add:<key>' (model, related collection, options)
* - 'remove:<key>' (model, related collection, options)
* - 'update:<key>' (model, related model or collection, options)
*/
Backbone.RelationalModel = Backbone.Model.extend({
relations : null, // Relation descriptions on the prototype
_relations : null, // Relation instances
_isInitialized : false,
_deferProcessing : false,
_queue : null,
constructor : function(attributes, options) {
// Nasty hack, for cases like 'model.get( <HasMany key> ).add( item )'.
// Defer 'processQueue', so that when 'Relation.createModels' is used we:
// a) Survive 'Backbone.Collection.add'; this takes care we won't error on "can't add model to a set twice"
// (by creating a model from properties, having the model add itself to the collection via one of
// it's relations, then trying to add it to the collection).
// b) Trigger 'HasMany' collection events only after the model is really fully set up.
// Example that triggers both a and b: "p.get('jobs').add( { company: c, person: p } )".
var dit = this;
if (options && options.collection) {
this._deferProcessing = true;
var processQueue = function(model) {
if (model === dit) {
dit._deferProcessing = false;
dit.processQueue();
options.collection.unbind('relational:add', processQueue);
}
};
options.collection.bind('relational:add', processQueue);
// So we do process the queue eventually, regardless of whether this model really gets added to 'options.collection'.
_.defer(function() {
processQueue(dit);
});
}
this._queue = new Backbone.BlockingQueue();
this._queue.block();
Backbone.Relational.eventQueue.block();
Backbone.Model.prototype.constructor.apply(this, arguments);
// Try to run the global queue holding external events
Backbone.Relational.eventQueue.unblock();
},
/**
* Override 'trigger' to queue 'change' and 'change:*' events
*/
trigger : function(eventName) {
if (eventName.length > 5 && 'change' === eventName.substr(0, 6)) {
var dit = this, args = arguments;
Backbone.Relational.eventQueue.add(function() {
Backbone.Model.prototype.trigger.apply(dit, args);
});
} else {
Backbone.Model.prototype.trigger.apply(this, arguments);
}
return this;
},
/**
* Initialize Relations present in this.relations; determine the type (HasOne/HasMany), then creates a new instance.
* Invoked in the first call so 'set' (which is made from the Backbone.Model constructor).
*/
initializeRelations : function() {
this.acquire();
// Setting up relations often also involve calls to 'set', and we only want to enter this function once
this._relations = [];
_.each(this.relations, function(rel) {
var type = !_.isString(rel.type) ? rel.type : Backbone[rel.type] || Backbone.Relational.store.getObjectByName(rel.type);
if (type && type.prototype instanceof Backbone.Relation.prototype.constructor) {
new type(this, rel);
// Also pushes the new Relation into _relations
} else {
Backbone.Relational.showWarnings && typeof console !== 'undefined' && console.warn('Relation=%o; missing or invalid type!', rel);
}
}, this);
this._isInitialized = true;
this.release();
this.processQueue();
},
/**
* When new values are set, notify this model's relations (also if options.silent is set).
* (Relation.setRelated locks this model before calling 'set' on it to prevent loops)
*/
updateRelations : function(options) {
if (this._isInitialized && !this.isLocked()) {
_.each(this._relations, function(rel) {
var val = this.attributes[rel.key];
if (rel.related !== val) {
this.trigger('relational:change:' + rel.key, this, val, options || {});
}
}, this);
}
},
/**
* Either add to the queue (if we're not initialized yet), or execute right away.
*/
queue : function(func) {
this._queue.add(func);
},
/**
* Process _queue
*/
processQueue : function() {
if (this._isInitialized && !this._deferProcessing && this._queue.isBlocked()) {
this._queue.unblock();
}
},
/**
* Get a specific relation.
* @param key {string} The relation key to look for.
* @return {Backbone.Relation} An instance of 'Backbone.Relation', if a relation was found for 'key', or null.
*/
getRelation : function(key) {
return _.detect(this._relations, function(rel) {
if (rel.key === key) {
return true;
}
}, this);
},
/**
* Get all of the created relations.
* @return {Backbone.Relation[]}
*/
getRelations : function() {
return this._relations;
},
fetch : function(options) {
console.log('overriden fetch')
var that = this;
var existingSuccess = options.success;
options.success = function(arg) {
_.each(that._relations, function(relation) {
that.fetchRelated(relation.key, _.isObject(relation.options.autoFetch) ? relation.options.autoFetch : {})
});
if (existingSuccess)
existingSuccess(arg);
};
return Backbone.Model.prototype.fetch.call(this, options);
},
/**
* Retrieve related objects.
* @param key {string} The relation key to fetch models for.
* @param options {object} Options for 'Backbone.Model.fetch' and 'Backbone.sync'.
* @return {jQuery.when[]} An array of request objects
*/
fetchRelated : function(key, options) {
options || ( options = {} );
var setUrl, requests = [], rel = this.getRelation(key), keyContents = rel && rel.keyContents, toFetch = keyContents && _.select(_.isArray(keyContents) ? keyContents : [keyContents], function(item) {
var id = _.isString(item) || _.isNumber(item) ? item : item[rel.relatedModel.prototype.idAttribute];
return id;
/*&& !Backbone.Relational.store.find( rel.relatedModel, id );*/
}, this);
if (toFetch && toFetch.length) {
// Create a model for each entry in 'keyContents' that is to be fetched
var models = _.map(toFetch, function(item) {
var model;
if ( typeof (item ) === 'object') {
model = new rel.relatedModel(item);
} else {
var attrs = {};
attrs[rel.relatedModel.prototype.idAttribute] = item;
model = new rel.relatedModel(attrs);
}
return model;
}, this);
// Try if the 'collection' can provide a url to fetch a set of models in one request.
if (rel.related instanceof Backbone.Collection && _.isFunction(rel.related.url)) {
setUrl = rel.related.url(models);
}
// An assumption is that when 'Backbone.Collection.url' is a function, it can handle building of set urls.
// To make sure it can, test if the url we got by supplying a list of models to fetch is different from
// the one supplied for the default fetch action (without args to 'url').
if (setUrl && setUrl !== rel.related.url()) {
var opts = _.defaults({
error : function() {
var args = arguments;
_.each(models, function(model) {
model.trigger('destroy', model, model.collection, options);
options.error && options.error.apply(model, args);
});
},
url : setUrl
}, options, {
add : true
});
requests = [rel.related.fetch(opts)];
} else {
requests = _.map(models, function(model) {
var opts = _.defaults({
error : function() {
model.trigger('destroy', model, model.collection, options);
options.error && options.error.apply(model, arguments);
}
}, options);
return model.fetch(opts);
}, this);
}
}
return requests;
},
set : function(key, value, options) {
Backbone.Relational.eventQueue.block();
// Duplicate backbone's behavior to allow separate key/value parameters, instead of a single 'attributes' object
var attributes;
if (_.isObject(key) || key == null) {
attributes = key;
options = value;
} else {
attributes = {};
attributes[key] = value;
}
var result = Backbone.Model.prototype.set.apply(this, arguments);
// 'set' is called quite late in 'Backbone.Model.prototype.constructor', but before 'initialize'.
// Ideal place to set up relations :)
if (!this._isInitialized && !this.isLocked()) {
Backbone.Relational.store.register(this);
this.initializeRelations();
}
// Update the 'idAttribute' in Backbone.store if; we don't want it to miss an 'id' update due to {silent:true}
else if (attributes && this.idAttribute in attributes) {
Backbone.Relational.store.update(this);
}
if (attributes) {
this.updateRelations(options);
}
// Try to run the global queue holding external events
Backbone.Relational.eventQueue.unblock();
return result;
},
unset : function(attribute, options) {
Backbone.Relational.eventQueue.block();
var result = Backbone.Model.prototype.unset.apply(this, arguments);
this.updateRelations(options);
// Try to run the global queue holding external events
Backbone.Relational.eventQueue.unblock();
return result;
},
clear : function(options) {
Backbone.Relational.eventQueue.block();
var result = Backbone.Model.prototype.clear.apply(this, arguments);
this.updateRelations(options);
// Try to run the global queue holding external events
Backbone.Relational.eventQueue.unblock();
return result;
},
/**
* Override 'change', so the change will only execute after 'set' has finised (relations are updated),
* and 'previousAttributes' will be available when the event is fired.
*/
change : function(options) {
var dit = this;
Backbone.Relational.eventQueue.add(function() {
Backbone.Model.prototype.change.apply(dit, arguments);
});
},
clone : function() {
var attributes = _.clone(this.attributes);
if (!_.isUndefined(attributes[this.idAttribute])) {
attributes[this.idAttribute] = null;
}
_.each(this.getRelations(), function(rel) {
delete attributes[rel.key];
});
return new this.constructor(attributes);
},
/**
* Convert relations to JSON, omits them when required
*/
toJSON : function() {
// If this Model has already been fully serialized in this branch once, return to avoid loops
if (this.isLocked()) {
return this.id;
}
this.acquire();
var json = Backbone.Model.prototype.toJSON.call(this);
_.each(this._relations, function(rel) {
var value = json[rel.key];
if (rel.options.includeInJSON === true && value && _.isFunction(value.toJSON)) {
json[rel.key] = value.toJSON();
} else if (_.isString(rel.options.includeInJSON)) {
if ( value instanceof Backbone.Collection) {
json[rel.key] = value.pluck(rel.options.includeInJSON);
} else if ( value instanceof Backbone.Model) {
json[rel.key] = value.get(rel.options.includeInJSON);
}
} else {
delete json[rel.key];
}
}, this);
this.release();
return json;
}
});
_.extend(Backbone.RelationalModel.prototype, Backbone.Semaphore);
/**
* Override Backbone.Collection.add, so objects fetched from the server multiple times will
* update the existing Model. Also, trigger 'relational:add'.
*/
var add = Backbone.Collection.prototype.add;
Backbone.Collection.prototype.add = function(models, options) {
options || ( options = {});
if (!_.isArray(models)) {
models = [models];
}
//console.debug( 'calling add on coll=%o; model=%o, options=%o', this, models, options );
_.each(models, function(model) {
if (!( model instanceof Backbone.Model )) {
// Try to find 'model' in Backbone.store. If it already exists, set the new properties on it.
var existingModel = Backbone.Relational.store.find(this.model, model[this.model.prototype.idAttribute]);
if (existingModel) {
existingModel.set(model, options);
model = existingModel;
} else {
model = Backbone.Collection.prototype._prepareModel.call(this, model, options);
}
}
if ( model instanceof Backbone.Model && !this.get(model) && !this.getByCid(model)) {
add.call(this, model, options);
this.trigger('relational:add', model, this, options);
}
}, this);
return this;
};
/**
* Override 'Backbone.Collection.remove' to trigger 'relational:remove'.
*/
var remove = Backbone.Collection.prototype.remove;
Backbone.Collection.prototype.remove = function(models, options) {
options || ( options = {});
if (!_.isArray(models)) {
models = [models];
}
//console.debug('calling remove on coll=%o; models=%o, options=%o', this, models, options );
_.each(models, function(model) {
model = this.getByCid(model) || this.get(model);
if ( model instanceof Backbone.Model) {
remove.call(this, model, options);
this.trigger('relational:remove', model, this, options);
}
}, this);
return this;
};
/**
* Override 'Backbone.Collection.reset' to trigger 'relational:reset'.
*/
var reset = Backbone.Collection.prototype.reset;
Backbone.Collection.prototype.reset = function(models, options) {
reset.call(this, models, options);
this.trigger('relational:reset', models, options);
return this;
};
/**
* Override 'Backbone.Collection.trigger' so 'add', 'remove' and 'reset' events are queued until relations
* are ready.
*/
var _trigger = Backbone.Collection.prototype.trigger;
Backbone.Collection.prototype.trigger = function(eventName) {
if (eventName === 'add' || eventName === 'remove' || eventName === 'reset') {
var dit = this, args = arguments;
Backbone.Relational.eventQueue.add(function() {
_trigger.apply(dit, args);
});
} else {
_trigger.apply(this, arguments);
}
return this;
};
// Override .extend() to check for reverseRelations to initialize.
Backbone.RelationalModel.extend = function(protoProps, classProps) {
var child = Backbone.Model.extend.apply(this, arguments);
var relations = (protoProps && protoProps.relations ) || [];
_.each(relations, function(rel) {
if (rel.reverseRelation) {
rel.model = child;
var preInitialize = true;
if (_.isString(rel.relatedModel)) {
/**
* The related model might not be defined for two reasons
* 1. it never gets defined, e.g. a typo
* 2. it is related to itself
* In neither of these cases do we need to pre-initialize reverse relations.
*/
var relatedModel = Backbone.Relational.store.getObjectByName(rel.relatedModel);
preInitialize = relatedModel && (relatedModel.prototype instanceof Backbone.RelationalModel.prototype.constructor );
}
var type = !_.isString(rel.type) ? rel.type : Backbone[rel.type] || Backbone.Relational.store.getObjectByName(rel.type);
if (preInitialize && type && type.prototype instanceof Backbone.Relation.prototype.constructor) {
new type(null, rel);
}
}
});
return child;
};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment