(function (root, callback) { // AMD if (typeof define !== 'undefined' && define.amd) { define(['exports', 'backbone', 'underscore'], callback); } // CommonJS else if (typeof exports !== 'undefined') { callback(exports, require('backbone'), require('underscore')); } // Globals else { callback(root.Supermodel = {}, root.Backbone, root._); } }(this, function (Supermodel, Backbone, _) { // Current version. Supermodel.VERSION = '0.0.4'; // # Association // // Track associations between models. Associated attributes are used and // then removed during `parse`. var Association = function(model, options) { this.required(options, 'name'); _.extend(this, _.pick(options, 'name', 'where', 'source', 'store')); _.defaults(this, { source: this.name, store: '_' + this.name }); // Store a reference to this association by name after ensuring it's // unique. var ctor = model; do { if (!ctor.associations()[this.name]) continue; throw new Error('Association already exists: ' + this.name); } while (ctor = ctor.parent); model.associations()[this.name] = this; // Listen for relevant events. if (this.initialize) model.all().on('initialize', this.initialize, this); if (this.change) model.all().on('change', this.change, this); if (this.parse) model.all().on('parse', this.parse, this); if (this.destroy) model.all().on('destroy', this.destroy, this); if (this.create) model.all().on('add', this.create, this); }; _.extend(Association.prototype, { // Notify `model` of its association with `other` using the `inverse` // option. associate: function(model, other) { if (!this.inverse) return; model.trigger('associate:' + this.inverse, model, other); }, // Notify `model` of its dissociation with `other` using the `inverse` // option. dissociate: function(model, other) { if (!this.inverse) return; model.trigger('dissociate:' + this.inverse, model, other); }, // Throw if the specified options are not provided. required: function(options) { var option; for (var i = 1; i < arguments.length; i++) { if (options[option = arguments[i]]) continue; throw new Error('Option required: ' + option); } }, // Wrap a function in order to capture it's context, prepend it to the // arguments and call it with the current context. andThis: function(func) { var context = this; return function() { return func.apply(context, [this].concat(_.toArray(arguments))); }; } }); // ## One // // One side of a one-to-one or one-to-many association. var One = function(model, options) { this.required(options, 'inverse', 'model'); Association.apply(this, arguments); _.extend(this, _.pick(options, 'inverse', 'model', 'id')); _.defaults(this, { id: this.name + '_id' }); model.all() .on('associate:' + this.name, this.replace, this) .on('dissociate:' + this.name, this.remove, this); }; _.extend(One.prototype, Association.prototype, { // Assign the getter/setter when a model is created. create: function(model) { model[this.name] = _.bind(this.access, this, model); }, // Return or replace the associated model. access: function(model, other) { if (arguments.length < 2) return model[this.store]; this.replace(model, other); }, // Parse the models attributes. If `source` isn't found use the `id` // attribute. initialize: function(model, options) { this.parse(model, model.attributes); var id = model.get(this.id); if( id==null ) return; var other = this.alter(model, id); if( !other ) return var owner = options.collection && options.collection.owner if( !owner || owner!=other ) this.associate(other, model); }, // If `source` is provided, use it to initialize the association after // removing it from the response object. parse: function(model, resp) { if (!resp || !_.has(resp, this.source)) return; var attrs = resp[this.source]; delete resp[this.source]; this.replace(model, attrs); }, // Update the association when the model `id` and relation `id` attribute changes. change: function(model) { var idAttr = model.constructor.prototype.idAttribute; if(!idAttr) idAttr = Backbone.Model.prototype.idAttribute; if(model.hasChanged(idAttr)) { var other = model[this.store]; if(other) { this.associate(other, model); } } if (!model.hasChanged(this.id)) return; this.replace(model, model.get(this.id)); }, // Remove the current association. remove: function(model) { this.replace(model, null); }, // When a model is destroyed, its association should be removed. destroy: function(model) { var other = model[this.store]; if (!other) return; this.remove(model); this.dissociate(other, model); }, replace: function(model, other) { var other = this.alter(model, other) if( other ) { this.associate(other, model); // Triggers the association replace. model.trigger("replace:"+this.name, model, other); } }, // Replace the current association with `other`, taking care to remove the // current association first. alter: function(model, other) { var id, current; if (!model) return; current = model[this.store]; // If `other` is a primitive, assume it's an id. if (other != null && !_.isObject(other)) { id = other; (other = {})[this.model.prototype.idAttribute] = id; } // Is `other` already the current model? if (other && !(other instanceof Model)) other = this.model.create(other); if (current === other) { // updates relationship when remote id is available if(other && other.getId() != model.get(this.id)) { var attr; (attr = {})[this.id] = other.id; model.set(attr, {silent: true}); } return; } // Tear down the current association. if (!other) model.unset(this.id); if (current) { current.off('all', null, this); delete model[this.store]; this.dissociate(current, model); } if (other) { // Set up the new association. model.set(this.id, other.id); model[this.store] = other; // Listening association events. other .on('all', function(eventName, object) { var splittedEvent = eventName.split(":"); // Avoids a loop generated by two-way associations. var reservedNamespace = splittedEvent[1]; if(this.inverse == reservedNamespace) return; var headEvent = splittedEvent[0]; var tailEvents = _.rest(splittedEvent).join(":"); // Building the new event to trigger (namespace, association name). var newEvent = headEvent+":"+this.name; if(tailEvents) newEvent += ":"+tailEvents; // Trigger new event from the concrete object. arguments[0] = newEvent; model.trigger.apply(model, arguments); }, this); return other; } } }); // # ManyToOne // The many side of a one-to-many association. var ManyToOne = function(model, options) { this.required(options, 'inverse', 'collection'); Association.apply(this, arguments); _.extend(this, _.pick(options, 'collection', 'inverse', 'id')); model.all() .on('associate:' + this.name, this._associate, this) .on('dissociate:' + this.name, this._dissociate, this); }; _.extend(ManyToOne.prototype, Association.prototype, { // When a model is created, instantiate the associated collection and // assign it using `store`. create: function(model) { if (!model[this.name]) model[this.name] = _.bind(this.get, this, model); }, // Return the associated collection. get: function(model) { var collection = model[this.store]; if (collection) return collection; // Create the collection for storing the associated models. Listen for // "add", "remove", and "reset" events and act accordingly. collection = model[this.store] = new this.collection() .on('add', this.add, this) .on('remove', this.remove, this) .on('reset', this.reset, this); // We'll need to know what model "owns" this collection in order to // handle events that it triggers. collection.owner = model; // Listening association events. collection .on('all', function(eventName, object) { var splittedEvents = eventName.split(":"); // Avoids a loop generated by two-way associations var reservedNamespace = splittedEvents[1]; if(this.inverse == reservedNamespace) return; var headEvent = splittedEvents[0]; var tailEvents = _.rest(splittedEvents).join(":"); // Building the new event to trigger (namespace, association name). var newEvent = headEvent+":"+this.name; if(tailEvents) newEvent += ":"+tailEvents; // Trigger new event from the concrete object arguments[0] = newEvent; model.trigger.apply(model, arguments); }, this); return collection; }, // Use the `source` property to reset the collection with the given models // after removing it from the response object. parse: function(model, resp) { if (!resp) return; var attrs = resp[this.source]; if (!attrs) return; delete resp[this.source]; var collection = this.get(model); attrs = collection.parse(attrs); // If `where` is not specified, reset the collection and bail. if (!this.where) { collection.reset(attrs); return; } // Reset the collection after filtering the models from `attrs`. collection.reset(_.filter(_.map(attrs, function(attrs) { return new collection.model(attrs); }), this.where)); }, // Parse the attributes to initialize a new model. initialize: function(model) { this.parse(model, model.attributes); }, // Models added to the collection should be associated with the owner. add: function(model, collection) { if (!model || !collection) return; this.associate(model, collection.owner); }, // Update the inverse association when the model `id` attribute changes. change: function(model) { var idAttr = model.constructor.prototype.idAttribute; if(!idAttr) idAttr = Backbone.Model.prototype.idAttribute; if(model.hasChanged(idAttr)) { var collection = model[this.store]; if(collection) { var self = this; _.each(collection.models, function(other) { self.associate(other, model); }); } } }, // Models removed from the collection should be dissociated from the owner. remove: function(model, collection) { if (!model || !collection) return; this.dissociate(model, collection.owner); }, // After a reset, all new models should be associated with the owner. reset: function(collection) { if (!collection) return; collection.each(function(model) { this.associate(model, collection.owner); }, this); }, // If the owner is destroyed, all models in the collection should be // dissociated from it. destroy: function(model) { var collection; if (!model || !(collection = model[this.store])) return; collection.each(function(other) { this.dissociate(other, model); }, this); }, // Associated models should be added to the collection. _associate: function(model, other) { if (!model || !other) return; if (this.where && !this.where(other)) return; this.get(model).add(other); }, // Dissociated models should be removed from the collection. _dissociate: function(model, other) { if (!model || !other || !model[this.store]) return; model[this.store].remove(other); } }); // # ManyToMany // // One side of a many-to-many association. var ManyToMany = function(model, options) { this.required(options, 'collection', 'through', 'source'); Association.apply(this, arguments); _.extend(this, _.pick(options, 'collection', 'through')); this._associate = this.andThis(this._associate); this._dissociate = this.andThis(this._dissociate); }; _.extend(ManyToMany.prototype, Association.prototype, { // When a new model is created, assign the getter. create: function(model) { if (!model[this.name]) model[this.name] = _.bind(this.get, this, model); }, // Lazy load the associated collection to avoid initialization costs. get: function(model) { var collection = model[this.store]; if (collection) return collection; // Create a new collection. collection = new this.collection(); // We'll need to know what model "owns" this collection in order to // handle events that it triggers. collection.owner = model; model[this.store] = collection; // Initialize listeners and models. this.reset(model[this.through]() .on('add', this.add, this) .on('remove', this.remove, this) .on('reset', this.reset, this) .on('associate:' + this.source, this._associate) .on('dissociate:' + this.source, this._dissociate)); // Listening association events. collection .on('all', function(eventName) { var splittedEvents = eventName.split(":"); // Avoids a loop generated by two-way associations var reservedNamespace = [splittedEvents[1], splittedEvents[2]]; if(this.through === reservedNamespace[0] || _.indexOf(reservedNamespace, this.name) > -1) return; var headEvent = splittedEvents[0]; var tailEvents = _.rest(splittedEvents).join(":"); // Building a new event to trigger (namespace, association name). var newEventName = headEvent+":"+this.name; if(tailEvents) newEventName += ":"+tailEvents; arguments[0] = newEventName; model.trigger.apply(model, arguments); // Building a new event to trigger (namespace, association through). var newEventThrough = headEvent+":"+this.through; if(tailEvents) newEventThrough += ":"+tailEvents; arguments[0] = newEventThrough; model.trigger.apply(model, arguments); }, this); return collection; }, // Add models to the collection when added to the through collection. add: function(model, through) { if (!model || !through || !(model = model[this.source]())) return; if (this.where && !this.where(model)) return; through.owner[this.name]().add(model); }, // Remove models from the collection when removed from the through // collection after checking for other instances. remove: function(model, through) { if (!model || !through || !(model = model[this.source]())) return; var exists = through.any(function(o) { return o[this.source]() === model; }, this); if (!exists) through.owner[this.name]().remove(model); }, // Reset when the through collection is reset. reset: function(through) { if (!through) return; var models = _.compact(_.uniq(_.invoke(through.models, this.source))); if (this.where) models = _.filter(models, this.where); through.owner[this.name]().reset(models); }, // Add associated models. _associate: function(through, model, other) { if (!through || !model || !other) return; if (this.where && !this.where(other)) return; through.owner[this.name]().add(other); }, // Remove dissociated models, taking care to check for other instances. _dissociate: function(through, model, other) { if (!through || !model || !other) return; var exists = through.any(function(o) { return o[this.source]() === other; }, this); if (!exists) through.owner[this.name]().remove(other); } }); // # has // Avoid naming collisions by providing one entry point for associations. var Has = function(model) { this.model = model; }; _.extend(Has.prototype, { // ## one // *Create a one-to-one or one-to-many association.* // // Options: // // * **inverse** - (*required*) The name of the inverse association. // * **model** - (*required*) The model constructor for the association. // * **id** - The associated id is stored here. // Defaults to `name` + '_id'. // * **source** - The attribute where nested data is stored. // Defaults to `name`. // * **store** - The property to store the association in. // Defaults to '_' + `name`. one: function(name, options) { options.name = name; new One(this.model, options); return this; }, // ## many // *Create a many-to-one or many-to-many association.* // // Options: // // * **collection** - (*required*) The collection constructor. // * **inverse** - (*required for many-to-one associations*) The name of the // inverse association. // * **through** - (*required for many-to-many associations*) The name of the // through association. // * **source** - (*required for many-to-many associations*) For many-to-one // associations, the attribute where nested data is stored. For many-to-many // associations, the name of the indirect association. // * **store** - The property to store the association in. // Defaults to '_' + `name`. many: function(name, options) { options.name = name; var Association = options.through ? ManyToMany : ManyToOne; new Association(this.model, options); return this; } }); // # Model var Model = Supermodel.Model = Backbone.Model.extend({ // The attribute to store the cid in for lookup. cidAttribute: 'cid', constructor: function() { // Enables a Supermodel to override initialize method if(this.initialize !== Model.prototype.initialize) { var self = this; var overridedInit = this.initialize; // Composes new initialize method that contains // both Supermodel's and overrided initialize method this.initialize = _.wrap(Model.prototype.initialize, function(supermodelInit) { supermodelInit.call(self, arguments[2]); overridedInit.call(self, arguments[2]); }); } return Backbone.Model.apply(this, arguments); }, initialize: function(arguments, options) { // Use `"cid"` for retrieving models by `attributes.cid`. this.set(this.cidAttribute, this.cid); // Add the model to `all` for each constructor in its prototype chain. var ctor = this.constructor; do { ctor.all().add(this); } while (ctor = ctor.parent); // Trigger 'initialize' for listening associations. this.trigger('initialize', this, options); }, // While `"cid"` is used for tracking models, it should not be persisted. toJSON: function() { var o = Backbone.Model.prototype.toJSON.apply(this, arguments); delete o[this.cidAttribute]; // if options set to include related models, set related model's toJSON response to // the attribute of object the same value if(this.withJSON) { for (var i = 0; i < this.withJSON.length; i++) { var related = this.withJSON[i]; // validate type of relationship exits and this model has an existing relationship if (this[ related ] && this[ related ]() ) { o[ related ] = this[ related ]().toJSON(); } } } return o; }, // Associations are initialized/updated during `parse`. They listen for // the `'parse'` event and remove the appropriate properties after parsing. parse: function(resp) { this.trigger('parse', this, resp); return resp; }, // Return remote `id` if exists, otherwise local id `cid` getId: function() { if(this.id) return this.id; else return this.cid; }, // Alters clone method to prepare a model copy ready to work as a Supermodel // but without associations clone: function() { var attrsCopy = this.cloneAttributes(); return new this.constructor(attrsCopy); }, // Returns attributes to be cloned cloneAttributes: function() { // Attributes to copy var attrsCopy = _.extend({}, this.attributes); var ctor = this.constructor; // Remove id attributes delete attrsCopy[ctor.prototype.cidAttribute]; delete attrsCopy[ctor.prototype.idAttribute]; // Remove associations var allAssociations = ctor.allAssociations(); for(var assoc in allAssociations) { delete attrsCopy[allAssociations[assoc].id]; } return attrsCopy; } }, { // ## create // Create a new model after checking for existence of a model with the same // id. create: function(attrs, options) { var id = attrs && attrs[this.prototype.idAttribute]; var model = this.find(attrs); if (!options) options = {}; // If found by id, modify and return it. if(model) { // Modifies only if `attrs` does not reference to an existing model. if(attrs !== model.attributes) { model.set(model.parse(attrs), _.extend(options, {silent: false})); return model; } // Makes validations if required by options if(options.validate) { model._validate({}, options); } return model; } // Throw if a model already exists with the same id in a superclass. var parent = this; while (parent = parent.parent) { if (!parent.all().get(id)) continue; throw new Error('Model with id "' + id + '" already exists.'); } // Ensure attributes are parsed. options.parse = true; return new this(attrs, options); }, // ## find // Attempt to find an existing model matching the provided attrs find: function(attrs, merge){ if (!attrs) return false; var cid = attrs[this.prototype.cidAttribute]; var id = attrs[this.prototype.idAttribute]; return (cid || id) && this.all().get(cid || id) || false; }, // Create associations for a model. has: function() { return new Has(this); }, // Return a collection of all models for a particular constructor. all: function() { if(!this._all) { var Constructor = this; var All = Backbone.Collection.extend({ model: Constructor }); this._all = new All(); } return this._all; }, // Return a hash of all associations for a particular constructor. associations: function() { return this._associations || (this._associations = {}); }, // Return a hash of all associations for each constructor in its prototype chain. allAssociations: function() { var allAssociations = {}; var ctor = this; do { _.extend(allAssociations, ctor._associations); } while (ctor = ctor.parent); return allAssociations; }, // Models and associations are tracked via `all` and `associations`, // respectively. `reset` removes all model references to allow garbage // collection. reset: function() { this._all = null; this._associations = {}; } }); }));