1 /**
  2     @fileOverview 
  3       It all starts with the Model. Just like in Rails, the model should really only deal with data and methods surrounding data. It shouldn't interact with the UI or (and this is up for debate) the server. Things like pagination, etc should be handled in the controller.
  4     @example
  5       MBX.MyModel = MBX.JsModel.create("MyModel");
  6     @author  <a href="mailto:topper@motionbox.com">Topper Bowers</a>
  7     @version 0.1  
  8 */
  9 
 10 if (!("MBX" in window)) {
 11     /** @namespace 
 12         @ignore
 13     */
 14     MBX = {};
 15 }
 16 
 17 /** 
 18     use this as a more convienient (sometimes) method instead of .prototype.blah.prototype chaining.  It tends
 19     to be a real javascript way of sub-classing
 20     
 21     @parm {Object} o the original object
 22     @returns a new object with the original object as a prototype
 23 */
 24 MBX.Constructor = function (o) {
 25     function F() {}
 26     F.prototype = o;
 27     return new F();
 28 };
 29 
 30 /**
 31     Use this to create instances of models and extend all models (and instances of all models)
 32     @class
 33 */
 34 MBX.JsModel = (function () {
 35     /**
 36         @memberof MBX.JsModel
 37         @namespace
 38     */
 39     var publicObj = {};
 40     var currentGUID = 0;
 41     
 42     /** used internally to prevent name collision 
 43         @private
 44     */
 45     var modelCache = {};
 46     
 47     /**
 48         Instances of a Model
 49         @name JsModel#instance
 50         @class A single instance of a Model
 51         @see MBX.Constructor
 52     */
 53     var oneJsModelInstance = 
 54         /** @lends JsModel#instance */
 55         {
 56         /**
 57             Use this to set attributes of an instance (rather than set them directly).
 58             It will automatically create events that will be relevant to controllers
 59             @param {String} key the key of the attribute
 60             @param value the value to be assigned to the attribute
 61             @example
 62               modelInstance.set('myAttr', 'foo');
 63               modelInstance.get('myAttr', 'foo');
 64         */
 65         set: function (key, value) {
 66             var changed = false;
 67             if (this.attributes[key] != value) {
 68                 changed = true;
 69             }
 70             this.attributes[key] = value;
 71             if (changed) {
 72                 this._fireChangeEvent(key);
 73             }
 74             return this;
 75         },
 76         
 77         /**
 78             Use to manually fire a change event on an attribute.
 79             @param {String} key the key of the attribute you want to fire the enent on
 80             @example
 81               modelInstance.touch("myAttr");
 82         */
 83         touch: function (key) {
 84             this._fireChangeEvent(key);
 85         },
 86         
 87         /**
 88             Use this to retreive attributes on an object (rather than accessing them directly);
 89             @param {String} key
 90             @example
 91               modelInstance.set('myAttr', 'foo');
 92               modelInstance.get('myAttr', 'foo');
 93         */
 94         get: function (key) {
 95             return this.attributes[key];
 96         },
 97         
 98         /**
 99             Take an Object literal and update all attributes on this instance
100             @param {Object} obj the attributes to update as key, value
101         */
102         updateAttributes: function (obj) {
103             obj = obj || {};
104             for (k in obj) {
105                 if (obj.hasOwnProperty(k)) {
106                     this.set(k, obj[k]);
107                 }
108             }
109         },
110         
111         /**
112             You should always use this to refer to instances.
113             Model.find uses this to grab objects from the instances 
114             @returns returns the primaryKey of the instance
115             @see JsModel.find
116         */
117         primaryKey: function () {
118             if (this.parentClass.primaryKey) {
119                 return this.get(this.parentClass.primaryKey);
120             } else {
121                 return this.GUID;
122             }
123         },
124         
125         /**
126             destroy this instance - works just like rails #destroy will fire off the destroy event as well
127             controllers will receive this event by default
128         */
129         destroy: function () {
130             delete this.parentClass.instanceCache[this.primaryKey()];
131             MBX.EventHandler.fireCustom(MBX, this.parentClass.Event.destroyInstance, { object: this });
132         },
133  
134         /**
135             listen to an attribute of a model
136             @params key {String} the key to listen to
137             @params func {Function} the function to pass to the EventHandler
138             @returns an EventHandler subscription object
139             @see MBX.EventHandler
140         */
141         observe: function (key, func) {
142             return MBX.EventHandler.subscribe(this, key + "_changed", func);
143         },
144         
145         /** @private */
146         _createGUID: function () {
147             this.GUID = this.parentClass.modelName + "_" + MBX.JsModel.nextGUID();
148         },
149         
150         _fireChangeEvent: function (key) {
151             MBX.EventHandler.fireCustom(MBX, this.parentClass.Event.changeInstance, {
152                 object: this,
153                 key: key
154             });
155             
156             MBX.EventHandler.fireCustom(this, key + "_changed");
157         }
158 
159     };
160     
161     /** 
162         @class A single instance of MBX.JsModel
163         @constructor
164         @throws an error if there's no name, a name already exists or you specified a primaryKey and it wasn't a string
165     */
166     JsModel = function (name, opts) {
167         opts = opts || {};
168         if (!name) {
169             throw new Error("A name must be specified");
170         }
171         if (modelCache[name]) {
172             throw new Error("The model: " + name + " already exists");
173         }
174         if (opts.primaryKey && (typeof opts.primaryKey != "string")) {
175             throw new Error("primaryKey specified was not a string");
176         }
177         Object.extend(this, opts);
178         
179         /** the model name of this model
180             @type String
181         */
182         this.modelName = name;
183         
184         /** the instances of this model
185             @private
186         */
187         this.instanceCache = {};
188         
189         /** class level attributes */
190         this.attributes = {};
191         
192         this.prototypeObject = MBX.Constructor(oneJsModelInstance);
193         
194         /**
195             instances get their parentClass assigned this model
196             @name JsModel#instance.parentClass
197             @type JsModel
198         */
199         this.prototypeObject.parentClass = this;
200         
201         /** events that this model will fire. Use this to hook into (at a very low level) events
202             @example
203               MBX.EventHandler.subscribe(MBX.cssClass, MyModel.Event.newInstance, function (instance) { // dostuff } );
204         */
205         this.Event = {
206             newInstance: this.modelName + "_new_instance",
207             changeInstance: this.modelName + "_change_instance",
208             destroyInstance: this.modelName + "_destroy_instance",
209             changeAttribute: this.modelName + "_change_attribute"
210         };
211         
212         /** add an instanceMethods attribute to the passed in attributes in order to extend
213             all instances of this model.  You can also specify default attributes by adding
214             a defaults attribute to this attribute.
215             @type Object
216             @name JsModel.instanceMethods
217             @example
218               MyModel = MBX.JsModel.create("MyModel", {
219                   instanceMethods: {
220                       defaults: {
221                           myAttribute: "myDefault"
222                       },
223                       myMethod: function (method) {
224                           return this.get('myAttribute');
225                       }
226                   }
227               });
228               MyModel.create().myMethod() == "myDefault";
229         */
230         if (opts.instanceMethods) {
231             Object.extend(this.prototypeObject, opts.instanceMethods);
232         }
233         
234         modelCache[name] = this;
235         
236         if (typeof this.initialize == "function") {
237             this.initialize();
238         }
239         
240         MBX.EventHandler.fireCustom(MBX, "new_model", {
241             object: this
242         });
243     };
244     
245     JsModel.prototype = {
246         /**
247             Create an instance of the model
248             @param {Object} attrs attributes you want the new instance to have
249             @returns JsModel#instance
250             @throws "trying to create an instance with the same primary key as another instance"
251                 if you are trying to create an instance that already exists
252             @example
253               MyModel = MBX.JsModel.create("MyModel");
254               var instance = MyModel.create({
255                   myAttr: 'boo'
256               });
257               instance.get('myAttr') == 'boo';
258         */
259         create: function (attrs) {
260             attrs = attrs || {};
261             var obj = MBX.Constructor(this.prototypeObject);
262             obj.errors = null;
263             obj.attributes = {};
264             if (obj.defaults) {
265                 Object.extend(obj.attributes, obj.defaults);
266                 $H(obj.attributes).each(function (pair) {
267                     if (Object.isArray(pair.value)) {
268                         obj.defaults[pair.key] = pair.value.clone();
269                     } else {
270                         if (typeof pair.value == "object") {
271                             obj.defaults[pair.key] == Object.clone(pair.value);
272                         }
273                     }
274                 });
275             }
276             Object.extend(obj.attributes, attrs);
277             if (typeof obj.beforeCreate == 'function') {
278                 obj.beforeCreate();
279             }
280             
281             if (!obj.errors) {
282                 if (this.validateObject(obj)) {
283                     obj._createGUID();
284                     this.cacheInstance(obj);
285                     MBX.EventHandler.fireCustom(MBX, this.Event.newInstance, {
286                         object: obj
287                     });
288                     if (typeof obj.afterCreate == "function") {
289                         obj.afterCreate();
290                     }
291                     return obj;
292                 } else {
293                     throw new Error("trying to create an instance of " + this.modelName + " with the same primary key: '" + obj.get(this.primaryKey) + "' as another instance");
294                 }
295             } else {
296                 MBX.EventHandler.fireCustom(MBX, this.Event.newInstance, {
297                     object: obj
298                 });
299                 return obj;
300             }
301         },
302         
303         /** this method to get extended later.  Used mostly internally.  Right now it only verifies
304             that a primaryKey is allowed to be used
305             @param {JsModel#instance} instance the instance that's being validated
306         */
307         validateObject: function (instance) {
308             // temporarily - this only will validate primary keys
309             if (this.primaryKey) {
310                 if (!instance.get(this.primaryKey)) {
311                     return false;
312                 }
313                 if (this.find(instance.get(this.primaryKey))) {
314                     return false;
315                 }
316             }
317             
318             return true;
319         },
320         
321         /** use this to extend all instances of a single model
322             @param {Object} attrs methods and attributes that you want to extend all instances with
323         */
324         extendInstances: function (attrs) {
325             attrs = attrs || {};
326             Object.extend(this.prototypeObject, attrs);
327         },
328         
329         /** store the instance into the cache. this is mostly used internally
330             @private
331         */
332         cacheInstance: function (instance) {
333             if (this.primaryKey) {
334                 this.instanceCache[instance.get(this.primaryKey)] = instance;
335             } else {
336                 this.instanceCache[instance.GUID] = instance;
337             }
338         },
339         
340         /** find a single instance
341             @param {String} primaryKey a JsModel#instance primaryKey
342             @returns an instance of this element
343             @see JsModel#instance.primaryKey
344         */
345         find: function (primaryKey) {
346             return this.instanceCache[primaryKey];
347         },
348         
349         /** @returns all instances of this model */
350         findAll: function () {
351             return $H(this.instanceCache).values();
352         },
353         
354         /** destroy all instances in the instance cache */
355         flush: function () {
356             this.instanceCache = {};
357         },
358         
359         // does this belong in the views?
360         /** given a domElement with certain classes - return the instance that it belongs to
361             @param {DomElement} el the element which has the correct classes on it
362             @returns an instance of this model or null
363         */
364         findByElement: function (el) {
365             el = $(el);
366             var modelCss = this.modelName.toLowerCase();
367             var match = el.className.match(new RegExp(modelCss + "_([^\\s$]+)"));
368             if (match) {
369                 var findIterator = function (pair) {
370                     if (pair[0].gsub(/[^\w\-]/, "_").toLowerCase() == match[1]) {
371                         return true;
372                     }
373                 };
374                 // find will return an array where result[0] is "key" and result[1] is "value"
375                 var instance = $H(this.instanceCache).find(findIterator);
376                 if (instance) {
377                     return instance[1];
378                 }
379             }
380         },
381         
382         /** Gives back the number of cached instances stored in this model
383             @returns {number} number of instances   */
384         count: function () {
385             return this.findAll().length;
386         },
387         
388         /**
389             Use this to set attributes of the model itself (rather than set them directly).
390             It will automatically create events that will be relevant to controllers
391             @param {String} key the key of the attribute
392             @param value the value to be assigned to the attribute
393             @see MBX.JsModel.get
394             @example
395               Model.set('myAttr', 'foo');
396               Model.get('myAttr', 'foo');
397         */
398         set: function (key, value) {
399             var changed = false;
400             if (this.attributes[key] != value) {
401                 changed = true;
402             }
403             this.attributes[key] = value;
404             if (changed) {
405                 MBX.EventHandler.fireCustom(MBX, this.Event.changeAttribute, {
406                     object: this,
407                     key: key
408                 });
409                 MBX.EventHandler.fireCustom(this, key + "_changed");
410             }
411         },
412         
413         /**
414             Use this to retreive attributes on a Model (rather than accessing them directly);
415             @param {String} key
416             @see MBX.JsModel.set
417             @example
418               Model.set('myAttr', 'foo');
419               Model.get('myAttr', 'foo');
420         */
421         get: function (key) {
422             return this.attributes[key];
423         },
424         /**
425             A convenience method to subscribe to new model instances
426             @example
427               AModelInstance.onInstanceCreate(function (evt) { console.log(evt) });
428         */
429         onInstanceCreate: function (func) {            
430             return MBX.EventHandler.subscribe(MBX, this.Event.newInstance, func);
431         },
432         
433         /**
434             A convenience method to subscribe to destroying model instances
435             @example
436               AModelInstance.onInstanceDestroy(function (evt) { console.log(evt); });
437         */
438         onInstanceDestroy: function (func) {
439             return MBX.EventHandler.subscribe(MBX, this.Event.destroyInstance, func);
440         },
441         
442         /**
443             A convenience method to subscribe to changing model instances
444             @example
445               AModelInstance.onInstanceChange(function (evt) { console.log(evt); });
446         */
447         onInstanceChange: function (func) {
448             return MBX.EventHandler.subscribe(MBX, this.Event.changeInstance, func);
449         },
450         
451         
452         /**
453             A convenience method to subscribe to changing model attributes
454             @example
455                 AModelInstance.onAttributeChange(function (evt) { console.dir(evt); });
456         */
457         onAttributeChange: function (func) {
458             return MBX.EventHandler.subscribe(MBX, this.Event.changeAttribute, func);
459         }
460     };
461     
462     publicObj.Event = {
463         newModel: "new_model"
464     };
465     
466     /**
467         Used for creating a new JsModel
468         @name MBX.JsModel.create
469         @function
470         
471         @param {String} name model name used to prevent name collision
472         @param {Object} opts defaults to {}
473         
474         @constructs
475         @example
476           var MyModel = MBX.JsModel.create("MyModel");
477           var instance = MyModel.create();
478     */
479     publicObj.create = function (name, opts) {
480         return new JsModel(name, opts);
481     };
482     
483     /**
484        Used internally to find the next GUID
485        @private
486     */
487     publicObj.nextGUID = function () {
488         return currentGUID++;
489     };
490     
491     /**
492         Extends all JsModels
493         @name MBX.JsModel.extend
494         @function
495         @param {Object} methsAndAttrs the methods and attributes to extend all models with
496     */
497     publicObj.extend = function (methsAndAttrs) {
498         methsAndAttrs = methsAndAttrs || {};
499         Object.extend(JsModel.prototype, methsAndAttrs);
500     };
501     
502     /**
503         Extend all instances of all models
504         @name MBX.JsModel.extendInstancePrototype
505         @function
506         @param {Object} methsAndAttrs the methods and attributes to extend all models' instances with
507     */
508     publicObj.extendInstancePrototype = function (methsAndAttrs) {
509         Object.extend(oneJsModelInstance, methsAndAttrs);
510     };
511     
512     /**
513            Destroy a controller and unsubscribe its event listeners
514            @param {String} name the name of the controller
515            @name MBX.JsModel.destroyModel
516            @function
517    */
518     publicObj.destroyModel = function (name) {
519         delete modelCache[name];
520     };
521     
522     return publicObj;
523 })();
524