diff options
Diffstat (limited to '')
-rw-r--r-- | comm/mailnews/addrbook/modules/SQLiteDirectory.jsm | 474 |
1 files changed, 474 insertions, 0 deletions
diff --git a/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm b/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm new file mode 100644 index 0000000000..a89f2880d7 --- /dev/null +++ b/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm @@ -0,0 +1,474 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SQLiteDirectory"]; + +const { AddrBookDirectory } = ChromeUtils.import( + "resource:///modules/AddrBookDirectory.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +const { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + newUID: "resource:///modules/AddrBookUtils.jsm", +}); + +var log = console.createInstance({ + prefix: "mail.addr_book", + maxLogLevel: "Warn", + maxLogLevelPref: "mail.addr_book.loglevel", +}); + +// Track all directories by filename, for SQLiteDirectory.forFile. +var directories = new Map(); + +// Keep track of all database connections, and close them at shutdown, since +// nothing else ever tells us to close them. +var connections = new Map(); + +/** + * Opens an SQLite connection to `file`, caches the connection, and upgrades + * the database schema if necessary. + */ +function openConnectionTo(file) { + const CURRENT_VERSION = 4; + + let connection = connections.get(file.path); + if (!connection) { + connection = Services.storage.openDatabase(file); + let fileVersion = connection.schemaVersion; + + // If we're upgrading the version, first create a backup. + if (fileVersion > 0 && fileVersion < CURRENT_VERSION) { + let backupFile = file.clone(); + backupFile.leafName = backupFile.leafName.replace( + /\.sqlite$/, + `.v${fileVersion}.sqlite` + ); + backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + + log.warn(`Backing up ${file.leafName} to ${backupFile.leafName}`); + file.copyTo(null, backupFile.leafName); + } + + switch (fileVersion) { + case 0: + connection.executeSimpleSQL("PRAGMA journal_mode=WAL"); + connection.executeSimpleSQL( + "CREATE TABLE properties (card TEXT, name TEXT, value TEXT)" + ); + connection.executeSimpleSQL( + "CREATE TABLE lists (uid TEXT PRIMARY KEY, name TEXT, nickName TEXT, description TEXT)" + ); + connection.executeSimpleSQL( + "CREATE TABLE list_cards (list TEXT, card TEXT, PRIMARY KEY(list, card))" + ); + // Falls through. + case 1: + connection.executeSimpleSQL( + "CREATE INDEX properties_card ON properties(card)" + ); + connection.executeSimpleSQL( + "CREATE INDEX properties_name ON properties(name)" + ); + // Falls through. + case 2: + connection.executeSimpleSQL("DROP TABLE IF EXISTS cards"); + // The lists table may have a localId column we no longer use, but + // since SQLite can't drop columns it's not worth effort to remove it. + // Falls through. + case 3: + // This version exists only to create an automatic backup before cards + // are transitioned to vCard. + connection.schemaVersion = CURRENT_VERSION; + break; + } + connections.set(file.path, connection); + } + return connection; +} + +/** + * Closes the SQLite connection to `file` and removes it from the cache. + */ +function closeConnectionTo(file) { + let connection = connections.get(file.path); + if (connection) { + return new Promise(resolve => { + connection.asyncClose({ + complete() { + resolve(); + }, + }); + connections.delete(file.path); + }); + } + return Promise.resolve(); +} + +// Close all open connections at shut down time. +AsyncShutdown.profileBeforeChange.addBlocker( + "Address Book: closing databases", + async () => { + let promises = []; + for (let directory of directories.values()) { + promises.push(directory.cleanUp()); + } + await Promise.allSettled(promises); + } +); + +// Close a connection on demand. This serves as an escape hatch from C++ code. +Services.obs.addObserver(async file => { + file.QueryInterface(Ci.nsIFile); + await closeConnectionTo(file); + Services.obs.notifyObservers(file, "addrbook-close-ab-complete"); +}, "addrbook-close-ab"); + +/** + * Adds SQLite storage to AddrBookDirectory. + */ +class SQLiteDirectory extends AddrBookDirectory { + init(uri) { + let uriParts = /^[\w-]+:\/\/([\w\.-]+\.\w+)$/.exec(uri); + if (!uriParts) { + throw new Components.Exception( + `Unexpected uri: ${uri}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + this._uri = uri; + let fileName = uriParts[1]; + if (fileName.includes("/")) { + fileName = fileName.substring(0, fileName.indexOf("/")); + } + + for (let child of Services.prefs.getChildList("ldap_2.servers.")) { + if ( + child.endsWith(".filename") && + Services.prefs.getStringPref(child) == fileName + ) { + this._dirPrefId = child.substring(0, child.length - ".filename".length); + break; + } + } + if (!this._dirPrefId) { + throw Components.Exception( + `Couldn't grab dirPrefId for uri=${uri}, fileName=${fileName}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + // Make sure we always have a file. If a file is not created, the + // filename may be accidentally reused. + let file = lazy.FileUtils.getFile("ProfD", [fileName]); + if (!file.exists()) { + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + } + + this._fileName = fileName; + + super.init(uri); + + directories.set(fileName, this); + // Create the DB connection here already, to let init() throw on corrupt SQLite files. + this._dbConnection; + } + async cleanUp() { + await super.cleanUp(); + + if (this.hasOwnProperty("_file")) { + await closeConnectionTo(this._file); + delete this._file; + } + + directories.delete(this._fileName); + } + + get _dbConnection() { + this._file = lazy.FileUtils.getFile("ProfD", [this.fileName]); + let connection = openConnectionTo(this._file); + + // SQLite cache size can be set by the cacheSize preference, in KiB. + // The default is 5 MiB but this can be lowered to 1 MiB if wanted. + // There is no maximum size. + let cacheSize = this.getIntValue("cacheSize", 5120); // 5 MiB + cacheSize = Math.max(cacheSize, 1024); // 1 MiB + connection.executeSimpleSQL(`PRAGMA cache_size=-${cacheSize}`); + + Object.defineProperty(this, "_dbConnection", { + enumerable: true, + value: connection, + writable: false, + }); + return connection; + } + get lists() { + let listCache = new Map(); + let selectStatement = this._dbConnection.createStatement( + "SELECT uid, name, nickName, description FROM lists" + ); + while (selectStatement.executeStep()) { + listCache.set(selectStatement.row.uid, { + uid: selectStatement.row.uid, + name: selectStatement.row.name, + nickName: selectStatement.row.nickName, + description: selectStatement.row.description, + }); + } + selectStatement.finalize(); + + Object.defineProperty(this, "lists", { + enumerable: true, + value: listCache, + writable: false, + }); + return listCache; + } + get cards() { + let cardCache = new Map(); + let propertiesStatement = this._dbConnection.createStatement( + "SELECT card, name, value FROM properties" + ); + while (propertiesStatement.executeStep()) { + let uid = propertiesStatement.row.card; + if (!cardCache.has(uid)) { + cardCache.set(uid, new Map()); + } + let card = cardCache.get(uid); + if (card) { + card.set(propertiesStatement.row.name, propertiesStatement.row.value); + } + } + propertiesStatement.finalize(); + + Object.defineProperty(this, "cards", { + enumerable: true, + value: cardCache, + writable: false, + }); + return cardCache; + } + + loadCardProperties(uid) { + if (this.hasOwnProperty("cards")) { + let cachedCard = this.cards.get(uid); + if (cachedCard) { + return new Map(cachedCard); + } + } + let properties = new Map(); + let propertyStatement = this._dbConnection.createStatement( + "SELECT name, value FROM properties WHERE card = :card" + ); + propertyStatement.params.card = uid; + while (propertyStatement.executeStep()) { + properties.set(propertyStatement.row.name, propertyStatement.row.value); + } + propertyStatement.finalize(); + return properties; + } + saveCardProperties(uid, properties) { + try { + this._dbConnection.beginTransaction(); + let deleteStatement = this._dbConnection.createStatement( + "DELETE FROM properties WHERE card = :card" + ); + deleteStatement.params.card = uid; + deleteStatement.execute(); + let insertStatement = this._dbConnection.createStatement( + "INSERT INTO properties VALUES (:card, :name, :value)" + ); + + for (let [name, value] of properties) { + if (value !== null && value !== undefined && value !== "") { + insertStatement.params.card = uid; + insertStatement.params.name = name; + insertStatement.params.value = value; + insertStatement.execute(); + insertStatement.reset(); + } + } + + this._dbConnection.commitTransaction(); + deleteStatement.finalize(); + insertStatement.finalize(); + } catch (ex) { + this._dbConnection.rollbackTransaction(); + throw ex; + } + } + deleteCard(uid) { + let deleteStatement = this._dbConnection.createStatement( + "DELETE FROM properties WHERE card = :cardUID" + ); + deleteStatement.params.cardUID = uid; + deleteStatement.execute(); + deleteStatement.finalize(); + } + saveList(list) { + // Ensure list cache exists. + this.lists; + + let replaceStatement = this._dbConnection.createStatement( + "REPLACE INTO lists (uid, name, nickName, description) " + + "VALUES (:uid, :name, :nickName, :description)" + ); + replaceStatement.params.uid = list._uid; + replaceStatement.params.name = list._name; + replaceStatement.params.nickName = list._nickName; + replaceStatement.params.description = list._description; + replaceStatement.execute(); + replaceStatement.finalize(); + + this.lists.set(list._uid, { + uid: list._uid, + name: list._name, + nickName: list._nickName, + description: list._description, + }); + } + deleteList(uid) { + let deleteListStatement = this._dbConnection.createStatement( + "DELETE FROM lists WHERE uid = :uid" + ); + deleteListStatement.params.uid = uid; + deleteListStatement.execute(); + deleteListStatement.finalize(); + + if (this.hasOwnProperty("lists")) { + this.lists.delete(uid); + } + + this._dbConnection.executeSimpleSQL( + "DELETE FROM list_cards WHERE list NOT IN (SELECT DISTINCT uid FROM lists)" + ); + } + async bulkAddCards(cards) { + if (cards.length == 0) { + return; + } + + let usedUIDs = new Set(); + let propertiesStatement = this._dbConnection.createStatement( + "INSERT INTO properties VALUES (:card, :name, :value)" + ); + let propertiesArray = propertiesStatement.newBindingParamsArray(); + for (let card of cards) { + let uid = card.UID; + if (!uid || usedUIDs.has(uid)) { + // A card cannot have the same UID as one that already exists. + // Assign a new UID to avoid losing data. + uid = lazy.newUID(); + } + usedUIDs.add(uid); + + let cachedCard; + if (this.hasOwnProperty("cards")) { + cachedCard = new Map(); + this.cards.set(uid, cachedCard); + } + + for (let [name, value] of this.prepareToSaveCard(card)) { + let propertiesParams = propertiesArray.newBindingParams(); + propertiesParams.bindByName("card", uid); + propertiesParams.bindByName("name", name); + propertiesParams.bindByName("value", value); + propertiesArray.addParams(propertiesParams); + + if (cachedCard) { + cachedCard.set(name, value); + } + } + } + try { + this._dbConnection.beginTransaction(); + if (propertiesArray.length > 0) { + propertiesStatement.bindParameters(propertiesArray); + await new Promise((resolve, reject) => { + propertiesStatement.executeAsync({ + handleError(error) { + this._error = error; + }, + handleCompletion(status) { + if (status == Ci.mozIStorageStatementCallback.REASON_ERROR) { + reject( + Components.Exception(this._error.message, Cr.NS_ERROR_FAILURE) + ); + } else { + resolve(); + } + }, + }); + }); + propertiesStatement.finalize(); + } + this._dbConnection.commitTransaction(); + + Services.obs.notifyObservers(this, "addrbook-directory-invalidated"); + } catch (ex) { + this._dbConnection.rollbackTransaction(); + throw ex; + } + } + + /* nsIAbDirectory */ + + get childCardCount() { + let countStatement = this._dbConnection.createStatement( + "SELECT COUNT(DISTINCT card) AS card_count FROM properties" + ); + countStatement.executeStep(); + let count = countStatement.row.card_count; + countStatement.finalize(); + return count; + } + getCardFromProperty(property, value, caseSensitive) { + let sql = caseSensitive + ? "SELECT card FROM properties WHERE name = :name AND value = :value LIMIT 1" + : "SELECT card FROM properties WHERE name = :name AND LOWER(value) = LOWER(:value) LIMIT 1"; + let selectStatement = this._dbConnection.createStatement(sql); + selectStatement.params.name = property; + selectStatement.params.value = value; + let result = null; + if (selectStatement.executeStep()) { + result = this.getCard(selectStatement.row.card); + } + selectStatement.finalize(); + return result; + } + getCardsFromProperty(property, value, caseSensitive) { + let sql = caseSensitive + ? "SELECT card FROM properties WHERE name = :name AND value = :value" + : "SELECT card FROM properties WHERE name = :name AND LOWER(value) = LOWER(:value)"; + let selectStatement = this._dbConnection.createStatement(sql); + selectStatement.params.name = property; + selectStatement.params.value = value; + let results = []; + while (selectStatement.executeStep()) { + results.push(this.getCard(selectStatement.row.card)); + } + selectStatement.finalize(); + return results; + } + + static forFile(fileName) { + return directories.get(fileName); + } +} +SQLiteDirectory.prototype.classID = Components.ID( + "{e96ee804-0bd3-472f-81a6-8a9d65277ad3}" +); |