summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/addrbook/modules/SQLiteDirectory.jsm')
-rw-r--r--comm/mailnews/addrbook/modules/SQLiteDirectory.jsm474
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}"
+);