zotero/chrome/content/zotero/xpcom/db.js
Dan Stillman 2e2fa0dcfa *As always, but in particular this time, do not test this commit with valuable data -- but please do test.*
- Massive optimization of data layer -- with ~11,000-item test library on a Mac Pro, decreased initial Zotero pane loading from several minutes to ~10 seconds. This included some small API changes and new methods (e.g. Items.cacheFiles()) in the data layer, but most of it was changing the way loading and caching of data worked internally.

- Moved unique itemData values out to separate itemDataValues table for better normalization

- Updated itemTreeView.sort() to be able to sort a single row into the items list for performance reasons -- itemTreeView.notify() now only sorts a single row when possible (and sometimes doesn't need to sort anything). This should make general interface use dramatically less sluggish with large libraries.

- Consolidated purging on item deletes, which should speed up multi-item deletes quite a bit -- clients should use Items.erase() instead of Item.erase(), since the former calls the new Items.purge() method (which calls the various other purge() methods) automatically

- Notifier no longer throws errors in notify() callbacks and instead just logs them to the Error Console -- this way a misbehaving utility (or Zotero itself) won't keep other observers from receiving change notifications

- Better handling of database corruption -- if an SQL query throws a file corruption error, Zotero adds a marker file to the storage directory and displays a message prompting the user to restart to attempt auto-repair--and, most importantly, no longer copies the corrupt file over the last backup.

- A "Loading items list..." message appears over the items list (at least, sometimes) while data is loading -- useful for large libraries, but may need to be fine-tuned to not be annoying for smaller ones.

- Note titles are now cached in itemNoteTitles table

- orderIndex values are no longer consolidated when removing items from collections -- it just leaves gaps

- Fixed shameful bug in getRandomID() that could result in an item with itemID 0, which wouldn't display correctly and would be impossible to remove

- Fixed autocomplete and search for new location of 'title' field

- Added proper multipart date support for type-specific 'date' fields

- Made the pre-modification array passed to Notifier observers on item updates actually be pre-modification

- New method Zotero.ItemFields.isFieldOfBase(field, baseField) -- for example, isFieldOfBase('label', 'publisher') returns true, as does isFieldOfBase('publisher', 'publisher')

- Restored ability to drag child items in collections into top-level items in those collections

- Disabled unresponsive script message when opening Zotero pane (necessary for large libraries, or at least was before the optimizations)

- Collections in background windows didn't update on item changes

- Modifying an item would cause it to appear incorrectly in other collections in background windows

- Fixed an error when dragging, hovering to open, and dropping a note or attachment on another item

- Removed deprecated Notifier methods registerCollectionObserver(), registerItemObserver(), unregisterCollectionObserver(), and unregisterItemObserver()

- Loading of Zotero core object can be cancelled on error with Zotero.skipLoading

- Removed old disabled DebugLogger code

- New method Zotero.log(message, type, sourceName, sourceLine, lineNumber, columnNumber, category) to log to Error Console -- wrapper for nsIConsoleService.logMessage(nsIScriptError)

- New method Zotero.getErrors(), currently unused, to return array of error strings that have occurred since startup, excluding CSS and content JS errors -- will enable an upcoming Talkback-like feature

- Fixed some JS strict warnings in Zotero.Date.strToMultipart()
2007-03-16 16:28:50 +00:00

816 lines
20 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright (c) 2006 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://chnm.gmu.edu
Licensed under the Educational Community License, Version 1.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.opensource.org/licenses/ecl1.php
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
***** END LICENSE BLOCK *****
*/
Zotero.DBConnection = function(dbName) {
if (!dbName) {
throw ('DB name not provided in Zotero.DBConnection()');
}
// Private members
this._dbName = dbName;
this._shutdown = false;
this._connection = null;
this._transactionRollback = null;
this._transactionNestingLevel = 0;
this._callbacks = { begin: [], commit: [], rollback: [] };
this._skipBackup = false;
this._self = this;
}
/////////////////////////////////////////////////////////////////
//
// Public methods
//
/////////////////////////////////////////////////////////////////
/*
* Run an SQL query
*
* Optional _params_ is an array of bind parameters in the form
* [1,"hello",3] or [{'int':2},{'string':'foobar'}]
*
* Returns:
* - Associative array (similar to mysql_fetch_assoc) for SELECT's
* - lastInsertId for INSERT's
* - TRUE for other successful queries
* - FALSE on error
*/
Zotero.DBConnection.prototype.query = function (sql,params) {
var db = this._getDBConnection();
try {
// Parse out the SQL command being used
var op = sql.match(/^[^a-z]*[^ ]+/i);
if (op) {
op = op.toString().toLowerCase();
}
// If SELECT statement, return result
if (op=='select') {
// Until the native dataset methods work (or at least exist),
// we build a multi-dimensional associative array manually
var statement = this.getStatement(sql, params);
var dataset = new Array();
while (statement.executeStep()) {
var row = new Array();
for(var i=0; i<statement.columnCount; i++) {
row[statement.getColumnName(i)] = this._getTypedValue(statement, i);
}
dataset.push(row);
}
statement.reset();
return dataset.length ? dataset : false;
}
else {
if (params) {
var statement = this.getStatement(sql, params);
statement.execute();
}
else {
this._debug(sql,5);
db.executeSimpleSQL(sql);
}
if (op=='insert') {
return db.lastInsertRowID;
}
// DEBUG: Can't get affected rows for UPDATE or DELETE?
else {
return true;
}
}
}
catch (e) {
this.checkException(e);
var dberr = (db.lastErrorString!='not an error')
? ' [ERROR: ' + db.lastErrorString + ']' : '';
throw(e + ' [QUERY: ' + sql + ']' + dberr);
}
}
/*
* Query a single value and return it
*/
Zotero.DBConnection.prototype.valueQuery = function (sql,params) {
var statement = this.getStatement(sql, params);
// No rows
if (!statement.executeStep()) {
statement.reset();
return false;
}
var value = this._getTypedValue(statement, 0);
statement.reset();
return value;
}
/*
* Run a query and return the first row
*/
Zotero.DBConnection.prototype.rowQuery = function (sql,params) {
var result = this.query(sql,params);
if (result) {
return result[0];
}
}
/*
* Run a query and return the first column as a numerically-indexed array
*/
Zotero.DBConnection.prototype.columnQuery = function (sql,params) {
var statement = this.getStatement(sql, params);
if (statement) {
var column = new Array();
while (statement.executeStep()) {
column.push(this._getTypedValue(statement, 0));
}
statement.reset();
return column.length ? column : false;
}
return false;
}
/*
/*
* Get a raw mozStorage statement from the DB for manual processing
*
* This should only be used externally for manual parameter binding for
* large repeated queries
*
* Optional _params_ is an array of bind parameters in the form
* [1,"hello",3] or [{'int':2},{'string':'foobar'}]
*/
Zotero.DBConnection.prototype.getStatement = function (sql, params) {
var db = this._getDBConnection();
try {
this._debug(sql,5);
var statement = db.createStatement(sql);
}
catch (e) {
var dberr = (db.lastErrorString!='not an error')
? ' [ERROR: ' + db.lastErrorString + ']' : '';
throw(e + ' [QUERY: ' + sql + ']' + dberr);
}
if (params) {
// If single scalar value or single non-array object, wrap in an array
if (typeof params != 'object' || params===null ||
(params && typeof params == 'object' && !params.length)) {
params = [params];
}
for (var i=0; i<params.length; i++) {
// Integer
if (params[i]!==null && typeof params[i]['int'] != 'undefined') {
var type = 'int';
var value = params[i]['int'];
}
// String
else if (params[i]!==null && typeof params[i]['string'] != 'undefined') {
var type = 'string';
var value = params[i]['string'];
}
// Null
else if (params[i]!==null && typeof params[i]['null'] != 'undefined') {
var type = 'null';
}
// Automatic (trust the JS type)
else {
switch (typeof params[i]) {
case 'string':
var type = 'string';
break;
case 'number':
var type = 'int';
break;
// Object
default:
if (params[i]===null) {
var type = 'null';
}
else {
throw('Invalid bound parameter ' + params[i] +
' in ' + Zotero.varDump(params));
}
}
var value = params[i];
}
// Bind the parameter as the correct type
switch (type) {
case 'int':
this._debug('Binding parameter ' + (i+1)
+ ' of type int: ' + value, 5);
statement.bindInt32Parameter(i, value);
break;
case 'string':
this._debug('Binding parameter ' + (i+1)
+ ' of type string: "' + value + '"', 5);
statement.bindUTF8StringParameter(i, value);
break;
case 'null':
this._debug('Binding parameter ' + (i+1)
+ ' of type NULL', 5);
statement.bindNullParameter(i);
break;
}
}
}
return statement;
}
/*
* Only for use externally with this.getStatement()
*/
Zotero.DBConnection.prototype.getLastInsertID = function () {
var db = this._getDBConnection();
return db.lastInsertRowID;
}
/*
* Only for use externally with this.getStatement()
*/
Zotero.DBConnection.prototype.getLastErrorString = function () {
var db = this._getDBConnection();
return db.lastErrorString;
}
Zotero.DBConnection.prototype.beginTransaction = function () {
var db = this._getDBConnection();
if (db.transactionInProgress) {
this._transactionNestingLevel++;
this._debug('Transaction in progress -- increasing level to '
+ this._transactionNestingLevel, 5);
}
else {
this._debug('Beginning DB transaction', 5);
db.beginTransaction();
// Run callbacks
for (var i=0; i<this._callbacks.begin.length; i++) {
if (this._callbacks.begin[i]) {
this._callbacks.begin[i]();
}
}
}
}
Zotero.DBConnection.prototype.commitTransaction = function () {
var db = this._getDBConnection();
if (this._transactionNestingLevel) {
this._transactionNestingLevel--;
this._debug('Decreasing transaction level to ' + this._transactionNestingLevel, 5);
}
else if (this._transactionRollback) {
this._debug('Rolling back previously flagged transaction', 5);
db.rollbackTransaction();
}
else {
this._debug('Committing transaction',5);
try {
db.commitTransaction();
// Run callbacks
for (var i=0; i<this._callbacks.commit.length; i++) {
if (this._callbacks.commit[i]) {
this._callbacks.commit[i]();
}
}
}
catch(e) {
var dberr = (db.lastErrorString!='not an error')
? ' [ERROR: ' + db.lastErrorString + ']' : '';
throw(e + dberr);
}
}
}
Zotero.DBConnection.prototype.rollbackTransaction = function () {
var db = this._getDBConnection();
if (!db.transactionInProgress) {
this._debug("Transaction is not in progress in rollbackTransaction()", 2);
return;
}
if (this._transactionNestingLevel) {
this._transactionNestingLevel--;
this._transactionRollback = true;
this._debug('Flagging nested transaction for rollback', 5);
}
else {
this._debug('Rolling back transaction', 5);
this._transactionRollback = false;
try {
db.rollbackTransaction();
// Run callbacks
for (var i=0; i<this._callbacks.rollback.length; i++) {
if (this._callbacks.rollback[i]) {
this._callbacks.rollback[i]();
}
}
}
catch(e) {
var dberr = (db.lastErrorString!='not an error')
? ' [ERROR: ' + db.lastErrorString + ']' : '';
throw(e + dberr);
}
}
}
Zotero.DBConnection.prototype.addCallback = function (type, cb) {
switch (type) {
case 'begin':
case 'commit':
case 'rollback':
break;
default:
throw ("Invalid callback type '" + type + "' in DB.addCallback()");
}
var id = this._callbacks[type].length;
this._callbacks[type][id] = cb;
return id;
}
Zotero.DBConnection.prototype.removeCallback = function (type, id) {
switch (type) {
case 'begin':
case 'commit':
case 'rollback':
break;
default:
throw ("Invalid callback type '" + type + "' in DB.removeCallback()");
}
delete this._callbacks[type][id];
}
Zotero.DBConnection.prototype.transactionInProgress = function () {
var db = this._getDBConnection();
return db.transactionInProgress;
}
/**
* Safety function used on shutdown to make sure we're not stuck in the
* middle of a transaction
*/
Zotero.DBConnection.prototype.commitAllTransactions = function () {
if (this.transactionInProgress()) {
var level = this._transactionNestingLevel;
this._transactionNestingLevel = 0;
try {
this.commitTransaction();
}
catch (e) {}
return level ? level : true;
}
return false;
}
Zotero.DBConnection.prototype.tableExists = function (table) {
return this._getDBConnection().tableExists(table);
}
Zotero.DBConnection.prototype.getColumns = function (table) {
var db = this._getDBConnection();
try {
var sql = "SELECT * FROM " + table + " LIMIT 1";
var statement = this.getStatement(sql);
var cols = new Array();
for (var i=0,len=statement.columnCount; i<len; i++) {
cols.push(statement.getColumnName(i));
}
statement.reset();
return cols;
}
catch (e) {
this._debug(e,1);
return false;
}
}
Zotero.DBConnection.prototype.getColumnHash = function (table) {
var cols = this.getColumns(table);
var hash = new Array();
if (cols.length) {
for (var i=0; i<cols.length; i++) {
hash[cols[i]] = true;
}
}
return hash;
}
/**
* Find the lowest unused integer >0 in a table column
*
* Note: This retrieves all the rows of the column, so it's not really
* meant for particularly large tables.
**/
Zotero.DBConnection.prototype.getNextID = function (table, column) {
var sql = 'SELECT ' + column + ' FROM ' + table + ' ORDER BY ' + column;
var vals = this.columnQuery(sql);
if (!vals) {
return 1;
}
if (vals[0] === '0') {
vals.shift();
}
for (var i=0, len=vals.length; i<len; i++) {
if (vals[i] != i+1) {
break;
}
}
return i+1;
}
/**
* Find the next lowest numeric suffix for a value in table column
*
* For example, if "Untitled" and "Untitled 2" and "Untitled 4",
* returns "Untitled 3"
*
* DEBUG: doesn't work once there's an "Untitled 10"
*
* If _name_ alone is available, returns that
**/
Zotero.DBConnection.prototype.getNextName = function (table, field, name)
{
var sql = "SELECT " + field + " FROM " + table + " WHERE " + field
+ " LIKE ? ORDER BY " + field + " COLLATE NOCASE";
var untitleds = this.columnQuery(sql, name + '%');
if (!untitleds || untitleds[0]!=name) {
return name;
}
var i = 1;
var num = 2;
while (untitleds[i] && untitleds[i]==(name + ' ' + num)) {
while (untitleds[i+1] && untitleds[i]==untitleds[i+1]) {
this._debug('Next ' + i + ' is ' + untitleds[i]);
i++;
}
i++;
num++;
}
return name + ' ' + num;
}
/*
* Shutdown observer
*/
Zotero.DBConnection.prototype.observe = function(subject, topic, data) {
switch (topic) {
case 'xpcom-shutdown':
if (this._shutdown) {
this._debug('returning');
return;
}
var level = this.commitAllTransactions();
if (level) {
level = level === true ? '0' : level;
this._debug("A transaction in DB '" + this._dbName + "' was still open! (level " + level + ")", 2);
}
this._shutdown = true;
this.backupDatabase();
break;
}
}
Zotero.DBConnection.prototype.checkException = function (e) {
if (e.name == 'NS_ERROR_FILE_CORRUPTED') {
var file = Zotero.getZoteroDatabase(this._dbName, 'is.corrupt');
var foStream = Components.classes["@mozilla.org/network/file-output-stream;1"]
.createInstance(Components.interfaces.nsIFileOutputStream);
foStream.init(file, 0x02 | 0x08 | 0x20, 0664, 0); // write, create, truncate
foStream.write('', 0);
foStream.close();
this._skipBackup = true;
var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
.getService(Components.interfaces.nsIPromptService);
var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING);
var index = ps.confirmEx(null,
Zotero.getString('db.dbCorrupted', this._dbName),
Zotero.getString('db.dbCorrupted.restart'),
buttonFlags,
Zotero.getString('general.restartNow'),
Zotero.getString('general.restartLater'),
null, null, {});
if (index == 0) {
var appStartup = Components.classes["@mozilla.org/toolkit/app-startup;1"]
.getService(Components.interfaces.nsIAppStartup);
appStartup.quit(Components.interfaces.nsIAppStartup.eRestart);
appStartup.quit(Components.interfaces.nsIAppStartup.eAttemptQuit);
}
Zotero.skipLoading = true;
return false;
}
return true;
}
Zotero.DBConnection.prototype.backupDatabase = function () {
if (this.transactionInProgress()) {
this._debug("Transaction in progress--skipping backup of DB '" + this._dbName + "'", 2);
return false;
}
var corruptMarker = Zotero.getZoteroDatabase(this._dbName, 'is.corrupt').exists();
if (this._skipBackup || corruptMarker) {
this._debug("Database '" + this._dbName + "' is marked as corrupt--skipping backup", 1);
return false;
}
this._debug("Backing up database '" + this._dbName + "'");
var file = Zotero.getZoteroDatabase(this._dbName);
var backupFile = Zotero.getZoteroDatabase(this._dbName, 'bak');
// 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 (tmpFile.exists()) {
tmpFile.remove(null);
}
try {
file.copyTo(file.parent, tmpFile.leafName);
}
catch (e){
// TODO: deal with low disk space
throw (e);
}
try {
var store = Components.classes["@mozilla.org/storage/service;1"].
getService(Components.interfaces.mozIStorageService);
var connection = store.openDatabase(tmpFile);
}
catch (e){
this._debug("Database file '" + tmpFile.leafName + "' is corrupt--skipping backup");
return false;
}
// Remove old backup file
if (backupFile.exists()) {
backupFile.remove(null);
}
tmpFile.moveTo(tmpFile.parent, backupFile.leafName);
return true;
}
/////////////////////////////////////////////////////////////////
//
// Private methods
//
/////////////////////////////////////////////////////////////////
/*
* Retrieve a link to the data store
*/
Zotero.DBConnection.prototype._getDBConnection = function () {
if (this._connection) {
return this._connection;
}
this._debug("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';
if (this._dbName == 'zotero' && ZOTERO_CONFIG['DB_REBUILD']) {
if (confirm('Erase all user data and recreate database from schema?')) {
// Delete existing Zotero database
if (file.exists()) {
file.remove(null);
}
// Delete existing storage folder
var dir = Zotero.getStorageDirectory();
if (dir.exists()) {
dir.remove(true);
}
}
}
// DEBUG: Temporary check
// Test the backup file (to make sure the backup mechanism is working)
if (backupFile.exists()) {
try {
this._connection = store.openDatabase(backupFile);
}
catch (e) {
this._debug("Backup file '" + backupFile.leafName + "' was corrupt!", 1);
}
this._connection = undefined;
}
catchBlock: try {
var corruptMarker = Zotero.getZoteroDatabase(this._dbName, 'is.corrupt');
if (corruptMarker.exists()) {
throw({ name: 'NS_ERROR_FILE_CORRUPTED' })
}
this._connection = store.openDatabase(file);
}
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);
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 {
this._connection = store.openDatabase(backupFile);
}
// Can't open backup either
catch (e) {
// Create new main database
var file = Zotero.getZoteroDatabase(this._dbName);
this._connection = store.openDatabase(file);
alert(Zotero.getString('db.dbRestoreFailed', fileName));
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 = store.openDatabase(file);
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);
}
// Register shutdown handler to call this.onShutdown() for DB backup
var observerService = Components.classes["@mozilla.org/observer-service;1"]
.getService(Components.interfaces.nsIObserverService);
observerService.addObserver(this, "xpcom-shutdown", false);
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);
switch (type) {
case statement.VALUE_TYPE_INTEGER:
var func = statement.getInt64;
break;
case statement.VALUE_TYPE_TEXT:
var func = statement.getUTF8String;
break;
case statement.VALUE_TYPE_NULL:
return null;
case statement.VALUE_TYPE_FLOAT:
var func = statement.getDouble;
break;
case statement.VALUE_TYPE_BLOB:
var func = statement.getBlob;
break;
}
return func(i);
}
// Initialize main database connection
Zotero.DB = new Zotero.DBConnection('zotero');