From 035f3913aaeb2a0c0355205b75a6eedea5a8b223 Mon Sep 17 00:00:00 2001 From: Gregory Szorc Date: Fri, 4 Jan 2013 15:36:59 -0800 Subject: [PATCH] Bug 813833 - Add a Promise-based API to mozIStorage/SQLite; r=mak, sr=Mossop --- testing/xpcshell/xpcshell.ini | 1 + toolkit/Makefile.in | 1 + toolkit/modules/Makefile.in | 18 + toolkit/modules/Sqlite.jsm | 648 ++++++++++++++++++ toolkit/modules/tests/Makefile.in | 16 + toolkit/modules/tests/xpcshell/test_sqlite.js | 259 +++++++ toolkit/modules/tests/xpcshell/xpcshell.ini | 5 + toolkit/toolkit-makefiles.sh | 2 + 8 files changed, 950 insertions(+) create mode 100644 toolkit/modules/Makefile.in create mode 100644 toolkit/modules/Sqlite.jsm create mode 100644 toolkit/modules/tests/Makefile.in create mode 100644 toolkit/modules/tests/xpcshell/test_sqlite.js create mode 100644 toolkit/modules/tests/xpcshell/xpcshell.ini diff --git a/testing/xpcshell/xpcshell.ini b/testing/xpcshell/xpcshell.ini index 2010a763dfb..9b1b573ad2d 100644 --- a/testing/xpcshell/xpcshell.ini +++ b/testing/xpcshell/xpcshell.ini @@ -56,6 +56,7 @@ skip-if = os == "android" [include:toolkit/forgetaboutsite/test/unit/xpcshell.ini] [include:toolkit/content/tests/unit/xpcshell.ini] [include:toolkit/identity/tests/unit/xpcshell.ini] +[include:toolkit/modules/tests/xpcshell/xpcshell.ini] [include:toolkit/mozapps/downloads/tests/unit/xpcshell.ini] [include:toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini] [include:toolkit/mozapps/extensions/test/xpcshell-unpack/xpcshell.ini] diff --git a/toolkit/Makefile.in b/toolkit/Makefile.in index f7f5ce2de6b..64312ff0147 100644 --- a/toolkit/Makefile.in +++ b/toolkit/Makefile.in @@ -17,6 +17,7 @@ PARALLEL_DIRS = \ forgetaboutsite \ identity \ locales \ + modules \ mozapps/downloads \ mozapps/extensions \ mozapps/handling \ diff --git a/toolkit/modules/Makefile.in b/toolkit/modules/Makefile.in new file mode 100644 index 00000000000..f35aa000278 --- /dev/null +++ b/toolkit/modules/Makefile.in @@ -0,0 +1,18 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DEPTH = @DEPTH@ +topsrcdir = @top_srcdir@ +srcdir = @srcdir@ +VPATH = @srcdir@ + +include $(DEPTH)/config/autoconf.mk + +TEST_DIRS += tests + +EXTRA_JS_MODULES := \ + Sqlite.jsm \ + $(NULL) + +include $(topsrcdir)/config/rules.mk diff --git a/toolkit/modules/Sqlite.jsm b/toolkit/modules/Sqlite.jsm new file mode 100644 index 00000000000..bdaf644b076 --- /dev/null +++ b/toolkit/modules/Sqlite.jsm @@ -0,0 +1,648 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "Sqlite", +]; + +const {interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/commonjs/promise/core.js"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://services-common/log4moz.js"); + +XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", + "resource://services-common/utils.js"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + + +// Counts the number of created connections per database basename(). This is +// used for logging to distinguish connection instances. +let connectionCounters = {}; + + +/** + * Opens a connection to a SQLite database. + * + * The following parameters can control the connection: + * + * path -- (string) The filesystem path of the database file to open. If the + * file does not exist, a new database will be created. + * + * sharedMemoryCache -- (bool) Whether multiple connections to the database + * share the same memory cache. Sharing the memory cache likely results + * in less memory utilization. However, sharing also requires connections + * to obtain a lock, possibly making database access slower. Defaults to + * true. + * + * + * FUTURE options to control: + * + * special named databases + * pragma TEMP STORE = MEMORY + * TRUNCATE JOURNAL + * SYNCHRONOUS = full + * + * @param options + * (Object) Parameters to control connection and open options. + * + * @return Promise + */ +function openConnection(options) { + let log = Log4Moz.repository.getLogger("Sqlite.ConnectionOpener"); + + if (!options.path) { + throw new Error("path not specified in connection options."); + } + + // Retains absolute paths and normalizes relative as relative to profile. + let path = OS.Path.join(OS.Constants.Path.profileDir, options.path); + + let sharedMemoryCache = "sharedMemoryCache" in options ? + options.sharedMemoryCache : true; + + let file = FileUtils.File(path); + let openDatabaseFn = sharedMemoryCache ? + Services.storage.openDatabase : + Services.storage.openUnsharedDatabase; + + let basename = OS.Path.basename(path); + + if (!connectionCounters[basename]) { + connectionCounters[basename] = 1; + } + + let number = connectionCounters[basename]++; + let identifier = basename + "#" + number; + + log.info("Opening database: " + path + " (" + identifier + ")"); + try { + let connection = openDatabaseFn(file); + + if (!connection.connectionReady) { + log.warn("Connection is not ready."); + return Promise.reject(new Error("Connection is not ready.")); + } + + return Promise.resolve(new OpenedConnection(connection, basename, number)); + } catch (ex) { + log.warn("Could not open database: " + CommonUtils.exceptionStr(ex)); + return Promise.reject(ex); + } +} + + +/** + * Handle on an opened SQLite database. + * + * This is essentially a glorified wrapper around mozIStorageConnection. + * However, it offers some compelling advantages. + * + * The main functions on this type are `execute` and `executeCached`. These are + * ultimately how all SQL statements are executed. It's worth explaining their + * differences. + * + * `execute` is used to execute one-shot SQL statements. These are SQL + * statements that are executed one time and then thrown away. They are useful + * for dynamically generated SQL statements and clients who don't care about + * performance (either their own or wasting resources in the overall + * application). Because of the performance considerations, it is recommended + * to avoid `execute` unless the statement you are executing will only be + * executed once or seldomly. + * + * `executeCached` is used to execute a statement that will presumably be + * executed multiple times. The statement is parsed once and stuffed away + * inside the connection instance. Subsequent calls to `executeCached` will not + * incur the overhead of creating a new statement object. This should be used + * in preference to `execute` when a specific SQL statement will be executed + * multiple times. + * + * Instances of this type are not meant to be created outside of this file. + * Instead, first open an instance of `UnopenedSqliteConnection` and obtain + * an instance of this type by calling `open`. + * + * FUTURE IMPROVEMENTS + * + * Ability to enqueue operations. Currently there can be race conditions, + * especially as far as transactions are concerned. It would be nice to have + * an enqueueOperation(func) API that serially executes passed functions. + * + * Support for SAVEPOINT (named/nested transactions) might be useful. + * + * @param connection + * (mozIStorageConnection) Underlying SQLite connection. + * @param basename + * (string) The basename of this database name. Used for logging. + * @param number + * (Number) The connection number to this database. + */ +function OpenedConnection(connection, basename, number) { + let log = Log4Moz.repository.getLogger("Sqlite.Connection." + basename); + + // Automatically prefix all log messages with the identifier. + for (let level in Log4Moz.Level) { + if (level == "Desc") { + continue; + } + + let lc = level.toLowerCase(); + log[lc] = function (msg) { + return Log4Moz.Logger.prototype[lc].call(log, "Conn #" + number + ": " + msg); + } + } + + this._log = log; + + this._log.info("Opened"); + + this._connection = connection; + this._open = true; + + this._cachedStatements = new Map(); + this._anonymousStatements = new Map(); + this._anonymousCounter = 0; + this._inProgressStatements = new Map(); + this._inProgressCounter = 0; + + this._inProgressTransaction = null; +} + +OpenedConnection.prototype = Object.freeze({ + TRANSACTION_DEFERRED: Ci.mozIStorageConnection.TRANSACTION_DEFERRED, + TRANSACTION_IMMEDIATE: Ci.mozIStorageConnection.TRANSACTION_IMMEDIATE, + TRANSACTION_EXCLUSIVE: Ci.mozIStorageConnection.TRANSACTION_EXCLUSIVE, + + get connectionReady() { + return this._open && this._connection.connectionReady; + }, + + /** + * The row ID from the last INSERT operation. + * + * Because all statements are executed asynchronously, this could + * return unexpected results if multiple statements are performed in + * parallel. It is the caller's responsibility to schedule + * appropriately. + * + * It is recommended to only use this within transactions (which are + * handled as sequential statements via Tasks). + */ + get lastInsertRowID() { + this._ensureOpen(); + return this._connection.lastInsertRowID; + }, + + /** + * The number of rows that were changed, inserted, or deleted by the + * last operation. + * + * The same caveats regarding asynchronous execution for + * `lastInsertRowID` also apply here. + */ + get affectedRows() { + this._ensureOpen(); + return this._connection.affectedRows; + }, + + /** + * The integer schema version of the database. + * + * This is 0 if not schema version has been set. + */ + get schemaVersion() { + this._ensureOpen(); + return this._connection.schemaVersion; + }, + + set schemaVersion(value) { + this._ensureOpen(); + this._connection.schemaVersion = value; + }, + + /** + * Close the database connection. + * + * This must be performed when you are finished with the database. + * + * Closing the database connection has the side effect of forcefully + * cancelling all active statements. Therefore, callers should ensure that + * all active statements have completed before closing the connection, if + * possible. + * + * The returned promise will be resolved once the connection is closed. + * + * IMPROVEMENT: Resolve the promise to a closed connection which can be + * reopened. + * + * @return Promise<> + */ + close: function () { + if (!this._connection) { + return Promise.resolve(); + } + + // Abort in-progress transaction. + if (this._inProgressTransaction) { + this._log.warn("Transaction in progress at time of close."); + try { + this._connection.rollbackTransaction(); + } catch (ex) { + this._log.warn("Error rolling back transaction: " + + CommonUtils.exceptionStr(ex)); + } + this._inProgressTransaction.reject(new Error("Connection being closed.")); + this._inProgressTransaction = null; + } + + // Cancel any in-progress statements. + for (let [k, statement] of this._inProgressStatements) { + statement.cancel(); + } + this._inProgressStatements.clear(); + + // Next we finalize all active statements. + for (let [k, statement] of this._anonymousStatements) { + statement.finalize(); + } + this._anonymousStatements.clear(); + + for (let [k, statement] of this._cachedStatements) { + statement.finalize(); + } + this._cachedStatements.clear(); + + // This guards against operations performed between the call to this + // function and asyncClose() finishing. See also bug 726990. + this._open = false; + + let deferred = Promise.defer(); + + this._connection.asyncClose({ + complete: function () { + this._log.info("Closed"); + this._connection = null; + deferred.resolve(); + }.bind(this), + }); + + return deferred.promise; + }, + + /** + * Execute a SQL statement and cache the underlying statement object. + * + * This function executes a SQL statement and also caches the underlying + * derived statement object so subsequent executions are faster and use + * less resources. + * + * This function optionally binds parameters to the statement as well as + * optionally invokes a callback for every row retrieved. + * + * By default, no parameters are bound and no callback will be invoked for + * every row. + * + * Bound parameters can be defined as an Array of positional arguments or + * an object mapping named parameters to their values. If there are no bound + * parameters, the caller can pass nothing or null for this argument. + * + * Callers are encouraged to pass objects rather than Arrays for bound + * parameters because they prevent foot guns. With positional arguments, it + * is simple to modify the parameter count or positions without fixing all + * users of the statement. Objects/named parameters are a little safer + * because changes in order alone won't result in bad things happening. + * + * When `onRow` is not specified, all returned rows are buffered before the + * returned promise is resolved. For INSERT or UPDATE statements, this has + * no effect because no rows are returned from these. However, it has + * implications for SELECT statements. + * + * If your SELECT statement could return many rows or rows with large amounts + * of data, for performance reasons it is recommended to pass an `onRow` + * handler. Otherwise, the buffering may consume unacceptable amounts of + * resources. + * + * If a `StopIteration` is thrown during execution of an `onRow` handler, + * the execution of the statement is immediately cancelled. Subsequent + * rows will not be processed and no more `onRow` invocations will be made. + * The promise is resolved immediately. + * + * If a non-`StopIteration` exception is thrown by the `onRow` handler, the + * exception is logged and processing of subsequent rows occurs as if nothing + * happened. The promise is still resolved (not rejected). + * + * The return value is a promise that will be resolved when the statement + * has completed fully. + * + * The promise will be rejected with an `Error` instance if the statement + * did not finish execution fully. The `Error` may have an `errors` property. + * If defined, it will be an Array of objects describing individual errors. + * Each object has the properties `result` and `message`. `result` is a + * numeric error code and `message` is a string description of the problem. + * + * @param name + * (string) The name of the registered statement to execute. + * @param params optional + * (Array or object) Parameters to bind. + * @param onRow optional + * (function) Callback to receive each row from result. + */ + executeCached: function (sql, params=null, onRow=null) { + this._ensureOpen(); + + if (!sql) { + throw new Error("sql argument is empty."); + } + + let statement = this._cachedStatements.get(sql); + if (!statement) { + statement = this._connection.createAsyncStatement(sql); + this._cachedStatements.set(sql, statement); + } + + return this._executeStatement(sql, statement, params, onRow); + }, + + /** + * Execute a one-shot SQL statement. + * + * If you find yourself feeding the same SQL string in this function, you + * should *not* use this function and instead use `executeCached`. + * + * See `executeCached` for the meaning of the arguments and extended usage info. + * + * @param sql + * (string) SQL to execute. + * @param params optional + * (Array or Object) Parameters to bind to the statement. + * @param onRow optional + * (function) Callback to receive result of a single row. + */ + execute: function (sql, params=null, onRow=null) { + if (typeof(sql) != "string") { + throw new Error("Must define SQL to execute as a string: " + sql); + } + + this._ensureOpen(); + + let statement = this._connection.createAsyncStatement(sql); + let index = this._anonymousCounter++; + + this._anonymousStatements.set(index, statement); + + let deferred = Promise.defer(); + + this._executeStatement(sql, statement, params, onRow).then( + function onResult(rows) { + this._anonymousStatements.delete(index); + statement.finalize(); + deferred.resolve(rows); + }.bind(this), + + function onError(error) { + this._anonymousStatements.delete(index); + statement.finalize(); + deferred.reject(error); + }.bind(this) + ); + + return deferred.promise; + }, + + /** + * Whether a transaction is currently in progress. + */ + get transactionInProgress() { + return this._open && this._connection.transactionInProgress; + }, + + /** + * Perform a transaction. + * + * A transaction is specified by a user-supplied function that is a + * generator function which can be used by Task.jsm's Task.spawn(). The + * function receives this connection instance as its argument. + * + * The supplied function is expected to yield promises. These are often + * promises created by calling `execute` and `executeCached`. If the + * generator is exhausted without any errors being thrown, the + * transaction is committed. If an error occurs, the transaction is + * rolled back. + * + * The returned value from this function is a promise that will be resolved + * once the transaction has been committed or rolled back. The promise will + * be resolved to whatever value the supplied function resolves to. If + * the transaction is rolled back, the promise is rejected. + * + * @param func + * (function) What to perform as part of the transaction. + * @param type optional + * One of the TRANSACTION_* constants attached to this type. + */ + executeTransaction: function (func, type=this.TRANSACTION_DEFERRED) { + this._ensureOpen(); + + if (this.transactionInProgress) { + throw new Error("A transaction is already active. Only one transaction " + + "can be active at a time."); + } + + this._log.debug("Beginning transaction"); + this._connection.beginTransactionAs(type); + + let deferred = Promise.defer(); + this._inProgressTransaction = deferred; + + Task.spawn(func(this)).then( + function onSuccess (result) { + this._connection.commitTransaction(); + this._inProgressTransaction = null; + this._log.debug("Transaction committed."); + + deferred.resolve(result); + }.bind(this), + + function onError (error) { + this._log.warn("Error during transaction. Rolling back: " + + CommonUtils.exceptionStr(error)); + this._connection.rollbackTransaction(); + this._inProgressTransaction = null; + + deferred.reject(error); + }.bind(this) + ); + + return deferred.promise; + }, + + /** + * Whether a table exists in the database. + * + * IMPROVEMENT: Look for temporary tables. + * + * @param name + * (string) Name of the table. + * + * @return Promise + */ + tableExists: function (name) { + return this.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + [name]) + .then(function onResult(rows) { + return Promise.resolve(rows.length > 0); + } + ); + }, + + /** + * Whether a named index exists. + * + * IMPROVEMENT: Look for indexes in temporary tables. + * + * @param name + * (string) Name of the index. + * + * @return Promise + */ + indexExists: function (name) { + return this.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + [name]) + .then(function onResult(rows) { + return Promise.resolve(rows.length > 0); + } + ); + }, + + _executeStatement: function (sql, statement, params, onRow) { + if (statement.state != statement.MOZ_STORAGE_STATEMENT_READY) { + throw new Error("Statement is not ready for execution."); + } + + if (onRow && typeof(onRow) != "function") { + throw new Error("onRow must be a function. Got: " + onRow); + } + + if (Array.isArray(params)) { + for (let i = 0; i < params.length; i++) { + statement.bindByIndex(i, params[i]); + } + } else if (params && typeof(params) == "object") { + for (let k in params) { + statement.bindByName(k, params[k]); + } + } else if (params) { + throw new Error("Invalid type for bound parameters. Expected Array or " + + "object. Got: " + params); + } + + let index = this._inProgressCounter++; + + let deferred = Promise.defer(); + let userCancelled = false; + let errors = []; + let rows = []; + + // Don't incur overhead for serializing params unless the messages go + // somewhere. + if (this._log.level <= Log4Moz.Level.Trace) { + let msg = "Stmt #" + index + " " + sql; + + if (params) { + msg += " - " + JSON.stringify(params); + } + this._log.trace(msg); + } else { + this._log.debug("Stmt #" + index + " starting"); + } + + let self = this; + let pending = statement.executeAsync({ + handleResult: function (resultSet) { + // .cancel() may not be immediate and handleResult() could be called + // after a .cancel(). + for (let row = resultSet.getNextRow(); row && !userCancelled; row = resultSet.getNextRow()) { + if (!onRow) { + rows.push(row); + continue; + } + + try { + onRow(row); + } catch (e if e instanceof StopIteration) { + userCancelled = true; + pending.cancel(); + break; + } catch (ex) { + self._log.warn("Exception when calling onRow callback: " + + CommonUtils.exceptionStr(ex)); + } + } + }, + + handleError: function (error) { + self._log.info("Error when executing SQL (" + error.result + "): " + + error.message); + errors.push(error); + }, + + handleCompletion: function (reason) { + self._log.debug("Stmt #" + index + " finished"); + self._inProgressStatements.delete(index); + + switch (reason) { + case Ci.mozIStorageStatementCallback.REASON_FINISHED: + // If there is an onRow handler, we always resolve to null. + let result = onRow ? null : rows; + deferred.resolve(result); + break; + + case Ci.mozIStorageStatementCallback.REASON_CANCELLED: + // It is not an error if the user explicitly requested cancel via + // the onRow handler. + if (userCancelled) { + let result = onRow ? null : rows; + deferred.resolve(result); + } else { + deferred.reject(new Error("Statement was cancelled.")); + } + + break; + + case Ci.mozIStorageStatementCallback.REASON_ERROR: + let error = new Error("Error(s) encountered during statement execution."); + error.errors = errors; + deferred.reject(error); + break; + + default: + deferred.reject(new Error("Unknown completion reason code: " + + reason)); + break; + } + }, + }); + + this._inProgressStatements.set(index, pending); + + return deferred.promise; + }, + + _ensureOpen: function () { + if (!this._open) { + throw new Error("Connection is not open."); + } + }, +}); + +this.Sqlite = { + openConnection: openConnection, +}; diff --git a/toolkit/modules/tests/Makefile.in b/toolkit/modules/tests/Makefile.in new file mode 100644 index 00000000000..2332dc0cc9c --- /dev/null +++ b/toolkit/modules/tests/Makefile.in @@ -0,0 +1,16 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DEPTH = @DEPTH@ +topsrcdir = @top_srcdir@ +srcdir = @srcdir@ +VPATH = @srcdir@ +relativesrcdir = @relativesrcdir@ + +include $(DEPTH)/config/autoconf.mk + +XPCSHELL_TESTS = xpcshell + +include $(topsrcdir)/config/rules.mk + diff --git a/toolkit/modules/tests/xpcshell/test_sqlite.js b/toolkit/modules/tests/xpcshell/test_sqlite.js new file mode 100644 index 00000000000..5c860b5ca88 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_sqlite.js @@ -0,0 +1,259 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const {utils: Cu} = Components; + +do_get_profile(); + +Cu.import("resource://gre/modules/commonjs/promise/core.js"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Sqlite.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + + +function getConnection(dbName) { + let path = dbName + ".sqlite"; + + return Sqlite.openConnection({path: path}); +} + +function getDummyDatabase(name) { + const TABLES = { + dirs: "id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT", + files: "id INTEGER PRIMARY KEY AUTOINCREMENT, dir_id INTEGER, path TEXT", + }; + + let c = yield getConnection(name); + + for (let [k, v] in Iterator(TABLES)) { + yield c.execute("CREATE TABLE " + k + "(" + v + ")"); + } + + throw new Task.Result(c); +} + + +function run_test() { + Cu.import("resource://testing-common/services-common/logging.js"); + initTestLogging("Trace"); + + run_next_test(); +} + +add_task(function test_open_normal() { + let c = yield Sqlite.openConnection({path: "test_open_normal.sqlite"}); + yield c.close(); +}); + +add_task(function test_open_unshared() { + let path = OS.Path.join(OS.Constants.Path.profileDir, "test_open_unshared.sqlite"); + + let c = yield Sqlite.openConnection({path: path, sharedMemoryCache: false}); + yield c.close(); +}); + +add_task(function test_get_dummy_database() { + let db = yield getDummyDatabase("get_dummy_database"); + + do_check_eq(typeof(db), "object"); + yield db.close(); +}); + +add_task(function test_simple_insert() { + let c = yield getDummyDatabase("simple_insert"); + + let result = yield c.execute("INSERT INTO dirs VALUES (NULL, 'foo')"); + do_check_true(Array.isArray(result)); + do_check_eq(result.length, 0); + yield c.close(); +}); + +add_task(function test_simple_bound_array() { + let c = yield getDummyDatabase("simple_bound_array"); + + let result = yield c.execute("INSERT INTO dirs VALUES (?, ?)", [1, "foo"]); + do_check_eq(result.length, 0); + yield c.close(); +}); + +add_task(function test_simple_bound_object() { + let c = yield getDummyDatabase("simple_bound_object"); + let result = yield c.execute("INSERT INTO dirs VALUES (:id, :path)", + {id: 1, path: "foo"}); + do_check_eq(result.length, 0); + do_check_eq(c.lastInsertRowID, 1); + do_check_eq(c.affectedRows, 1); + yield c.close(); +}); + +// This is mostly a sanity test to ensure simple executions work. +add_task(function test_simple_insert_then_select() { + let c = yield getDummyDatabase("simple_insert_then_select"); + + yield c.execute("INSERT INTO dirs VALUES (NULL, 'foo')"); + yield c.execute("INSERT INTO dirs (path) VALUES (?)", ["bar"]); + + let result = yield c.execute("SELECT * FROM dirs"); + do_check_eq(result.length, 2); + + let i = 0; + for (let row of result) { + i++; + + do_check_eq(row.numEntries, 2); + do_check_eq(row.getResultByIndex(0), i); + + let expected = {1: "foo", 2: "bar"}[i]; + do_check_eq(row.getResultByName("path"), expected); + } + + yield c.close(); +}); + +add_task(function test_repeat_execution() { + let c = yield getDummyDatabase("repeat_execution"); + + let sql = "INSERT INTO dirs (path) VALUES (:path)"; + yield c.executeCached(sql, {path: "foo"}); + yield c.executeCached(sql); + + let result = yield c.execute("SELECT * FROM dirs"); + + do_check_eq(result.length, 2); + + yield c.close(); +}); + +add_task(function test_table_exists() { + let c = yield getDummyDatabase("table_exists"); + + do_check_false(yield c.tableExists("does_not_exist")); + do_check_true(yield c.tableExists("dirs")); + do_check_true(yield c.tableExists("files")); + + yield c.close(); +}); + +add_task(function test_index_exists() { + let c = yield getDummyDatabase("index_exists"); + + do_check_false(yield c.indexExists("does_not_exist")); + + yield c.execute("CREATE INDEX my_index ON dirs (path)"); + do_check_true(yield c.indexExists("my_index")); + + yield c.close(); +}); + +add_task(function test_close_cached() { + let c = yield getDummyDatabase("close_cached"); + + yield c.executeCached("SELECT * FROM dirs"); + yield c.executeCached("SELECT * FROM files"); + + yield c.close(); +}); + +add_task(function test_execute_invalid_statement() { + let c = yield getDummyDatabase("invalid_statement"); + + let deferred = Promise.defer(); + + c.execute("SELECT invalid FROM unknown").then(do_throw, function onError(error) { + deferred.resolve(); + }); + + yield deferred.promise; + yield c.close(); +}); + +add_task(function test_on_row_exception_ignored() { + let c = yield getDummyDatabase("on_row_exception_ignored"); + + let sql = "INSERT INTO dirs (path) VALUES (?)"; + for (let i = 0; i < 10; i++) { + yield c.executeCached(sql, ["dir" + i]); + } + + let i = 0; + yield c.execute("SELECT * FROM DIRS", null, function onRow(row) { + i++; + + throw new Error("Some silly error."); + }); + + do_check_eq(i, 10); + + yield c.close(); +}); + +// Ensure StopIteration during onRow causes processing to stop. +add_task(function test_on_row_stop_iteration() { + let c = yield getDummyDatabase("on_row_stop_iteration"); + + let sql = "INSERT INTO dirs (path) VALUES (?)"; + for (let i = 0; i < 10; i++) { + yield c.executeCached(sql, ["dir" + i]); + } + + let i = 0; + let result = yield c.execute("SELECT * FROM dirs", null, function onRow(row) { + i++; + + if (i == 5) { + throw StopIteration; + } + }); + + do_check_null(result); + do_check_eq(i, 5); + + yield c.close(); +}); + +add_task(function test_execute_transaction_success() { + let c = yield getDummyDatabase("execute_transaction_success"); + + do_check_false(c.transactionInProgress); + + yield c.executeTransaction(function transaction(conn) { + do_check_eq(c, conn); + do_check_true(conn.transactionInProgress); + + yield conn.execute("INSERT INTO dirs (path) VALUES ('foo')"); + }); + + do_check_false(c.transactionInProgress); + let rows = yield c.execute("SELECT * FROM dirs"); + do_check_true(Array.isArray(rows)); + do_check_eq(rows.length, 1); + + yield c.close(); +}); + +add_task(function test_execute_transaction_rollback() { + let c = yield getDummyDatabase("execute_transaction_rollback"); + + let deferred = Promise.defer(); + + c.executeTransaction(function transaction(conn) { + yield conn.execute("INSERT INTO dirs (path) VALUES ('foo')"); + print("Expecting error with next statement."); + yield conn.execute("INSERT INTO invalid VALUES ('foo')"); + + // We should never get here. + do_throw(); + }).then(do_throw, function onError(error) { + deferred.resolve(); + }); + + yield deferred.promise; + + let rows = yield c.execute("SELECT * FROM dirs"); + do_check_eq(rows.length, 0); + + yield c.close(); +}); + diff --git a/toolkit/modules/tests/xpcshell/xpcshell.ini b/toolkit/modules/tests/xpcshell/xpcshell.ini new file mode 100644 index 00000000000..908d784e19a --- /dev/null +++ b/toolkit/modules/tests/xpcshell/xpcshell.ini @@ -0,0 +1,5 @@ +[DEFAULT] +head = +tail = + +[test_sqlite.js] diff --git a/toolkit/toolkit-makefiles.sh b/toolkit/toolkit-makefiles.sh index a232f1bfbe2..7c31b50a984 100644 --- a/toolkit/toolkit-makefiles.sh +++ b/toolkit/toolkit-makefiles.sh @@ -491,6 +491,8 @@ MAKEFILES_xulapp=" toolkit/forgetaboutsite/test/browser/Makefile toolkit/identity/Makefile toolkit/locales/Makefile + toolkit/modules/Makefile + toolkit/modules/tests/Makefile toolkit/mozapps/downloads/Makefile toolkit/mozapps/extensions/Makefile toolkit/mozapps/handling/Makefile