db.js (36843B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 "use strict"; 27 28 // Exclusive locking mode (default) prevents access to Zotero database while Zotero is open 29 // and speeds up DB access (http://www.sqlite.org/pragma.html#pragma_locking_mode). 30 // Normal mode is more convenient for development, but risks database corruption, particularly if 31 // the same database is accessed simultaneously by multiple Zotero instances. 32 const DB_LOCK_EXCLUSIVE = true; 33 34 Zotero.DBConnection = function(dbNameOrPath) { 35 if (!dbNameOrPath) { 36 throw ('DB name not provided in Zotero.DBConnection()'); 37 } 38 39 this.MAX_BOUND_PARAMETERS = 999; 40 this.DB_CORRUPTION_STRING = "2152857611"; 41 42 Components.utils.import("resource://gre/modules/Sqlite.jsm", this); 43 44 this.closed = false; 45 this.skipBackup = false; 46 47 // JS Date 48 this.__defineGetter__('transactionDate', function () { 49 if (this._transactionDate) { 50 this._lastTransactionDate = this._transactionDate; 51 return this._transactionDate; 52 } 53 54 throw new Error("Transaction not in progress"); 55 56 // Use second granularity rather than millisecond 57 // for comparison purposes 58 var d = new Date(Math.floor(new Date / 1000) * 1000); 59 this._lastTransactionDate = d; 60 return d; 61 }); 62 // SQL DATETIME 63 this.__defineGetter__('transactionDateTime', function () { 64 var d = this.transactionDate; 65 return Zotero.Date.dateToSQL(d, true); 66 }); 67 // Unix timestamp 68 this.__defineGetter__('transactionTimestamp', function () { 69 var d = this.transactionDate; 70 return Zotero.Date.toUnixTimestamp(d); 71 }); 72 73 // Absolute path to DB 74 if (dbNameOrPath.startsWith('/') || (Zotero.isWin && dbNameOrPath.includes('\\'))) { 75 this._dbName = OS.Path.basename(dbNameOrPath).replace(/\.sqlite$/, ''); 76 this._dbPath = dbNameOrPath; 77 this._externalDB = true; 78 } 79 // DB name in data directory 80 else { 81 this._dbName = dbNameOrPath; 82 this._dbPath = Zotero.DataDirectory.getDatabase(dbNameOrPath); 83 this._externalDB = false; 84 } 85 this._shutdown = false; 86 this._connection = null; 87 this._transactionID = null; 88 this._transactionDate = null; 89 this._lastTransactionDate = null; 90 this._transactionRollback = false; 91 this._transactionNestingLevel = 0; 92 this._callbacks = { 93 begin: [], 94 commit: [], 95 rollback: [], 96 current: { 97 commit: [], 98 rollback: [] 99 } 100 }; 101 this._dbIsCorrupt = null 102 103 this._transactionPromise = null; 104 105 if (dbNameOrPath == 'zotero') { 106 this.IncompatibleVersionException = function (msg, dbClientVersion) { 107 this.message = msg; 108 this.dbClientVersion = dbClientVersion; 109 } 110 this.IncompatibleVersionException.prototype = Object.create(Error.prototype); 111 } 112 } 113 114 ///////////////////////////////////////////////////////////////// 115 // 116 // Public methods 117 // 118 ///////////////////////////////////////////////////////////////// 119 120 /** 121 * Test a read-only connection to the database, throwing any errors that occur 122 * 123 * @return void 124 */ 125 Zotero.DBConnection.prototype.test = function () { 126 return this._getConnectionAsync().then(() => {}); 127 } 128 129 Zotero.DBConnection.prototype.getAsyncStatement = Zotero.Promise.coroutine(function* (sql) { 130 var conn = yield this._getConnectionAsync(); 131 conn = conn._connection; 132 133 try { 134 this._debug(sql, 4); 135 return conn.createAsyncStatement(sql); 136 } 137 catch (e) { 138 var dberr = (conn.lastErrorString != 'not an error') 139 ? ' [ERROR: ' + conn.lastErrorString + ']' : ''; 140 throw new Error(e + ' [QUERY: ' + sql + ']' + dberr); 141 } 142 }); 143 144 145 Zotero.DBConnection.prototype.parseQueryAndParams = function (sql, params) { 146 // If single scalar value, wrap in an array 147 if (!Array.isArray(params)) { 148 if (typeof params == 'string' || typeof params == 'number' || typeof params == 'object' 149 || params === null) { 150 params = [params]; 151 } 152 else { 153 params = []; 154 } 155 } 156 // Otherwise, since we might make changes, only work on a copy of the array 157 else { 158 params = params.concat(); 159 } 160 161 // Find placeholders 162 if (params.length) { 163 let matches = sql.match(/\?\d*/g); 164 if (!matches) { 165 throw new Error("Parameters provided for query without placeholders " 166 + "[QUERY: " + sql + "]"); 167 } 168 else { 169 // Count numbered parameters (?1) properly 170 let num = 0; 171 let numbered = {}; 172 for (let i = 0; i < matches.length; i++) { 173 let match = matches[i]; 174 if (match == '?') { 175 num++; 176 } 177 else { 178 numbered[match] = true; 179 } 180 } 181 num += Object.keys(numbered).length; 182 183 if (params.length != num) { 184 throw new Error("Incorrect number of parameters provided for query " 185 + "(" + params.length + ", expecting " + num + ") " 186 + "[QUERY: " + sql + "]"); 187 } 188 } 189 190 // First, determine the type of query using first word 191 let queryMethod = sql.match(/^[^\s\(]*/)[0].toLowerCase(); 192 193 // Reset lastIndex, since regexp isn't recompiled dynamically 194 let placeholderRE = /\s*[=,(]\s*\?/g; 195 for (var i=0; i<params.length; i++) { 196 // Find index of this parameter, skipping previous ones 197 matches = placeholderRE.exec(sql); 198 199 if (typeof params[i] == 'boolean') { 200 throw new Error("Invalid boolean parameter " + i + " '" + params[i] + "' " 201 + "[QUERY: " + sql + "]"); 202 } 203 else if (params[i] === undefined) { 204 throw new Error('Parameter ' + i + ' is undefined [QUERY: ' + sql + ']'); 205 } 206 207 if (params[i] !== null) { 208 // Force parameter type if specified 209 210 // Int 211 if (typeof params[i]['int'] != 'undefined') { 212 params[i] = parseInt(params[i]['int']); 213 if (isNaN(params[i])) { 214 throw new Error("Invalid bound parameter " + i + " integer value '" + params[i] + "' " 215 + "[QUERY: " + sql + "]") 216 } 217 } 218 // String 219 else if (typeof params[i]['string'] != 'undefined') { 220 params[i] = params[i]['string'] + ""; 221 } 222 223 continue; 224 } 225 226 // 227 // Replace NULL bound parameters with hard-coded NULLs 228 // 229 if (!matches) { 230 throw new Error("Null parameter provided for a query without placeholders " 231 + "-- use false or undefined [QUERY: " + sql + "]"); 232 } 233 234 if (matches[0].trim().indexOf('=') == -1) { 235 if (queryMethod == 'select') { 236 throw new Error("NULL cannot be used for parenthesized placeholders " 237 + "in SELECT queries [QUERY: " + sql + "]"); 238 } 239 var repl = matches[0].replace('?', 'NULL'); 240 } 241 else if (queryMethod == 'select') { 242 var repl = ' IS NULL'; 243 } 244 else { 245 var repl = '=NULL'; 246 } 247 248 var subpos = matches.index; 249 var sublen = matches[0].length; 250 sql = sql.substring(0, subpos) + repl + sql.substr(subpos + sublen); 251 252 //Zotero.debug("Hard-coding null bound parameter " + i); 253 254 params.splice(i, 1); 255 i--; 256 } 257 if (!params.length) { 258 params = []; 259 } 260 } 261 else if (/\?/g.test(sql)) { 262 throw new Error("Parameters not provided for query containing placeholders " 263 + "[QUERY: " + sql + "]"); 264 } 265 266 return [sql, params]; 267 }; 268 269 270 /** 271 * Execute an asynchronous statement with previously bound parameters 272 * 273 * Warning: This will freeze if used with a write statement within executeTransaction()! 274 * 275 * @param {mozIStorageAsyncStatement} statement - Statement to run 276 * @param {Function} [progressHandler] - Function to pass each available row to for SELECT queries 277 * @return {Promise} - Resolved on completion, rejected with a reason on error 278 */ 279 Zotero.DBConnection.prototype.executeAsyncStatement = Zotero.Promise.method(function (statement, progressHandler) { 280 var resolve; 281 var reject; 282 statement.executeAsync({ 283 handleResult: function (resultSet) { 284 if (progressHandler) { 285 progressHandler(resultSet.getNextRow()); 286 } 287 }, 288 289 handleError: function (e) { 290 reject(e); 291 }, 292 293 handleCompletion: function (reason) { 294 if (reason != Components.interfaces.mozIStorageStatementCallback.REASON_FINISHED) { 295 reject(reason); 296 } 297 resolve(); 298 } 299 }); 300 return new Zotero.Promise(function () { 301 resolve = arguments[0]; 302 reject = arguments[1]; 303 }); 304 }); 305 306 307 308 Zotero.DBConnection.prototype.addCallback = function (type, cb) { 309 switch (type) { 310 case 'begin': 311 case 'commit': 312 case 'rollback': 313 break; 314 315 default: 316 throw ("Invalid callback type '" + type + "' in DB.addCallback()"); 317 } 318 319 var id = this._callbacks[type].length; 320 this._callbacks[type][id] = cb; 321 return id; 322 } 323 324 325 Zotero.DBConnection.prototype.addCurrentCallback = function (type, cb) { 326 this.requireTransaction(); 327 this._callbacks.current[type].push(cb); 328 } 329 330 331 Zotero.DBConnection.prototype.removeCallback = function (type, id) { 332 switch (type) { 333 case 'begin': 334 case 'commit': 335 case 'rollback': 336 break; 337 338 default: 339 throw ("Invalid callback type '" + type + "' in DB.removeCallback()"); 340 } 341 342 delete this._callbacks[type][id]; 343 } 344 345 346 /* 347 * Used on shutdown to rollback all open transactions 348 * 349 * TODO: update or remove 350 */ 351 Zotero.DBConnection.prototype.rollbackAllTransactions = function () { 352 if (this.transactionInProgress()) { 353 var level = this._transactionNestingLevel; 354 this._transactionNestingLevel = 0; 355 try { 356 this.rollbackTransaction(); 357 } 358 catch (e) {} 359 return level ? level : true; 360 } 361 return false; 362 } 363 364 365 Zotero.DBConnection.prototype.getColumns = function (table) { 366 return this.queryAsync("PRAGMA table_info(" + table + ")") 367 .then(function (rows) { 368 return rows.map(row => row.name); 369 }) 370 .catch(function (e) { 371 this._debug(e, 1); 372 return false; 373 }); 374 } 375 376 377 /** 378 * Find the next lowest numeric suffix for a value in table column 379 * 380 * For example, if "Untitled" and "Untitled 2" and "Untitled 4", 381 * returns "Untitled 3" 382 * 383 * If _name_ alone is available, returns that 384 **/ 385 Zotero.DBConnection.prototype.getNextName = Zotero.Promise.coroutine(function* (libraryID, table, field, name) 386 { 387 if (typeof name == 'undefined') { 388 Zotero.debug("WARNING: The parameters of Zotero.DB.getNextName() have changed -- update your code", 2); 389 [libraryID, table, field, name] = [null, libraryID, table, field]; 390 } 391 392 var sql = "SELECT SUBSTR(" + field + ", " + (name.length + 1) + ") FROM " + table 393 + " WHERE libraryID=? AND " + field + " LIKE ? ORDER BY " + field; 394 var params = [libraryID, name + "%"]; 395 var suffixes = yield this.columnQueryAsync(sql, params); 396 suffixes.filter(x => x.match(/^( [0-9]+)?$/)); 397 398 // If none found or first one has a suffix, use default name 399 if (!suffixes.length || suffixes[0]) { 400 return name; 401 } 402 403 suffixes.sort(function (a, b) { 404 return parseInt(a) - parseInt(b); 405 }); 406 407 var i = 1; 408 while (suffixes[i] === "") { 409 i++; 410 } 411 var num = 2; 412 while (suffixes[i] == num) { 413 while (suffixes[i+1] && suffixes[i] == suffixes[i+1]) { 414 i++; 415 } 416 i++; 417 num++; 418 } 419 return name + ' ' + num; 420 }); 421 422 423 // 424 // Async methods 425 // 426 // 427 // Zotero.DB.executeTransaction(function* (conn) { 428 // var created = yield Zotero.DB.queryAsync("CREATE TEMPORARY TABLE tmpFoo (foo TEXT, bar INT)"); 429 // 430 // // created == true 431 // 432 // var result = yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('a', ?)", 1); 433 // 434 // // result == 1 435 // 436 // yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('b', 2)"); 437 // yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('c', 3)"); 438 // yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('d', 4)"); 439 // 440 // var value = yield Zotero.DB.valueQueryAsync("SELECT foo FROM tmpFoo WHERE bar=?", 2); 441 // 442 // // value == "b" 443 // 444 // var vals = yield Zotero.DB.columnQueryAsync("SELECT foo FROM tmpFoo"); 445 // 446 // // '0' => "a" 447 // // '1' => "b" 448 // // '2' => "c" 449 // // '3' => "d" 450 // 451 // let rows = yield Zotero.DB.queryAsync("SELECT * FROM tmpFoo"); 452 // for (let i=0; i<rows.length; i++) { 453 // let row = rows[i]; 454 // // row.foo == 'a', row.bar == 1 455 // // row.foo == 'b', row.bar == 2 456 // // row.foo == 'c', row.bar == 3 457 // // row.foo == 'd', row.bar == 4 458 // } 459 // 460 // return rows; 461 // }) 462 // .then(function (rows) { 463 // // rows == same as above 464 // }); 465 // 466 /** 467 * @param {Function} func - Generator function that yields promises, 468 * generally from queryAsync() and similar 469 * @return {Promise} - Promise for result of generator function 470 */ 471 Zotero.DBConnection.prototype.executeTransaction = Zotero.Promise.coroutine(function* (func, options) { 472 options = options || {}; 473 var resolve; 474 475 // Set temporary options for this transaction that will be reset at the end 476 var origOptions = {}; 477 if (options) { 478 for (let option in options) { 479 origOptions[option] = this[option]; 480 this[option] = options[option]; 481 } 482 } 483 484 var startedTransaction = false; 485 var id = Zotero.Utilities.randomString(); 486 487 try { 488 while (this._transactionID) { 489 yield this.waitForTransaction(id).timeout(options.waitTimeout || 30000); 490 } 491 startedTransaction = true; 492 this._transactionID = id; 493 494 Zotero.debug(`Beginning DB transaction ${id}`, 4); 495 496 this._transactionPromise = new Zotero.Promise(function () { 497 resolve = arguments[0]; 498 }); 499 500 // Set a timestamp for this transaction 501 this._transactionDate = new Date(Math.floor(new Date / 1000) * 1000); 502 503 // Run begin callbacks 504 for (var i=0; i<this._callbacks.begin.length; i++) { 505 if (this._callbacks.begin[i]) { 506 this._callbacks.begin[i](id); 507 } 508 } 509 var conn = this._getConnection(options) || (yield this._getConnectionAsync(options)); 510 var result = yield conn.executeTransaction(func); 511 Zotero.debug(`Committed DB transaction ${id}`, 4); 512 513 // Clear transaction time 514 if (this._transactionDate) { 515 this._transactionDate = null; 516 } 517 518 if (options.vacuumOnCommit) { 519 Zotero.debug('Vacuuming database'); 520 yield this.queryAsync('VACUUM'); 521 Zotero.debug('Done vacuuming'); 522 523 } 524 525 this._transactionID = null; 526 527 // Function to run once transaction has been committed but before any 528 // permanent callbacks 529 if (options.onCommit) { 530 this._callbacks.current.commit.push(options.onCommit); 531 } 532 this._callbacks.current.rollback = []; 533 534 // Run temporary commit callbacks 535 var f; 536 while (f = this._callbacks.current.commit.shift()) { 537 yield Zotero.Promise.resolve(f(id)); 538 } 539 540 // Run commit callbacks 541 for (var i=0; i<this._callbacks.commit.length; i++) { 542 if (this._callbacks.commit[i]) { 543 yield this._callbacks.commit[i](id); 544 } 545 } 546 547 return result; 548 } 549 catch (e) { 550 if (e.name == "TimeoutError") { 551 Zotero.debug(`Timed out waiting for transaction ${id}`, 1); 552 } 553 else { 554 Zotero.debug(`Rolled back DB transaction ${id}`, 1); 555 Zotero.debug(e.message, 1); 556 } 557 if (startedTransaction) { 558 this._transactionID = null; 559 } 560 561 // Function to run once transaction has been committed but before any 562 // permanent callbacks 563 if (options.onRollback) { 564 this._callbacks.current.rollback.push(options.onRollback); 565 } 566 567 // Run temporary commit callbacks 568 var f; 569 while (f = this._callbacks.current.rollback.shift()) { 570 yield Zotero.Promise.resolve(f(id)); 571 } 572 573 // Run rollback callbacks 574 for (var i=0; i<this._callbacks.rollback.length; i++) { 575 if (this._callbacks.rollback[i]) { 576 yield Zotero.Promise.resolve(this._callbacks.rollback[i](id)); 577 } 578 } 579 580 throw e; 581 } 582 finally { 583 // Reset options back to their previous values 584 if (options) { 585 for (let option in options) { 586 this[option] = origOptions[option]; 587 } 588 } 589 590 // Process all resolvers 591 if (resolve) { 592 resolve.call(); 593 } 594 } 595 }); 596 597 598 Zotero.DBConnection.prototype.inTransaction = function () { 599 return !!this._transactionID; 600 } 601 602 603 Zotero.DBConnection.prototype.waitForTransaction = function (id) { 604 if (!this._transactionID) { 605 return Zotero.Promise.resolve(); 606 } 607 if (Zotero.Debug.enabled) { 608 Zotero.debug(`Waiting for DB transaction ${this._transactionID} to finish` 609 + (id ? ` to start ${id}` : ""), 4); 610 Zotero.debug(Zotero.Debug.filterStack((new Error).stack), 5); 611 } 612 return this._transactionPromise; 613 }; 614 615 616 Zotero.DBConnection.prototype.requireTransaction = function () { 617 if (!this._transactionID) { 618 throw new Error("Not in transaction"); 619 } 620 }; 621 622 623 /** 624 * @param {String} sql SQL statement to run 625 * @param {Array|String|Integer} [params] SQL parameters to bind 626 * @return {Promise|Array} A promise for an array of rows. The individual 627 * rows are Proxy objects that return values from the 628 * underlying mozIStorageRows based on column names. 629 */ 630 Zotero.DBConnection.prototype.queryAsync = Zotero.Promise.coroutine(function* (sql, params, options) { 631 try { 632 let onRow = null; 633 let conn = this._getConnection(options) || (yield this._getConnectionAsync(options)); 634 [sql, params] = this.parseQueryAndParams(sql, params); 635 if (Zotero.Debug.enabled) { 636 this.logQuery(sql, params, options); 637 } 638 var failed = false; 639 if (options && options.onRow) { 640 // Errors in onRow don't stop the query unless StopIteration is thrown 641 onRow = function (row) { 642 try { 643 options.onRow(row); 644 } 645 catch (e) { 646 // If the onRow throws a StopIteration, stop gracefully 647 if (e instanceof StopIteration) { 648 Zotero.debug("Query cancelled", 3); 649 } 650 // Otherwise, mark the promise as rejected, which Sqlite.jsm doesn't do 651 // on a StopIteration by default 652 else { 653 failed = e; 654 } 655 throw StopIteration; 656 } 657 } 658 } 659 let rows; 660 if (options && options.noCache) { 661 rows = yield conn.execute(sql, params, onRow); 662 } 663 else { 664 rows = yield conn.executeCached(sql, params, onRow); 665 } 666 if (failed) { 667 throw failed; 668 } 669 // Parse out the SQL command being used 670 let op = sql.match(/^[^a-z]*[^ ]+/i); 671 if (op) { 672 op = op.toString().toLowerCase(); 673 } 674 675 // If SELECT statement, return result 676 if (op == 'select' || op == 'pragma') { 677 if (onRow) { 678 return; 679 } 680 // Fake an associative array with a proxy 681 let handler = { 682 get: function(target, name) { 683 // Ignore promise check 684 if (name == 'then') { 685 return undefined; 686 } 687 688 try { 689 return target.getResultByName(name); 690 } 691 catch (e) { 692 Zotero.debug(e, 1); 693 var msg = "DB column '" + name + "' not found"; 694 Zotero.debug(msg, 1); 695 throw new Error(msg); 696 } 697 }, 698 has: function(target, name) { 699 try { 700 return !!target.getResultByName(name); 701 } catch (e) { 702 return false; 703 } 704 } 705 }; 706 for (let i=0, len=rows.length; i<len; i++) { 707 rows[i] = new Proxy(rows[i], handler); 708 } 709 return rows; 710 } 711 else { 712 // lastInsertRowID is unreliable for async queries, so we don't bother 713 // returning it for SELECT and REPLACE queries 714 return; 715 } 716 } 717 catch (e) { 718 if (e.errors && e.errors[0]) { 719 var eStr = e + ""; 720 eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e; 721 throw new Error(eStr + ' [QUERY: ' + sql + '] ' 722 + (params ? '[PARAMS: ' + params.join(', ') + '] ' : '') 723 + '[ERROR: ' + e.errors[0].message + ']'); 724 } 725 else { 726 throw e; 727 } 728 } 729 }); 730 731 732 Zotero.DBConnection.prototype.queryTx = function (sql, params, options) { 733 return this.executeTransaction(function* () { 734 options = options || {}; 735 delete options.tx; 736 return this.queryAsync(sql, params, options); 737 }.bind(this)); 738 }; 739 740 741 /** 742 * @param {String} sql SQL statement to run 743 * @param {Array|String|Integer} [params] SQL parameters to bind 744 * @return {Promise<Array|Boolean>} A promise for either the value or FALSE if no result 745 */ 746 Zotero.DBConnection.prototype.valueQueryAsync = Zotero.Promise.coroutine(function* (sql, params, options = {}) { 747 try { 748 let conn = this._getConnection(options) || (yield this._getConnectionAsync(options)); 749 [sql, params] = this.parseQueryAndParams(sql, params); 750 if (Zotero.Debug.enabled) { 751 this.logQuery(sql, params, options); 752 } 753 let rows = yield conn.executeCached(sql, params); 754 return rows.length ? rows[0].getResultByIndex(0) : false; 755 } 756 catch (e) { 757 if (e.errors && e.errors[0]) { 758 var eStr = e + ""; 759 eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e; 760 throw new Error(eStr + ' [QUERY: ' + sql + '] ' 761 + (params ? '[PARAMS: ' + params.join(', ') + '] ' : '') 762 + '[ERROR: ' + e.errors[0].message + ']'); 763 } 764 else { 765 throw e; 766 } 767 } 768 }); 769 770 771 /** 772 * @param {String} sql SQL statement to run 773 * @param {Array|String|Integer} [params] SQL parameters to bind 774 * @return {Promise<Object>} A promise for a proxied storage row 775 */ 776 Zotero.DBConnection.prototype.rowQueryAsync = function (sql, params) { 777 return this.queryAsync(sql, params) 778 .then(function (rows) { 779 return rows.length ? rows[0] : false; 780 }); 781 }; 782 783 784 /** 785 * @param {String} sql SQL statement to run 786 * @param {Array|String|Integer} [params] SQL parameters to bind 787 * @return {Promise<Array>} A promise for an array of values in the column 788 */ 789 Zotero.DBConnection.prototype.columnQueryAsync = Zotero.Promise.coroutine(function* (sql, params, options = {}) { 790 try { 791 let conn = this._getConnection(options) || (yield this._getConnectionAsync(options)); 792 [sql, params] = this.parseQueryAndParams(sql, params); 793 if (Zotero.Debug.enabled) { 794 this.logQuery(sql, params, options); 795 } 796 let rows = yield conn.executeCached(sql, params); 797 var column = []; 798 for (let i=0, len=rows.length; i<len; i++) { 799 column.push(rows[i].getResultByIndex(0)); 800 } 801 return column; 802 } 803 catch (e) { 804 if (e.errors && e.errors[0]) { 805 var eStr = e + ""; 806 eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e; 807 throw new Error(eStr + ' [QUERY: ' + sql + '] ' 808 + (params ? '[PARAMS: ' + params.join(', ') + '] ' : '') 809 + '[ERROR: ' + e.errors[0].message + ']'); 810 } 811 else { 812 throw e; 813 } 814 } 815 }); 816 817 818 Zotero.DBConnection.prototype.logQuery = function (sql, params = [], options) { 819 if (options && options.debug === false) return; 820 var msg = sql; 821 if (params.length) { 822 msg += " ["; 823 for (let i = 0; i < params.length; i++) { 824 let param = params[i]; 825 let paramType = typeof param; 826 if (paramType == 'string') { 827 msg += "'" + param + "', "; 828 } 829 else { 830 msg += param + ", "; 831 } 832 } 833 msg = msg.substr(0, msg.length - 2) + "]"; 834 } 835 Zotero.debug(msg, 4); 836 } 837 838 839 Zotero.DBConnection.prototype.tableExists = Zotero.Promise.coroutine(function* (table, db) { 840 yield this._getConnectionAsync(); 841 var prefix = db ? db + '.' : ''; 842 var sql = `SELECT COUNT(*) FROM ${prefix}sqlite_master WHERE type='table' AND tbl_name=?`; 843 var count = yield this.valueQueryAsync(sql, [table]); 844 return !!count; 845 }); 846 847 848 /** 849 * Parse SQL string and execute transaction with all statements 850 * 851 * @return {Promise} 852 */ 853 Zotero.DBConnection.prototype.executeSQLFile = Zotero.Promise.coroutine(function* (sql) { 854 var nonCommentRE = /^[^-]/; 855 var trailingCommentRE = /^(.*?)(?:--.+)?$/; 856 857 sql = sql.trim() 858 // Ugly hack to parse triggers with embedded semicolons 859 .replace(/;---/g, "TEMPSEMI") 860 .split("\n") 861 .filter(x => nonCommentRE.test(x)) 862 .map(x => x.match(trailingCommentRE)[1]) 863 .join(""); 864 if (sql.substr(-1) == ";") { 865 sql = sql.substr(0, sql.length - 1); 866 } 867 868 var statements = sql.split(";") 869 .map(x => x.replace(/TEMPSEMI/g, ";")); 870 871 this.requireTransaction(); 872 873 var statement; 874 while (statement = statements.shift()) { 875 yield this.queryAsync(statement); 876 } 877 }); 878 879 880 /* 881 * Implements nsIObserver 882 */ 883 Zotero.DBConnection.prototype.observe = function(subject, topic, data) { 884 switch (topic) { 885 case 'idle': 886 this.backupDatabase(); 887 break; 888 } 889 } 890 891 892 // TEMP 893 Zotero.DBConnection.prototype.vacuum = function () { 894 return this.executeTransaction(function* () {}, { vacuumOnCommit: true }); 895 }; 896 897 898 // TEMP 899 Zotero.DBConnection.prototype.info = Zotero.Promise.coroutine(function* () { 900 var info = {}; 901 var pragmas = ['auto_vacuum', 'cache_size', 'main.locking_mode', 'page_size']; 902 for (let p of pragmas) { 903 info[p] = yield Zotero.DB.valueQueryAsync(`PRAGMA ${p}`); 904 } 905 return info; 906 }); 907 908 909 Zotero.DBConnection.prototype.integrityCheck = Zotero.Promise.coroutine(function* () { 910 var ok = yield this.valueQueryAsync("PRAGMA integrity_check"); 911 return ok == 'ok'; 912 }); 913 914 915 Zotero.DBConnection.prototype.checkException = function (e) { 916 if (this._externalDB) { 917 return true; 918 } 919 920 if (e.message.includes(this.DB_CORRUPTION_STRING)) { 921 // Write corrupt marker to data directory 922 var file = Zotero.File.pathToFile(this._dbPath + '.is.corrupt'); 923 Zotero.File.putContents(file, ''); 924 925 this._dbIsCorrupt = true; 926 927 var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] 928 .getService(Components.interfaces.nsIPromptService); 929 930 var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) 931 + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING); 932 933 var index = ps.confirmEx(null, 934 Zotero.getString('general.error'), 935 Zotero.getString('db.dbCorrupted', this._dbName) + '\n\n' + Zotero.getString('db.dbCorrupted.restart', Zotero.appName), 936 buttonFlags, 937 Zotero.getString('general.restartNow'), 938 Zotero.getString('general.restartLater'), 939 null, null, {}); 940 941 if (index == 0) { 942 var appStartup = Components.classes["@mozilla.org/toolkit/app-startup;1"] 943 .getService(Components.interfaces.nsIAppStartup); 944 appStartup.quit(Components.interfaces.nsIAppStartup.eRestart); 945 appStartup.quit(Components.interfaces.nsIAppStartup.eAttemptQuit); 946 } 947 948 Zotero.skipLoading = true; 949 return false; 950 } 951 return true; 952 } 953 954 955 /** 956 * Close the database 957 * @param {Boolean} [permanent] If true, throw an error instead of 958 * allowing code to re-open the database again 959 */ 960 Zotero.DBConnection.prototype.closeDatabase = Zotero.Promise.coroutine(function* (permanent) { 961 if (this._connection) { 962 Zotero.debug("Closing database"); 963 this.closed = true; 964 yield this._connection.close(); 965 this._connection = undefined; 966 this._connection = permanent ? false : null; 967 Zotero.debug("Database closed"); 968 } 969 }); 970 971 972 Zotero.DBConnection.prototype.backupDatabase = Zotero.Promise.coroutine(function* (suffix, force) { 973 if (this.skipBackup || this._externalDB || Zotero.skipLoading) { 974 this._debug("Skipping backup of database '" + this._dbName + "'", 1); 975 return false; 976 } 977 978 var storageService = Components.classes["@mozilla.org/storage/service;1"] 979 .getService(Components.interfaces.mozIStorageService); 980 981 if (!suffix) { 982 var numBackups = Zotero.Prefs.get("backup.numBackups"); 983 if (numBackups < 1) { 984 return false; 985 } 986 if (numBackups > 24) { 987 numBackups = 24; 988 } 989 } 990 991 if (Zotero.locked && !force) { 992 this._debug("Zotero is locked -- skipping backup of DB '" + this._dbName + "'", 2); 993 return false; 994 } 995 996 if (this._backupPromise && this._backupPromise.isPending()) { 997 this._debug("Database " + this._dbName + " is already being backed up -- skipping", 2); 998 return false; 999 } 1000 1001 // Start a promise that will be resolved when the backup is finished 1002 var resolveBackupPromise; 1003 if (this.inTransaction()) { 1004 yield this.waitForTransaction(); 1005 } 1006 this._backupPromise = new Zotero.Promise(function () { 1007 resolveBackupPromise = arguments[0]; 1008 }); 1009 1010 try { 1011 let corruptMarker = Zotero.File.pathToFile(this._dbPath + '.is.corrupt'); 1012 1013 if (this._dbIsCorrupt || corruptMarker.exists()) { 1014 this._debug("Database '" + this._dbName + "' is marked as corrupt -- skipping backup", 1); 1015 return false; 1016 } 1017 1018 let file = this._dbPath; 1019 1020 // For standard backup, make sure last backup is old enough to replace 1021 if (!suffix && !force) { 1022 let backupFile = this._dbPath + '.bak'; 1023 if (yield OS.File.exists(backupFile)) { 1024 let currentDBTime = (yield OS.File.stat(file.path)).lastModificationDate; 1025 let lastBackupTime = (yield OS.File.stat(backupFile)).lastModificationDate; 1026 if (currentDBTime == lastBackupTime) { 1027 Zotero.debug("Database '" + this._dbName + "' hasn't changed -- skipping backup"); 1028 return; 1029 } 1030 1031 var now = new Date(); 1032 var intervalMinutes = Zotero.Prefs.get('backup.interval'); 1033 var interval = intervalMinutes * 60 * 1000; 1034 if ((now - lastBackupTime) < interval) { 1035 Zotero.debug("Last backup of database '" + this._dbName 1036 + "' was less than " + intervalMinutes + " minutes ago -- skipping backup"); 1037 return; 1038 } 1039 } 1040 } 1041 1042 this._debug("Backing up database '" + this._dbName + "'"); 1043 1044 // Copy via a temporary file so we don't run into disk space issues 1045 // after deleting the old backup file 1046 var tmpFile = this._dbPath + '.tmp'; 1047 if (yield OS.File.exists(tmpFile)) { 1048 try { 1049 yield OS.File.remove(tmpFile); 1050 } 1051 catch (e) { 1052 if (e.name == 'NS_ERROR_FILE_ACCESS_DENIED') { 1053 alert("Cannot delete " + OS.Path.basename(tmpFile)); 1054 } 1055 throw (e); 1056 } 1057 } 1058 1059 // Turn off DB locking before backup and reenable after, since otherwise 1060 // the lock is lost 1061 try { 1062 if (DB_LOCK_EXCLUSIVE) { 1063 yield this.queryAsync("PRAGMA main.locking_mode=NORMAL", false, { inBackup: true }); 1064 } 1065 storageService.backupDatabaseFile( 1066 Zotero.File.pathToFile(file), 1067 OS.Path.basename(tmpFile), 1068 Zotero.File.pathToFile(file).parent 1069 ); 1070 } 1071 catch (e) { 1072 Zotero.logError(e); 1073 return false; 1074 } 1075 finally { 1076 if (DB_LOCK_EXCLUSIVE) { 1077 yield this.queryAsync("PRAGMA main.locking_mode=EXCLUSIVE", false, { inBackup: true }); 1078 } 1079 } 1080 1081 // Open the backup to check for corruption 1082 try { 1083 var connection = storageService.openDatabase(Zotero.File.pathToFile(tmpFile)); 1084 } 1085 catch (e) { 1086 Zotero.logError(e); 1087 this._debug("Database file '" + OS.Path.basename(tmpFile) + "' can't be opened -- skipping backup"); 1088 if (yield OS.File.exists(tmpFile)) { 1089 yield OS.File.remove(tmpFile); 1090 } 1091 return false; 1092 } 1093 finally { 1094 if (connection) { 1095 let deferred = Zotero.Promise.defer(); 1096 connection.asyncClose({ 1097 complete: function () { 1098 deferred.resolve(); 1099 } 1100 }); 1101 yield deferred.promise; 1102 } 1103 } 1104 1105 // Special backup 1106 if (!suffix && numBackups > 1) { 1107 // Remove oldest backup file 1108 let targetFile = this._dbPath + '.' + (numBackups - 1) + '.bak'; 1109 if (yield OS.File.exists(targetFile)) { 1110 yield OS.File.remove(targetFile); 1111 } 1112 1113 // Shift old versions up 1114 for (var i=(numBackups - 1); i>=1; i--) { 1115 var targetNum = i; 1116 var sourceNum = targetNum - 1; 1117 1118 let targetFile = this._dbPath + '.' + targetNum + '.bak'; 1119 let sourceFile = this._dbPath + '.' + (sourceNum ? sourceNum + '.bak' : 'bak') 1120 1121 if (!(yield OS.File.exists(sourceFile))) { 1122 continue; 1123 } 1124 1125 Zotero.debug("Moving " + OS.Path.basename(sourceFile) 1126 + " to " + OS.Path.basename(targetFile)); 1127 yield OS.File.move(sourceFile, targetFile); 1128 } 1129 } 1130 1131 let backupFile = this._dbPath + '.' + (suffix ? suffix + '.' : '') + 'bak'; 1132 1133 // Remove old backup file 1134 if (yield OS.File.exists(backupFile)) { 1135 OS.File.remove(backupFile); 1136 } 1137 1138 yield OS.File.move(tmpFile, backupFile); 1139 Zotero.debug("Backed up to " + OS.Path.basename(backupFile)); 1140 1141 return true; 1142 } 1143 finally { 1144 resolveBackupPromise(); 1145 } 1146 }); 1147 1148 1149 ///////////////////////////////////////////////////////////////// 1150 // 1151 // Private methods 1152 // 1153 ///////////////////////////////////////////////////////////////// 1154 1155 Zotero.DBConnection.prototype._getConnection = function (options) { 1156 if (this._backupPromise && this._backupPromise.isPending() && (!options || !options.inBackup)) { 1157 return false; 1158 } 1159 if (this._connection === false) { 1160 throw new Error("Database permanently closed; not re-opening"); 1161 } 1162 return this._connection || false; 1163 } 1164 1165 /* 1166 * Retrieve a link to the data store asynchronously 1167 */ 1168 Zotero.DBConnection.prototype._getConnectionAsync = async function (options) { 1169 // If a backup is in progress, wait until it's done 1170 if (this._backupPromise && this._backupPromise.isPending() && (!options || !options.inBackup)) { 1171 Zotero.debug("Waiting for database backup to complete", 2); 1172 await this._backupPromise; 1173 } 1174 1175 if (this._connection) { 1176 return this._connection; 1177 } 1178 else if (this._connection === false) { 1179 throw new Error("Database permanently closed; not re-opening"); 1180 } 1181 1182 this._debug("Asynchronously opening database '" + this._dbName + "'"); 1183 Zotero.debug(this._dbPath); 1184 1185 // Get the storage service 1186 var store = Components.classes["@mozilla.org/storage/service;1"]. 1187 getService(Components.interfaces.mozIStorageService); 1188 1189 var file = this._dbPath; 1190 var backupFile = this._dbPath + '.bak'; 1191 var fileName = OS.Path.basename(file); 1192 var corruptMarker = this._dbPath + '.is.corrupt'; 1193 1194 catchBlock: try { 1195 if (await OS.File.exists(corruptMarker)) { 1196 throw new Error(this.DB_CORRUPTION_STRING); 1197 } 1198 this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({ 1199 path: file 1200 })); 1201 } 1202 catch (e) { 1203 // Don't deal with corrupted external dbs 1204 if (this._externalDB) { 1205 throw e; 1206 } 1207 1208 Zotero.logError(e); 1209 1210 if (e.message.includes(this.DB_CORRUPTION_STRING)) { 1211 this._debug(`Database file '${fileName}' corrupted`, 1); 1212 1213 // No backup file! Eek! 1214 if (!await OS.File.exists(backupFile)) { 1215 this._debug("No backup file for DB '" + this._dbName + "' exists", 1); 1216 1217 // Save damaged filed 1218 this._debug('Saving damaged DB file with .damaged extension', 1); 1219 let damagedFile = this._dbPath + '.damaged'; 1220 Zotero.moveToUnique(file, damagedFile); 1221 1222 // Create new main database 1223 this._connection = store.openDatabase(file); 1224 1225 if (await OS.File.exists(corruptMarker)) { 1226 await OS.File.remove(corruptMarker); 1227 } 1228 1229 Zotero.alert( 1230 null, 1231 Zotero.getString('startupError'), 1232 Zotero.getString('db.dbCorruptedNoBackup', fileName) 1233 ); 1234 break catchBlock; 1235 } 1236 1237 // Save damaged file 1238 this._debug('Saving damaged DB file with .damaged extension', 1); 1239 let damagedFile = this._dbPath + '.damaged'; 1240 Zotero.moveToUnique(file, damagedFile); 1241 1242 // Test the backup file 1243 try { 1244 Zotero.debug("Asynchronously opening DB connection"); 1245 this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({ 1246 path: backupFile 1247 })); 1248 } 1249 // Can't open backup either 1250 catch (e) { 1251 // Create new main database 1252 this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({ 1253 path: file 1254 })); 1255 1256 Zotero.alert( 1257 null, 1258 Zotero.getString('general.error'), 1259 Zotero.getString('db.dbRestoreFailed', fileName) 1260 ); 1261 1262 if (await OS.File.exists(corruptMarker)) { 1263 await OS.File.remove(corruptMarker); 1264 } 1265 1266 break catchBlock; 1267 } 1268 1269 this._connection = undefined; 1270 1271 // Copy backup file to main DB file 1272 this._debug("Restoring database '" + this._dbName + "' from backup file", 1); 1273 try { 1274 await OS.File.copy(backupFile, file); 1275 } 1276 catch (e) { 1277 // TODO: deal with low disk space 1278 throw (e); 1279 } 1280 1281 // Open restored database 1282 this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({ 1283 path: file 1284 })); 1285 this._debug('Database restored', 1); 1286 Zotero.alert( 1287 null, 1288 Zotero.getString('general.warning'), 1289 Zotero.getString('db.dbRestored', [ 1290 fileName, 1291 Zotero.Date.getFileDateString(Zotero.File.pathToFile(backupFile)), 1292 Zotero.Date.getFileTimeString(Zotero.File.pathToFile(backupFile)) 1293 ]) 1294 ); 1295 1296 if (await OS.File.exists(corruptMarker)) { 1297 await OS.File.remove(corruptMarker); 1298 } 1299 1300 break catchBlock; 1301 } 1302 1303 // Some other error that we don't yet know how to deal with 1304 throw (e); 1305 } 1306 1307 if (!this._externalDB) { 1308 if (DB_LOCK_EXCLUSIVE) { 1309 await this.queryAsync("PRAGMA main.locking_mode=EXCLUSIVE"); 1310 } 1311 else { 1312 await this.queryAsync("PRAGMA main.locking_mode=NORMAL"); 1313 } 1314 1315 // Set page cache size to 8MB 1316 let pageSize = await this.valueQueryAsync("PRAGMA page_size"); 1317 let cacheSize = 8192000 / pageSize; 1318 await this.queryAsync("PRAGMA cache_size=" + cacheSize); 1319 1320 // Enable foreign key checks 1321 await this.queryAsync("PRAGMA foreign_keys=true"); 1322 1323 // Register idle observer for DB backup 1324 Zotero.Schema.schemaUpdatePromise.then(() => { 1325 Zotero.debug("Initializing DB backup idle observer"); 1326 var idleService = Components.classes["@mozilla.org/widget/idleservice;1"] 1327 .getService(Components.interfaces.nsIIdleService); 1328 idleService.addIdleObserver(this, 300); 1329 }); 1330 } 1331 1332 return this._connection; 1333 }; 1334 1335 1336 Zotero.DBConnection.prototype._debug = function (str, level) { 1337 var prefix = this._dbName == 'zotero' ? '' : '[' + this._dbName + '] '; 1338 Zotero.debug(prefix + str, level); 1339 }