1 // ========================================================================== 2 // Project: The M-Project - Mobile HTML5 Application Framework 3 // Copyright: (c) 2010 M-Way Solutions GmbH. All rights reserved. 4 // (c) 2011 panacoda GmbH. All rights reserved. 5 // Creator: Sebastian 6 // Date: 28.10.2010 7 // License: Dual licensed under the MIT or GPL Version 2 licenses. 8 // http://github.com/mwaylabs/The-M-Project/blob/master/MIT-LICENSE 9 // http://github.com/mwaylabs/The-M-Project/blob/master/GPL-LICENSE 10 // ========================================================================== 11 12 M.STATE_UNDEFINED = 'state_undefined'; 13 M.STATE_NEW = 'state_new'; 14 M.STATE_INSYNCPOS = 'state_insyncpos'; 15 M.STATE_INSYNCNEG = 'state_insyncneg'; 16 M.STATE_LOCALCHANGED = 'state_localchange'; 17 M.STATE_VALID = 'state_valid'; 18 M.STATE_INVALID = 'state_invalid'; 19 M.STATE_DELETED = 'state_deleted'; 20 21 m_require('core/utility/logger.js'); 22 23 /** 24 * @class 25 * 26 * M.Model is the prototype for every model and for every model record (a model itself is the blueprint for a model record). 27 * Models hold the business data of an application respectively the application's state. It's usually the part of an application that is persisted to storage. 28 * M.Model acts as the gatekeeper to storage. It uses data provider for persistence and validators to validate its records. 29 * 30 * @extends M.Object 31 */ 32 M.Model = M.Object.extend( 33 /** @scope M.Model.prototype */ { 34 /** 35 * The type of this object. 36 * 37 * @type String 38 */ 39 type: 'M.Model', 40 41 /** 42 * The name of the model. 43 * 44 * @type String 45 */ 46 name: '', 47 48 /** 49 * Unique identifier for the model record. 50 * 51 * It's as unique as it needs to be: four digits, each digits can be one of 32 chars 52 * 53 * @type String 54 */ 55 m_id: null, 56 57 /** 58 * The model's record defines the properties that are semantically bound to this model: 59 * e.g. a person's record is (in simplest case): firstname, lastname, age. 60 * 61 * @type Object record 62 */ 63 record: null, 64 65 /** 66 * Object containing all meta information for the object's properties 67 * @type Object 68 */ 69 __meta: {}, 70 71 /** 72 * Manages records of this model 73 * @type Object 74 */ 75 recordManager: null, 76 77 /** 78 * List containing all models in application 79 * @type Object|Array 80 */ 81 modelList: {}, 82 83 /** 84 * A constant defining the model's state. Important e.g. for syncing storage 85 * @type String 86 */ 87 state: M.STATE_UNDEFINED, 88 89 /** 90 * 91 * @type String 92 */ 93 state_remote: M.STATE_UNDEFINED, 94 95 /** 96 * determines whether model shall be validated before saving to storage or not. 97 * @type Boolean 98 */ 99 usesValidation: NO, 100 101 /** 102 * The model's data provider. A data provider persists the model to a certain storage. 103 * 104 * @type Object 105 */ 106 dataProvider: null, 107 108 getUniqueId: function() { 109 return M.UniqueId.uuid(4); 110 }, 111 112 /** 113 * Creates a new record of the model, means an instance of the model based on the blueprint. 114 * You pass the object's specific attributes to it as an object. 115 * 116 * @param {Object} obj The properties object, e.g. {firstname: 'peter', lastname ='fox'} 117 * @returns {Object} The model record with the passed properties set. State depends on newly creation or fetch from storage: if 118 * from storage then state is M.STATE_NEW or 'state_new', if fetched from database then it is M.STATE_VALID or 'state_valid' 119 */ 120 createRecord: function(obj) { 121 122 var rec = this.extend({ 123 m_id: obj.m_id ? obj.m_id : this.getUniqueId(), 124 record: obj /* properties that are added to record here, but are not part of __meta, are deleted later (see below) */ 125 }); 126 delete obj.m_id; 127 rec.state = obj.state ? obj.state : M.STATE_NEW; 128 delete obj.state; 129 130 /* set timestamps if new */ 131 if(rec.state === M.STATE_NEW) { 132 rec.record[M.META_CREATED_AT] = +new Date(); 133 rec.record[M.META_UPDATED_AT] = +new Date(); 134 } 135 136 for(var i in rec.record) { 137 138 if(i === M.META_CREATED_AT || i === M.META_UPDATED_AT) { 139 continue; 140 } 141 142 /* if record contains properties that are not part of __meta (means that are not defined in the model blueprint) delete them */ 143 if(!rec.__meta.hasOwnProperty(i)) { 144 M.Logger.log('Deleting "' + i + '" property. It\'s not part of ' + this.name + ' definition.', M.WARN); 145 delete rec.record[i]; 146 continue; 147 } 148 149 /* if reference to a record entity is in param obj, assign it like in set. */ 150 if(rec.__meta[i].dataType === 'Reference' && rec.record[i] && rec.record[i].type && rec.record[i].type === 'M.Model') { 151 // call set of model 152 rec.set(i, rec.record[i]); 153 } 154 155 if(rec.__meta[i]) { 156 rec.__meta[i].isUpdated = NO; 157 } 158 } 159 160 this.recordManager.add(rec); 161 return rec; 162 }, 163 164 /** 165 * Create defines a new model blueprint. It is passed an object with the model's attributes and the model's business logic 166 * and after it the type of data provider to use. 167 * 168 * @param {Object} obj An object defining the model's 169 * @param {Object} dp The data provider to use, e. g. M.LocalStorageProvider 170 * @returns {Object} The model blueprint: acts as blueprint to all records created with @link M.Model#createRecord 171 */ 172 create: function(obj, dp) { 173 var model = M.Model.extend({ 174 __meta: {}, 175 name: obj.__name__, 176 dataProvider: dp, 177 recordManager: {}, 178 usesValidation: obj.usesValidation ? obj.usesValidation : this.usesValidation 179 }); 180 delete obj.__name__; 181 delete obj.usesValidation; 182 183 for(var prop in obj) { 184 if(typeof(obj[prop]) === 'function') { 185 model[prop] = obj[prop]; 186 } else if(obj[prop].type === 'M.ModelAttribute') { 187 model.__meta[prop] = obj[prop]; 188 } 189 } 190 191 /* add ID, _createdAt and _modifiedAt properties in meta for timestamps */ 192 model.__meta[M.META_CREATED_AT] = this.attr('String', { // could be 'Date', too 193 isRequired:YES 194 }); 195 model.__meta[M.META_UPDATED_AT] = this.attr('String', { // could be 'Date', too 196 isRequired:YES 197 }); 198 199 model.recordManager = M.RecordManager.extend({records:[]}); 200 201 /* save model in modelList with model name as key */ 202 this.modelList[model.name] = model; 203 204 return model; 205 }, 206 207 /** 208 * Defines a to-one-relationship. 209 * @param refName 210 * @param refEntity 211 */ 212 hasOne: function(refEntity, obj) { 213 var relAttr = this.attr('Reference', { 214 refType: 'toOne', 215 reference: refEntity, 216 validators: obj.validators, 217 isRequired: obj.isRequired 218 }); 219 return relAttr; 220 }, 221 222 /** 223 * Defines a to-many-relationship 224 * @param colName 225 * @param refEntity 226 * @param invRel 227 */ 228 hasMany: function(colName, refEntity, invRel) { 229 var relAttr = this.attr('Reference', { 230 refType: 'toMany', 231 reference: refEntity 232 }); 233 return relAttr; 234 }, 235 236 /** 237 * Returns a M.ModelAttribute object to map an attribute in our record. 238 * 239 * @param {String} type type of the attribute 240 * @param {Object} opts options for the attribute, like required flag and validators array 241 * @returns {Object} An M.ModelAttribute object configured with the type and options passed to the function. 242 */ 243 attr: function(type, opts) { 244 return M.ModelAttribute.attr(type, opts); 245 }, 246 247 /* 248 * get and set methods for encapsulated attribute access 249 */ 250 251 /** 252 * Get attribute propName from model, if async provider is used, get on references does not return the property value but a boolean indicating 253 * the load status. get must then be called again in onSuccess callback to retrieve the value 254 * @param {String} propName the name of the property whose value shall be returned 255 * @param {Object} obj optional parameter containing the load force flag and callbacks, e.g.: 256 * { 257 * force: YES, 258 * onSuccess: function() { console.log('yeah'); } 259 * } 260 * @returns {Boolean|Object|String} value of property or boolean indicating the load status 261 */ 262 get: function(propName, obj) { 263 var metaProp = this.__meta[propName]; 264 var recProp = this.record[propName]; 265 /* return ref entity if property is a reference */ 266 if(metaProp && metaProp.dataType === 'Reference') { 267 if(metaProp.refEntity) {// if entity is already loaded and assigned here in model record 268 return metaProp.refEntity; 269 } else if(recProp) { // if model record has a reference set, but it is not loaded yet 270 if(obj && obj.force) { // if force flag was set 271 /* custom call to deepFind with record passed only being the one property that needs to be filled, type of dp checked in deepFind */ 272 var callback = this.dataProvider.isAsync ? obj.onSuccess : null 273 this.deepFind([{ 274 prop: propName, 275 name: metaProp.reference, 276 model: this.modelList[metaProp.reference], 277 m_id: recProp 278 }], callback); 279 if(!this.dataProvider.isAsync) { // if data provider acts synchronous, we can now return the fetched entity 280 return metaProp.refEntity; 281 } 282 return YES; 283 } else { // if force flag was not set, and object is not in memory and record manager load is not done and we return NO 284 var r = this.recordManager.getRecordById(recProp); 285 if(r) { /* if object is already loaded and in record manager don't access storage */ 286 return r; 287 } else { 288 return NO; // return 289 } 290 } 291 } else { // if reference has not been set yet 292 return null; 293 } 294 } 295 /* if propName is not a reference, but a "simple" property, just return it */ 296 return recProp; 297 }, 298 299 /** 300 * Set attribute propName of model with value val, sets' property to isUpdated (=> will be included in UPDATE call) 301 * and sets a new timestamp to _updatedAt. Will not do anything, if newVal is the same as the current prop value. 302 * @param {String} propName the name of the property whose value shall be set 303 * @param {String|Object} val the new value 304 */ 305 set: function(propName, val) { 306 if(this.__meta[propName].dataType === 'Reference' && val.type && val.type === 'M.Model') { // reference set 307 /* first check if new value is passed */ 308 if(this.record[propName] !== val.m_id) { 309 /* set m_id of reference in record */ 310 this.record[propName] = val.m_id; 311 this.__meta[propName].refEntity = val; 312 } 313 return; 314 } 315 316 if(this.record[propName] !== val) { 317 this.record[propName] = val; 318 this.__meta[propName].isUpdated = YES; 319 /* mark record as updated with new timestamp*/ 320 this.record[M.META_UPDATED_AT] = +new Date(); 321 } 322 }, 323 324 /** 325 * Returns the records array of the model's record manager. 326 * @returns {Object|Array} The records array of record manager. 327 */ 328 records: function() { 329 if(this.recordManager && this.recordManager.records) { 330 return this.recordManager.records; 331 } 332 }, 333 334 /** 335 * Validates the model, means calling validate for each property. 336 * @returns {Boolean} Indicating whether this record is valid (YES|true) or not (NO|false). 337 */ 338 validate: function() { 339 var isValid = YES; 340 var validationErrorOccured = NO; 341 /* clear validation error buffer before validation */ 342 M.Validator.clearErrorBuffer(); 343 344 /* 345 * validationBasis depends on the state of the model: if the model is in state NEW, all properties (__meta includes all) 346 * shall be considered for validation. if model is in another state, the model's record is used. example: the model is loaded from 347 * a database with only two properties included (select name, age FROM...). record now only contains these two properties but __meta 348 * still has all properties listed. models are valid if loaded from database so when saved again only the loaded properties need to get 349 * validated because all others have not been touched. that's why then record is used. 350 * */ 351 var validationBasis = this.state === M.STATE_NEW ? this.__meta : this.record; 352 353 for (var i in validationBasis) { 354 if(i === 'ID') { // skip property ID 355 continue; 356 } 357 var prop = this.__meta[i]; 358 var obj = { 359 value: this.record[i], 360 modelId: this.name + '_' + this.m_id, 361 property: i 362 }; 363 if (!prop.validate(obj)) { 364 isValid = NO; 365 } 366 } 367 /* set state of model */ 368 /*if(!isValid) { 369 this.state = M.STATE_INVALID; 370 } else { 371 this.state = M.STATE_VALID; 372 }*/ 373 return isValid; 374 }, 375 376 /* CRUD Methods below */ 377 /** 378 * Calls the corresponding find() of the data provider to fetch data based on the passed query or key. 379 * 380 * @param {Object} obj The param object with query or key and callbacks. 381 * @returns {Boolean|Object} Depends on data provider used. When WebSQL used, a boolean is returned, the find result is returned asynchronously, 382 * because the call itself is asynchronous. If LocalStorage is used, the result of the query is returned. 383 */ 384 find: function(obj){ 385 if(!this.dataProvider) { 386 M.Logger.log('No data provider given.', M.ERR); 387 } 388 obj = obj ? obj : {}; 389 /* check if the record list shall be cleared (default) before new found model records are appended to the record list */ 390 obj.deleteRecordList = obj.deleteRecordList ? obj.deleteRecordList : YES; 391 if(obj.deleteRecordList) { 392 this.recordManager.removeAll(); 393 } 394 if(!this.dataProvider) { 395 M.Logger.log('No data provider given.', M.ERR); 396 } 397 398 /* extends the given obj with self as model property in obj */ 399 return this.dataProvider.find( $.extend(obj, {model: this}) ); 400 }, 401 402 /** 403 * Create or update a record in storage if it is valid (first check this). 404 * If param obj includes cascade:YES then save is cascadaded through all references recursively. 405 * 406 * @param {Object} obj The param object with query, cascade flag and callbacks. 407 * @returns {Boolean} The result of the data provider function call. Is a boolean. With LocalStorage used, it indicates if the save operation was successful. 408 * When WebSQL is used, the result of the save operation returns asynchronously. The result then is just the standard result returned by the web sql provider's save method 409 * which does not necessarily indicate whether the operation was successful, because the operation is asynchronous, means the operation's result is not predictable. 410 */ 411 save: function(obj) { 412 if(!this.dataProvider) { 413 M.Logger.log('No data provider given.', M.ERR); 414 } 415 obj = obj ? obj: {}; 416 if(!this.m_id) { 417 return NO; 418 } 419 var isValid = YES; 420 421 if(this.usesValidation) { 422 isValid = this.validate(); 423 } 424 425 if(obj.cascade) { 426 for(var prop in this.__meta) { 427 if(this.__meta[prop] && this.__meta[prop].dataType === 'Reference' && this.__meta[prop].refEntity) { 428 this.__meta[prop].refEntity.save({cascade:YES}); // cascades recursively through all referenced model records 429 } 430 } 431 } 432 433 if(isValid) { 434 return this.dataProvider.save($.extend(obj, {model: this})); 435 } 436 }, 437 438 /** 439 * Delete a record in storage. 440 * @returns {Boolean} Indicating whether deletion was successful or not (only with synchronous data providers, e.g. LocalStorage). When asynchronous data providers 441 * are used, e.g. WebSQL provider the real result comes asynchronous and here just the result of the del() function call of the @link M.WebSqlProvider is used. 442 */ 443 del: function(obj) { 444 if(!this.dataProvider) { 445 M.Logger.log('No data provider given.', M.ERR); 446 } 447 obj = obj ? obj : {}; 448 if(!this.m_id) { 449 return NO; 450 } 451 452 var isDel = this.dataProvider.del($.extend(obj, {model: this})); 453 if(isDel) { 454 this.state = M.STATE_DELETED; 455 return YES; 456 } 457 }, 458 459 /** 460 * completes the model record by loading all referenced entities. 461 * 462 * @param {Function | Object} obj The param object with query, cascade flag and callbacks. 463 */ 464 complete: function(callback) { 465 //console.log('complete...'); 466 var records = []; 467 for(var i in this.record) { 468 if(this.__meta[i].dataType === 'Reference') { 469 //records.push(this.__meta[i].refEntity); 470 records.push({ 471 prop:i, 472 name: this.__meta[i].reference, 473 model: this.modelList[this.__meta[i].reference], 474 m_id: this.record[i] 475 }); 476 } 477 } 478 this.deepFind(records, callback); 479 }, 480 481 deepFind: function(records, callback) { 482 //console.log('deepFind...'); 483 //console.log('### records.length: ' + records.length); 484 if(records.length < 1) { // recursion end constraint 485 if(callback) { 486 callback(); 487 } 488 return; 489 } 490 var curRec = records.pop(); // delete last element, decreases length of records by 1 => important for recursion constraint above 491 var cb = this.bindToCaller(this, this.deepFind,[records, callback]); // cb is callback for find in data provider 492 var that = this; 493 494 495 switch(this.dataProvider.type) { 496 case 'M.DataProviderLocalStorage': 497 var ref = this.modelList[curRec.name].find({ 498 key: curRec.m_id 499 }); 500 this.__meta[curRec.prop].refEntity = ref; 501 502 this.deepFind(records, callback); // recursion 503 break; 504 505 default: 506 break; 507 } 508 }, 509 510 setReference: function(result, that, prop, callback) { 511 that.__meta[prop].refEntity = result[0]; // set reference in source model defined by that 512 callback(); 513 } 514 515 });