toolkit/components/satchel/FormHistory.jsm

Fri, 16 Jan 2015 18:13:44 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Fri, 16 Jan 2015 18:13:44 +0100
branch
TOR_BUG_9701
changeset 14
925c144e1f1f
permissions
-rw-r--r--

Integrate suggestion from review to improve consistency with existing code.

     1 /* This Source Code Form is subject to the terms of the Mozilla Public
     2  * License, v. 2.0. If a copy of the MPL was not distributed with this
     3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     5 /**
     6  * FormHistory
     7  *
     8  * Used to store values that have been entered into forms which may later
     9  * be used to automatically fill in the values when the form is visited again.
    10  *
    11  * search(terms, queryData, callback)
    12  *   Look up values that have been previously stored.
    13  *     terms - array of terms to return data for
    14  *     queryData - object that contains the query terms
    15  *       The query object contains properties for each search criteria to match, where the value
    16  *       of the property specifies the value that term must have. For example,
    17  *       { term1: value1, term2: value2 }
    18  *     callback - callback that is called when results are available or an error occurs.
    19  *       The callback is passed a result array containing each found entry. Each element in
    20  *       the array is an object containing a property for each search term specified by 'terms'.
    21  * count(queryData, callback)
    22  *   Find the number of stored entries that match the given criteria.
    23  *     queryData - array of objects that indicate the query. See the search method for details.
    24  *     callback - callback that is called when results are available or an error occurs.
    25  *       The callback is passed the number of found entries.
    26  * update(changes, callback)
    27  *    Write data to form history storage.
    28  *      changes - an array of changes to be made. If only one change is to be made, it
    29  *                may be passed as an object rather than a one-element array.
    30  *        Each change object is of the form:
    31  *          { op: operation, term1: value1, term2: value2, ... }
    32  *        Valid operations are:
    33  *          add - add a new entry
    34  *          update - update an existing entry
    35  *          remove - remove an entry
    36  *          bump - update the last accessed time on an entry
    37  *        The terms specified allow matching of one or more specific entries. If no terms
    38  *        are specified then all entries are matched. This means that { op: "remove" } is
    39  *        used to remove all entries and clear the form history.
    40  *      callback - callback that is called when results have been stored.
    41  * getAutoCompeteResults(searchString, params, callback)
    42  *   Retrieve an array of form history values suitable for display in an autocomplete list.
    43  *   Returns an mozIStoragePendingStatement that can be used to cancel the operation if
    44  *   needed.
    45  *     searchString - the string to search for, typically the entered value of a textbox
    46  *     params - zero or more filter arguments:
    47  *       fieldname - form field name
    48  *       agedWeight
    49  *       bucketSize
    50  *       expiryDate
    51  *       maxTimeGroundings
    52  *       timeGroupingSize
    53  *       prefixWeight
    54  *       boundaryWeight
    55  *     callback - callback that is called with the array of results. Each result in the array
    56  *                is an object with four arguments:
    57  *                  text, textLowerCase, frecency, totalScore
    58  * schemaVersion
    59  *   This property holds the version of the database schema
    60  *
    61  * Terms:
    62  *  guid - entry identifier. For 'add', a guid will be generated.
    63  *  fieldname - form field name
    64  *  value - form value
    65  *  timesUsed - the number of times the entry has been accessed
    66  *  firstUsed - the time the the entry was first created
    67  *  lastUsed - the time the entry was last accessed
    68  *  firstUsedStart - search for entries created after or at this time
    69  *  firstUsedEnd - search for entries created before or at this time
    70  *  lastUsedStart - search for entries last accessed after or at this time
    71  *  lastUsedEnd - search for entries last accessed before or at this time
    72  *  newGuid - a special case valid only for 'update' and allows the guid for
    73  *            an existing record to be updated. The 'guid' term is the only
    74  *            other term which can be used (ie, you can not also specify a
    75  *            fieldname, value etc) and indicates the guid of the existing
    76  *            record that should be updated.
    77  *
    78  * In all of the above methods, the callback argument should be an object with
    79  * handleResult(result), handleFailure(error) and handleCompletion(reason) functions.
    80  * For search and getAutoCompeteResults, result is an object containing the desired
    81  * properties. For count, result is the integer count. For, update, handleResult is
    82  * not called. For handleCompletion, reason is either 0 if successful or 1 if
    83  * an error occurred.
    84  */
    86 this.EXPORTED_SYMBOLS = ["FormHistory"];
    88 const Cc = Components.classes;
    89 const Ci = Components.interfaces;
    90 const Cr = Components.results;
    92 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
    93 Components.utils.import("resource://gre/modules/Services.jsm");
    95 XPCOMUtils.defineLazyServiceGetter(this, "uuidService",
    96                                    "@mozilla.org/uuid-generator;1",
    97                                    "nsIUUIDGenerator");
    99 const DB_SCHEMA_VERSION = 4;
   100 const DAY_IN_MS  = 86400000; // 1 day in milliseconds
   101 const MAX_SEARCH_TOKENS = 10;
   102 const NOOP = function noop() {};
   104 let supportsDeletedTable =
   105 #ifdef ANDROID
   106   true;
   107 #else
   108   false;
   109 #endif
   111 let Prefs = {
   112   initialized: false,
   114   get debug() { this.ensureInitialized(); return this._debug; },
   115   get enabled() { this.ensureInitialized(); return this._enabled; },
   116   get expireDays() { this.ensureInitialized(); return this._expireDays; },
   118   ensureInitialized: function() {
   119     if (this.initialized)
   120       return;
   122     this.initialized = true;
   124     this._debug = Services.prefs.getBoolPref("browser.formfill.debug");
   125     this._enabled = Services.prefs.getBoolPref("browser.formfill.enable");
   126     this._expireDays = Services.prefs.getIntPref("browser.formfill.expire_days");
   127   }
   128 };
   130 function log(aMessage) {
   131   if (Prefs.debug) {
   132     Services.console.logStringMessage("FormHistory: " + aMessage);
   133   }
   134 }
   136 function sendNotification(aType, aData) {
   137   if (typeof aData == "string") {
   138     let strWrapper = Cc["@mozilla.org/supports-string;1"].
   139                      createInstance(Ci.nsISupportsString);
   140     strWrapper.data = aData;
   141     aData = strWrapper;
   142   }
   143   else if (typeof aData == "number") {
   144     let intWrapper = Cc["@mozilla.org/supports-PRInt64;1"].
   145                      createInstance(Ci.nsISupportsPRInt64);
   146     intWrapper.data = aData;
   147     aData = intWrapper;
   148   }
   149   else if (aData) {
   150     throw Components.Exception("Invalid type " + (typeof aType) + " passed to sendNotification",
   151                                Cr.NS_ERROR_ILLEGAL_VALUE);
   152   }
   154   Services.obs.notifyObservers(aData, "satchel-storage-changed", aType);
   155 }
   157 /**
   158  * Current database schema
   159  */
   161 const dbSchema = {
   162   tables : {
   163     moz_formhistory : {
   164       "id"        : "INTEGER PRIMARY KEY",
   165       "fieldname" : "TEXT NOT NULL",
   166       "value"     : "TEXT NOT NULL",
   167       "timesUsed" : "INTEGER",
   168       "firstUsed" : "INTEGER",
   169       "lastUsed"  : "INTEGER",
   170       "guid"      : "TEXT",
   171     },
   172     moz_deleted_formhistory: {
   173         "id"          : "INTEGER PRIMARY KEY",
   174         "timeDeleted" : "INTEGER",
   175         "guid"        : "TEXT"
   176     }
   177   },
   178   indices : {
   179     moz_formhistory_index : {
   180       table   : "moz_formhistory",
   181       columns : [ "fieldname" ]
   182     },
   183     moz_formhistory_lastused_index : {
   184       table   : "moz_formhistory",
   185       columns : [ "lastUsed" ]
   186     },
   187     moz_formhistory_guid_index : {
   188       table   : "moz_formhistory",
   189       columns : [ "guid" ]
   190     },
   191   }
   192 };
   194 /**
   195  * Validating and processing API querying data
   196  */
   198 const validFields = [
   199   "fieldname",
   200   "value",
   201   "timesUsed",
   202   "firstUsed",
   203   "lastUsed",
   204   "guid",
   205 ];
   207 const searchFilters = [
   208   "firstUsedStart",
   209   "firstUsedEnd",
   210   "lastUsedStart",
   211   "lastUsedEnd",
   212 ];
   214 function validateOpData(aData, aDataType) {
   215   let thisValidFields = validFields;
   216   // A special case to update the GUID - in this case there can be a 'newGuid'
   217   // field and of the normally valid fields, only 'guid' is accepted.
   218   if (aDataType == "Update" && "newGuid" in aData) {
   219     thisValidFields = ["guid", "newGuid"];
   220   }
   221   for (let field in aData) {
   222     if (field != "op" && thisValidFields.indexOf(field) == -1) {
   223       throw Components.Exception(
   224         aDataType + " query contains an unrecognized field: " + field,
   225         Cr.NS_ERROR_ILLEGAL_VALUE);
   226     }
   227   }
   228   return aData;
   229 }
   231 function validateSearchData(aData, aDataType) {
   232   for (let field in aData) {
   233     if (field != "op" && validFields.indexOf(field) == -1 && searchFilters.indexOf(field) == -1) {
   234       throw Components.Exception(
   235         aDataType + " query contains an unrecognized field: " + field,
   236         Cr.NS_ERROR_ILLEGAL_VALUE);
   237     }
   238   }
   239 }
   241 function makeQueryPredicates(aQueryData, delimiter = ' AND ') {
   242   return Object.keys(aQueryData).map(function(field) {
   243     if (field == "firstUsedStart") {
   244       return "firstUsed >= :" + field;
   245     } else if (field == "firstUsedEnd") {
   246       return "firstUsed <= :" + field;
   247     } else if (field == "lastUsedStart") {
   248       return "lastUsed >= :" + field;
   249     } else if (field == "lastUsedEnd") {
   250       return "lastUsed <= :" + field;
   251     }
   252     return field + " = :" + field;
   253   }).join(delimiter);
   254 }
   256 /**
   257  * Storage statement creation and parameter binding
   258  */
   260 function makeCountStatement(aSearchData) {
   261   let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory";
   262   let queryTerms = makeQueryPredicates(aSearchData);
   263   if (queryTerms) {
   264     query += " WHERE " + queryTerms;
   265   }
   266   return dbCreateAsyncStatement(query, aSearchData);
   267 }
   269 function makeSearchStatement(aSearchData, aSelectTerms) {
   270   let query = "SELECT " + aSelectTerms.join(", ") + " FROM moz_formhistory";
   271   let queryTerms = makeQueryPredicates(aSearchData);
   272   if (queryTerms) {
   273     query += " WHERE " + queryTerms;
   274   }
   276   return dbCreateAsyncStatement(query, aSearchData);
   277 }
   279 function makeAddStatement(aNewData, aNow, aBindingArrays) {
   280   let query = "INSERT INTO moz_formhistory (fieldname, value, timesUsed, firstUsed, lastUsed, guid) " +
   281               "VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)";
   283   aNewData.timesUsed = aNewData.timesUsed || 1;
   284   aNewData.firstUsed = aNewData.firstUsed || aNow;
   285   aNewData.lastUsed = aNewData.lastUsed || aNow;
   286   return dbCreateAsyncStatement(query, aNewData, aBindingArrays);
   287 }
   289 function makeBumpStatement(aGuid, aNow, aBindingArrays) {
   290   let query = "UPDATE moz_formhistory SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid";
   291   let queryParams = {
   292     lastUsed : aNow,
   293     guid : aGuid,
   294   };
   296   return dbCreateAsyncStatement(query, queryParams, aBindingArrays);
   297 }
   299 function makeRemoveStatement(aSearchData, aBindingArrays) {
   300   let query = "DELETE FROM moz_formhistory";
   301   let queryTerms = makeQueryPredicates(aSearchData);
   303   if (queryTerms) {
   304     log("removeEntries");
   305     query += " WHERE " + queryTerms;
   306   } else {
   307     log("removeAllEntries");
   308     // Not specifying any fields means we should remove all entries. We
   309     // won't need to modify the query in this case.
   310   }
   312   return dbCreateAsyncStatement(query, aSearchData, aBindingArrays);
   313 }
   315 function makeUpdateStatement(aGuid, aNewData, aBindingArrays) {
   316   let query = "UPDATE moz_formhistory SET ";
   317   let queryTerms = makeQueryPredicates(aNewData, ', ');
   319   if (!queryTerms) {
   320     throw Components.Exception("Update query must define fields to modify.",
   321                                Cr.NS_ERROR_ILLEGAL_VALUE);
   322   }
   324   query += queryTerms + " WHERE guid = :existing_guid";
   325   aNewData["existing_guid"] = aGuid;
   327   return dbCreateAsyncStatement(query, aNewData, aBindingArrays);
   328 }
   330 function makeMoveToDeletedStatement(aGuid, aNow, aData, aBindingArrays) {
   331   if (supportsDeletedTable) {
   332     let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)";
   333     let queryTerms = makeQueryPredicates(aData);
   335     if (aGuid) {
   336       query += " VALUES (:guid, :timeDeleted)";
   337     } else {
   338       // TODO: Add these items to the deleted items table once we've sorted
   339       //       out the issues from bug 756701
   340       if (!queryTerms)
   341         return;
   343       query += " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " + queryTerms;
   344     }
   346     aData.timeDeleted = aNow;
   348     return dbCreateAsyncStatement(query, aData, aBindingArrays);
   349   }
   351   return null;
   352 }
   354 function generateGUID() {
   355   // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}"
   356   let uuid = uuidService.generateUUID().toString();
   357   let raw = ""; // A string with the low bytes set to random values
   358   let bytes = 0;
   359   for (let i = 1; bytes < 12 ; i+= 2) {
   360     // Skip dashes
   361     if (uuid[i] == "-")
   362       i++;
   363     let hexVal = parseInt(uuid[i] + uuid[i + 1], 16);
   364     raw += String.fromCharCode(hexVal);
   365     bytes++;
   366   }
   367   return btoa(raw);
   368 }
   370 /**
   371  * Database creation and access
   372  */
   374 let _dbConnection = null;
   375 XPCOMUtils.defineLazyGetter(this, "dbConnection", function() {
   376   let dbFile;
   378   try {
   379     dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone();
   380     dbFile.append("formhistory.sqlite");
   381     log("Opening database at " + dbFile.path);
   383     _dbConnection = Services.storage.openUnsharedDatabase(dbFile);
   384     dbInit();
   385   } catch (e if e.result == Cr.NS_ERROR_FILE_CORRUPTED) {
   386     dbCleanup(dbFile);
   387     _dbConnection = Services.storage.openUnsharedDatabase(dbFile);
   388     dbInit();
   389   }
   391   return _dbConnection;
   392 });
   395 let dbStmts = new Map();
   397 /*
   398  * dbCreateAsyncStatement
   399  *
   400  * Creates a statement, wraps it, and then does parameter replacement
   401  */
   402 function dbCreateAsyncStatement(aQuery, aParams, aBindingArrays) {
   403   if (!aQuery)
   404     return null;
   406   let stmt = dbStmts.get(aQuery);
   407   if (!stmt) {
   408     log("Creating new statement for query: " + aQuery);
   409     stmt = dbConnection.createAsyncStatement(aQuery);
   410     dbStmts.set(aQuery, stmt);
   411   }
   413   if (aBindingArrays) {
   414     let bindingArray = aBindingArrays.get(stmt);
   415     if (!bindingArray) {
   416       // first time using a particular statement in update
   417       bindingArray = stmt.newBindingParamsArray();
   418       aBindingArrays.set(stmt, bindingArray);
   419     }
   421     if (aParams) {
   422       let bindingParams = bindingArray.newBindingParams();
   423       for (let field in aParams) {
   424         bindingParams.bindByName(field, aParams[field]);
   425       }
   426       bindingArray.addParams(bindingParams);
   427     }
   428   } else {
   429     if (aParams) {
   430       for (let field in aParams) {
   431         stmt.params[field] = aParams[field];
   432       }
   433     }
   434   }
   436   return stmt;
   437 }
   439 /**
   440  * dbInit
   441  *
   442  * Attempts to initialize the database. This creates the file if it doesn't
   443  * exist, performs any migrations, etc.
   444  */
   445 function dbInit() {
   446   log("Initializing Database");
   448   if (!_dbConnection.tableExists("moz_formhistory")) {
   449     dbCreate();
   450     return;
   451   }
   453   // When FormHistory is released, we will no longer support the various schema versions prior to
   454   // this release that nsIFormHistory2 once did.
   455   let version = _dbConnection.schemaVersion;
   456   if (version < 3) {
   457     throw Components.Exception("DB version is unsupported.",
   458                                Cr.NS_ERROR_FILE_CORRUPTED);
   459   } else if (version != DB_SCHEMA_VERSION) {
   460     dbMigrate(version);
   461   }
   462 }
   464 function dbCreate() {
   465   log("Creating DB -- tables");
   466   for (let name in dbSchema.tables) {
   467     let table = dbSchema.tables[name];
   468     let tSQL = [[col, table[col]].join(" ") for (col in table)].join(", ");
   469     log("Creating table " + name + " with " + tSQL);
   470     _dbConnection.createTable(name, tSQL);
   471   }
   473   log("Creating DB -- indices");
   474   for (let name in dbSchema.indices) {
   475     let index = dbSchema.indices[name];
   476     let statement = "CREATE INDEX IF NOT EXISTS " + name + " ON " + index.table +
   477                     "(" + index.columns.join(", ") + ")";
   478     _dbConnection.executeSimpleSQL(statement);
   479   }
   481   _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
   482 }
   484 function dbMigrate(oldVersion) {
   485   log("Attempting to migrate from version " + oldVersion);
   487   if (oldVersion > DB_SCHEMA_VERSION) {
   488     log("Downgrading to version " + DB_SCHEMA_VERSION);
   489     // User's DB is newer. Sanity check that our expected columns are
   490     // present, and if so mark the lower version and merrily continue
   491     // on. If the columns are borked, something is wrong so blow away
   492     // the DB and start from scratch. [Future incompatible upgrades
   493     // should switch to a different table or file.]
   495     if (!dbAreExpectedColumnsPresent()) {
   496       throw Components.Exception("DB is missing expected columns",
   497                                  Cr.NS_ERROR_FILE_CORRUPTED);
   498     }
   500     // Change the stored version to the current version. If the user
   501     // runs the newer code again, it will see the lower version number
   502     // and re-upgrade (to fixup any entries the old code added).
   503     _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
   504     return;
   505   }
   507   // Note that migration is currently performed synchronously.
   508   _dbConnection.beginTransaction();
   510   try {
   511     for (let v = oldVersion + 1; v <= DB_SCHEMA_VERSION; v++) {
   512       this.log("Upgrading to version " + v + "...");
   513       Migrators["dbMigrateToVersion" + v]();
   514     }
   515   } catch (e) {
   516     this.log("Migration failed: "  + e);
   517     this.dbConnection.rollbackTransaction();
   518     throw e;
   519   }
   521   _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
   522   _dbConnection.commitTransaction();
   524   log("DB migration completed.");
   525 }
   527 var Migrators = {
   528   /*
   529    * Updates the DB schema to v3 (bug 506402).
   530    * Adds deleted form history table.
   531    */
   532   dbMigrateToVersion4: function dbMigrateToVersion4() {
   533     if (!_dbConnection.tableExists("moz_deleted_formhistory")) {
   534       let table = dbSchema.tables["moz_deleted_formhistory"];
   535       let tSQL = [[col, table[col]].join(" ") for (col in table)].join(", ");
   536       _dbConnection.createTable("moz_deleted_formhistory", tSQL);
   537     }
   538   }
   539 };
   541 /**
   542  * dbAreExpectedColumnsPresent
   543  *
   544  * Sanity check to ensure that the columns this version of the code expects
   545  * are present in the DB we're using.
   546  */
   547 function dbAreExpectedColumnsPresent() {
   548   for (let name in dbSchema.tables) {
   549     let table = dbSchema.tables[name];
   550     let query = "SELECT " +
   551                 [col for (col in table)].join(", ") +
   552                 " FROM " + name;
   553     try {
   554       let stmt = _dbConnection.createStatement(query);
   555       // (no need to execute statement, if it compiled we're good)
   556       stmt.finalize();
   557     } catch (e) {
   558       return false;
   559     }
   560   }
   562   log("verified that expected columns are present in DB.");
   563   return true;
   564 }
   566 /**
   567  * dbCleanup
   568  *
   569  * Called when database creation fails. Finalizes database statements,
   570  * closes the database connection, deletes the database file.
   571  */
   572 function dbCleanup(dbFile) {
   573   log("Cleaning up DB file - close & remove & backup");
   575   // Create backup file
   576   let backupFile = dbFile.leafName + ".corrupt";
   577   Services.storage.backupDatabaseFile(dbFile, backupFile);
   579   dbClose(false);
   580   dbFile.remove(false);
   581 }
   583 function dbClose(aShutdown) {
   584   log("dbClose(" + aShutdown + ")");
   586   if (aShutdown) {
   587     sendNotification("formhistory-shutdown", null);
   588   }
   590   // Connection may never have been created if say open failed but we still
   591   // end up calling dbClose as part of the rest of dbCleanup.
   592   if (!_dbConnection) {
   593     return;
   594   }
   596   log("dbClose finalize statements");
   597   for (let stmt of dbStmts.values()) {
   598     stmt.finalize();
   599   }
   601   dbStmts = new Map();
   603   let closed = false;
   604   _dbConnection.asyncClose(function () closed = true);
   606   if (!aShutdown) {
   607     let thread = Services.tm.currentThread;
   608     while (!closed) {
   609       thread.processNextEvent(true);
   610     }
   611   }
   612 }
   614 /**
   615  * updateFormHistoryWrite
   616  *
   617  * Constructs and executes database statements from a pre-processed list of
   618  * inputted changes.
   619  */
   620 function updateFormHistoryWrite(aChanges, aCallbacks) {
   621   log("updateFormHistoryWrite  " + aChanges.length);
   623   // pass 'now' down so that every entry in the batch has the same timestamp
   624   let now = Date.now() * 1000;
   626   // for each change, we either create and append a new storage statement to
   627   // stmts or bind a new set of parameters to an existing storage statement.
   628   // stmts and bindingArrays are updated when makeXXXStatement eventually
   629   // calls dbCreateAsyncStatement.
   630   let stmts = [];
   631   let notifications = [];
   632   let bindingArrays = new Map();
   634   for each (let change in aChanges) {
   635     let operation = change.op;
   636     delete change.op;
   637     let stmt;
   638     switch (operation) {
   639       case "remove":
   640         log("Remove from form history  " + change);
   641         let delStmt = makeMoveToDeletedStatement(change.guid, now, change, bindingArrays);
   642         if (delStmt && stmts.indexOf(delStmt) == -1)
   643           stmts.push(delStmt);
   644         if ("timeDeleted" in change)
   645           delete change.timeDeleted;
   646         stmt = makeRemoveStatement(change, bindingArrays);
   647         notifications.push([ "formhistory-remove", change.guid ]);
   648         break;
   649       case "update":
   650         log("Update form history " + change);
   651         let guid = change.guid;
   652         delete change.guid;
   653         // a special case for updating the GUID - the new value can be
   654         // specified in newGuid.
   655         if (change.newGuid) {
   656           change.guid = change.newGuid
   657           delete change.newGuid;
   658         }
   659         stmt = makeUpdateStatement(guid, change, bindingArrays);
   660         notifications.push([ "formhistory-update", guid ]);
   661         break;
   662       case "bump":
   663         log("Bump form history " + change);
   664         if (change.guid) {
   665           stmt = makeBumpStatement(change.guid, now, bindingArrays);
   666           notifications.push([ "formhistory-update", change.guid ]);
   667         } else {
   668           change.guid = generateGUID();
   669           stmt = makeAddStatement(change, now, bindingArrays);
   670           notifications.push([ "formhistory-add", change.guid ]);
   671         }
   672         break;
   673       case "add":
   674         log("Add to form history " + change);
   675         change.guid = generateGUID();
   676         stmt = makeAddStatement(change, now, bindingArrays);
   677         notifications.push([ "formhistory-add", change.guid ]);
   678         break;
   679       default:
   680         // We should've already guaranteed that change.op is one of the above
   681         throw Components.Exception("Invalid operation " + operation,
   682                                    Cr.NS_ERROR_ILLEGAL_VALUE);
   683     }
   685     // As identical statements are reused, only add statements if they aren't already present.
   686     if (stmt && stmts.indexOf(stmt) == -1) {
   687       stmts.push(stmt);
   688     }
   689   }
   691   for (let stmt of stmts) {
   692     stmt.bindParameters(bindingArrays.get(stmt));
   693   }
   695   let handlers = {
   696     handleCompletion : function(aReason) {
   697       if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
   698         for (let [notification, param] of notifications) {
   699           // We're either sending a GUID or nothing at all.
   700           sendNotification(notification, param);
   701         }
   702       }
   704       if (aCallbacks && aCallbacks.handleCompletion) {
   705         aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
   706       }
   707     },
   708     handleError : function(aError) {
   709       if (aCallbacks && aCallbacks.handleError) {
   710         aCallbacks.handleError(aError);
   711       }
   712     },
   713     handleResult : NOOP
   714   };
   716   dbConnection.executeAsync(stmts, stmts.length, handlers);
   717 }
   719 /**
   720  * Functions that expire entries in form history and shrinks database
   721  * afterwards as necessary initiated by expireOldEntries.
   722  */
   724 /**
   725  * expireOldEntriesDeletion
   726  *
   727  * Removes entries from database.
   728  */
   729 function expireOldEntriesDeletion(aExpireTime, aBeginningCount) {
   730   log("expireOldEntriesDeletion(" + aExpireTime + "," + aBeginningCount + ")");
   732   FormHistory.update([
   733     {
   734       op: "remove",
   735       lastUsedEnd : aExpireTime,
   736     }], {
   737       handleCompletion: function() {
   738         expireOldEntriesVacuum(aExpireTime, aBeginningCount);
   739       },
   740       handleError: function(aError) {
   741         log("expireOldEntriesDeletionFailure");
   742       }
   743   });
   744 }
   746 /**
   747  * expireOldEntriesVacuum
   748  *
   749  * Counts number of entries removed and shrinks database as necessary.
   750  */
   751 function expireOldEntriesVacuum(aExpireTime, aBeginningCount) {
   752   FormHistory.count({}, {
   753     handleResult: function(aEndingCount) {
   754       if (aBeginningCount - aEndingCount > 500) {
   755         log("expireOldEntriesVacuum");
   757         let stmt = dbCreateAsyncStatement("VACUUM");
   758         stmt.executeAsync({
   759           handleResult : NOOP,
   760           handleError : function(aError) {
   761             log("expireVacuumError");
   762           },
   763           handleCompletion : NOOP
   764         });
   765       }
   767       sendNotification("formhistory-expireoldentries", aExpireTime);
   768     },
   769     handleError: function(aError) {
   770       log("expireEndCountFailure");
   771     }
   772   });
   773 }
   775 this.FormHistory = {
   776   get enabled() Prefs.enabled,
   778   search : function formHistorySearch(aSelectTerms, aSearchData, aCallbacks) {
   779     // if no terms selected, select everything
   780     aSelectTerms = (aSelectTerms) ?  aSelectTerms : validFields;
   781     validateSearchData(aSearchData, "Search");
   783     let stmt = makeSearchStatement(aSearchData, aSelectTerms);
   785     let handlers = {
   786       handleResult : function(aResultSet) {
   787         let formHistoryFields = dbSchema.tables.moz_formhistory;
   788         for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
   789           let result = {};
   790           for each (let field in aSelectTerms) {
   791             result[field] = row.getResultByName(field);
   792           }
   794           if (aCallbacks && aCallbacks.handleResult) {
   795             aCallbacks.handleResult(result);
   796           }
   797         }
   798       },
   800       handleError : function(aError) {
   801         if (aCallbacks && aCallbacks.handleError) {
   802           aCallbacks.handleError(aError);
   803         }
   804       },
   806       handleCompletion : function searchCompletionHandler(aReason) {
   807         if (aCallbacks && aCallbacks.handleCompletion) {
   808           aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
   809         }
   810       }
   811     };
   813     stmt.executeAsync(handlers);
   814   },
   816   count : function formHistoryCount(aSearchData, aCallbacks) {
   817     validateSearchData(aSearchData, "Count");
   818     let stmt = makeCountStatement(aSearchData);
   819     let handlers = {
   820       handleResult : function countResultHandler(aResultSet) {
   821         let row = aResultSet.getNextRow();
   822         let count = row.getResultByName("numEntries");
   823         if (aCallbacks && aCallbacks.handleResult) {
   824           aCallbacks.handleResult(count);
   825         }
   826       },
   828       handleError : function(aError) {
   829         if (aCallbacks && aCallbacks.handleError) {
   830           aCallbacks.handleError(aError);
   831         }
   832       },
   834       handleCompletion : function searchCompletionHandler(aReason) {
   835         if (aCallbacks && aCallbacks.handleCompletion) {
   836           aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
   837         }
   838       }
   839     };
   841     stmt.executeAsync(handlers);
   842   },
   844   update : function formHistoryUpdate(aChanges, aCallbacks) {
   845     if (!Prefs.enabled) {
   846       return;
   847     }
   849     // Used to keep track of how many searches have been started. When that number
   850     // are finished, updateFormHistoryWrite can be called.
   851     let numSearches = 0;
   852     let completedSearches = 0;
   853     let searchFailed = false;
   855     function validIdentifier(change) {
   856       // The identifier is only valid if one of either the guid or the (fieldname/value) are set
   857       return Boolean(change.guid) != Boolean(change.fieldname && change.value);
   858     }
   860     if (!("length" in aChanges))
   861       aChanges = [aChanges];
   863     for each (let change in aChanges) {
   864       switch (change.op) {
   865         case "remove":
   866           validateSearchData(change, "Remove");
   867           continue;
   868         case "update":
   869           if (validIdentifier(change)) {
   870             validateOpData(change, "Update");
   871             if (change.guid) {
   872               continue;
   873             }
   874           } else {
   875             throw Components.Exception(
   876               "update op='update' does not correctly reference a entry.",
   877               Cr.NS_ERROR_ILLEGAL_VALUE);
   878           }
   879           break;
   880         case "bump":
   881           if (validIdentifier(change)) {
   882             validateOpData(change, "Bump");
   883             if (change.guid) {
   884               continue;
   885             }
   886           } else {
   887             throw Components.Exception(
   888               "update op='bump' does not correctly reference a entry.",
   889               Cr.NS_ERROR_ILLEGAL_VALUE);
   890           }
   891           break;
   892         case "add":
   893           if (change.guid) {
   894             throw Components.Exception(
   895               "op='add' cannot contain field 'guid'. Either use op='update' " +
   896                 "explicitly or make 'guid' undefined.",
   897               Cr.NS_ERROR_ILLEGAL_VALUE);
   898           } else if (change.fieldname && change.value) {
   899             validateOpData(change, "Add");
   900           }
   901           break;
   902         default:
   903           throw Components.Exception(
   904             "update does not recognize op='" + change.op + "'",
   905             Cr.NS_ERROR_ILLEGAL_VALUE);
   906       }
   908       numSearches++;
   909       let changeToUpdate = change;
   910       FormHistory.search(
   911         [ "guid" ],
   912         {
   913           fieldname : change.fieldname,
   914           value : change.value
   915         }, {
   916           foundResult : false,
   917           handleResult : function(aResult) {
   918             if (this.foundResult) {
   919               log("Database contains multiple entries with the same fieldname/value pair.");
   920               if (aCallbacks && aCallbacks.handleError) {
   921                 aCallbacks.handleError({
   922                   message :
   923                     "Database contains multiple entries with the same fieldname/value pair.",
   924                   result : 19 // Constraint violation
   925                 });
   926               }
   928               searchFailed = true;
   929               return;
   930             }
   932             this.foundResult = true;
   933             changeToUpdate.guid = aResult["guid"];
   934           },
   936           handleError : function(aError) {
   937             if (aCallbacks && aCallbacks.handleError) {
   938               aCallbacks.handleError(aError);
   939             }
   940           },
   942           handleCompletion : function(aReason) {
   943             completedSearches++;
   944             if (completedSearches == numSearches) {
   945               if (!aReason && !searchFailed) {
   946                 updateFormHistoryWrite(aChanges, aCallbacks);
   947               }
   948               else if (aCallbacks && aCallbacks.handleCompletion) {
   949                 aCallbacks.handleCompletion(1);
   950               }
   951             }
   952           }
   953         });
   954     }
   956     if (numSearches == 0) {
   957       // We don't have to wait for any statements to return.
   958       updateFormHistoryWrite(aChanges, aCallbacks);
   959     }
   960   },
   962   getAutoCompleteResults: function getAutoCompleteResults(searchString, params, aCallbacks) {
   963     // only do substring matching when the search string contains more than one character
   964     let searchTokens;
   965     let where = ""
   966     let boundaryCalc = "";
   967     if (searchString.length > 1) {
   968         searchTokens = searchString.split(/\s+/);
   970         // build up the word boundary and prefix match bonus calculation
   971         boundaryCalc = "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + (";
   972         // for each word, calculate word boundary weights for the SELECT clause and
   973         // add word to the WHERE clause of the query
   974         let tokenCalc = [];
   975         let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
   976         for (let i = 0; i < searchTokenCount; i++) {
   977             tokenCalc.push("(value LIKE :tokenBegin" + i + " ESCAPE '/') + " +
   978                             "(value LIKE :tokenBoundary" + i + " ESCAPE '/')");
   979             where += "AND (value LIKE :tokenContains" + i + " ESCAPE '/') ";
   980         }
   981         // add more weight if we have a traditional prefix match and
   982         // multiply boundary bonuses by boundary weight
   983         boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)";
   984     } else if (searchString.length == 1) {
   985         where = "AND (value LIKE :valuePrefix ESCAPE '/') ";
   986         boundaryCalc = "1";
   987         delete params.prefixWeight;
   988         delete params.boundaryWeight;
   989     } else {
   990         where = "";
   991         boundaryCalc = "1";
   992         delete params.prefixWeight;
   993         delete params.boundaryWeight;
   994     }
   996     params.now = Date.now() * 1000; // convert from ms to microseconds
   998     /* Three factors in the frecency calculation for an entry (in order of use in calculation):
   999      * 1) average number of times used - items used more are ranked higher
  1000      * 2) how recently it was last used - items used recently are ranked higher
  1001      * 3) additional weight for aged entries surviving expiry - these entries are relevant
  1002      *    since they have been used multiple times over a large time span so rank them higher
  1003      * The score is then divided by the bucket size and we round the result so that entries
  1004      * with a very similar frecency are bucketed together with an alphabetical sort. This is
  1005      * to reduce the amount of moving around by entries while typing.
  1006      */
  1008     let query = "/* do not warn (bug 496471): can't use an index */ " +
  1009                 "SELECT value, " +
  1010                 "ROUND( " +
  1011                     "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " +
  1012                     "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * "+
  1013                     "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " +
  1014                     ":bucketSize "+
  1015                 ", 3) AS frecency, " +
  1016                 boundaryCalc + " AS boundaryBonuses " +
  1017                 "FROM moz_formhistory " +
  1018                 "WHERE fieldname=:fieldname " + where +
  1019                 "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC";
  1021     let stmt = dbCreateAsyncStatement(query, params);
  1023     // Chicken and egg problem: Need the statement to escape the params we
  1024     // pass to the function that gives us the statement. So, fix it up now.
  1025     if (searchString.length >= 1)
  1026       stmt.params.valuePrefix = stmt.escapeStringForLIKE(searchString, "/") + "%";
  1027     if (searchString.length > 1) {
  1028       let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
  1029       for (let i = 0; i < searchTokenCount; i++) {
  1030         let escapedToken = stmt.escapeStringForLIKE(searchTokens[i], "/");
  1031         stmt.params["tokenBegin" + i] = escapedToken + "%";
  1032         stmt.params["tokenBoundary" + i] =  "% " + escapedToken + "%";
  1033         stmt.params["tokenContains" + i] = "%" + escapedToken + "%";
  1035     } else {
  1036       // no additional params need to be substituted into the query when the
  1037       // length is zero or one
  1040     let pending = stmt.executeAsync({
  1041       handleResult : function (aResultSet) {
  1042         for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
  1043           let value = row.getResultByName("value");
  1044           let frecency = row.getResultByName("frecency");
  1045           let entry = {
  1046             text :          value,
  1047             textLowerCase : value.toLowerCase(),
  1048             frecency :      frecency,
  1049             totalScore :    Math.round(frecency * row.getResultByName("boundaryBonuses"))
  1050           };
  1051           if (aCallbacks && aCallbacks.handleResult) {
  1052             aCallbacks.handleResult(entry);
  1055       },
  1057       handleError : function (aError) {
  1058         if (aCallbacks && aCallbacks.handleError) {
  1059           aCallbacks.handleError(aError);
  1061       },
  1063       handleCompletion : function (aReason) {
  1064         if (aCallbacks && aCallbacks.handleCompletion) {
  1065           aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
  1068     });
  1069     return pending;
  1070   },
  1072   get schemaVersion() {
  1073     return dbConnection.schemaVersion;
  1074   },
  1076   // This is used only so that the test can verify deleted table support.
  1077   get _supportsDeletedTable() {
  1078     return supportsDeletedTable;
  1079   },
  1080   set _supportsDeletedTable(val) {
  1081     supportsDeletedTable = val;
  1082   },
  1084   // The remaining methods are called by FormHistoryStartup.js
  1085   updatePrefs: function updatePrefs() {
  1086     Prefs.initialized = false;
  1087   },
  1089   expireOldEntries: function expireOldEntries() {
  1090     log("expireOldEntries");
  1092     // Determine how many days of history we're supposed to keep.
  1093     // Calculate expireTime in microseconds
  1094     let expireTime = (Date.now() - Prefs.expireDays * DAY_IN_MS) * 1000;
  1096     sendNotification("formhistory-beforeexpireoldentries", expireTime);
  1098     FormHistory.count({}, {
  1099       handleResult: function(aBeginningCount) {
  1100         expireOldEntriesDeletion(expireTime, aBeginningCount);
  1101       },
  1102       handleError: function(aError) {
  1103         log("expireStartCountFailure");
  1105     });
  1106   },
  1108   shutdown: function shutdown() { dbClose(true); }
  1109 };
  1111 // Prevent add-ons from redefining this API
  1112 Object.freeze(FormHistory);

mercurial