Bug 1265836 - Part 3: Implement insert and insertMany in History.jsm. r=mak
☠☠ backed out by 4dfbc3031061 ☠ ☠
authorBob Silverberg <bsilverberg@mozilla.com>
Fri, 13 May 2016 11:09:06 -0400
changeset 338119 548660e408d00c05ece753fdc6a29621226aba8a
parent 338118 800df6b80dc0f1ca053203696e5a9be8722006f3
child 338120 1151e3a09e77a29b6089657df1b3e395430480e9
push id6249
push userjlund@mozilla.com
push dateMon, 01 Aug 2016 13:59:36 +0000
treeherdermozilla-beta@bad9d4f5bf7e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmak
bugs1265836
milestone49.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1265836 - Part 3: Implement insert and insertMany in History.jsm. r=mak MozReview-Commit-ID: GmXVDPuULtq
toolkit/components/places/Bookmarks.jsm
toolkit/components/places/History.jsm
toolkit/components/places/PlacesUtils.jsm
toolkit/components/places/tests/history/test_insert.js
toolkit/components/places/tests/history/xpcshell.ini
--- a/toolkit/components/places/Bookmarks.jsm
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -171,29 +171,29 @@ var Bookmarks = Object.freeze({
       }
 
       let item = yield insertBookmark(insertInfo, parent);
 
       // Notify onItemAdded to listeners.
       let observers = PlacesUtils.bookmarks.getObservers();
       // We need the itemId to notify, though once the switch to guids is
       // complete we may stop using it.
-      let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
+      let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
       let itemId = yield PlacesUtils.promiseItemId(item.guid);
       notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
                                          item.type, uri, item.title || null,
-                                         toPRTime(item.dateAdded), item.guid,
+                                         PlacesUtils.toPRTime(item.dateAdded), item.guid,
                                          item.parentGuid ]);
 
       // If it's a tag, notify OnItemChanged to all bookmarks for this URL.
       let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
       if (isTagging) {
         for (let entry of (yield fetchBookmarksByURL(item))) {
           notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
-                                               toPRTime(entry.lastModified),
+                                               PlacesUtils.toPRTime(entry.lastModified),
                                                entry.type, entry._parentId,
                                                entry.guid, entry.parentGuid,
                                                "" ]);
         }
       }
 
       // Remove non-enumerable properties.
       return Object.assign({}, item);
@@ -318,36 +318,36 @@ var Bookmarks = Object.freeze({
         let observers = PlacesUtils.bookmarks.getObservers();
         // For lastModified, we only care about the original input, since we
         // should not notify implciit lastModified changes.
         if (info.hasOwnProperty("lastModified") &&
             updateInfo.hasOwnProperty("lastModified") &&
             item.lastModified != updatedItem.lastModified) {
           notify(observers, "onItemChanged", [ updatedItem._id, "lastModified",
                                                false,
-                                               `${toPRTime(updatedItem.lastModified)}`,
-                                               toPRTime(updatedItem.lastModified),
+                                               `${PlacesUtils.toPRTime(updatedItem.lastModified)}`,
+                                               PlacesUtils.toPRTime(updatedItem.lastModified),
                                                updatedItem.type,
                                                updatedItem._parentId,
                                                updatedItem.guid,
                                                updatedItem.parentGuid, "" ]);
         }
         if (updateInfo.hasOwnProperty("title")) {
           notify(observers, "onItemChanged", [ updatedItem._id, "title",
                                                false, updatedItem.title,
-                                               toPRTime(updatedItem.lastModified),
+                                               PlacesUtils.toPRTime(updatedItem.lastModified),
                                                updatedItem.type,
                                                updatedItem._parentId,
                                                updatedItem.guid,
                                                updatedItem.parentGuid, "" ]);
         }
         if (updateInfo.hasOwnProperty("url")) {
           notify(observers, "onItemChanged", [ updatedItem._id, "uri",
                                                false, updatedItem.url.href,
-                                               toPRTime(updatedItem.lastModified),
+                                               PlacesUtils.toPRTime(updatedItem.lastModified),
                                                updatedItem.type,
                                                updatedItem._parentId,
                                                updatedItem.guid,
                                                updatedItem.parentGuid,
                                                item.url.href ]);
         }
         // If the item was moved, notify onItemMoved.
         if (item.parentGuid != updatedItem.parentGuid ||
@@ -403,26 +403,26 @@ var Bookmarks = Object.freeze({
       let item = yield fetchBookmark(removeInfo);
       if (!item)
         throw new Error("No bookmarks found for the provided GUID.");
 
       item = yield removeBookmark(item, options);
 
       // Notify onItemRemoved to listeners.
       let observers = PlacesUtils.bookmarks.getObservers();
-      let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
+      let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
       notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
                                            item.type, uri, item.guid,
                                            item.parentGuid ]);
 
       let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
       if (isUntagging) {
         for (let entry of (yield fetchBookmarksByURL(item))) {
           notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
-                                               toPRTime(entry.lastModified),
+                                               PlacesUtils.toPRTime(entry.lastModified),
                                                entry.type, entry._parentId,
                                                entry.guid, entry.parentGuid,
                                                "" ]);
         }
       }
 
       // Remove non-enumerable properties.
       return Object.assign({}, item);
@@ -437,17 +437,17 @@ var Bookmarks = Object.freeze({
    * @return {Promise} resolved when the removal is complete.
    * @resolves once the removal is complete.
    */
   eraseEverything: function() {
     return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything",
       db => db.executeTransaction(function* () {
         const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid];
         yield removeFoldersContents(db, folderGuids);
-        const time = toPRTime(new Date());
+        const time = PlacesUtils.toPRTime(new Date());
         for (let folderGuid of folderGuids) {
           yield db.executeCached(
             `UPDATE moz_bookmarks SET lastModified = :time
              WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
             `, { folderGuid, time });
         }
       }.bind(this))
     );
@@ -764,17 +764,17 @@ function notify(observers, notification,
 // Update implementation.
 
 function updateBookmark(info, item, newParent) {
   return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
     Task.async(function*(db) {
 
     let tuples = new Map();
     if (info.hasOwnProperty("lastModified"))
-      tuples.set("lastModified", { value: toPRTime(info.lastModified) });
+      tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) });
     if (info.hasOwnProperty("title"))
       tuples.set("title", { value: info.title });
 
     yield db.executeTransaction(function* () {
       if (info.hasOwnProperty("url")) {
         // Ensure a page exists in moz_places for this URL.
         yield db.executeCached(
           `INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
@@ -881,18 +881,18 @@ function insertBookmark(item, parent) {
       // Insert the bookmark into the database.
       yield db.executeCached(
         `INSERT INTO moz_bookmarks (fk, type, parent, position, title,
                                     dateAdded, lastModified, guid)
          VALUES ((SELECT id FROM moz_places WHERE url = :url), :type, :parent,
                  :index, :title, :date_added, :last_modified, :guid)
         `, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
              type: item.type, parent: parent._id, index: item.index,
-             title: item.title, date_added: toPRTime(item.dateAdded),
-             last_modified: toPRTime(item.lastModified), guid: item.guid });
+             title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded),
+             last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid });
 
       yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
     });
 
     // If not a tag recalculate frecency...
     let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
     if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
       // ...though we don't wait for the calculation.
@@ -1235,63 +1235,30 @@ function removeSameValueProperties(dest,
         remove = dest[prop] == src[prop];
     }
     if (remove && prop != "guid")
       delete dest[prop];
   }
 }
 
 /**
- * Converts an URL object to an nsIURI.
- *
- * @param url
- *        the URL object to convert.
- * @return nsIURI for the given URL.
- */
-function toURI(url) {
-  return NetUtil.newURI(url.href);
-}
-
-/**
- * Convert a Date object to a PRTime (microseconds).
- *
- * @param date
- *        the Date object to convert.
- * @return microseconds from the epoch.
- */
-function toPRTime(date) {
-  return date * 1000;
-}
-
-/**
- * Convert a PRTime to a Date object.
- *
- * @param time
- *        microseconds from the epoch.
- * @return a Date object.
- */
-function toDate(time) {
-  return new Date(parseInt(time / 1000));
-}
-
-/**
  * Convert an array of mozIStorageRow objects to an array of bookmark objects.
  *
  * @param rows
  *        the array of mozIStorageRow objects.
  * @return an array of bookmark objects.
  */
 function rowsToItemsArray(rows) {
   return rows.map(row => {
     let item = {};
     for (let prop of ["guid", "index", "type"]) {
       item[prop] = row.getResultByName(prop);
     }
     for (let prop of ["dateAdded", "lastModified"]) {
-      item[prop] = toDate(row.getResultByName(prop));
+      item[prop] = PlacesUtils.toDate(row.getResultByName(prop));
     }
     for (let prop of ["title", "parentGuid", "url" ]) {
       let val = row.getResultByName(prop);
       if (val)
         item[prop] = prop === "url" ? new URL(val) : val;
     }
     for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
       let val = row.getResultByName(prop);
@@ -1327,17 +1294,17 @@ function simpleValidateFunc(boolValidate
 
 /**
  * List of validators, one per each known property.
  * Validators must throw if the property value is invalid and return a fixed up
  * version of the value, if needed.
  */
 const VALIDATORS = Object.freeze({
   guid: simpleValidateFunc(v => typeof(v) == "string" &&
-                                /^[a-zA-Z0-9\-_]{12}$/.test(v)),
+                                PlacesUtils.isValidGuid(v)),
   parentGuid: simpleValidateFunc(v => typeof(v) == "string" &&
                                       /^[a-zA-Z0-9\-_]{12}$/.test(v)),
   index: simpleValidateFunc(v => Number.isInteger(v) &&
                                  v >= Bookmarks.DEFAULT_INDEX),
   dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
   lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
   type: simpleValidateFunc(v => Number.isInteger(v) &&
                                 [ Bookmarks.TYPE_BOOKMARK
@@ -1510,17 +1477,17 @@ var setAncestorsLastModified = Task.asyn
        UNION ALL
        SELECT parent FROM moz_bookmarks
        JOIN ancestors ON id = aid
        WHERE type = :type
      )
      UPDATE moz_bookmarks SET lastModified = :time
      WHERE id IN ancestors
     `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
-         time: toPRTime(time) });
+         time: PlacesUtils.toPRTime(time) });
 });
 
 /**
  * Remove all descendants of one or more bookmark folders.
  *
  * @param db
  *        the Sqlite.jsm connection handle.
  * @param folderGuids
@@ -1575,25 +1542,25 @@ Task.async(function* (db, folderGuids) {
   // Send onItemRemoved notifications to listeners.
   // TODO (Bug 1087580): for the case of eraseEverything, this should send a
   // single clear bookmarks notification rather than notifying for each
   // bookmark.
 
   // Notify listeners in reverse order to serve children before parents.
   let observers = PlacesUtils.bookmarks.getObservers();
   for (let item of itemsRemoved.reverse()) {
-    let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
+    let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
     notify(observers, "onItemRemoved", [ item._id, item._parentId,
                                          item.index, item.type, uri,
                                          item.guid, item.parentGuid ]);
 
     let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
     if (isUntagging) {
       for (let entry of (yield fetchBookmarksByURL(item))) {
         notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
-                                             toPRTime(entry.lastModified),
+                                             PlacesUtils.toPRTime(entry.lastModified),
                                              entry.type, entry._parentId,
                                              entry.guid, entry.parentGuid,
                                              "" ]);
       }
     }
   }
 });
--- a/toolkit/components/places/History.jsm
+++ b/toolkit/components/places/History.jsm
@@ -127,71 +127,136 @@ this.History = Object.freeze({
    *      If `guidOrURI` does not have the expected type or if it is a string
    *      that may be parsed neither as a valid URL nor as a valid GUID.
    */
   fetch: function (guidOrURI) {
     throw new Error("Method not implemented");
   },
 
   /**
-   * Adds a set of visits for one or more page.
+   * Adds a number of visits for a single page.
    *
    * Any change may be observed through nsINavHistoryObserver
    *
-   * @note This function recomputes the frecency of the page automatically,
-   * regardless of the value of property `frecency` passed as argument.
-   * @note If there is no entry for the page, the entry is created.
-   *
-   * @param infos: (PageInfo)
+   * @param pageInfo: (PageInfo)
    *      Information on a page. This `PageInfo` MUST contain
-   *        - either a property `guid` or a property `url`, as specified
-   *          by the definition of `PageInfo`;
+   *        - a property `url`, as specified by the definition of `PageInfo`.
    *        - a property `visits`, as specified by the definition of
    *          `PageInfo`, which MUST contain at least one visit.
    *      If a property `title` is provided, the title of the page
    *      is updated.
-   *      If the `visitDate` of a visit is not provided, it defaults
+   *      If the `date` of a visit is not provided, it defaults
    *      to now.
-   *            or (Array<PageInfo>)
-   *      An array of the above, to batch requests.
-   * @param onResult: (function(PageInfo), [optional])
-   *      A callback invoked for each page, with the updated
-   *      information on that page. Note that this `PageInfo`
-   *      does NOT contain the visit data (i.e. `visits` is
-   *      `undefined`).
+   *      If the `transition` of a visit is not provided, it defaults to
+   *      TRANSITION_LINK.
    *
    * @return (Promise)
-   *      A promise resolved once the operation is complete, including
-   *      all calls to `onResult`.
-   * @resolves (bool)
-   *      `true` if at least one page entry was created, `false` otherwise
-   *       (i.e. if page entries were updated but not created).
+   *      A promise resolved once the operation is complete.
+   * @resolves (PageInfo)
+   *      A PageInfo object populated with data after the insert is complete.
+   * @rejects (Error)
+   *      Rejects if the insert was unsuccessful.
    *
    * @throws (Error)
    *      If the `url` specified was for a protocol that should not be
    *      stored (e.g. "chrome:", "mailbox:", "about:", "imap:", "news:",
    *      "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:",
    *      "javascript:", "blob:").
    * @throws (Error)
-   *      If `infos` has an unexpected type.
+   *      If `pageInfo` has an unexpected type.
+   * @throws (Error)
+   *      If `pageInfo` does not have a `url`.
+   * @throws (Error)
+   *      If `pageInfo` does not have a `visits` property or if the
+   *      value of `visits` is ill-typed or is an empty array.
+   * @throws (Error)
+   *      If an element of `visits` has an invalid `date`.
    * @throws (Error)
-   *      If a `PageInfo` has neither `guid` nor `url`.
+   *      If an element of `visits` has an invalid `transition`.
+   */
+  insert: function (pageInfo) {
+    if (typeof pageInfo != "object" || !pageInfo) {
+      throw new TypeError("pageInfo must be an object");
+    }
+
+    let info = validatePageInfo(pageInfo);
+
+    return PlacesUtils.withConnectionWrapper("History.jsm: insert",
+      db => insert(db, info));
+  },
+
+  /**
+   * Adds a number of visits for a number of pages.
+   *
+   * Any change may be observed through nsINavHistoryObserver
+   *
+   * @param pageInfos: (Array<PageInfo>)
+   *      Information on a page. This `PageInfo` MUST contain
+   *        - a property `url`, as specified by the definition of `PageInfo`.
+   *        - a property `visits`, as specified by the definition of
+   *          `PageInfo`, which MUST contain at least one visit.
+   *      If a property `title` is provided, the title of the page
+   *      is updated.
+   *      If the `date` of a visit is not provided, it defaults
+   *      to now.
+   *      If the `transition` of a visit is not provided, it defaults to
+   *      TRANSITION_LINK.
+   * @param onResult: (function(PageInfo))
+   *      A callback invoked for each page inserted.
+   * @param onError: (function(PageInfo))
+   *      A callback invoked for each page which generated an error
+   *      when an insert was attempted.
+   *
+   * @return (Promise)
+   *      A promise resolved once the operation is complete.
+   * @resolves (null)
+   * @rejects (Error)
+   *      Rejects if all of the inserts were unsuccessful.
+   *
    * @throws (Error)
-   *      If a `guid` property provided is not a valid GUID.
+   *      If the `url` specified was for a protocol that should not be
+   *      stored (e.g. "chrome:", "mailbox:", "about:", "imap:", "news:",
+   *      "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:",
+   *      "javascript:", "blob:").
+   * @throws (Error)
+   *      If `pageInfos` has an unexpected type.
+   * @throws (Error)
+   *      If a `pageInfo` does not have a `url`.
    * @throws (Error)
    *      If a `PageInfo` does not have a `visits` property or if the
    *      value of `visits` is ill-typed or is an empty array.
    * @throws (Error)
    *      If an element of `visits` has an invalid `date`.
    * @throws (Error)
-   *      If an element of `visits` is missing `transition` or if
-   *      the value of `transition` is invalid.
+   *      If an element of `visits` has an invalid `transition`.
    */
-  update: function (infos, onResult) {
-    throw new Error("Method not implemented");
+  insertMany: function (pageInfos, onResult, onError) {
+    let infos = [];
+
+    if (!Array.isArray(pageInfos)) {
+      throw new TypeError("pageInfos must be an array");
+    }
+    if (!pageInfos.length) {
+      throw new TypeError("pageInfos may not be an empty array");
+    }
+
+    if (onResult && typeof onResult != "function") {
+      throw new TypeError(`onResult: ${onResult} is not a valid function`);
+    }
+    if (onError && typeof onError != "function") {
+      throw new TypeError(`onError: ${onError} is not a valid function`);
+    }
+
+    for (let pageInfo of pageInfos) {
+      let info = validatePageInfo(pageInfo);
+      infos.push(info);
+    }
+
+    return PlacesUtils.withConnectionWrapper("History.jsm: insertMany",
+      db => insertMany(db, infos, onResult, onError));
   },
 
   /**
    * Remove pages from the database.
    *
    * Any change may be observed through nsINavHistoryObserver
    *
    *
@@ -385,29 +450,126 @@ this.History = Object.freeze({
   TRANSITION_DOWNLOAD: Ci.nsINavHistoryService.TRANSITION_REDIRECT_DOWNLOAD,
 
   /**
    * The user followed a link and got a visit in a frame.
    */
   TRANSITION_FRAMED_LINK: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
 });
 
+/**
+ * Validate an input PageInfo object, returning a valid PageInfo object.
+ *
+ * @param pageInfo: (PageInfo)
+ * @return (PageInfo)
+ */
+function validatePageInfo(pageInfo) {
+  let info = {
+    visits: [],
+  };
+
+  if (!pageInfo.url) {
+    throw new TypeError("PageInfo object must have a url property");
+  }
+
+  info.url = normalizeToURLOrGUID(pageInfo.url);
+
+  if (typeof pageInfo.title === "string" && pageInfo.title.length) {
+    info.title = pageInfo.title;
+  } else if (pageInfo.title != null && pageInfo.title != undefined) {
+    throw new TypeError(`title property of PageInfo object: ${pageInfo.title} must be a string if provided`);
+  }
+
+  if (!pageInfo.visits || !Array.isArray(pageInfo.visits) || !pageInfo.visits.length) {
+    throw new TypeError("PageInfo object must have an array of visits");
+  }
+  for (let inVisit of pageInfo.visits) {
+    let visit = {
+      date: new Date(),
+      transition: inVisit.transition || History.TRANSITION_LINK,
+    };
+
+    if (!isValidTransitionType(visit.transition)) {
+      throw new TypeError(`transition: ${visit.transition} is not a valid transition type`);
+    }
+
+    if (inVisit.date) {
+      ensureDate(inVisit.date);
+      if (inVisit.date > Date.now()) {
+        throw new TypeError(`date: ${inVisit.date} cannot be a future date`);
+      }
+      visit.date = inVisit.date;
+    }
+
+    if (inVisit.referrer) {
+      visit.referrer = normalizeToURLOrGUID(inVisit.referrer);
+    }
+    info.visits.push(visit);
+  }
+  return info;
+}
+
+/**
+ * Convert a PageInfo object into the format expected by updatePlaces.
+ *
+ * Note: this assumes that the PageInfo object has already been validated
+ * via validatePageInfo.
+ *
+ * @param pageInfo: (PageInfo)
+ * @return (info)
+ */
+function convertForUpdatePlaces(pageInfo) {
+  let info = {
+    uri: PlacesUtils.toURI(pageInfo.url),
+    title: pageInfo.title,
+    visits: [],
+  };
+
+  for (let inVisit of pageInfo.visits) {
+    let visit = {
+      visitDate: PlacesUtils.toPRTime(inVisit.date),
+      transitionType: inVisit.transition,
+      referrerURI: (inVisit.referrer) ? PlacesUtils.toURI(inVisit.referrer) : undefined,
+    };
+    info.visits.push(visit);
+  }
+  return info;
+}
+
+/**
+ * Is a value a valid transition type?
+ *
+ * @param transitionType: (String)
+ * @return (Boolean)
+ */
+function isValidTransitionType(transitionType) {
+  return [
+    History.TRANSITION_LINK,
+    History.TRANSITION_TYPED,
+    History.TRANSITION_BOOKMARK,
+    History.TRANSITION_EMBED,
+    History.TRANSITION_REDIRECT_PERMANENT,
+    History.TRANSITION_REDIRECT_TEMPORARY,
+    History.TRANSITION_DOWNLOAD,
+    History.TRANSITION_FRAMED_LINK
+  ].includes(transitionType);
+}
 
 /**
  * Normalize a key to either a string (if it is a valid GUID) or an
  * instance of `URL` (if it is a `URL`, `nsIURI`, or a string
  * representing a valid url).
  *
  * @throws (TypeError)
  *         If the key is neither a valid guid nor a valid url.
  */
 function normalizeToURLOrGUID(key) {
   if (typeof key === "string") {
     // A string may be a URL or a guid
-    if (/^[a-zA-Z0-9\-_]{12}$/.test(key)) {
+    if (PlacesUtils.isValidGuid(key)) {
       return key;
     }
     return new URL(key);
   }
   if (key instanceof URL) {
     return key;
   }
   if (key instanceof Ci.nsIURI) {
@@ -762,8 +924,92 @@ var remove = Task.async(function*(db, {g
     notifyOnResult(onResultData, onResult); // don't wait
   } finally {
     // Ensure we cleanup embed visits, even if we bailed out early.
     PlacesUtils.history.clearEmbedVisits();
   }
 
   return hasPagesToRemove;
 });
+
+/**
+ * Merges an updateInfo object, as returned by asyncHistory.updatePlaces
+ * into a PageInfo object as defined in this file.
+ *
+ * @param updateInfo: (Object)
+ *      An object that represents a page that is generated by
+ *      asyncHistory.updatePlaces.
+ * @param pageInfo: (PageInfo)
+ *      An PageInfo object into which to merge the data from updateInfo.
+ *      Defaults to an empty object so that this method can be used
+ *      to simply convert an updateInfo object into a PageInfo object.
+ *
+ * @return (PageInfo)
+ *      A PageInfo object populated with data from updateInfo.
+ */
+function mergeUpdateInfoIntoPageInfo(updateInfo, pageInfo={}) {
+  pageInfo.guid = updateInfo.guid;
+  if (!pageInfo.url) {
+    pageInfo.url = new URL(updateInfo.uri.spec);
+    pageInfo.title = updateInfo.title;
+    pageInfo.visits = updateInfo.visits.map(visit => {
+      return {
+        date: PlacesUtils.toDate(visit.visitDate),
+        transition: visit.transitionType,
+        referrer: (visit.referrerURI) ? new URL(visit.referrerURI.spec) : null
+      }
+    });
+  }
+  return pageInfo;
+}
+
+// Inner implementation of History.insert.
+var insert = Task.async(function*(db, pageInfo) {
+  let info = convertForUpdatePlaces(pageInfo);
+
+  return new Promise((resolve, reject) => {
+    PlacesUtils.asyncHistory.updatePlaces(info, {
+      handleError: error => {
+        reject(error);
+      },
+      handleResult: result => {
+        pageInfo = mergeUpdateInfoIntoPageInfo(result, pageInfo);
+      },
+      handleCompletion: () => {
+        resolve(pageInfo);
+      }
+    });
+  });
+});
+
+// Inner implementation of History.insertMany.
+var insertMany = Task.async(function*(db, pageInfos, onResult, onError) {
+  let infos = [];
+  let onResultData = [];
+  let onErrorData = [];
+
+  for (let pageInfo of pageInfos) {
+    let info = convertForUpdatePlaces(pageInfo);
+    infos.push(info);
+  }
+
+  return new Promise((resolve, reject) => {
+    PlacesUtils.asyncHistory.updatePlaces(infos, {
+      handleError: (resultCode, result) => {
+        let pageInfo = mergeUpdateInfoIntoPageInfo(result);
+        onErrorData.push(pageInfo);
+      },
+      handleResult: result => {
+        let pageInfo = mergeUpdateInfoIntoPageInfo(result);
+        onResultData.push(pageInfo);
+      },
+      handleCompletion: () => {
+        notifyOnResult(onResultData, onResult);
+        notifyOnResult(onErrorData, onError);
+        if (onResultData.length) {
+          resolve();
+        } else {
+          reject({message: "No items were added to history."})
+        }
+      }
+    });
+  });
+});
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -256,16 +256,39 @@ this.PlacesUtils = {
    *          The string spec of the URI
    * @returns A URI object for the spec.
    */
   _uri: function PU__uri(aSpec) {
     return NetUtil.newURI(aSpec);
   },
 
   /**
+   * Is a string a valid GUID?
+   *
+   * @param guid: (String)
+   * @return (Boolean)
+   */
+  isValidGuid(guid) {
+    return (/^[a-zA-Z0-9\-_]{12}$/.test(guid));
+  },
+
+  /**
+   * Converts a string or n URL object to an nsIURI.
+   *
+   * @param url (URL) or (String)
+   *        the URL to convert.
+   * @return nsIURI for the given URL.
+   */
+  toURI(url) {
+    url = (url instanceof URL) ? url.href : url;
+
+    return NetUtil.newURI(url);
+  },
+
+  /**
    * Convert a Date object to a PRTime (microseconds).
    *
    * @param date
    *        the Date object to convert.
    * @return microseconds from the epoch.
    */
   toPRTime(date) {
     return date * 1000;
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_insert.js
@@ -0,0 +1,264 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.insert` and `History.insertMany`, as implemented in History.jsm
+
+"use strict";
+
+add_task(function* test_insert_error_cases() {
+  const TEST_URL = "http://mozilla.com";
+
+  let validPageInfo = {
+    url: TEST_URL,
+    visits: [
+      {transition: TRANSITION_LINK}
+    ]
+  };
+
+  Assert.throws(
+    () =>  PlacesUtils.history.insert(),
+    /TypeError: pageInfo must be an object/,
+    "passing a null into History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert(1),
+    /TypeError: pageInfo must be an object/,
+    "passing a non object into History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({}),
+    /TypeError: PageInfo object must have a url property/,
+    "passing an object without a url to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: 123}),
+    /TypeError: Invalid url or guid: 123/,
+    "passing an object with an invalid url to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: TEST_URL}),
+    /TypeError: PageInfo object must have an array of visits/,
+    "passing an object without a visits property to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: TEST_URL, visits: 1}),
+    /TypeError: PageInfo object must have an array of visits/,
+    "passing an object with a non-array visits property to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: TEST_URL, visits: []}),
+    /TypeError: PageInfo object must have an array of visits/,
+    "passing an object with an empty array as the visits property to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {
+          transition: TRANSITION_LINK,
+          date: "a"
+        }
+      ]}),
+    /TypeError: Expected a Date, got a/,
+    "passing a visit object with an invalid date to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {
+          transition: TRANSITION_LINK
+        },
+        {
+          transition: TRANSITION_LINK,
+          date: "a"
+        }
+      ]}),
+    /TypeError: Expected a Date, got a/,
+    "passing a second visit object with an invalid date to History.insert should throw a TypeError"
+  );
+  let futureDate = new Date();
+  futureDate.setDate(futureDate.getDate() + 1);
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {
+          transition: TRANSITION_LINK,
+          date: futureDate,
+        }
+      ]}),
+    `TypeError: date: ${futureDate} is not a valid date`,
+    "passing a visit object with a future date to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {transition: "a"}
+      ]}),
+    /TypeError: transition: a is not a valid transition type/,
+    "passing a visit object with an invalid transition to History.insert should throw a TypeError"
+  );
+});
+
+add_task(function* test_history_insert() {
+  const TEST_URL = "http://mozilla.com/";
+
+  let inserter = Task.async(function*(name, filter, referrer, date, transition) {
+    do_print(name);
+    do_print(`filter: ${filter}, referrer: ${referrer}, date: ${date}, transition: ${transition}`);
+
+    let uri = NetUtil.newURI(TEST_URL + Math.random());
+    let title = "Visit " + Math.random();
+
+    let pageInfo = {
+      title,
+      visits: [
+        {transition: transition, referrer: referrer, date: date,}
+      ]
+    };
+
+    pageInfo.url = yield filter(uri);
+
+    let result = yield PlacesUtils.history.insert(pageInfo);
+
+    Assert.ok(PlacesUtils.isValidGuid(result.guid), "guid for pageInfo object is valid");
+    Assert.equal(uri.spec, result.url.href, "url is correct for pageInfo object");
+    Assert.equal(title, result.title, "title is correct for pageInfo object");
+    Assert.equal(TRANSITION_LINK, result.visits[0].transition, "transition is correct for pageInfo object");
+    if (referrer) {
+      Assert.equal(referrer, result.visits[0].referrer.href, "url of referrer for visit is correct");
+    } else {
+      Assert.equal(null, result.visits[0].referrer, "url of referrer for visit is correct");
+    }
+    if (date) {
+      Assert.equal(Number(date),
+                   Number(result.visits[0].date),
+                   "date of visit is correct");
+    }
+
+    Assert.ok(yield PlacesTestUtils.isPageInDB(uri), "Page was added");
+    Assert.ok(yield PlacesTestUtils.visitsInDB(uri), "Visit was added");
+  });
+
+  try {
+    for (let referrer of [TEST_URL, null]) {
+      for (let date of [new Date(), null]) {
+        for (let transition of [TRANSITION_LINK, null]) {
+          yield inserter("Testing History.insert() with an nsIURI", x => x, referrer, date, transition);
+          yield inserter("Testing History.insert() with a string url", x => x.spec, referrer, date, transition);
+          yield inserter("Testing History.insert() with a URL object", x => new URL(x.spec), referrer, date, transition);
+        }
+      }
+    }
+  } finally {
+    yield PlacesTestUtils.clearHistory();
+  }
+});
+
+add_task(function* test_insert_multiple_error_cases() {
+  let validPageInfo = {
+    url: "http://mozilla.com",
+    visits: [
+      {transition: TRANSITION_LINK}
+    ]
+  };
+
+  Assert.throws(
+    () =>  PlacesUtils.history.insertMany(),
+    /TypeError: pageInfos must be an array/,
+    "passing a null into History.insertMany should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insertMany([]),
+    /TypeError: pageInfos may not be an empty array/,
+    "passing an empty array into History.insertMany should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insertMany([validPageInfo, {}]),
+    /TypeError: PageInfo object must have a url property/,
+    "passing a second invalid PageInfo object to History.insertMany should throw a TypeError"
+  );
+});
+
+add_task(function* test_history_insertMany() {
+  const BAD_URLS = ["about:config", "chrome://browser/content/browser.xul"];
+  const GOOD_URLS = [1, 2, 3].map(x => {return `http://mozilla.com/${x}`;});
+
+  let makePageInfos = Task.async(function*(urls, filter = x => x) {
+    let pageInfos = [];
+    for (let url of urls) {
+      let uri = NetUtil.newURI(url);
+
+      let pageInfo = {
+        title: `Visit to ${url}`,
+        visits: [
+          {transition: TRANSITION_LINK}
+        ]
+      };
+
+      pageInfo.url = yield filter(uri);
+      pageInfos.push(pageInfo);
+    }
+    return pageInfos;
+  });
+
+  let inserter = Task.async(function*(name, filter, useCallbacks) {
+    do_print(name);
+    do_print(`filter: ${filter}`);
+    do_print(`useCallbacks: ${useCallbacks}`);
+    yield PlacesTestUtils.clearHistory();
+
+    let result;
+    let allUrls = GOOD_URLS.concat(BAD_URLS);
+    let pageInfos = yield makePageInfos(allUrls, filter);
+
+    if (useCallbacks) {
+      let onResultUrls = [];
+      let onErrorUrls = [];
+      result = yield PlacesUtils.history.insertMany(pageInfos, pageInfo => {
+        let url = pageInfo.url.href;
+        Assert.ok(GOOD_URLS.includes(url), "onResult callback called for correct url");
+        onResultUrls.push(url);
+        Assert.equal(`Visit to ${url}`, pageInfo.title, "onResult callback provides the correct title");
+        Assert.ok(PlacesUtils.isValidGuid(pageInfo.guid), "onResult callback provides a valid guid");
+      }, pageInfo => {
+        let url = pageInfo.url.href;
+        Assert.ok(BAD_URLS.includes(url), "onError callback called for correct uri");
+        onErrorUrls.push(url);
+        Assert.equal(undefined, pageInfo.title, "onError callback provides the correct title");
+        Assert.equal(undefined, pageInfo.guid, "onError callback provides the expected guid");
+      });
+      Assert.equal(GOOD_URLS.sort().toString(), onResultUrls.sort().toString(), "onResult callback was called for each good url");
+      Assert.equal(BAD_URLS.sort().toString(), onErrorUrls.sort().toString(), "onError callback was called for each bad url");
+    } else {
+      result = yield PlacesUtils.history.insertMany(pageInfos);
+    }
+
+    Assert.equal(undefined, result, "insertMany returned undefined");
+
+    for (let url of allUrls) {
+      let expected = GOOD_URLS.includes(url);
+      Assert.equal(expected, yield PlacesTestUtils.isPageInDB(url), `isPageInDB for ${url} is ${expected}`);
+      Assert.equal(expected, yield PlacesTestUtils.visitsInDB(url), `visitsInDB for ${url} is ${expected}`);
+    }
+  });
+
+  try {
+    for (let useCallbacks of [false, true]) {
+      yield inserter("Testing History.insertMany() with an nsIURI", x => x, useCallbacks);
+      yield inserter("Testing History.insertMany() with a string url", x => x.spec, useCallbacks);
+      yield inserter("Testing History.insertMany() with a URL object", x => new URL(x.spec), useCallbacks);
+    }
+    // Test rejection when no items added
+    let pageInfos = yield makePageInfos(BAD_URLS);
+    PlacesUtils.history.insertMany(pageInfos).then(() => {
+      Assert.ok(false, "History.insertMany rejected promise with all bad URLs");
+    }, error => {
+      Assert.equal("No items were added to history.", error.message, "History.insertMany rejected promise with all bad URLs");
+    });
+  } finally {
+    yield PlacesTestUtils.clearHistory();
+  }
+});
--- a/toolkit/components/places/tests/history/xpcshell.ini
+++ b/toolkit/components/places/tests/history/xpcshell.ini
@@ -1,7 +1,8 @@
 [DEFAULT]
 head = head_history.js
 tail =
 
+[test_insert.js]
 [test_remove.js]
 [test_removeVisits.js]
 [test_removeVisitsByFilter.js]