view extension/modules/datastore.js @ 51:7938fd5e956a

first version of river of news view that works
author Myk Melez <myk@mozilla.org>
date Wed, 07 May 2008 13:45:18 -0700
parents c1e19ca9f364
children 30739558a3e7
line wrap: on
line source

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;
const Cu = Components.utils;

EXPORTED_SYMBOLS = ["SnowlDatastore"];

const TABLE_TYPE_NORMAL = 0;
const TABLE_TYPE_FULLTEXT = 1;

let SnowlDatastore = {
  get _storage() {
    var storage = Cc["@mozilla.org/storage/service;1"].
                  getService(Ci.mozIStorageService);
    this.__defineGetter__("_storage", function() { return storage });
    return this._storage;
  },

  //**************************************************************************//
  // Database Creation & Access

  _dbVersion: 4,

  _dbSchema: {
    // Note: the timestamp is stored as JavaScript milliseconds since epoch.

    // Note: the universalID is a unique identifier established by the source
    // of the message which remains constant across message transfer points
    // and destinations.  For feeds this is the entry ID; for email messages
    // it's the message ID.

    // FIXME: make the datastore support multiple authors.
    // FIXME: support labeling the subject as HTML or another content type.
    // FIXME: make universalID be called externalID instead.
    // FIXME: index by universalID to make lookups (f.e. when checking if we
    // already have a message) and updates (f.e. when setting current) faster.

    tables: {
      sources: {
        type: TABLE_TYPE_NORMAL,
        columns: [
          "id INTEGER PRIMARY KEY",
          // FIXME: rename this 'link'
          "url TEXT NOT NULL",
          "title TEXT NOT NULL",
          "lastRefreshed INTEGER",
        ]
      },

      messages: {
        type: TABLE_TYPE_NORMAL,
        columns: [
          "id INTEGER PRIMARY KEY",
          "sourceID INTEGER NOT NULL REFERENCES sources(id)",
          "universalID TEXT",
          "subject TEXT",
          "author TEXT",
          "timestamp INTEGER",
          "link TEXT",
          "current BOOLEAN",
          "read BOOLEAN"
        ]
      },

/*
      parts: {
        type: TABLE_TYPE_FULLTEXT,
        columns: [
          "messageID INTEGER NOT NULL REFERENCES messages(id)",
          "contentType",
          "content"
        ]
      },
*/

      attributes: {
        type: TABLE_TYPE_NORMAL,
        columns: [
          "id INTEGER PRIMARY KEY",
          "namespace TEXT",
          "name TEXT NOT NULL"
        ]
      },

      metadata: {
        type: TABLE_TYPE_FULLTEXT,
        columns: [
          "messageID INTEGER NOT NULL REFERENCES messages(id)",
          "attributeID INTEGER NOT NULL REFERENCES attributes(id)",
          "contentType TEXT NOT NULL",
          "value BLOB"
        ]
      }

    },

    fulltextTables: {
      // FIXME: add "primary" boolean column that identifies the main content
      // for the message (or put that into the messages table?).
      parts:      "id INTEGER PRIMARY KEY, \
                   messageID INTEGER NOT NULL REFERENCES messages(id), \
                   title TEXT, \
                   content BLOB NOT NULL, \
                   contentType TEXT NOT NULL"
    },

    indices: {}
  },

  dbConnection: null,

  createStatement: function(aSQLString) {
    try {
      var statement = this.dbConnection.createStatement(aSQLString);
    }
    catch(ex) {
      Cu.reportError("error creating statement " + aSQLString + ": " +
                     this.dbConnection.lastError + " - " +
                     this.dbConnection.lastErrorString);
      throw ex;
    }

    var wrappedStatement = Cc["@mozilla.org/storage/statement-wrapper;1"].
                           createInstance(Ci.mozIStorageStatementWrapper);
    wrappedStatement.initialize(statement);
    return wrappedStatement;
  },

  // _dbInit, the methods it calls (_dbCreateTables, _dbMigrate), and methods
  // those methods call must be careful not to call any method of the service
  // that assumes the database connection has already been initialized,
  // since it won't be initialized until this function returns.

  _dbInit: function() {
    var dirService = Cc["@mozilla.org/file/directory_service;1"].
                     getService(Ci.nsIProperties);
    var dbFile = dirService.get("ProfD", Ci.nsIFile);
    dbFile.append("messages.sqlite");

    var dbService = Cc["@mozilla.org/storage/service;1"].
                    getService(Ci.mozIStorageService);

    var dbConnection;

    if (!dbFile.exists()) {
      dbConnection = this._dbCreate(dbService, dbFile);
    }
    else {
      try {
        dbConnection = dbService.openUnsharedDatabase(dbFile);

        // Get the version of the database in the file.
        var version = dbConnection.schemaVersion;

        if (version != this._dbVersion)
          this._dbMigrate(dbConnection, version, this._dbVersion);
      }
      catch (ex) {
        // If the database file is corrupted, I'm not sure whether we should
        // just delete the corrupted file or back it up.  For now I'm just
        // deleting it, but here's some code that backs it up (but doesn't limit
        // the number of backups, which is probably necessary, thus I'm not
        // using this code):
        //var backup = this._dbFile.clone();
        //backup.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
        //backup.remove(false);
        //this._dbFile.moveTo(null, backup.leafName);
        if (ex.result == Cr.NS_ERROR_FILE_CORRUPTED) {
          // Remove the corrupted file, then recreate it.
          dbFile.remove(false);
          dbConnection = this._dbCreate(dbService, dbFile);
        }
        else
          throw ex;
      }
    }

    this.dbConnection = dbConnection;
  },

  _dbCreate: function(aDBService, aDBFile) {
    var dbConnection = aDBService.openUnsharedDatabase(aDBFile);

    dbConnection.beginTransaction();
    try {
      this._dbCreateTables(dbConnection);
      dbConnection.commitTransaction();
    }
    catch(ex) {
      dbConnection.rollbackTransaction();
      throw ex;
    }

    return dbConnection;
  },

  _dbCreateTables: function(aDBConnection) {
    for (var tableName in this._dbSchema.tables) {
      var table = this._dbSchema.tables[tableName];
      switch (table.type) {
        case TABLE_TYPE_FULLTEXT:
          this._dbCreateFulltextTable(aDBConnection, tableName, table.columns);
          break;
        case TABLE_TYPE_NORMAL:
        default:
          aDBConnection.createTable(tableName, table.columns.join(", "));
          break;
      }
    }

    aDBConnection.schemaVersion = this._dbVersion;
  },

  _dbCreateFulltextTable: function(aDBConnection, aTableName, aColumns) {
    aDBConnection.executeSimpleSQL(
      "CREATE VIRTUAL TABLE " + aTableName +
      " USING fts3(" + aColumns.join(", ") + ")"
    );
  },

  _dbMigrate: function(aDBConnection, aOldVersion, aNewVersion) {
    if (this["_dbMigrate" + aOldVersion + "To" + aNewVersion]) {
      aDBConnection.beginTransaction();
      try {
        this["_dbMigrate" + aOldVersion + "To" + aNewVersion](aDBConnection);
        aDBConnection.schemaVersion = aNewVersion;
        aDBConnection.commitTransaction();
      }
      catch(ex) {
        aDBConnection.rollbackTransaction();
        throw ex;
      }
    }
    else
      throw("can't migrate database from v" + aOldVersion +
            " to v" + aNewVersion + ": no migrator function");
  },

  /**
   * Migrate the database schema from version 0 to version 1.  We never create
   * a database with version 0, so the database can only have that version
   * if the database file was created without the schema being constructed.
   * Thus migrating the database is as simple as constructing the schema as if
   * from scratch.
   */
  _dbMigrate0To2: function(aDBConnection) {
    this._dbCreateTables(aDBConnection);
  },

  _dbMigrate2To3: function(aDBConnection) {
    aDBConnection.executeSimpleSQL("ALTER TABLE messages ADD COLUMN current BOOLEAN");
  },

  _dbMigrate3To4: function(aDBConnection) {
    aDBConnection.executeSimpleSQL("ALTER TABLE messages ADD COLUMN read BOOLEAN");
  },

  get _selectSourcesStatement() {
    let statement = this.createStatement(
      "SELECT id, url, title, lastRefreshed FROM sources"
    );
    this.__defineGetter__("_selectSourcesStatement", function() { return statement });
    return this._selectSourcesStatement;
  },

  selectSources: function() {
    let sources = [];

    try {
      while (this._selectSourcesStatement.step()) {
        let row = this._selectSourcesStatement.row;
        sources.push({ id: row.id,
                       url: row.url,
                       title: row.title,
                       lastRefreshed: new Date(row.lastRefreshed)
                     });
      }
    }
    finally {
      this._selectSourcesStatement.reset();
    }

    return sources;
  },

  get _selectSourceIDStatement() {
    let statement = this.createStatement(
      "SELECT id FROM sources WHERE url = :url"
    );
    this.__defineGetter__("_selectSourceIDStatement", function() { return statement });
    return this._selectSourceIDStatement;
  },

  selectSourceID: function(aURL) {
    let id;

    try {
      this._selectSourceIDStatement.params.url = aURL;
      if (this._selectSourceIDStatement.step())
        id = this._selectSourceIDStatement.row["id"];
    }
    finally {
      this._selectSourceIDStatement.reset();
    }

    return id;
  },

  get _selectHasMessageStatement() {
    let statement = this.createStatement(
      "SELECT 1 FROM messages WHERE universalID = :universalID"
    );
    this.__defineGetter__("_selectHasMessageStatement", function() { return statement });
    return this._selectHasMessageStatement;
  },

  selectHasMessage: function(aUniversalID) {
    try {
      this._selectHasMessageStatement.params.universalID = aUniversalID;
      if (this._selectHasMessageStatement.step())
        return true;
    }
    finally {
      this._selectHasMessageStatement.reset();
    }

    return false;
  },

  get _selectInternalIDForExternalIDStatement() {
    let statement = this.createStatement(
      "SELECT id FROM messages WHERE universalID = :externalID"
    );
    this.__defineGetter__("_selectInternalIDForExternalIDStatement", function() { return statement });
    return this._selectInternalIDForExternalIDStatement;
  },

  selectInternalIDForExternalID: function(aExternalID) {
    let internalID;

    try {
      this._selectInternalIDForExternalIDStatement.params.externalID = aExternalID;
      if (this._selectInternalIDForExternalIDStatement.step())
        internalID = this._selectInternalIDForExternalIDStatement.row["id"];
    }
    finally {
      this._selectInternalIDForExternalIDStatement.reset();
    }

    return internalID;
  },

  get _insertMessageStatement() {
    let statement = this.createStatement(
      "INSERT INTO messages(sourceID, universalID, subject, author, timestamp, link) \
       VALUES (:sourceID, :universalID, :subject, :author, :timestamp, :link)"
    );
    this.__defineGetter__("_insertMessageStatement", function() { return statement });
    return this._insertMessageStatement;
  },

  /**
   * Insert a record into the messages table.
   * 
   * @param aSourceID    {integer} the record ID of the message source
   * @param aUniversalID {string}  the universal ID of the message
   * @param aSubject     {string}  the title of the message
   * @param aAuthor      {string}  the author of the message
   * @param aTimestamp   {Date}    the date/time at which the message was sent
   * @param aLink        {nsIURI}  a link to the content of the message,
   *                               if the content is hosted on a server
   *
   * @returns {integer} the ID of the newly-created record
   */
  insertMessage: function(aSourceID, aUniversalID, aSubject, aAuthor, aTimestamp, aLink) {
    this._insertMessageStatement.params.sourceID = aSourceID;
    this._insertMessageStatement.params.universalID = aUniversalID;
    this._insertMessageStatement.params.subject = aSubject;
    this._insertMessageStatement.params.author = aAuthor;
    this._insertMessageStatement.params.timestamp = aTimestamp;
    this._insertMessageStatement.params.link = aLink;
    this._insertMessageStatement.execute();
    return this.dbConnection.lastInsertRowID;
  },

  get _insertPartStatement() {
    let statement = this.createStatement(
      "INSERT INTO parts(messageID, content, contentType) \
       VALUES (:messageID, :content, :contentType)"
    );
    this.__defineGetter__("_insertPartStatement", function() { return statement });
    return this._insertPartStatement;
  },

  /**
   * Insert a record into the parts table.
   * 
   * @param aMessageID    {integer} the record ID of the message
   * @param aContentType  {string}  the Internet media type of the content
   * @param aContent      {string}  the content
   *
   * @returns {integer} the ID of the newly-created record
   */
  insertPart: function(aMessageID, aContent, aContentType) {
    this._insertPartStatement.params.messageID = aMessageID;
    this._insertPartStatement.params.content = aContent;
    this._insertPartStatement.params.contentType = aContentType;
    this._insertPartStatement.execute();
    return this.dbConnection.lastInsertRowID;
  },

  get _selectAttributeIDStatement() {
    let statement = this.createStatement(
      "SELECT id FROM attributes WHERE name = :name"
    );
    this.__defineGetter__("_selectAttributeIDStatement", function() { return statement });
    return this._selectAttributeIDStatement;
  },

  selectAttributeID: function(aName) {
    let id;

    try {
      this._selectAttributeIDStatement.params.name = aName;
      if (this._selectAttributeIDStatement.step())
        id = this._selectAttributeIDStatement.row["id"];
    }
    finally {
      this._selectAttributeIDStatement.reset();
    }

    return id;
  },

  get _insertAttributeStatement() {
    let statement = this.createStatement(
      "INSERT INTO attributes (name) VALUES (:name)"
    );
    this.__defineGetter__("_insertAttributeStatement", function() { return statement });
    return this._insertAttributeStatement;
  },

  /**
   * Insert a record into the attributes table.
   * 
   * @param aName         {string} the name of the attribute
   *
   * @returns {integer} the record ID of the newly-created record
   */
  insertAttribute: function(aName) {
    this._insertAttributeStatement.params.name = aName;
    this._insertAttributeStatement.execute();
    return this.dbConnection.lastInsertRowID;
  },

  get _insertMetadatumStatement() {
    let statement = this.createStatement(
      "INSERT INTO metadata (messageID, attributeID, value) \
       VALUES (:messageID, :attributeID, :value)"
    );
    this.__defineGetter__("_insertMetadatumStatement", function() { return statement });
    return this._insertMetadatumStatement;
  },

  /**
   * Insert a record into the metadata table.
   * 
   * @param aMessageID    {integer} the record ID of the message
   * @param aAttributeID  {integer} the record ID of the attribute
   * @param aValue        {string}  the value of the metadatum
   *
   * @returns {integer} the record ID of the newly-created record
   */
  insertMetadatum: function(aMessageID, aAttributeID, aValue) {
    this._insertMetadatumStatement.params.messageID = aMessageID;
    this._insertMetadatumStatement.params.attributeID = aAttributeID;

try {
    this._insertMetadatumStatement.params.value = aValue;
}
catch(ex) {
  dump(ex + " with attribute ID: " + aAttributeID + " and value: " + aValue + "\n");
  throw ex;
}

    this._insertMetadatumStatement.execute();
    return this.dbConnection.lastInsertRowID;
  }
};

SnowlDatastore._dbInit();