Bug 1076775 - Implement History.removeHistoryByFilter. r=mak

This commit is contained in:
David Rajchenbach-Teller 2015-04-09 16:47:58 +02:00
Родитель 6165efa7d4
Коммит e5d93c30dd
5 изменённых файлов: 619 добавлений и 82 удалений

Просмотреть файл

@ -84,47 +84,47 @@ 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;
})
);
@ -319,6 +319,78 @@ this.History = Object.freeze({
});
},
/**
* 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.
*
@ -439,6 +511,15 @@ function normalizeToURLOrGUID(key) {
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.
@ -504,10 +585,220 @@ let clear = Task.async(function* () {
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
@ -515,79 +806,60 @@ let remove = Task.async(function*({guids, urls}, onResult = null) {
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) {
hasPagesToRemove = true;
} else {
let hasForeign = row.getResultByName("foreign_count") != 0;
if (hasForeign) {
hasPagesToKeep = true;
} else {
hasPagesToRemove = 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;
});

Просмотреть файл

@ -2303,6 +2303,7 @@ let GuidHelper = {
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 });
@ -2321,6 +2322,7 @@ let GuidHelper = {
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 });

Просмотреть файл

@ -1,8 +1,5 @@
/* -*- 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
@ -89,13 +86,15 @@ add_task(function* test_remove_single() {
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 {

Просмотреть файл

@ -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();
}

Просмотреть файл

@ -3,3 +3,4 @@ head = head_history.js
tail =
[test_remove.js]
[test_removeVisitsByFilter.js]