1 // ==========================================================================
  2 // Project:   The M-Project - Mobile HTML5 Application Framework
  3 // Copyright: (c) 2010 M-Way Solutions GmbH. All rights reserved.
  4 // Creator:   Sebastian
  5 // Date:      18.11.2010
  6 // License:   Dual licensed under the MIT or GPL Version 2 licenses.
  7 //            http://github.com/mwaylabs/The-M-Project/blob/master/MIT-LICENSE
  8 //            http://github.com/mwaylabs/The-M-Project/blob/master/GPL-LICENSE
  9 // ==========================================================================
 10 
 11 m_require('core/detox/data_provider.js');
 12 
 13 /**
 14  * @class
 15  *
 16  * Encapsulates access to WebSQL (in-browser sqlite storage). All CRUD operations are asynchronous. That means that onSuccess
 17  * and onError callbacks have to be passed to the function calls to have the result returned when operation finished. 
 18  *
 19  * @extends M.DataProvider
 20  */
 21 M.WebSqlProvider = M.DataProvider.extend(
 22 /** @scope M.WebSqlProvider.prototype */ {
 23 
 24     /**
 25      * The type of this object.
 26      * @type String
 27      */
 28     type: 'M.WebSqlProvider',
 29 
 30     /**
 31      * Configuration object
 32      * @type Object
 33      */
 34     config: {},
 35 
 36     /**
 37      * Is set to YES when initialization ran successfully, means when {@link M.WebSqlProvider#init} was called, db and table created. 
 38      * @type Boolean
 39      */
 40     isInitialized: NO,
 41 
 42     /**
 43      * Object containing all rules for mapping JavaScript data types to SQLite data types.
 44      * @type Object
 45      */
 46     typeMapping: {
 47         'String': 'varchar(255)',
 48         'Text': 'text',
 49         'Float': 'float',
 50         'Integer': 'integer',
 51         'Number': 'interger',
 52         'Date': 'date',
 53         'Boolean': 'boolean'
 54     },
 55 
 56     /**
 57      * Is set when database "opened". Acts as handler for all db operations => transactions, queries, etc.
 58      * @type Object
 59      */
 60     dbHandler: null,
 61 
 62     /**
 63      * Saves the internal callback function. Is needed when provider/db is not initialized and init() must be executed first to have the return point again after
 64      * initialization. 
 65      * @type Function
 66      */
 67     internalCallback: null,
 68 
 69     /**
 70      * Used internally. External callback for success case. 
 71      * @private
 72      */
 73     onSuccess: null,
 74 
 75     /**
 76      * Used internally. External callback for error case.
 77      * @private
 78      */
 79     onError: null,
 80 
 81     /**
 82      * Opens a database and creates the appropriate table for the model record.
 83      * 
 84      * @param {Object} obj The param obj, includes model. Not used here, just passed through.
 85      * @param {Function} callback The function that called init as callback bind to this.
 86      * @private
 87      */
 88     init: function(obj, callback) {
 89         this.openDb();
 90         this.createTable(obj, callback);
 91     },
 92 
 93     /*
 94     * CRUD methods
 95     */
 96 
 97     /**
 98      * Saves a model in the database. Constructs the sql query from the model record. Prepares an INSERT or UPDATE depending on the state
 99      * of the model. If M.STATE_NEW then prepares an INSERT, if M.STATE_VALID then prepares an UPDATE. The operation itself
100      * is done by {@link M.WebSqlProvider#performOp} that is called
101      *
102      * @param {Object} obj The param obj, includes:
103      * * onSuccess callback
104      * * onError callback
105      * * the model
106      */
107      save: function(obj) {
108         console.log('save() called.');
109 
110         this.onSuccess = obj.onSuccess;
111         this.onError = obj.onError;
112 
113         /**
114          * if not already done, initialize db/table first
115          */
116         if(!this.isInitialized) {
117             this.internalCallback = this.save;
118             this.init(obj.model);
119             return;
120         }
121 
122 
123         if(obj.model.state === M.STATE_NEW) { // perform an INSERT
124 
125             var sql = 'INSERT INTO ' + obj.model.name + ' (';
126             for(var prop in obj.model.record) {
127                 sql += prop + ', ';
128             }
129 
130             sql = sql.substring(0, sql.lastIndexOf(',')) + ') ';
131             sql += 'VALUES (';
132 
133             for(var prop in obj.model.record) {
134                 /* if property is string or text write value in quotes */
135                 var pre_suffix = obj.model.__meta[prop].dataType === 'String' || obj.model.__meta[prop].dataType === 'Text' ? '"' : '';
136                 sql += pre_suffix + obj.model.record[prop] + pre_suffix + ', ';
137             }
138             sql = sql.substring(0, sql.lastIndexOf(',')) + '); ';
139 
140             console.log(sql);
141 
142             this.performOp(sql, obj, 'INSERT');
143 
144         } else { // perform an UPDATE with id of model
145 
146             var sql = 'UPDATE ' + obj.model.name + ' SET ';
147 
148             for(var prop in obj.model.record) {
149                 var pre_suffix = obj.model.__meta[prop].dataType === 'String' || obj.model.__meta[prop].dataType === 'Text' ? '"' : '';
150                 sql += prop + ' = ' + pre_suffix + obj.model.record[prop] + pre_suffix + ', ';
151             }
152             sql = sql.substring(0, sql.lastIndexOf(','));
153             sql += ' WHERE ' + 'ID = ' + obj.model.record.ID;
154 
155             console.log(sql);
156 
157             this.performOp(sql, obj, 'UPDATE');
158         }
159     },
160 
161     /**
162      * Performs operation on WebSQL storage: INSERT, UPDATE or DELETE. Is used by {@link M.WebSqlProvider#save} and {@link M.WebSqlProvider#del}.
163      * Calls is made asynchronously, means that result is just available in callback.
164      * @param {String} sql The query.
165      * @param {Object} obj The param object. Contains e.g. callbacks (onError & onSuccess)
166      * @param {String} opType The String identifying the operation: 'INSERT', 'UPDATE' oder 'DELETE'
167      * @private
168      */
169     performOp: function(sql, obj, opType) {
170         var that = this;
171         this.dbHandler.transaction(function(t) {
172             t.executeSql(sql, null, function() {
173                 if(opType === 'INSERT') { /* after INSERT operation set the assigned DB ID to the model records id */
174                     that.queryDbForId(obj.model);
175                 }
176             }, function() { // error callback for SQLStatementTransaction
177                 M.Logger.log('Incorrect statement: ' + sql, M.ERROR);       
178             });
179         },
180         function() { // errorCallback
181             /* bind error callback */
182             if (obj.onError && obj.onError.target && obj.onError.action) {
183                 obj.onError = this.bindToCaller(obj.onError.target, obj.onError.target[obj.onError.action]);
184                 obj.onError();
185             }
186         },
187 
188         function() { // voidCallback (success)
189 
190             /* add or delete to the model blueprints record list */
191             switch(opType) {
192                 case 'INSERT':
193                     obj.model.recordManager.add(obj.model);
194                 break;
195                 case 'DELETE':
196                     obj.model.recordManager.remove(obj.model.id);
197                     break;
198                 default:
199                     // do nothing
200                 break;
201             }
202 
203             /* bind success callback */
204             if (obj.onSuccess && obj.onSuccess.target && obj.onSuccess.action) {
205                 obj.onSuccess = that.bindToCaller(obj.onSuccess.target, obj.onSuccess.target[obj.onSuccess.action]);
206                 obj.onSuccess();
207             } else if (typeof(obj.onError) !== 'function') {
208                 M.Logger.log('Target and action in onSuccess not defined.', M.INFO);
209             }
210         });        
211     },
212 
213     /**
214      * Prepares delete query for a model record. Operation itself is performed by {@link M.WebSqlProvider#performOp}.
215      * Tuple is identified by ID (not the internal model id, but the ID provided by the DB in record).
216      *
217      * @param {Object} obj The param obj, includes:
218      * * onSuccess callback
219      * * onError callback
220      * * the model
221      */
222     del: function(obj) {
223         console.log('del() called.');
224         if(!this.isInitialized) {
225             this.internalCallback = this.del;
226             this.init(obj, this.bindToCaller(this, this.del));
227             return;
228         }
229 
230         var sql = 'DELETE FROM ' + obj.model.name + ' WHERE ID=' + obj.model.record.ID + ';';
231 
232         console.log(sql);
233 
234         this.performOp(sql, obj, 'DELETE');
235     },
236 
237 
238     /**
239      * Finds model records in the database. If a constraint is given, result is filtered. 
240      * @param {Object} obj The param object. Includes:
241      * * model: the model blueprint
242      * * onSuccess:
243      * * onError:
244      * * columns: Array of strings naming the properties to be selected: ['name', 'age'] => SELECT name, age FROM...
245      * * constraint: Object containing itself two properties:
246      *      * statement: a string with the statement, e.g. 'WHERE ID = ?'
247      *      * parameters: array of strings with the parameters, length array must be the same as the number of ? in statement
248      *          => ? are substituted with the parameters
249      * * order: String with the ORDER expression: e.g. 'ORDER BY price ASC'
250      * * limit: Number defining the number of max. result items
251      */
252     find: function(obj) {
253         console.log('find() called.');
254         
255         this.onSuccess = obj.onSuccess;
256         this.onError = obj.onError;
257         
258         if(!this.isInitialized) {
259             this.internalCallback = this.find;
260             this.init(obj, this.bindToCaller(this, this.find));
261             return;
262         }
263 
264         var sql = 'SELECT ';
265 
266         if(obj.columns) {
267             /* ID column always needs to be in de result relation */
268             if(!(_.include(obj.columns, 'ID'))) {
269                 obj.columns.push('ID');
270             }
271 
272             if(obj.columns.length > 1) {
273                 sql += obj.columns.join(', ');
274             } else if(obj.columns.length == 1) {
275                 sql += obj.columns[0] + ' ';
276             }
277         } else {
278             sql += '* ';
279         }
280         
281         sql += ' FROM ' + obj.model.name;
282 
283         var stmtParameters = [];
284 
285         /* now process constraint */
286         if(obj.constraint) {
287 
288             var n = obj.constraint.statement.split("?").length - 1;
289 
290             /* if parameters are passed we assign them to stmtParameters, the array that is passed for prepared statement substitution*/
291             if(obj.constraint.parameters) {
292 
293                 if(n === obj.constraint.parameters.length) { /* length of parameters list must match number of ? in statement */
294                     sql += obj.constraint.statement;
295                     stmtParameters = obj.constraint.parameters;
296                 } else {
297                     M.Logger.log('Not enough parameters provided for statement.', M.ERROR);
298                     return NO;
299                 }    
300            /* if no ? are in statement, we handle it as a non-prepared statement
301             * => developer needs to take care of it by himself regarding
302             * sql injection => all statements that are constructed with dynamic
303             * input should be done as prepared statements
304             */
305             } else if(n === 0) {
306                 sql += obj.constraint.statement;
307             }
308         }
309 
310         /* now attach order */
311         if(obj.order) {
312             sql += ' ORDER BY ' + obj.order
313         }
314 
315         /* now attach limit */
316         if(obj.limit) {
317             sql += ' LIMIT ' + obj.limit
318         }
319 
320         console.log(sql);
321 
322         var result = [];
323         var that = this;
324         this.dbHandler.readTransaction(function(t) {
325             t.executeSql(sql, stmtParameters, function (tx, res) {
326                 var len = res.rows.length, i;
327                 for (i = 0; i < len; i++) {
328                     /* create model record from result with state valid */
329                     /* $.extend merges param1 object with param2 object*/
330                     result.push(obj.model.createRecord($.extend(res.rows.item(i), {state: M.STATE_VALID}), this));
331                 }
332             }, function(){M.Logger.log('Incorrect statement: ' + sql, M.ERROR)}) // callbacks: SQLStatementErrorCallback
333         }, function(){ // errorCallback
334             /* bind error callback */
335             if(obj.onError && obj.onError.target && obj.onError.action) {
336                 obj.onError = this.bindToCaller(obj.onError.target, obj.onError.target[obj.onError.action]);
337                 obj.onError();
338             } else if (typeof(obj.onError) !== 'function') {
339                 M.Logger.log('Target and action in onError not defined.', M.ERROR);
340             }
341         }, function() { // voidCallback (success)
342             /* add to model blueprint's recordmanager record list */
343             /* first reset record list */
344             obj.model.recordManager.removeAll();
345             obj.model.recordManager.addMany(result);
346 
347             /* bind success callback */
348             if(obj.onSuccess && obj.onSuccess.target && obj.onSuccess.action) {
349                 /* [result] is a workaround for bindToCaller: bindToCaller uses call() instead of apply() if an array is given.
350                  * result is an array, but we what call is doing with it is wrong in this case. call maps each array element to one method
351                  * parameter of the function called. Our callback only has one parameter so it would just pass the first value of our result set to the
352                  * callback. therefor we now put result into an array (so we have an array inside an array) and result as a whole is now passed as the first
353                  * value to the callback.
354                  *  */
355                 obj.onSuccess = that.bindToCaller(obj.onSuccess.target, obj.onSuccess.target[obj.onSuccess.action], [result]);
356                 obj.onSuccess();
357             }
358         });
359     },
360 
361 
362     /**
363      * @private
364      */
365     openDb: function() {
366         console.log('openDb() called.');
367         /* openDatabase(db_name, version, description, estimated_size, callback) */
368         this.dbHandler = openDatabase(this.config.dbName, '2.0', 'Database for M app', this.config.size);
369     },
370 
371 
372     /**
373      * Creates the table corresponding to the model record.
374      * @private
375      */
376     createTable: function(obj, callback) {
377         console.log('createTable() called.');
378         var sql = 'CREATE TABLE IF NOT EXISTS '  + obj.model.name
379                     + ' (ID INTEGER PRIMARY KEY ASC AUTOINCREMENT UNIQUE';
380 
381         for(var r in obj.model.__meta) {
382            sql += ', ' + this.buildDbAttrFromProp(obj.model, r);
383         }
384 
385         sql += ');';
386 
387         console.log(sql);
388         
389         if(this.dbHandler) {
390             var that = this;
391             try {
392                 /* transaction has 3 parameters: the transaction callback, the error callback and the success callback */
393                 this.dbHandler.transaction(function(t) {
394                     t.executeSql(sql);
395                 }, null, that.bindToCaller(that, that.handleDbReturn, [obj, callback]));
396             } catch(e) {
397                 M.Logger.log('Error code: ' + e.code + ' msg: ' + e.message, M.ERROR);
398                 return;
399             }
400         } else {
401             M.Logger.log('dbHandler does not exist.', M.ERROR);
402         }
403     },
404 
405     /**
406      * Creates a new data provider instance with the passed configuration parameters
407      * @param {Object} obj Includes dbName
408      */
409     configure: function(obj) {
410         console.log('configure() called.');
411         // maybe some value checking
412         return this.extend({
413             config:obj
414         });
415     },
416 
417     /* Helper methods */
418 
419     /**
420      * @private
421      * Creates the column definitions from the properties of the model record with help of the meta information that
422      * the {@link M.ModelAttribute} objects provide.
423      * @param {Object}
424      * @returns {String} The string used for db create to represent this property.
425      */
426     buildDbAttrFromProp: function(model, prop) {
427         console.log(model);
428         console.log(prop);
429         var type = this.typeMapping[model.__meta[prop].dataType].toUpperCase();
430 
431         var isReqStr = model.__meta[prop].isRequired ? ' NOT NULL' : '';
432 
433         return prop + ' ' + type + isReqStr;
434     },
435 
436 
437     /**
438      * Queries the WebSQL storage for the maximum db ID that was provided for a table that is defined by model.name. Delegates to
439      * {@link M.WebSqlProvider#setDbIdOfModel}.
440      * 
441      * @param {Object} model The table's model
442      */
443     queryDbForId: function(model) {
444         var that = this;
445         this.dbHandler.readTransaction(function(t) {
446             var r = t.executeSql('SELECT seq as ID FROM sqlite_sequence WHERE name="' + model.name + '"', [], function (tx, res) {
447                 that.setDbIdOfModel(model, res.rows.item(0).ID);
448             });
449         });
450     },
451 
452     /**
453      * @private
454      * Is called when creating table successfully returned and therefor sets the initialization flag of the provider to YES.
455      * Then calls the internal callback => the function that called init().
456      */
457     handleDbReturn: function(obj, callback) {
458         console.log('handleDbReturn() called.');
459         this.isInitialized = YES;
460         this.internalCallback(obj, callback);
461     },
462 
463     /**
464      * @private
465      * Is called from queryDbForId, sets the model record's ID to the latest value of ID in the database.
466      */
467     setDbIdOfModel: function(model, id) {
468         model.record.ID = id;
469     }
470 
471 });