Bug 824502 - Migrate folder=<id> based queries to parent=<guid> for Places' bookmark queries. r?mak draft
authorMark Banner <standard8@mozilla.com>
Fri, 04 May 2018 17:03:13 +0100
changeset 799742 17bb5160cb43d6efd658d9483302f6a0e016ebe8
parent 799741 9ff051238dd7f8ec683d9dde11657f3f392efeb3
child 799743 0d19efd19ce66d99150a25c00d631b86e839051f
push id111160
push userbmo:standard8@mozilla.com
push dateFri, 25 May 2018 10:21:09 +0000
reviewersmak
bugs824502
milestone62.0a1
Bug 824502 - Migrate folder=<id> based queries to parent=<guid> for Places' bookmark queries. r?mak MozReview-Commit-ID: 9A4SdNpp9wU
toolkit/components/places/Database.cpp
toolkit/components/places/Database.h
toolkit/components/places/Helpers.cpp
toolkit/components/places/Helpers.h
toolkit/components/places/nsNavHistory.h
toolkit/components/places/nsNavHistoryQuery.cpp
toolkit/components/places/tests/head_common.js
toolkit/components/places/tests/migration/test_current_from_v45.js
toolkit/components/places/tests/migration/test_current_from_v48.js
toolkit/components/places/tests/migration/xpcshell.ini
--- a/toolkit/components/places/Database.cpp
+++ b/toolkit/components/places/Database.cpp
@@ -1290,17 +1290,22 @@ Database::InitSchema(bool* aDatabaseMigr
         NS_ENSURE_SUCCESS(rv, rv);
       }
 
       if (currentSchemaVersion < 49) {
         rv = MigrateV49Up();
         NS_ENSURE_SUCCESS(rv, rv);
       }
 
-      // Firefox 62 uses schema version 49.
+      if (currentSchemaVersion < 50) {
+        rv = MigrateV50Up();
+        NS_ENSURE_SUCCESS(rv, rv);
+      }
+
+      // Firefox 62 uses schema version 50.
 
       // Schema Upgrades must add migration code here.
       // >>> IMPORTANT! <<<
       // NEVER MIX UP SYNC AND ASYNC EXECUTION IN MIGRATORS, YOU MAY LOCK THE
       // CONNECTION AND CAUSE FURTHER STEPS TO FAIL.
       // In case, set a bool and do the async work in the ScopeExit guard just
       // before the migration steps.
     }
@@ -2486,16 +2491,173 @@ Database::MigrateV49Up() {
   Unused << Preferences::ClearUser("places.frecency.stats.count");
   Unused << Preferences::ClearUser("places.frecency.stats.sum");
   Unused << Preferences::ClearUser("places.frecency.stats.sumOfSquares");
 
   return NS_OK;
 }
 
 nsresult
+Database::MigrateV50Up() {
+  // Convert the existing queries. We don't have REGEX available, so the simplest
+  // thing to do is to pull the urls out, and process them manually.
+  nsCOMPtr<mozIStorageStatement> stmt;
+  nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+    "SELECT id, url FROM moz_places "
+    "WHERE url_hash BETWEEN hash('place', 'prefix_lo') AND "
+                           "hash('place', 'prefix_hi') "
+      "AND url LIKE '%folder=%' "
+  ), getter_AddRefs(stmt));
+  if (NS_FAILED(rv)) return rv;
+
+  AutoTArray<Pair<int64_t, nsCString>, 32> placeURLs;
+
+  bool hasMore = false;
+  nsCString url;
+  while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+    int64_t placeId;
+    rv = stmt->GetInt64(0, &placeId);
+    if (NS_FAILED(rv)) return rv;
+    rv = stmt->GetUTF8String(1, url);
+    if (NS_FAILED(rv)) return rv;
+
+    if (!placeURLs.AppendElement(MakePair(placeId, url))) {
+      return NS_ERROR_OUT_OF_MEMORY;
+    }
+  }
+
+  if (placeURLs.IsEmpty()) {
+    return NS_OK;
+  }
+
+  int64_t placeId;
+  for (uint32_t i = 0; i < placeURLs.Length(); ++i) {
+    placeId = placeURLs[i].first();
+    url = placeURLs[i].second();
+
+    rv = ConvertOldStyleQuery(url);
+    // Something bad happened, and we can't convert it, so just continue.
+    if (NS_WARN_IF(NS_FAILED(rv))) {
+      continue;
+    }
+
+    nsCOMPtr<mozIStorageStatement> updateStmt;
+    rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+      "UPDATE moz_places "
+      "SET url = :url, url_hash = hash(:url) "
+      "WHERE id = :placeId "
+    ), getter_AddRefs(updateStmt));
+    if (NS_FAILED(rv)) return rv;
+
+    rv = URIBinder::Bind(updateStmt, NS_LITERAL_CSTRING("url"), url);
+    if (NS_FAILED(rv)) return rv;
+    rv = updateStmt->BindInt64ByName(NS_LITERAL_CSTRING("placeId"), placeId);
+    if (NS_FAILED(rv)) return rv;
+
+    rv = updateStmt->Execute();
+    if (NS_FAILED(rv)) return rv;
+
+    // Update Sync fields for these queries.
+    nsCOMPtr<mozIStorageStatement> syncStmt;
+    rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+      "UPDATE moz_bookmarks SET syncChangeCounter = syncChangeCounter + 1 "
+      "WHERE fk = :placeId "
+    ), getter_AddRefs(syncStmt));
+    if (NS_FAILED(rv)) return rv;
+
+    rv = syncStmt->BindInt64ByName(NS_LITERAL_CSTRING("placeId"), placeId);
+    if (NS_FAILED(rv)) return rv;
+
+    rv = syncStmt->Execute();
+    if (NS_FAILED(rv)) return rv;
+  }
+
+  return NS_OK;
+}
+
+
+nsresult
+Database::ConvertOldStyleQuery(nsCString& aURL)
+{
+  AutoTArray<QueryKeyValuePair, 8> tokens;
+  nsresult rv = TokenizeQueryString(aURL, &tokens);
+  NS_ENSURE_SUCCESS(rv, rv);
+
+  AutoTArray<QueryKeyValuePair, 8> newTokens;
+  bool invalid = false;
+  nsAutoCString guid;
+
+  for (uint32_t j = 0; j < tokens.Length(); ++j) {
+    const QueryKeyValuePair& kvp = tokens[j];
+
+    if (!kvp.key.EqualsLiteral("folder")) {
+      if (!newTokens.AppendElement(kvp)) {
+        return NS_ERROR_OUT_OF_MEMORY;
+      }
+      continue;
+    }
+
+    int64_t itemId = kvp.value.ToInteger(&rv);
+    if (NS_SUCCEEDED(rv)) {
+      // We have the folder's ID, now to find its GUID.
+      nsCOMPtr<mozIStorageStatement> stmt;
+      nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+        "SELECT guid FROM moz_bookmarks "
+        "WHERE id = :itemId "
+      ), getter_AddRefs(stmt));
+      if (NS_FAILED(rv)) return rv;
+
+      rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("itemId"), itemId);
+      if (NS_FAILED(rv)) return rv;
+
+      bool hasMore = false;
+      if (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+        rv = stmt->GetUTF8String(0, guid);
+        if (NS_FAILED(rv)) return rv;
+      }
+    } else if (kvp.value.EqualsLiteral("PLACES_ROOT")) {
+      guid = NS_LITERAL_CSTRING(ROOT_GUID);
+    } else if (kvp.value.EqualsLiteral("BOOKMARKS_MENU")) {
+      guid = NS_LITERAL_CSTRING(MENU_ROOT_GUID);
+    } else if (kvp.value.EqualsLiteral("TAGS")) {
+      guid = NS_LITERAL_CSTRING(TAGS_ROOT_GUID);
+    } else if (kvp.value.EqualsLiteral("UNFILED_BOOKMARKS")) {
+      guid = NS_LITERAL_CSTRING(UNFILED_ROOT_GUID);
+    } else if (kvp.value.EqualsLiteral("TOOLBAR")) {
+      guid = NS_LITERAL_CSTRING(TOOLBAR_ROOT_GUID);
+    } else if (kvp.value.EqualsLiteral("MOBILE_BOOKMARKS")) {
+      guid = NS_LITERAL_CSTRING(MOBILE_ROOT_GUID);
+    }
+
+    QueryKeyValuePair* newPair;
+    if (guid.IsEmpty()) {
+      // This is invalid, so we'll change this key/value pair to something else
+      // so that the query remains a valid url.
+      newPair = new QueryKeyValuePair(NS_LITERAL_CSTRING("invalidOldParentId"), kvp.value);
+      invalid = true;
+    } else {
+      newPair = new QueryKeyValuePair(NS_LITERAL_CSTRING("parent"), guid);
+    }
+    if (!newTokens.AppendElement(*newPair)) {
+      return NS_ERROR_OUT_OF_MEMORY;
+    }
+    delete newPair;
+  }
+
+  if (invalid) {
+    // One or more of the folders don't exist, replace with an empty query.
+    newTokens.AppendElement(QueryKeyValuePair(NS_LITERAL_CSTRING("excludeItems"),
+                                              NS_LITERAL_CSTRING("1")));
+  }
+
+  TokensToQueryString(newTokens, aURL);
+  return NS_OK;
+}
+
+nsresult
 Database::GetItemsWithAnno(const nsACString& aAnnoName, int32_t aItemType,
                            nsTArray<int64_t>& aItemIds)
 {
   nsCOMPtr<mozIStorageStatement> stmt;
   nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
     "SELECT b.id FROM moz_items_annos a "
     "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
     "JOIN moz_bookmarks b ON b.id = a.item_id "
--- a/toolkit/components/places/Database.h
+++ b/toolkit/components/places/Database.h
@@ -14,17 +14,17 @@
 #include "mozilla/storage/StatementCache.h"
 #include "mozilla/Attributes.h"
 #include "nsIEventTarget.h"
 #include "Shutdown.h"
 #include "nsCategoryCache.h"
 
 // This is the schema version. Update it at any schema change and add a
 // corresponding migrateVxx method below.
-#define DATABASE_SCHEMA_VERSION 49
+#define DATABASE_SCHEMA_VERSION 50
 
 // Fired after Places inited.
 #define TOPIC_PLACES_INIT_COMPLETE "places-init-complete"
 // This topic is received when the profile is about to be lost.  Places does
 // initial shutdown work and notifies TOPIC_PLACES_SHUTDOWN to all listeners.
 // Any shutdown work that requires the Places APIs should happen here.
 #define TOPIC_PROFILE_CHANGE_TEARDOWN "profile-change-teardown"
 // Fired when Places is shutting down.  Any code should stop accessing Places
@@ -331,24 +331,26 @@ protected:
   nsresult MigrateV42Up();
   nsresult MigrateV43Up();
   nsresult MigrateV44Up();
   nsresult MigrateV45Up();
   nsresult MigrateV46Up();
   nsresult MigrateV47Up();
   nsresult MigrateV48Up();
   nsresult MigrateV49Up();
+  nsresult MigrateV50Up();
 
   void MigrateV48Frecencies();
 
   nsresult UpdateBookmarkRootTitles();
 
   friend class ConnectionShutdownBlocker;
 
   int64_t CreateMobileRoot();
+  nsresult ConvertOldStyleQuery(nsCString& aURL);
   nsresult GetItemsWithAnno(const nsACString& aAnnoName, int32_t aItemType,
                             nsTArray<int64_t>& aItemIds);
   nsresult DeleteBookmarkItem(int32_t aItemId);
 
 private:
   ~Database();
 
   /**
--- a/toolkit/components/places/Helpers.cpp
+++ b/toolkit/components/places/Helpers.cpp
@@ -355,16 +355,69 @@ bool
 GetHiddenState(bool aIsRedirect,
                uint32_t aTransitionType)
 {
   return aTransitionType == nsINavHistoryService::TRANSITION_FRAMED_LINK ||
          aTransitionType == nsINavHistoryService::TRANSITION_EMBED ||
          aIsRedirect;
 }
 
+nsresult
+TokenizeQueryString(const nsACString& aQuery,
+                    nsTArray<QueryKeyValuePair>* aTokens)
+{
+  // Strip off the "place:" prefix
+  const uint32_t prefixlen = 6; // = strlen("place:");
+  nsCString query;
+  if (aQuery.Length() >= prefixlen &&
+      Substring(aQuery, 0, prefixlen).EqualsLiteral("place:"))
+    query = Substring(aQuery, prefixlen);
+  else
+    query = aQuery;
+
+  int32_t keyFirstIndex = 0;
+  int32_t equalsIndex = 0;
+  for (uint32_t i = 0; i < query.Length(); i ++) {
+    if (query[i] == '&') {
+      // new clause, save last one
+      if (i - keyFirstIndex > 1) {
+        if (! aTokens->AppendElement(QueryKeyValuePair(query, keyFirstIndex,
+                                                       equalsIndex, i)))
+          return NS_ERROR_OUT_OF_MEMORY;
+      }
+      keyFirstIndex = equalsIndex = i + 1;
+    } else if (query[i] == '=') {
+      equalsIndex = i;
+    }
+  }
+
+  // handle last pair, if any
+  if (query.Length() - keyFirstIndex > 1) {
+    if (! aTokens->AppendElement(QueryKeyValuePair(query, keyFirstIndex,
+                                                   equalsIndex, query.Length())))
+      return NS_ERROR_OUT_OF_MEMORY;
+  }
+  return NS_OK;
+}
+
+void
+TokensToQueryString(const nsTArray<QueryKeyValuePair> &aTokens,
+                    nsACString &aQuery)
+{
+  aQuery = NS_LITERAL_CSTRING("place:");
+  for (uint32_t i = 0; i < aTokens.Length(); i++) {
+    if (i > 0) {
+      aQuery.Append("&");
+    }
+    aQuery.Append(aTokens[i].key);
+    aQuery.AppendLiteral("=");
+    aQuery.Append(aTokens[i].value);
+  }
+}
+
 ////////////////////////////////////////////////////////////////////////////////
 //// AsyncStatementCallbackNotifier
 
 NS_IMETHODIMP
 AsyncStatementCallbackNotifier::HandleCompletion(uint16_t aReason)
 {
   if (aReason != mozIStorageStatementCallback::REASON_FINISHED)
     return NS_ERROR_UNEXPECTED;
--- a/toolkit/components/places/Helpers.h
+++ b/toolkit/components/places/Helpers.h
@@ -172,16 +172,62 @@ PRTime RoundToMilliseconds(PRTime aTime)
  *
  * @return @see PR_Now, RoundToMilliseconds.
  */
 PRTime RoundedPRNow();
 
 nsresult HashURL(const nsAString& aSpec, const nsACString& aMode,
                  uint64_t *_hash);
 
+class QueryKeyValuePair final
+{
+public:
+
+  QueryKeyValuePair(const nsACString &aKey, const nsACString &aValue)
+  {
+    key = aKey;
+    value = aValue;
+  };
+
+  // QueryKeyValuePair
+  //
+  //                  01234567890
+  //    input : qwerty&key=value&qwerty
+  //                  ^   ^     ^
+  //          aKeyBegin   |     aPastEnd (may point to null terminator)
+  //                      aEquals
+  //
+  //    Special case: if aKeyBegin == aEquals, then there is only one string
+  //    and no equal sign, so we treat the entire thing as a key with no value
+
+  QueryKeyValuePair(const nsACString& aSource, int32_t aKeyBegin,
+                    int32_t aEquals, int32_t aPastEnd)
+  {
+    if (aEquals == aKeyBegin)
+      aEquals = aPastEnd;
+    key = Substring(aSource, aKeyBegin, aEquals - aKeyBegin);
+    if (aPastEnd - aEquals > 0)
+      value = Substring(aSource, aEquals + 1, aPastEnd - aEquals - 1);
+  }
+  nsCString key;
+  nsCString value;
+ };
+
+ /**
+  * Tokenizes a QueryString.
+  *
+  * @param aQuery The string to tokenize.
+  * @param aTokens The tokenized result.
+  */
+nsresult TokenizeQueryString(const nsACString& aQuery,
+                             nsTArray<QueryKeyValuePair>* aTokens);
+
+void TokensToQueryString(const nsTArray<QueryKeyValuePair> &aTokens,
+                         nsACString &aQuery);
+
 /**
  * Used to finalize a statementCache on a specified thread.
  */
 template<typename StatementType>
 class FinalizeStatementCacheProxy : public Runnable
 {
 public:
   /**
--- a/toolkit/components/places/nsNavHistory.h
+++ b/toolkit/components/places/nsNavHistory.h
@@ -73,17 +73,16 @@
 #define MOBILE_ROOT_GUID "mobile______"
 
 class nsIAutoCompleteController;
 class nsIEffectiveTLDService;
 class nsIIDNService;
 class nsNavHistory;
 class PlacesDecayFrecencyCallback;
 class PlacesSQLQueryBuilder;
-class QueryKeyValuePair;
 
 // nsNavHistory
 
 class nsNavHistory final : public nsSupportsWeakReference
                          , public nsINavHistoryService
                          , public nsIObserver
                          , public mozIStorageVacuumParticipant
 {
@@ -636,17 +635,17 @@ protected:
   int32_t mReloadVisitBonus;
 
   void DecayFrecencyCompleted(uint16_t reason);
   uint32_t mDecayFrecencyPendingCount;
 
   nsresult RecalculateFrecencyStatsInternal();
 
   // in nsNavHistoryQuery.cpp
-  nsresult TokensToQuery(const nsTArray<QueryKeyValuePair>& aTokens,
+  nsresult TokensToQuery(const nsTArray<mozilla::places::QueryKeyValuePair>& aTokens,
                          nsNavHistoryQuery* aQuery,
                          nsNavHistoryQueryOptions* aOptions);
 
   int64_t mTagsFolder;
 
   int32_t mDaysOfHistory;
   int64_t mLastCachedStartOfDay;
   int64_t mLastCachedEndOfDay;
--- a/toolkit/components/places/nsNavHistoryQuery.cpp
+++ b/toolkit/components/places/nsNavHistoryQuery.cpp
@@ -16,47 +16,18 @@
 #include "nsEscape.h"
 #include "nsCOMArray.h"
 #include "nsNetUtil.h"
 #include "nsTArray.h"
 #include "prprf.h"
 #include "nsVariant.h"
 
 using namespace mozilla;
-
-class QueryKeyValuePair
-{
-public:
-
-  // QueryKeyValuePair
-  //
-  //                  01234567890
-  //    input : qwerty&key=value&qwerty
-  //                  ^   ^     ^
-  //          aKeyBegin   |     aPastEnd (may point to null terminator)
-  //                      aEquals
-  //
-  //    Special case: if aKeyBegin == aEquals, then there is only one string
-  //    and no equal sign, so we treat the entire thing as a key with no value
+using namespace mozilla::places;
 
-  QueryKeyValuePair(const nsACString& aSource, int32_t aKeyBegin,
-                    int32_t aEquals, int32_t aPastEnd)
-  {
-    if (aEquals == aKeyBegin)
-      aEquals = aPastEnd;
-    key = Substring(aSource, aKeyBegin, aEquals - aKeyBegin);
-    if (aPastEnd - aEquals > 0)
-      value = Substring(aSource, aEquals + 1, aPastEnd - aEquals - 1);
-  }
-  nsCString key;
-  nsCString value;
-};
-
-static nsresult TokenizeQueryString(const nsACString& aQuery,
-                                    nsTArray<QueryKeyValuePair>* aTokens);
 static nsresult ParseQueryBooleanString(const nsCString& aString,
                                         bool* aValue);
 
 // query getters
 typedef decltype(&nsINavHistoryQuery::GetOnlyBookmarked) BoolQueryGetter;
 typedef decltype(&nsINavHistoryQuery::GetBeginTimeReference) Uint32QueryGetter;
 typedef decltype(&nsINavHistoryQuery::GetBeginTime) Int64QueryGetter;
 static void AppendBoolKeyValueIfTrue(nsACString& aString,
@@ -409,56 +380,16 @@ nsNavHistory::QueryToQueryString(nsINavH
   }
 
   aQueryString.AssignLiteral("place:");
   aQueryString.Append(queryString);
   return NS_OK;
 }
 
 
-// TokenizeQueryString
-
-nsresult
-TokenizeQueryString(const nsACString& aQuery,
-                    nsTArray<QueryKeyValuePair>* aTokens)
-{
-  // Strip off the "place:" prefix
-  const uint32_t prefixlen = 6; // = strlen("place:");
-  nsCString query;
-  if (aQuery.Length() >= prefixlen &&
-      Substring(aQuery, 0, prefixlen).EqualsLiteral("place:"))
-    query = Substring(aQuery, prefixlen);
-  else
-    query = aQuery;
-
-  int32_t keyFirstIndex = 0;
-  int32_t equalsIndex = 0;
-  for (uint32_t i = 0; i < query.Length(); i ++) {
-    if (query[i] == '&') {
-      // new clause, save last one
-      if (i - keyFirstIndex > 1) {
-        if (! aTokens->AppendElement(QueryKeyValuePair(query, keyFirstIndex,
-                                                       equalsIndex, i)))
-          return NS_ERROR_OUT_OF_MEMORY;
-      }
-      keyFirstIndex = equalsIndex = i + 1;
-    } else if (query[i] == '=') {
-      equalsIndex = i;
-    }
-  }
-
-  // handle last pair, if any
-  if (query.Length() - keyFirstIndex > 1) {
-    if (! aTokens->AppendElement(QueryKeyValuePair(query, keyFirstIndex,
-                                                   equalsIndex, query.Length())))
-      return NS_ERROR_OUT_OF_MEMORY;
-  }
-  return NS_OK;
-}
-
 nsresult
 nsNavHistory::TokensToQuery(const nsTArray<QueryKeyValuePair>& aTokens,
                             nsNavHistoryQuery* aQuery,
                             nsNavHistoryQueryOptions* aOptions)
 {
   nsresult rv;
 
   if (aTokens.Length() == 0)
--- a/toolkit/components/places/tests/head_common.js
+++ b/toolkit/components/places/tests/head_common.js
@@ -1,14 +1,14 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-const CURRENT_SCHEMA_VERSION = 49;
+const CURRENT_SCHEMA_VERSION = 50;
 const FIRST_UPGRADABLE_SCHEMA_VERSION = 30;
 
 const NS_APP_USER_PROFILE_50_DIR = "ProfD";
 
 // Shortcuts to transitions type.
 const TRANSITION_LINK = Ci.nsINavHistoryService.TRANSITION_LINK;
 const TRANSITION_TYPED = Ci.nsINavHistoryService.TRANSITION_TYPED;
 const TRANSITION_BOOKMARK = Ci.nsINavHistoryService.TRANSITION_BOOKMARK;
--- a/toolkit/components/places/tests/migration/test_current_from_v45.js
+++ b/toolkit/components/places/tests/migration/test_current_from_v45.js
@@ -17,16 +17,17 @@ let gTags = [
   { folder: 345678,
     url: "place:type=7&folder=345678&queryType=1",
     title: "tag3",
     hash: "268506471927988",
   },
   // This will point to an invalid folder id.
   { folder: 456789,
     url: "place:type=7&folder=456789&queryType=1",
+    expectedUrl: "place:type=7&invalidOldParentId=456789&queryType=1&excludeItems=1",
     title: "invalid",
     hash: "268505972797836",
   },
 ];
 gTags.forEach(t => t.guid = t.title.padEnd(12, "_"));
 
 add_task(async function setup() {
   await setupPlacesDatabase("places_v43.sqlite");
@@ -56,17 +57,17 @@ add_task(async function database_is_vali
                PlacesUtils.history.DATABASE_STATUS_UPGRADED);
 
   let db = await PlacesUtils.promiseDBConnection();
   Assert.equal((await db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
 });
 
 add_task(async function test_queries_converted() {
   for (let tag of gTags) {
-    let url = tag.title == "invalid" ? tag.url : "place:tag=" + tag.title;
+    let url = tag.title == "invalid" ? tag.expectedUrl : "place:tag=" + tag.title;
     let page = await PlacesUtils.history.fetch(tag.guid);
     Assert.equal(page.url.href, url);
   }
 });
 
 add_task(async function test_sync_fields() {
   let db = await PlacesUtils.promiseDBConnection();
   for (let tag of gTags) {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v48.js
@@ -0,0 +1,146 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const gCreatedParentGuid = "m47___FOLDER";
+
+const gTestItems = [{
+  // Folder shortcuts to built-in folders.
+  guid: "m47_____ROOT",
+  url: "place:folder=PLACES_ROOT",
+  targetParentGuid: "rootGuid",
+}, {
+  guid: "m47_____MENU",
+  url: "place:folder=BOOKMARKS_MENU",
+  targetParentGuid: "menuGuid",
+}, {
+  guid: "m47_____TAGS",
+  url: "place:folder=TAGS",
+  targetParentGuid: "tagsGuid",
+}, {
+  guid: "m47____OTHER",
+  url: "place:folder=UNFILED_BOOKMARKS",
+  targetParentGuid: "unfiledGuid",
+}, {
+  guid: "m47__TOOLBAR",
+  url: "place:folder=TOOLBAR",
+  targetParentGuid: "toolbarGuid",
+}, {
+  guid: "m47___MOBILE",
+  url: "place:folder=MOBILE_BOOKMARKS",
+  targetParentGuid: "mobileGuid",
+}, {
+  // Folder shortcut to using id.
+  guid: "m47_______ID",
+  url: "place:folder=%id%",
+  expectedUrl: "place:parent=%guid%"
+}, {
+  // Folder shortcut to multiple folders.
+  guid: "m47____MULTI",
+  url: "place:folder=TOOLBAR&folder=%id%&sort=1",
+  expectedUrl: "place:parent=%toolbarGuid%&parent=%guid%&sort=1"
+}, {
+  // Folder shortcut to non-existent folder.
+  guid: "m47______NON",
+  url: "place:folder=454554545",
+  expectedUrl: "place:invalidOldParentId=454554545&excludeItems=1"
+}];
+
+add_task(async function setup() {
+  await setupPlacesDatabase("places_v43.sqlite");
+
+  // Setup database contents to be migrated.
+  let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+  let db = await Sqlite.openConnection({ path });
+
+  let rows = await db.execute(`SELECT id FROM moz_bookmarks
+                               WHERE guid = :guid`,
+                               { guid: PlacesUtils.bookmarks.unfiledGuid });
+
+  let unfiledId = rows[0].getResultByName("id");
+
+  // Insert a test folder.
+  await db.execute(`INSERT INTO moz_bookmarks (guid, title, parent)
+                    VALUES (:guid, "Folder", :parent)`,
+                    { guid: gCreatedParentGuid, parent: unfiledId });
+
+  rows = await db.execute(`SELECT id FROM moz_bookmarks
+                           WHERE guid = :guid`,
+                           { guid: gCreatedParentGuid });
+
+  let createdFolderId = rows[0].getResultByName("id");
+
+  for (let item of gTestItems) {
+    item.url = item.url.replace("%id%", createdFolderId);
+
+    // We can reuse the same guid, it doesn't matter for this test.
+    await db.execute(`INSERT INTO moz_places (url, guid, url_hash)
+                      VALUES (:url, :guid, :hash)
+                     `, { url: item.url, guid: item.guid, hash: PlacesUtils.history.hashURL(item.url) });
+    await db.execute(`INSERT INTO moz_bookmarks (id, fk, guid, title, parent)
+                      VALUES (:id, (SELECT id FROM moz_places WHERE guid = :guid),
+                              :guid, :title,
+                              (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid))
+                    `, {
+      id: item.folder,
+      guid: item.guid,
+      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+      title: item.guid
+    });
+  }
+
+  await db.close();
+});
+
+add_task(async function database_is_valid() {
+  // Accessing the database for the first time triggers migration.
+  Assert.equal(PlacesUtils.history.databaseStatus,
+               PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+  let db = await PlacesUtils.promiseDBConnection();
+  Assert.equal((await db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(async function test_correct_folder_queries() {
+  for (let item of gTestItems) {
+    let bm = await PlacesUtils.bookmarks.fetch(item.guid);
+
+    if (item.targetParentGuid) {
+      Assert.equal(bm.url, `place:parent=${PlacesUtils.bookmarks[item.targetParentGuid]}`,
+        `Should have updated the URL for ${item.guid}`);
+    } else {
+      let expected = item.expectedUrl
+        .replace("%guid%", gCreatedParentGuid)
+        .replace("%toolbarGuid%", PlacesUtils.bookmarks.toolbarGuid);
+
+      Assert.equal(bm.url, expected, `Should have updated the URL for ${item.guid}`);
+    }
+  }
+});
+
+add_task(async function test_hashes_valid() {
+  let db = await PlacesUtils.promiseDBConnection();
+  // Ensure all the hashes in moz_places are valid.
+  let rows = await db.execute(`SELECT url, url_hash FROM moz_places`);
+
+  for (let row of rows) {
+    let url = row.getResultByName("url");
+    let url_hash = row.getResultByName("url_hash");
+    Assert.equal(url_hash, PlacesUtils.history.hashURL(url),
+      `url hash should be correct for ${url}`);
+  }
+});
+
+add_task(async function test_sync_counters_updated() {
+  let db = await PlacesUtils.promiseDBConnection();
+
+  for (let test of gTestItems) {
+    let rows = await db.execute(`SELECT syncChangeCounter FROM moz_bookmarks
+                                 WHERE guid = :guid`, {guid: test.guid});
+
+    Assert.equal(rows.length, 1, `Should only be one record for ${test.guid}`);
+    Assert.equal(rows[0].getResultByName("syncChangeCounter"), 2,
+      `Should have bumped the syncChangeCounter for ${test.guid}`);
+  }
+});
--- a/toolkit/components/places/tests/migration/xpcshell.ini
+++ b/toolkit/components/places/tests/migration/xpcshell.ini
@@ -21,8 +21,9 @@ support-files =
 [test_current_from_v36.js]
 [test_current_from_v38.js]
 [test_current_from_v41.js]
 [test_current_from_v42.js]
 [test_current_from_v43.js]
 [test_current_from_v45.js]
 [test_current_from_v46.js]
 [test_current_from_v47.js]
+[test_current_from_v48.js]