      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 });