Bug 1076775 - Implement History.removeHistoryByFilter. r=mak
☠☠ backed out by 3bc058c3acfe ☠ ☠
authorDavid Rajchenbach-Teller <dteller@mozilla.com>
Fri, 10 Apr 2015 11:23:07 +0200
changeset 256491 c426c7eb3b43943b332585ee1ed556e04e4af5fb
parent 256490 4c16c59c8158c2f8b764ac86a6608b5efbede6d4
child 256492 1786e8cb842e68668b8d92e86a6233d541b99108
push id1483
push usermichael.l.comella@gmail.com
push dateFri, 10 Apr 2015 15:12:05 +0000
reviewersmak
bugs1076775
milestone40.0a1
Bug 1076775 - Implement History.removeHistoryByFilter. r=mak
toolkit/components/places/History.jsm
toolkit/components/places/PlacesUtils.jsm
toolkit/components/places/tests/history/test_remove.js
toolkit/components/places/tests/history/test_removeVisitsByFilter.js
toolkit/components/places/tests/history/xpcshell.ini
--- a/toolkit/components/places/History.jsm
+++ b/toolkit/components/places/History.jsm
@@ -79,57 +79,57 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/Sqlite.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 Cu.importGlobalProperties(["URL"]);
 
 /**
  * Whenever we update or remove numerous pages, it is preferable
  * to yield time to the main thread every so often to avoid janking.
- * This constant determines the maximal number of notifications we
+ * These constants determine the maximal number of notifications we
  * may emit before we yield.
  */
 const NOTIFICATION_CHUNK_SIZE = 300;
+const ONRESULT_CHUNK_SIZE = 300;
 
 /**
  * Private shutdown barrier blocked by ongoing operations.
  */
 XPCOMUtils.defineLazyGetter(this, "operationsBarrier", () =>
-  new AsyncShutdown.Barrier("Sqlite.jsm: wait until all connections are closed")
+  new AsyncShutdown.Barrier("History.jsm: wait until all connections are closed")
 );
 
 /**
  * Shared connection
  */
-XPCOMUtils.defineLazyGetter(this, "DBConnPromised",
-  () => new Promise((resolve) => {
-    Sqlite.wrapStorageConnection({ connection: PlacesUtils.history.DBConnection } )
-          .then(db => {
-      try {
-        Sqlite.shutdown.addBlocker(
-          "Places History.jsm: Closing database wrapper",
-          Task.async(function*() {
-            yield operationsBarrier.wait();
-            gIsClosed = true;
-            yield db.close();
-          }),
-          () => ({
-            fetchState: () => ({
-              isClosed: gIsClosed,
-              operations: operationsBarrier.state,
-            })
-          }));
-      } catch (ex) {
-        // It's too late to block shutdown of Sqlite, so close the connection
-        // immediately.
-        db.close();
-        throw ex;
-      }
-      resolve(db);
-    });
+ XPCOMUtils.defineLazyGetter(this, "DBConnPromised", () =>
+  Task.spawn(function*() {
+    let db = yield PlacesUtils.promiseWrappedConnection();
+    try {
+      Sqlite.shutdown.addBlocker(
+        "Places History.jsm: Closing database wrapper",
+        Task.async(function*() {
+          yield operationsBarrier.wait();
+          gIsClosed = true;
+          yield db.close();
+        }),
+        () => ({
+          fetchState: () => ({
+            isClosed: gIsClosed,
+            operations: operationsBarrier.state,
+          })
+        })
+      );
+    } catch (ex) {
+      // It's too late to block shutdown of Sqlite, so close the connection
+      // immediately.
+      db.close();
+      throw ex;
+    }
+    return db;
   })
 );
 
 /**
  * `true` once this module has been shutdown.
  */
 let gIsClosed = false;
 function ensureModuleIsOpen() {
@@ -315,16 +315,88 @@ this.History = Object.freeze({
       } finally {
         // Cleanup the barrier.
         operationsBarrier.client.removeBlocker(promise);
       }
     });
   },
 
   /**
+   * Remove visits matching specific characteristics.
+   *
+   * Any change may be observed through nsINavHistoryObserver.
+   *
+   * @param filter: (object)
+   *      The `object` may contain some of the following
+   *      properties:
+   *          - beginDate: (Date) Remove visits that have
+   *                been added since this date (inclusive).
+   *          - endDate: (Date) Remove visits that have
+   *                been added before this date (inclusive).
+   *      If both `beginDate` and `endDate` are specified,
+   *      visits between `beginDate` (inclusive) and `end`
+   *      (inclusive) are removed.
+   *
+   * @param onResult: (function(VisitInfo), [optional])
+   *     A callback invoked for each visit found and removed.
+   *     Note that the referrer property of `VisitInfo`
+   *     is NOT populated.
+   *
+   * @return (Promise)
+   * @resolve (bool)
+   *      `true` if at least one visit was removed, `false`
+   *      otherwise.
+   * @throws (TypeError)
+   *      If `filter` does not have the expected type, in
+   *      particular if the `object` is empty.
+   */
+  removeVisitsByFilter: function(filter, onResult = null) {
+    ensureModuleIsOpen();
+
+    if (!filter || typeof filter != "object") {
+      throw new TypeError("Expected a filter");
+    }
+
+    let hasBeginDate = "beginDate" in filter;
+    let hasEndDate = "endDate" in filter;
+    if (hasBeginDate) {
+      ensureDate(filter.beginDate);
+    }
+    if (hasEndDate) {
+      ensureDate(filter.endDate);
+    }
+    if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) {
+      throw new TypeError("`beginDate` should be at least as old as `endDate`");
+    }
+    if (!hasBeginDate && !hasEndDate) {
+      throw new TypeError("Expected a non-empty filter");
+    }
+
+    if (onResult && typeof onResult != "function") {
+      throw new TypeError("Invalid function: " + onResult);
+    }
+
+    return Task.spawn(function*() {
+      let promise = removeVisitsByFilter(filter, onResult);
+
+      operationsBarrier.client.addBlocker(
+        "History.removeVisitsByFilter",
+        promise
+      );
+
+      try {
+        return (yield promise);
+      } finally {
+        // Cleanup the barrier.
+        operationsBarrier.client.removeBlocker(promise);
+      }
+    });
+  },
+
+  /**
    * Determine if a page has been visited.
    *
    * @param pages: (URL or nsIURI)
    *      The full URI of the page.
    *            or (string)
    *      The full URI of the page or the GUID of the page.
    *
    * @return (Promise)
@@ -435,16 +507,25 @@ function normalizeToURLOrGUID(key) {
   }
   if (key instanceof Ci.nsIURI) {
     return new URL(key.spec);
   }
   throw new TypeError("Invalid url or guid: " + key);
 }
 
 /**
+ * Throw if an object is not a Date object.
+ */
+function ensureDate(arg) {
+  if (!arg || typeof arg != "object" || arg.constructor.name != "Date") {
+    throw new TypeError("Expected a Date, got " + arg);
+  }
+}
+
+/**
  * Convert a list of strings or numbers to its SQL
  * representation as a string.
  */
 function sqlList(list) {
   return list.map(JSON.stringify).join();
 }
 
 /**
@@ -499,95 +580,286 @@ let clear = Task.async(function* () {
       ELSE -1
       END)
      WHERE frecency > 0`);
 
   // Notify frecency change observers.
   notify(observers, "onManyFrecenciesChanged");
 });
 
+/**
+ * Remove a list of pages from `moz_places` by their id.
+ *
+ * @param db: (Sqlite connection)
+ *      The database.
+ * @param idList: (Array of integers)
+ *      The `moz_places` identifiers for the places to remove.
+ * @return (Promise)
+ */
+let removePagesById = Task.async(function*(db, idList) {
+  if (idList.length == 0) {
+    return;
+  }
+  yield db.execute(`DELETE FROM moz_places
+                    WHERE id IN ( ${ sqlList(idList) } )`);
+});
+
+/**
+ * Clean up pages whose history has been modified, by either
+ * removing them entirely (if they are marked for removal,
+ * typically because all visits have been removed and there
+ * are no more foreign keys such as bookmarks) or updating
+ * their frecency (otherwise).
+ *
+ * @param db: (Sqlite connection)
+ *      The database.
+ * @param pages: (Array of objects)
+ *      Pages that have been touched and that need cleaning up.
+ *      Each object should have the following properties:
+ *          - id: (number) The `moz_places` identifier for the place.
+ *          - hasVisits: (boolean) If `true`, there remains at least one
+ *              visit to this page, so the page should be kept and its
+ *              frecency updated.
+ *          - hasForeign: (boolean) If `true`, the page has at least
+ *              one foreign reference (i.e. a bookmark), so the page should
+ *              be kept and its frecency updated.
+ * @return (Promise)
+ */
+let cleanupPages = Task.async(function*(db, pages) {
+  yield invalidateFrecencies(db, [p.id for (p of pages) if (p.hasForeign || p.hasVisits)]);
+  yield removePagesById(db, [p.id for (p of pages) if (!p.hasForeign && !p.hasVisits)]);
+});
+
+/**
+ * Notify observers that pages have been removed/updated.
+ *
+ * @param db: (Sqlite connection)
+ *      The database.
+ * @param pages: (Array of objects)
+ *      Pages that have been touched and that need cleaning up.
+ *      Each object should have the following properties:
+ *          - id: (number) The `moz_places` identifier for the place.
+ *          - hasVisits: (boolean) If `true`, there remains at least one
+ *              visit to this page, so the page should be kept and its
+ *              frecency updated.
+ *          - hasForeign: (boolean) If `true`, the page has at least
+ *              one foreign reference (i.e. a bookmark), so the page should
+ *              be kept and its frecency updated.
+ * @return (Promise)
+ */
+let notifyCleanup = Task.async(function*(db, pages) {
+  let notifiedCount = 0;
+  let observers = PlacesUtils.history.getObservers();
+
+  let reason = Ci.nsINavHistoryObserver.REASON_DELETED;
+
+  for (let page of pages) {
+    let uri = NetUtil.newURI(page.url.href);
+    let guid = page.guid;
+    if (page.hasVisits) {
+      // For the moment, we do not have the necessary observer API
+      // to notify when we remove a subset of visits, see bug 937560.
+      continue;
+    }
+    if (page.hasForeign) {
+      // We have removed all visits, but the page is still alive, e.g.
+      // because of a bookmark.
+      notify(observers, "onDeleteVisits",
+        [uri, /*last visit*/0, guid, reason, -1]);
+    } else {
+      // The page has been entirely removed.
+      notify(observers, "onDeleteURI",
+        [uri, guid, reason]);
+    }
+    if (++notifiedCount % NOTIFICATION_CHUNK_SIZE == 0) {
+      // Every few notifications, yield time back to the main
+      // thread to avoid jank.
+      yield Promise.resolve();
+    }
+  }
+});
+
+/**
+ * Notify an `onResult` callback of a set of operations
+ * that just took place.
+ *
+ * @param data: (Array)
+ *      The data to send to the callback.
+ * @param onResult: (function [optional])
+ *      If provided, call `onResult` with `data[0]`, `data[1]`, etc.
+ *      Otherwise, do nothing.
+ */
+let notifyOnResult = Task.async(function*(data, onResult) {
+  if (!onResult) {
+    return;
+  }
+  let notifiedCount = 0;
+  for (let info of data) {
+    try {
+      onResult(info);
+    } catch (ex) {
+      // Errors should be reported but should not stop the operation.
+      Promise.reject(ex);
+    }
+    if (++notifiedCount % ONRESULT_CHUNK_SIZE == 0) {
+      // Every few notifications, yield time back to the main
+      // thread to avoid jank.
+      yield Promise.resolve();
+    }
+  }
+});
+
+// Inner implementation of History.removeVisitsByFilter.
+let removeVisitsByFilter = Task.async(function*(filter, onResult = null) {
+  let db = yield DBConnPromised;
+
+  // 1. Determine visits that took place during the interval.  Note
+  // that the database uses microseconds, while JS uses milliseconds,
+  // so we need to *1000 one way and /1000 the other way.
+  let dates = {
+    conditions: [],
+    args: {},
+  };
+  if ("beginDate" in filter) {
+    dates.conditions.push("visit_date >= :begin * 1000");
+    dates.args.begin = Number(filter.beginDate);
+  }
+  if ("endDate" in filter) {
+    dates.conditions.push("visit_date <= :end * 1000");
+    dates.args.end = Number(filter.endDate);
+  }
+
+  let visitsToRemove = [];
+  let pagesToInspect = new Set();
+  let onResultData = onResult ? [] : null;
+
+  yield db.executeCached(
+    `SELECT id, place_id, visit_date / 1000 AS date, visit_type FROM moz_historyvisits
+     WHERE ${ dates.conditions.join(" AND ") }`,
+     dates.args,
+     row => {
+       let id = row.getResultByName("id");
+       let place_id = row.getResultByName("place_id");
+       visitsToRemove.push(id);
+       pagesToInspect.add(place_id);
+
+       if (onResult) {
+         onResultData.push({
+           date: new Date(row.getResultByName("date")),
+           transition: row.getResultByName("visit_type")
+         });
+       }
+     }
+  );
+
+  try {
+    if (visitsToRemove.length == 0) {
+      // Nothing to do
+      return false;
+    }
+
+    let pages = [];
+    yield db.executeTransaction(function*() {
+      // 2. Remove all offending visits.
+      yield db.execute(`DELETE FROM moz_historyvisits
+                        WHERE id IN (${ sqlList(visitsToRemove) } )`);
+
+      // 3. Find out which pages have been orphaned
+      yield db.execute(
+        `SELECT id, url, guid,
+          (foreign_count != 0) AS has_foreign,
+          (last_visit_date NOTNULL) as has_visits
+         FROM moz_places
+         WHERE id IN (${ sqlList([...pagesToInspect]) })`,
+         null,
+         row => {
+           let page = {
+             id:  row.getResultByName("id"),
+             guid: row.getResultByName("guid"),
+             hasForeign: row.getResultByName("has_foreign"),
+             hasVisits: row.getResultByName("has_visits"),
+             url: new URL(row.getResultByName("url")),
+           };
+           pages.push(page);
+         });
+
+      // 4. Clean up and notify
+      yield cleanupPages(db, pages);
+    });
+
+    notifyCleanup(db, pages);
+    notifyOnResult(onResultData, onResult); // don't wait
+  } finally {
+    // Ensure we cleanup embed visits, even if we bailed out early.
+    PlacesUtils.history.clearEmbedVisits();
+  }
+
+  return visitsToRemove.length != 0;
+});
+
+
 // Inner implementation of History.remove.
 let remove = Task.async(function*({guids, urls}, onResult = null) {
   let db = yield DBConnPromised;
-
   // 1. Find out what needs to be removed
   let query =
     `SELECT id, url, guid, foreign_count, title, frecency FROM moz_places
      WHERE guid IN (${ sqlList(guids) })
         OR url  IN (${ sqlList(urls)  })
      `;
 
+  let onResultData = onResult ? [] : null;
   let pages = [];
   let hasPagesToKeep = false;
   let hasPagesToRemove = false;
   yield db.execute(query, null, Task.async(function*(row) {
-    let toRemove = row.getResultByName("foreign_count") == 0;
-    if (toRemove) {
+    let hasForeign = row.getResultByName("foreign_count") != 0;
+    if (hasForeign) {
+      hasPagesToKeep = true;
+    } else {
       hasPagesToRemove = true;
-    } else {
-      hasPagesToKeep = true;
     }
     let id = row.getResultByName("id");
     let guid = row.getResultByName("guid");
     let url = row.getResultByName("url");
     let page = {
-      id: id,
-      guid: guid,
-      toRemove: toRemove,
-      uri: NetUtil.newURI(url),
+      id,
+      guid,
+      hasForeign,
+      hasVisits: false,
+      url: new URL(url),
     };
     pages.push(page);
     if (onResult) {
-      let pageInfo = {
+      onResultData.push({
         guid: guid,
         title: row.getResultByName("title"),
         frecency: row.getResultByName("frecency"),
         url: new URL(url)
-      };
-      try {
-        yield onResult(pageInfo);
-      } catch (ex) {
-        // Errors should be reported but should not stop `remove`.
-        Promise.reject(ex);
-      }
+      });
     }
   }));
 
-  if (pages.length == 0) {
-    // Nothing to do
-    return false;
-  }
-
-  yield db.executeTransaction(function*() {
-    // 2. Remove all visits to these pages.
-    yield db.execute(`DELETE FROM moz_historyvisits
-                      WHERE place_id IN (${ sqlList([p.id for (p of pages)]) })
-                     `);
-
-     // 3. For pages that should not be removed, invalidate frecencies.
-    if (hasPagesToKeep) {
-      yield invalidateFrecencies(db, [p.id for (p of pages) if (!p.toRemove)]);
+  try {
+    if (pages.length == 0) {
+      // Nothing to do
+      return false;
     }
 
-    // 4. For pages that should be removed, remove page.
-    if (hasPagesToRemove) {
-      let ids = [p.id for (p of pages) if (p.toRemove)];
-      yield db.execute(`DELETE FROM moz_places
-                        WHERE id IN (${ sqlList(ids) })
+    yield db.executeTransaction(function*() {
+      // 2. Remove all visits to these pages.
+      yield db.execute(`DELETE FROM moz_historyvisits
+                        WHERE place_id IN (${ sqlList([p.id for (p of pages)]) })
                        `);
-    }
 
-    // 5. Notify observers.
-    let observers = PlacesUtils.history.getObservers();
-    let reason = Ci.nsINavHistoryObserver.REASON_DELETED;
-    for (let {guid, uri, toRemove} of pages) {
-      if (toRemove) {
-        notify(observers, "onDeleteURI", [uri, guid, reason]);
-      } else {
-        notify(observers, "onDeleteVisits", [uri, 0, guid, reason, 0]);
-      }
-    }
-  });
+      // 3. Clean up and notify
+      yield cleanupPages(db, pages);
+    });
 
-  PlacesUtils.history.clearEmbedVisits();
+    notifyCleanup(db, pages);
+    notifyOnResult(onResultData, onResult); // don't wait
+  } finally {
+    // Ensure we cleanup embed visits, even if we bailed out early.
+    PlacesUtils.history.clearEmbedVisits();
+  }
 
   return hasPagesToRemove;
 });
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -2298,16 +2298,17 @@ let GuidHelper = {
   idsForGuids: new Map(),
 
   getItemId: Task.async(function* (aGuid) {
     let cached = this.idsForGuids.get(aGuid);
     if (cached !== undefined)
       return cached;
 
     let conn = yield PlacesUtils.promiseDBConnection();
+
     let rows = yield conn.executeCached(
       "SELECT b.id, b.guid from moz_bookmarks b WHERE b.guid = :guid LIMIT 1",
       { guid: aGuid });
     if (rows.length == 0)
       throw new Error("no item found for the given GUID");
 
     this.ensureObservingRemovedItems();
     let itemId = rows[0].getResultByName("id");
@@ -2316,16 +2317,17 @@ let GuidHelper = {
   }),
 
   getItemGuid: Task.async(function* (aItemId) {
     let cached = this.guidsForIds.get(aItemId);
     if (cached !== undefined)
       return cached;
 
     let conn = yield PlacesUtils.promiseDBConnection();
+
     let rows = yield conn.executeCached(
       "SELECT b.id, b.guid from moz_bookmarks b WHERE b.id = :id LIMIT 1",
       { id: aItemId });
     if (rows.length == 0)
       throw new Error("no item found for the given itemId");
 
     this.ensureObservingRemovedItems();
     let guid = rows[0].getResultByName("guid");
--- a/toolkit/components/places/tests/history/test_remove.js
+++ b/toolkit/components/places/tests/history/test_remove.js
@@ -1,23 +1,24 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim:set ts=2 sw=2 sts=2 et: */
-/* 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/. */
 
 // Tests for `History.remove`, as implemented in History.jsm
 
 "use strict";
 
 Cu.importGlobalProperties(["URL"]);
 
 
 // Test removing a single page
 add_task(function* test_remove_single() {
+  yield PlacesTestUtils.clearHistory();
+  yield PlacesUtils.bookmarks.eraseEverything();
+
+
   let WITNESS_URI = NetUtil.newURI("http://mozilla.com/test_browserhistory/test_remove/" + Math.random());
   yield PlacesTestUtils.addVisits(WITNESS_URI);
   Assert.ok(page_in_database(WITNESS_URI));
 
   let remover = Task.async(function*(name, filter, options) {
     do_print(name);
     do_print(JSON.stringify(options));
     do_print("Setting up visit");
@@ -84,23 +85,25 @@ add_task(function* test_remove_single() 
       };
     });
     PlacesUtils.history.addObserver(observer, false);
 
     do_print("Performing removal");
     let removed = false;
     if (options.useCallback) {
       let onRowCalled = false;
+      let guid = do_get_guid_for_uri(uri);
+      let frecency = frecencyForUrl(uri);
       removed = yield PlacesUtils.history.remove(removeArg, page => {
         Assert.equal(onRowCalled, false, "Callback has not been called yet");
         onRowCalled = true;
         Assert.equal(page.url.href, uri.spec, "Callback provides the correct url");
-        Assert.equal(page.guid, do_get_guid_for_uri(uri), "Callback provides the correct guid");
+        Assert.equal(page.guid, guid, "Callback provides the correct guid");
         Assert.equal(page.title, title, "Callback provides the correct title");
-        Assert.equal(page.frecency, frecencyForUrl(uri), "Callback provides the correct frecency");
+        Assert.equal(page.frecency, frecency, "Callback provides the correct frecency");
       });
       Assert.ok(onRowCalled, "Callback has been called");
     } else {
       removed = yield PlacesUtils.history.remove(removeArg);
     }
 
     yield promiseObserved;
     PlacesUtils.history.removeObserver(observer);
@@ -134,16 +137,19 @@ add_task(function* test_remove_single() 
   }
   return;
 });
 
 // Test removing a list of pages
 add_task(function* test_remove_many() {
   const SIZE = 10;
 
+  yield PlacesTestUtils.clearHistory();
+  yield PlacesUtils.bookmarks.eraseEverything();
+
   do_print("Adding a witness page");
   let WITNESS_URI = NetUtil.newURI("http://mozilla.com/test_browserhistory/test_remove/" + Math.random());;
   yield PlacesTestUtils.addVisits(WITNESS_URI);
   Assert.ok(page_in_database(WITNESS_URI), "Witness page added");
 
   do_print("Generating samples");
   let pages = [];
   for (let i = 0; i < SIZE; ++i) {
@@ -261,20 +267,21 @@ add_task(function* test_remove_many() {
     Assert.ok(visits_in_database(page.uri) == 0, "History entry has disappeared");
     Assert.equal(page_in_database(page.uri) != 0, page.hasBookmark, "Page is present only if it also has bookmarks");
     Assert.equal(page.onFrecencyChangedCalled, page.onDeleteVisitsCalled, "onDeleteVisits was called iff onFrecencyChanged was called");
     Assert.ok(page.onFrecencyChangedCalled ^ page.onDeleteURICalled, "Either onFrecencyChanged or onDeleteURI was called");
   }
 
   Assert.notEqual(visits_in_database(WITNESS_URI), 0, "Witness URI still has visits");
   Assert.notEqual(page_in_database(WITNESS_URI), 0, "Witness URI is still here");
+});
 
-  do_print("Cleaning up");
+add_task(function* cleanup() {
   yield PlacesTestUtils.clearHistory();
-
+  yield PlacesUtils.bookmarks.eraseEverything();  
 });
 
 // Test the various error cases
 add_task(function* test_error_cases() {
   Assert.throws(
     () =>  PlacesUtils.history.remove(),
     /TypeError: Invalid url/,
     "History.remove with no argument should throw a TypeError"
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_removeVisitsByFilter.js
@@ -0,0 +1,263 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.removeVisitsByFilter`, as implemented in History.jsm
+
+"use strict";
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/PromiseUtils.jsm", this);
+
+add_task(function* test_removeVisitsByFilter() {
+  let referenceDate = new Date(1999, 9, 9, 9, 9);
+
+  /**
+   * Populate a database with 20 entries, remove a subset of entries,
+   * ensure consistency.
+   */
+  let remover = Task.async(function*(options) {
+    do_print("Remover with options " + JSON.stringify(options));
+    let SAMPLE_SIZE = options.sampleSize;
+
+    yield PlacesTestUtils.clearHistory();
+    yield PlacesUtils.bookmarks.eraseEverything();
+
+    // Populate the database.
+    // Create `SAMPLE_SIZE` visits, from the oldest to the newest.
+
+    let bookmarks = new Set(options.bookmarks);
+    let visits = [];
+    let visitByURI = new Map();
+    for (let i = 0; i < SAMPLE_SIZE; ++i) {
+      let spec = "http://mozilla.com/test_browserhistory/test_removeVisitsByFilter/removeme/" + i + "/" + Math.random();
+      let uri = NetUtil.newURI(spec);
+      let jsDate = new Date(Number(referenceDate) + 3600 * 1000 * i);
+      let dbDate = jsDate * 1000;
+      let hasBookmark = bookmarks.has(i);
+      do_print("Generating " + uri.spec + ", " + dbDate);
+      let visit = {
+        uri,
+        title: "visit " + i,
+        visitDate: dbDate,
+        test: {
+          // `visitDate`, as a Date
+          jsDate: jsDate,
+          // `true` if we expect that the visit will be removed
+          toRemove: false,
+          // `true` if `onRow` informed of the removal of this visit
+          announcedByOnRow: false,
+          // `true` if there is a bookmark for this URI, i.e. of the page
+          // should not be entirely removed.
+          hasBookmark: hasBookmark,
+          onFrecencyChanged: null,
+          onDeleteURI: null,
+        },
+      };
+      visits.push(visit);
+      visitByURI.set(spec, visit);
+      if (hasBookmark) {
+        do_print("Adding a bookmark to visit " + i);
+        yield PlacesUtils.bookmarks.insert({
+          url: uri,
+          parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+          title: "test bookmark"
+        });
+        do_print("Bookmark added");
+      }
+    }
+
+    do_print("Adding visits");
+    yield PlacesTestUtils.addVisits(visits);
+
+    do_print("Preparing filters");
+    let filter = {
+    };
+    let beginIndex = 0;
+    let endIndex = visits.length - 1;
+    if ("begin" in options) {
+      let ms = Number(visits[options.begin].test.jsDate) - 1000;
+      filter.beginDate = new Date(ms);
+      beginIndex = options.begin;
+    }
+    if ("end" in options) {
+      let ms = Number(visits[options.end].test.jsDate) + 1000;
+      filter.endDate = new Date(ms);
+      endIndex = options.end;
+    }
+    for (let i = beginIndex; i <= endIndex; ++i) {
+      let test = visits[i].test;
+      do_print("Marking visit " + i + " as expecting removal");
+      test.toRemove = true;
+      if (test.hasBookmark) {
+        test.onFrecencyChanged = PromiseUtils.defer();
+      } else {
+        test.onDeleteURI = PromiseUtils.defer();
+      }
+    }
+
+    let observer = {
+      deferred: PromiseUtils.defer(),
+      onBeginUpdateBatch: function() {},
+      onEndUpdateBatch: function() {},
+      onVisit: function(uri) {
+        this.deferred.reject(new Error("Unexpected call to onVisit " + uri.spec));
+      },
+      onTitleChanged: function(uri) {
+        this.deferred.reject(new Error("Unexpected call to onTitleChanged " + uri.spec));
+      },
+      onClearHistory: function() {
+        this.deferred.reject("Unexpected call to onClearHistory");
+      },
+      onPageChanged: function(uri) {
+        this.deferred.reject(new Error("Unexpected call to onPageChanged " + uri.spec));
+      },
+      onFrecencyChanged: function(aURI) {
+        do_print("onFrecencyChanged " + aURI.spec);
+        let visit = visitByURI.get(aURI.spec);
+        Assert.ok(!!visit.test.onFrecencyChanged, "Observing onFrecencyChanged");
+        visit.test.onFrecencyChanged.resolve();
+      },
+      onManyFrecenciesChanged: function() {
+        do_print("Many frecencies changed");
+        for (let visit of visits) {
+          if (visit.onFrecencyChanged) {
+            visit.onFrecencyChanged.resolve();
+          }
+        }
+      },
+      onDeleteURI: function(aURI) {
+        do_print("onDeleteURI " + aURI.spec);
+        let visit = visitByURI.get(aURI.spec);
+        Assert.ok(!!visit.test.onDeleteURI, "Observing onDeleteURI");
+        visit.test.onDeleteURI.resolve();
+      },
+      onDeleteVisits: function(aURI) {
+        // Not sure we can test anything.
+      }
+    };
+    PlacesUtils.history.addObserver(observer, false);
+
+    let cbarg;
+    if (options.useCallback) {
+      do_print("Setting up callback");
+      cbarg = [info => {
+        for (let visit of visits) {
+          do_print("Comparing " + info.date + " and " + visit.test.jsDate);
+          if (Math.abs(visit.test.jsDate - info.date) < 100) { // Assume rounding errors
+            Assert.ok(!visit.test.announcedByOnRow,
+              "This is the first time we announce the removal of this visit");
+            Assert.ok(visit.test.toRemove,
+              "This is a visit we intended to remove");
+            visit.test.announcedByOnRow = true;
+            return;
+          }
+        }
+        Assert.ok(false, "Could not find the visit we attempt to remove");
+      }];
+    } else {
+      do_print("No callback");
+      cbarg = [];
+    }
+    let result = yield PlacesUtils.history.removeVisitsByFilter(filter, ...cbarg);
+
+    Assert.ok(result, "Removal succeeded");
+
+    // Make sure that we have eliminated exactly the entries we expected
+    // to eliminate.
+    for (let i = 0; i < visits.length; ++i) {
+      let visit = visits[i];
+      do_print("Controlling the results on visit " + i);
+      Assert.equal(
+        visits_in_database(visit.uri) == 0,
+        visit.test.toRemove,
+        "Visit is still present iff expected");
+      if (options.useCallback) {
+        Assert.equal(
+          visit.test.toRemove,
+          visit.test.announcedByOnRow,
+          "Visit removal has been announced by onResult iff expected");
+      }
+      if (visit.test.hasBookmark || !visit.test.toRemove) {
+        Assert.notEqual(page_in_database(visit.uri), 0, "The page is should still appear in the db");
+      } else {
+        Assert.equal(page_in_database(visit.uri), 0, "The page should have been removed from the db");
+      }
+    }
+
+    // Make sure that the observer has been called wherever applicable.
+    for (let visit of visits) {
+      do_print("Making sure that the observer has been called for " + visit.uri.spec);
+      let test = visit.test;
+      do_print("Checking onFrecencyChanged");
+      if (test.onFrecencyChanged) {
+        yield test.onFrecencyChanged.promise;
+      }
+      do_print("Checking onDeleteURI");
+      if (test.onDeleteURI) {
+        yield test.onDeleteURI.promise;
+      }
+    }
+    PlacesUtils.history.removeObserver(observer);
+  });
+
+  let size = 20;
+  for (let range of [
+    {begin: 0},
+    {end: 19},
+    {begin: 0, end: 10},
+    {begin: 3, end: 4},
+  ]) {
+    for (let bookmarks of [[], [5, 6]]) {
+      let options = {
+        sampleSize: size,
+        bookmarks: bookmarks,
+      };
+      if ("begin" in range) {
+        options.begin = range.begin;
+      }
+      if ("end" in range) {
+        options.end = range.end;
+      }
+      yield remover(options);
+    }
+  }
+  yield PlacesTestUtils.clearHistory();
+});
+
+// Test the various error cases
+add_task(function* test_error_cases() {
+  Assert.throws(
+    () => PlacesUtils.history.removeVisitsByFilter(),
+    /TypeError: Expected a filter/
+  );
+  Assert.throws(
+    () => PlacesUtils.history.removeVisitsByFilter("obviously, not a filter"),
+    /TypeError: Expected a filter/
+  );
+  Assert.throws(
+    () => PlacesUtils.history.removeVisitsByFilter({}),
+    /TypeError: Expected a non-empty filter/
+  );
+  Assert.throws(
+    () => PlacesUtils.history.removeVisitsByFilter({beginDate: "now"}),
+    /TypeError: Expected a Date/
+  );
+  Assert.throws(
+    () => PlacesUtils.history.removeVisitsByFilter({beginDate: Date.now()}),
+    /TypeError: Expected a Date/
+  );
+  Assert.throws(
+    () => PlacesUtils.history.removeVisitsByFilter({beginDate: new Date()}, "obviously, not a callback"),
+    /TypeError: Invalid function/
+  );
+  Assert.throws(
+    () => PlacesUtils.history.removeVisitsByFilter({beginDate: new Date(1000), endDate: new Date(0)}),
+    /TypeError: `beginDate` should be at least as old/
+  );
+});
+
+
+function run_test() {
+  run_next_test();
+}
--- a/toolkit/components/places/tests/history/xpcshell.ini
+++ b/toolkit/components/places/tests/history/xpcshell.ini
@@ -1,5 +1,6 @@
 [DEFAULT]
 head = head_history.js
 tail =
 
 [test_remove.js]
+[test_removeVisitsByFilter.js]