/* ***** BEGIN LICENSE BLOCK ***** Copyright © 2009 Center for History and New Media George Mason University, Fairfax, Virginia, USA http://zotero.org This file is part of Zotero. Zotero is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Zotero is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with Zotero. If not, see . ***** END LICENSE BLOCK ***** */ "use strict"; // Exclusive locking mode (default) prevents access to Zotero database while Zotero is open // and speeds up DB access (http://www.sqlite.org/pragma.html#pragma_locking_mode). // Normal mode is more convenient for development, but risks database corruption, particularly if // the same database is accessed simultaneously by multiple Zotero instances. const DB_LOCK_EXCLUSIVE = true; Zotero.DBConnection = function(dbName) { if (!dbName) { throw ('DB name not provided in Zotero.DBConnection()'); } this.MAX_BOUND_PARAMETERS = 999; Components.utils.import("resource://gre/modules/Sqlite.jsm", this); this.skipBackup = false; this.transactionVacuum = false; // JS Date this.__defineGetter__('transactionDate', function () { if (this._transactionDate) { this._lastTransactionDate = this._transactionDate; return this._transactionDate; } throw new Error("Transaction not in progress"); // Use second granularity rather than millisecond // for comparison purposes var d = new Date(Math.floor(new Date / 1000) * 1000); this._lastTransactionDate = d; return d; }); // SQL DATETIME this.__defineGetter__('transactionDateTime', function () { var d = this.transactionDate; return Zotero.Date.dateToSQL(d, true); }); // Unix timestamp this.__defineGetter__('transactionTimestamp', function () { var d = this.transactionDate; return Zotero.Date.toUnixTimestamp(d); }); // Private members this._dbName = dbName; this._shutdown = false; this._connection = null; this._transactionID = null; this._transactionDate = null; this._lastTransactionDate = null; this._transactionRollback = false; this._transactionNestingLevel = 0; this._callbacks = { begin: [], commit: [], rollback: [], current: { commit: [], rollback: [] } }; this._dbIsCorrupt = null this._transactionPromise = null; } ///////////////////////////////////////////////////////////////// // // Public methods // ///////////////////////////////////////////////////////////////// /** * Test a read-only connection to the database, throwing any errors that occur * * @return void */ Zotero.DBConnection.prototype.test = function () { return this._getConnectionAsync().return(); } Zotero.DBConnection.prototype.getAsyncStatement = Zotero.Promise.coroutine(function* (sql) { var conn = yield this._getConnectionAsync(); conn = conn._connection; try { this._debug(sql, 4); return conn.createAsyncStatement(sql); } catch (e) { var dberr = (conn.lastErrorString != 'not an error') ? ' [ERROR: ' + conn.lastErrorString + ']' : ''; throw new Error(e + ' [QUERY: ' + sql + ']' + dberr); } }); Zotero.DBConnection.prototype.parseQueryAndParams = function (sql, params) { // If single scalar value, wrap in an array if (!Array.isArray(params)) { if (typeof params == 'string' || typeof params == 'number' || params === null) { params = [params]; } else { params = []; } } // Otherwise, since we might make changes, only work on a copy of the array else { params = params.concat(); } // Find placeholders if (params.length) { let matches = sql.match(/\?\d*/g); if (!matches) { throw new Error("Parameters provided for query without placeholders " + "[QUERY: " + sql + "]"); } else { // Count numbered parameters (?1) properly let num = 0; let numbered = {}; for (let i = 0; i < matches.length; i++) { let match = matches[i]; if (match == '?') { num++; } else { numbered[match] = true; } } num += Object.keys(numbered).length; if (params.length != num) { throw new Error("Incorrect number of parameters provided for query " + "(" + params.length + ", expecting " + num + ") " + "[QUERY: " + sql + "]"); } } // First, determine the type of query using first word let queryMethod = sql.match(/^[^\s\(]*/)[0].toLowerCase(); // Reset lastIndex, since regexp isn't recompiled dynamically let placeholderRE = /\s*[=,(]\s*\?/g; for (var i=0; i "a" // // '1' => "b" // // '2' => "c" // // '3' => "d" // // let rows = yield Zotero.DB.queryAsync("SELECT * FROM tmpFoo"); // for (let i=0; i} A promise for either the value or FALSE if no result */ Zotero.DBConnection.prototype.valueQueryAsync = Zotero.Promise.coroutine(function* (sql, params, options = {}) { try { let conn = this._getConnection(options) || (yield this._getConnectionAsync(options)); [sql, params] = this.parseQueryAndParams(sql, params); if (Zotero.Debug.enabled) { this.logQuery(sql, params); } let rows = yield conn.executeCached(sql, params); return rows.length ? rows[0].getResultByIndex(0) : false; } catch (e) { if (e.errors && e.errors[0]) { var eStr = e + ""; eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e; throw new Error(eStr + ' [QUERY: ' + sql + '] ' + (params ? '[PARAMS: ' + params.join(', ') + '] ' : '') + '[ERROR: ' + e.errors[0].message + ']'); } else { throw e; } } }); /** * @param {String} sql SQL statement to run * @param {Array|String|Integer} [params] SQL parameters to bind * @return {Promise} A promise for a proxied storage row */ Zotero.DBConnection.prototype.rowQueryAsync = function (sql, params) { return this.queryAsync(sql, params) .then(function (rows) { return rows.length ? rows[0] : false; }); }; /** * @param {String} sql SQL statement to run * @param {Array|String|Integer} [params] SQL parameters to bind * @return {Promise} A promise for an array of values in the column */ Zotero.DBConnection.prototype.columnQueryAsync = Zotero.Promise.coroutine(function* (sql, params, options = {}) { try { let conn = this._getConnection(options) || (yield this._getConnectionAsync(options)); [sql, params] = this.parseQueryAndParams(sql, params); if (Zotero.Debug.enabled) { this.logQuery(sql, params); } let rows = yield conn.executeCached(sql, params); var column = []; for (let i=0, len=rows.length; i 24) { numBackups = 24; } } if (Zotero.locked && !force) { this._debug("Zotero is locked -- skipping backup of DB '" + this._dbName + "'", 2); return false; } if (this._backupPromise && this._backupPromise.isPending()) { this._debug("Database " + this._dbName + " is already being backed up -- skipping", 2); return false; } // Start a promise that will be resolved when the backup is finished var resolveBackupPromise; yield this.waitForTransaction(); this._backupPromise = new Zotero.Promise(function () { resolveBackupPromise = arguments[0]; }); try { var corruptMarker = Zotero.getZoteroDatabase(this._dbName, 'is.corrupt'); if (this.skipBackup || Zotero.skipLoading) { this._debug("Skipping backup of database '" + this._dbName + "'", 1); return false; } else if (this._dbIsCorrupt || corruptMarker.exists()) { this._debug("Database '" + this._dbName + "' is marked as corrupt -- skipping backup", 1); return false; } var file = Zotero.getZoteroDatabase(this._dbName); // For standard backup, make sure last backup is old enough to replace if (!suffix && !force) { var backupFile = Zotero.getZoteroDatabase(this._dbName, 'bak'); if (yield OS.File.exists(backupFile.path)) { var currentDBTime = (yield OS.File.stat(file.path)).lastModificationDate; var lastBackupTime = (yield OS.File.stat(backupFile.path)).lastModificationDate; if (currentDBTime == lastBackupTime) { Zotero.debug("Database '" + this._dbName + "' hasn't changed -- skipping backup"); return; } var now = new Date(); var intervalMinutes = Zotero.Prefs.get('backup.interval'); var interval = intervalMinutes * 60 * 1000; if ((now - lastBackupTime) < interval) { Zotero.debug("Last backup of database '" + this._dbName + "' was less than " + intervalMinutes + " minutes ago -- skipping backup"); return; } } } this._debug("Backing up database '" + this._dbName + "'"); // Copy via a temporary file so we don't run into disk space issues // after deleting the old backup file var tmpFile = Zotero.getZoteroDatabase(this._dbName, 'tmp'); if (yield OS.File.exists(tmpFile.path)) { try { yield OS.File.remove(tmpFile.path); } catch (e) { if (e.name == 'NS_ERROR_FILE_ACCESS_DENIED') { alert("Cannot delete " + tmpFile.leafName); } throw (e); } } // Turn off DB locking before backup and reenable after, since otherwise // the lock is lost try { if (DB_LOCK_EXCLUSIVE) { yield this.queryAsync("PRAGMA locking_mode=NORMAL", false, { inBackup: true }); } storageService.backupDatabaseFile(file, tmpFile.leafName, file.parent); } catch (e) { Zotero.debug(e); Components.utils.reportError(e); return false; } finally { if (DB_LOCK_EXCLUSIVE) { yield this.queryAsync("PRAGMA locking_mode=EXCLUSIVE", false, { inBackup: true }); } } // Open the backup to check for corruption try { var connection = storageService.openDatabase(tmpFile); } catch (e) { this._debug("Database file '" + tmpFile.leafName + "' is corrupt -- skipping backup"); if (yield OS.File.exists(tmpFile.path)) { yield OS.File.remove(tmpFile.path); } return false; } finally { let resolve; connection.asyncClose({ complete: function () { resolve(); } }); yield new Zotero.Promise(function () { resolve = arguments[0]; }); } // Special backup if (!suffix && numBackups > 1) { // Remove oldest backup file var targetFile = Zotero.getZoteroDatabase(this._dbName, (numBackups - 1) + '.bak') if (yield OS.File.exists(targetFile.path)) { yield OS.File.remove(targetFile.path); } // Shift old versions up for (var i=(numBackups - 1); i>=1; i--) { var targetNum = i; var sourceNum = targetNum - 1; var targetFile = Zotero.getZoteroDatabase( this._dbName, targetNum + '.bak' ); var sourceFile = Zotero.getZoteroDatabase( this._dbName, sourceNum ? sourceNum + '.bak' : 'bak' ); if (!(yield OS.File.exists(sourceFile.path))) { continue; } Zotero.debug("Moving " + sourceFile.leafName + " to " + targetFile.leafName); yield OS.File.move(sourceFile.path, targetFile.path); } } var backupFile = Zotero.getZoteroDatabase( this._dbName, (suffix ? suffix + '.' : '') + 'bak' ); // Remove old backup file if (yield OS.File.exists(backupFile.path)) { OS.File.remove(backupFile.path); } yield OS.File.move(tmpFile.path, backupFile.path); Zotero.debug("Backed up to " + backupFile.leafName); return true; } finally { resolveBackupPromise(); } }); /** * Determine the necessary data type for SQLite parameter binding * * @return int 0 for string, 32 for int32, 64 for int64 */ Zotero.DBConnection.prototype.getSQLDataType = function(value) { var strVal = value + ''; if (strVal.match(/^[1-9]+[0-9]*$/)) { // These upper bounds also specified in Zotero.DB // // Store as 32-bit signed integer if (value <= 2147483647) { return 32; } // Store as 64-bit signed integer // 2^53 is JS's upper-bound for decimal integers else if (value < 9007199254740992) { return 64; } } return 0; } ///////////////////////////////////////////////////////////////// // // Private methods // ///////////////////////////////////////////////////////////////// Zotero.DBConnection.prototype._getConnection = function (options) { if (this._backupPromise && this._backupPromise.isPending() && (!options || !options.inBackup)) { return false; } if (this._connection === false) { throw new Error("Database permanently closed; not re-opening"); } return this._connection || false; } /* * Retrieve a link to the data store asynchronously */ Zotero.DBConnection.prototype._getConnectionAsync = Zotero.Promise.coroutine(function* (options) { // If a backup is in progress, wait until it's done if (this._backupPromise && this._backupPromise.isPending() && (!options || !options.inBackup)) { Zotero.debug("Waiting for database backup to complete", 2); yield this._backupPromise; } if (this._connection) { return this._connection; } else if (this._connection === false) { throw new Error("Database permanently closed; not re-opening"); } this._debug("Asynchronously opening database '" + this._dbName + "'"); // Get the storage service var store = Components.classes["@mozilla.org/storage/service;1"]. getService(Components.interfaces.mozIStorageService); var file = Zotero.getZoteroDatabase(this._dbName); var backupFile = Zotero.getZoteroDatabase(this._dbName, 'bak'); var fileName = this._dbName + '.sqlite'; catchBlock: try { var corruptMarker = Zotero.getZoteroDatabase(this._dbName, 'is.corrupt'); if (corruptMarker.exists()) { throw { name: 'NS_ERROR_FILE_CORRUPTED' }; } this._connection = yield Zotero.Promise.resolve(this.Sqlite.openConnection({ path: file.path })); } catch (e) { if (e.name=='NS_ERROR_FILE_CORRUPTED') { this._debug("Database file '" + file.leafName + "' corrupted", 1); // No backup file! Eek! if (!backupFile.exists()) { this._debug("No backup file for DB '" + this._dbName + "' exists", 1); // Save damaged filed this._debug('Saving damaged DB file with .damaged extension', 1); var damagedFile = Zotero.getZoteroDatabase(this._dbName, 'damaged'); Zotero.moveToUnique(file, damagedFile); // Create new main database var file = Zotero.getZoteroDatabase(this._dbName); this._connection = store.openDatabase(file); if (corruptMarker.exists()) { corruptMarker.remove(null); } alert(Zotero.getString('db.dbCorruptedNoBackup', fileName)); break catchBlock; } // Save damaged file this._debug('Saving damaged DB file with .damaged extension', 1); var damagedFile = Zotero.getZoteroDatabase(this._dbName, 'damaged'); Zotero.moveToUnique(file, damagedFile); // Test the backup file try { Zotero.debug("Asynchronously opening DB connection"); this._connection = yield Zotero.Promise.resolve(this.Sqlite.openConnection({ path: backupFile.path })); } // Can't open backup either catch (e) { // Create new main database var file = Zotero.getZoteroDatabase(this._dbName); this._connection = yield Zotero.Promise.resolve(this.Sqlite.openConnection({ path: file.path })); alert(Zotero.getString('db.dbRestoreFailed', fileName)); if (corruptMarker.exists()) { corruptMarker.remove(null); } break catchBlock; } this._connection = undefined; // Copy backup file to main DB file this._debug("Restoring database '" + this._dbName + "' from backup file", 1); try { backupFile.copyTo(backupFile.parent, fileName); } catch (e) { // TODO: deal with low disk space throw (e); } // Open restored database var file = Zotero.getZoteroDirectory(); file.append(fileName); this._connection = yield Zotero.Promise.resolve(this.Sqlite.openConnection({ path: file.path })); this._debug('Database restored', 1); var msg = Zotero.getString('db.dbRestored', [ fileName, Zotero.Date.getFileDateString(backupFile), Zotero.Date.getFileTimeString(backupFile) ]); alert(msg); if (corruptMarker.exists()) { corruptMarker.remove(null); } break catchBlock; } // Some other error that we don't yet know how to deal with throw (e); } if (DB_LOCK_EXCLUSIVE) { yield Zotero.DB.queryAsync("PRAGMA locking_mode=EXCLUSIVE"); } else { yield Zotero.DB.queryAsync("PRAGMA locking_mode=NORMAL"); } // Set page cache size to 8MB var pageSize = yield Zotero.DB.valueQueryAsync("PRAGMA page_size"); var cacheSize = 8192000 / pageSize; yield Zotero.DB.queryAsync("PRAGMA cache_size=" + cacheSize); // Enable foreign key checks yield Zotero.DB.queryAsync("PRAGMA foreign_keys=true"); // Register idle and shutdown handlers to call this.observe() for DB backup var idleService = Components.classes["@mozilla.org/widget/idleservice;1"] .getService(Components.interfaces.nsIIdleService); idleService.addIdleObserver(this, 60); idleService = null; return this._connection; }); Zotero.DBConnection.prototype._debug = function (str, level) { var prefix = this._dbName == 'zotero' ? '' : '[' + this._dbName + '] '; Zotero.debug(prefix + str, level); } Zotero.DBConnection.prototype._getTypedValue = function (statement, i) { var type = statement.getTypeOfIndex(i); // For performance, we hard-code these constants switch (type) { case 1: //VALUE_TYPE_INTEGER return statement.getInt64(i); case 3: //VALUE_TYPE_TEXT return statement.getUTF8String(i); case 0: //VALUE_TYPE_NULL return null; case 2: //VALUE_TYPE_FLOAT return statement.getDouble(i); case 4: //VALUE_TYPE_BLOB return statement.getBlob(i, {}); } } // Initialize main database connection Zotero.DB = new Zotero.DBConnection('zotero'); Zotero.DB.IncompatibleVersionException = function (msg, dbClientVersion) { this.message = msg; this.dbClientVersion = dbClientVersion; } Zotero.DB.IncompatibleVersionException.prototype = Object.create(Error.prototype);