/* 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/. */ import Foundation import Shared @testable import Storage import Deferred import XCTest let threeMonthsInMillis: UInt64 = 3 * 30 * 24 * 60 * 60 * 1000 let threeMonthsInMicros: UInt64 = UInt64(threeMonthsInMillis) * UInt64(1000) // Start everything three months ago. let baseInstantInMillis = Date.now() - threeMonthsInMillis let baseInstantInMicros = Date.nowMicroseconds() - threeMonthsInMicros func advanceTimestamp(_ timestamp: Timestamp, by: Int) -> Timestamp { return timestamp + UInt64(by) } func advanceMicrosecondTimestamp(_ timestamp: MicrosecondTimestamp, by: Int) -> MicrosecondTimestamp { return timestamp + UInt64(by) } extension Site { func asPlace() -> Place { return Place(guid: self.guid!, url: self.url, title: self.title) } } class BaseHistoricalBrowserSchema: Schema { var name: String { return "BROWSER" } var version: Int { return -1 } func update(_ db: SQLiteDBConnection, from: Int) -> Bool { fatalError("Should never be called.") } func create(_ db: SQLiteDBConnection) -> Bool { return false } func drop(_ db: SQLiteDBConnection) -> Bool { return false } var supportsPartialIndices: Bool { let v = sqlite3_libversion_number() return v >= 3008000 // 3.8.0. } let oldFaviconsSQL = "CREATE TABLE IF NOT EXISTS favicons (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "url TEXT NOT NULL UNIQUE, " + "width INTEGER, " + "height INTEGER, " + "type INTEGER NOT NULL, " + "date REAL NOT NULL" + ") " func run(_ db: SQLiteDBConnection, sql: String?, args: Args? = nil) -> Bool { if let sql = sql { do { try db.executeChange(sql, withArgs: args) } catch { return false } } return true } func run(_ db: SQLiteDBConnection, queries: [String?]) -> Bool { for sql in queries { if let sql = sql { if !run(db, sql: sql) { return false } } } return true } func run(_ db: SQLiteDBConnection, queries: [String]) -> Bool { for sql in queries { if !run(db, sql: sql) { return false } } return true } } // Versions of BrowserSchema that we care about: // v6, prior to 001c73ea1903c238be1340950770879b40c41732, July 2015. // This is when we first started caring about database versions. // // v7, 81e22fa6f7446e27526a5a9e8f4623df159936c3. History tiles. // // v8, 02c08ddc6d805d853bbe053884725dc971ef37d7. Favicons. // // v10, 4428c7d181ff4779ab1efb39e857e41bdbf4de67. Mirroring. We skipped v9. // // These tests snapshot the table creation code at each of these points. class BrowserSchemaV6: BaseHistoricalBrowserSchema { override var version: Int { return 6 } func prepopulateRootFolders(_ db: SQLiteDBConnection) -> Bool { let type = BookmarkNodeType.folder.rawValue let root = BookmarkRoots.RootID let titleMobile = NSLocalizedString("Mobile Bookmarks", tableName: "Storage", comment: "The title of the folder that contains mobile bookmarks. This should match bookmarks.folder.mobile.label on Android.") let titleMenu = NSLocalizedString("Bookmarks Menu", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the menu. This should match bookmarks.folder.menu.label on Android.") let titleToolbar = NSLocalizedString("Bookmarks Toolbar", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the toolbar. This should match bookmarks.folder.toolbar.label on Android.") let titleUnsorted = NSLocalizedString("Unsorted Bookmarks", tableName: "Storage", comment: "The name of the folder that contains unsorted desktop bookmarks. This should match bookmarks.folder.unfiled.label on Android.") let args: Args = [ root, BookmarkRoots.RootGUID, type, "Root", root, BookmarkRoots.MobileID, BookmarkRoots.MobileFolderGUID, type, titleMobile, root, BookmarkRoots.MenuID, BookmarkRoots.MenuFolderGUID, type, titleMenu, root, BookmarkRoots.ToolbarID, BookmarkRoots.ToolbarFolderGUID, type, titleToolbar, root, BookmarkRoots.UnfiledID, BookmarkRoots.UnfiledFolderGUID, type, titleUnsorted, root, ] let sql = "INSERT INTO bookmarks (id, guid, type, url, title, parent) VALUES " + "(?, ?, ?, NULL, ?, ?), " + // Root "(?, ?, ?, NULL, ?, ?), " + // Mobile "(?, ?, ?, NULL, ?, ?), " + // Menu "(?, ?, ?, NULL, ?, ?), " + // Toolbar "(?, ?, ?, NULL, ?, ?) " // Unsorted return self.run(db, sql: sql, args: args) } func CreateHistoryTable() -> String { return "CREATE TABLE IF NOT EXISTS \(TableHistory) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + // Not null, but the value might be replaced by the server's. "url TEXT UNIQUE, " + // May only be null for deleted records. "title TEXT NOT NULL, " + "server_modified INTEGER, " + // Can be null. Integer milliseconds. "local_modified INTEGER, " + // Can be null. Client clock. In extremis only. "is_deleted TINYINT NOT NULL, " + // Boolean. Locally deleted. "should_upload TINYINT NOT NULL, " + // Boolean. Set when changed or visits added. "domain_id INTEGER REFERENCES \(TableDomains)(id) ON DELETE CASCADE, " + "CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1)" + ")" } func CreateDomainsTable() -> String { return "CREATE TABLE IF NOT EXISTS \(TableDomains) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "domain TEXT NOT NULL UNIQUE, " + "showOnTopSites TINYINT NOT NULL DEFAULT 1" + ")" } func CreateQueueTable() -> String { return "CREATE TABLE IF NOT EXISTS \(TableQueuedTabs) (" + "url TEXT NOT NULL UNIQUE, " + "title TEXT" + ") " } override func create(_ db: SQLiteDBConnection) -> Bool { let visits = "CREATE TABLE IF NOT EXISTS \(TableVisits) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES \(TableHistory)(id) ON DELETE CASCADE, " + "date REAL NOT NULL, " + // Microseconds since epoch. "type INTEGER NOT NULL, " + "is_local TINYINT NOT NULL, " + // Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. "UNIQUE (siteID, date, type) " + ") " let indexShouldUpload: String if self.supportsPartialIndices { // There's no point tracking rows that are not flagged for upload. indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON \(TableHistory) (should_upload) WHERE should_upload = 1" } else { indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON \(TableHistory) (should_upload)" } let indexSiteIDDate = "CREATE INDEX IF NOT EXISTS \(IndexVisitsSiteIDIsLocalDate) " + "ON \(TableVisits) (siteID, is_local, date)" let faviconSites = "CREATE TABLE IF NOT EXISTS \(TableFaviconSites) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES \(TableHistory)(id) ON DELETE CASCADE, " + "faviconID INTEGER NOT NULL REFERENCES \(TableFavicons)(id) ON DELETE CASCADE, " + "UNIQUE (siteID, faviconID) " + ") " let widestFavicons = "CREATE VIEW IF NOT EXISTS \(ViewWidestFaviconsForSites) AS " + "SELECT " + "\(TableFaviconSites).siteID AS siteID, " + "\(TableFavicons).id AS iconID, " + "\(TableFavicons).url AS iconURL, " + "\(TableFavicons).date AS iconDate, " + "\(TableFavicons).type AS iconType, " + "MAX(\(TableFavicons).width) AS iconWidth " + "FROM \(TableFaviconSites), \(TableFavicons) WHERE " + "\(TableFaviconSites).faviconID = \(TableFavicons).id " + "GROUP BY siteID " let historyIDsWithIcon = "CREATE VIEW IF NOT EXISTS \(ViewHistoryIDsWithWidestFavicons) AS " + "SELECT \(TableHistory).id AS id, " + "iconID, iconURL, iconDate, iconType, iconWidth " + "FROM \(TableHistory) " + "LEFT OUTER JOIN " + "\(ViewWidestFaviconsForSites) ON history.id = \(ViewWidestFaviconsForSites).siteID " let iconForURL = "CREATE VIEW IF NOT EXISTS \(ViewIconForURL) AS " + "SELECT history.url AS url, icons.iconID AS iconID FROM " + "\(TableHistory), \(ViewWidestFaviconsForSites) AS icons WHERE " + "\(TableHistory).id = icons.siteID " let bookmarks = "CREATE TABLE IF NOT EXISTS bookmarks (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + "type TINYINT NOT NULL, " + "url TEXT, " + "parent INTEGER REFERENCES bookmarks(id) NOT NULL, " + "faviconID INTEGER REFERENCES favicons(id) ON DELETE SET NULL, " + "title TEXT" + ") " let queries = [ // This used to be done by FaviconsTable. self.oldFaviconsSQL, CreateDomainsTable(), CreateHistoryTable(), visits, bookmarks, faviconSites, indexShouldUpload, indexSiteIDDate, widestFavicons, historyIDsWithIcon, iconForURL, CreateQueueTable(), ] return self.run(db, queries: queries) && self.prepopulateRootFolders(db) } } class BrowserSchemaV7: BaseHistoricalBrowserSchema { override var version: Int { return 7 } func prepopulateRootFolders(_ db: SQLiteDBConnection) -> Bool { let type = BookmarkNodeType.folder.rawValue let root = BookmarkRoots.RootID let titleMobile = NSLocalizedString("Mobile Bookmarks", tableName: "Storage", comment: "The title of the folder that contains mobile bookmarks. This should match bookmarks.folder.mobile.label on Android.") let titleMenu = NSLocalizedString("Bookmarks Menu", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the menu. This should match bookmarks.folder.menu.label on Android.") let titleToolbar = NSLocalizedString("Bookmarks Toolbar", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the toolbar. This should match bookmarks.folder.toolbar.label on Android.") let titleUnsorted = NSLocalizedString("Unsorted Bookmarks", tableName: "Storage", comment: "The name of the folder that contains unsorted desktop bookmarks. This should match bookmarks.folder.unfiled.label on Android.") let args: Args = [ root, BookmarkRoots.RootGUID, type, "Root", root, BookmarkRoots.MobileID, BookmarkRoots.MobileFolderGUID, type, titleMobile, root, BookmarkRoots.MenuID, BookmarkRoots.MenuFolderGUID, type, titleMenu, root, BookmarkRoots.ToolbarID, BookmarkRoots.ToolbarFolderGUID, type, titleToolbar, root, BookmarkRoots.UnfiledID, BookmarkRoots.UnfiledFolderGUID, type, titleUnsorted, root, ] let sql = "INSERT INTO bookmarks (id, guid, type, url, title, parent) VALUES " + "(?, ?, ?, NULL, ?, ?), " + // Root "(?, ?, ?, NULL, ?, ?), " + // Mobile "(?, ?, ?, NULL, ?, ?), " + // Menu "(?, ?, ?, NULL, ?, ?), " + // Toolbar "(?, ?, ?, NULL, ?, ?) " // Unsorted return self.run(db, sql: sql, args: args) } func getHistoryTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS history (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + // Not null, but the value might be replaced by the server's. "url TEXT UNIQUE, " + // May only be null for deleted records. "title TEXT NOT NULL, " + "server_modified INTEGER, " + // Can be null. Integer milliseconds. "local_modified INTEGER, " + // Can be null. Client clock. In extremis only. "is_deleted TINYINT NOT NULL, " + // Boolean. Locally deleted. "should_upload TINYINT NOT NULL, " + // Boolean. Set when changed or visits added. "domain_id INTEGER REFERENCES \(TableDomains)(id) ON DELETE CASCADE, " + "CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1)" + ")" } func getDomainsTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableDomains) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "domain TEXT NOT NULL UNIQUE, " + "showOnTopSites TINYINT NOT NULL DEFAULT 1" + ")" } func getQueueTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableQueuedTabs) (" + "url TEXT NOT NULL UNIQUE, " + "title TEXT" + ") " } override func create(_ db: SQLiteDBConnection) -> Bool { // Right now we don't need to track per-visit deletions: Sync can't // represent them! See Bug 1157553 Comment 6. // We flip the should_upload flag on the history item when we add a visit. // If we ever want to support logic like not bothering to sync if we added // and then rapidly removed a visit, then we need an 'is_new' flag on each visit. let visits = "CREATE TABLE IF NOT EXISTS \(TableVisits) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, " + "date REAL NOT NULL, " + // Microseconds since epoch. "type INTEGER NOT NULL, " + "is_local TINYINT NOT NULL, " + // Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. "UNIQUE (siteID, date, type) " + ") " let indexShouldUpload: String if self.supportsPartialIndices { // There's no point tracking rows that are not flagged for upload. indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON history (should_upload) WHERE should_upload = 1" } else { indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON history (should_upload)" } let indexSiteIDDate = "CREATE INDEX IF NOT EXISTS \(IndexVisitsSiteIDIsLocalDate) " + "ON \(TableVisits) (siteID, is_local, date)" let faviconSites = "CREATE TABLE IF NOT EXISTS \(TableFaviconSites) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, " + "faviconID INTEGER NOT NULL REFERENCES favicons(id) ON DELETE CASCADE, " + "UNIQUE (siteID, faviconID) " + ") " let widestFavicons = "CREATE VIEW IF NOT EXISTS \(ViewWidestFaviconsForSites) AS " + "SELECT " + "\(TableFaviconSites).siteID AS siteID, " + "favicons.id AS iconID, " + "favicons.url AS iconURL, " + "favicons.date AS iconDate, " + "favicons.type AS iconType, " + "MAX(favicons.width) AS iconWidth " + "FROM \(TableFaviconSites), favicons WHERE " + "\(TableFaviconSites).faviconID = favicons.id " + "GROUP BY siteID " let historyIDsWithIcon = "CREATE VIEW IF NOT EXISTS \(ViewHistoryIDsWithWidestFavicons) AS " + "SELECT history.id AS id, " + "iconID, iconURL, iconDate, iconType, iconWidth " + "FROM history " + "LEFT OUTER JOIN " + "\(ViewWidestFaviconsForSites) ON history.id = \(ViewWidestFaviconsForSites).siteID " let iconForURL = "CREATE VIEW IF NOT EXISTS \(ViewIconForURL) AS " + "SELECT history.url AS url, icons.iconID AS iconID FROM " + "\(TableHistory), \(ViewWidestFaviconsForSites) AS icons WHERE " + "\(TableHistory).id = icons.siteID " let bookmarks = "CREATE TABLE IF NOT EXISTS bookmarks (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + "type TINYINT NOT NULL, " + "url TEXT, " + "parent INTEGER REFERENCES bookmarks(id) NOT NULL, " + "faviconID INTEGER REFERENCES favicons(id) ON DELETE SET NULL, " + "title TEXT" + ") " let queries = [ // This used to be done by FaviconsTable. self.oldFaviconsSQL, getDomainsTableCreationString(), getHistoryTableCreationString(), visits, bookmarks, faviconSites, indexShouldUpload, indexSiteIDDate, widestFavicons, historyIDsWithIcon, iconForURL, getQueueTableCreationString(), ] return self.run(db, queries: queries) && self.prepopulateRootFolders(db) } } class BrowserSchemaV8: BaseHistoricalBrowserSchema { override var version: Int { return 8 } func prepopulateRootFolders(_ db: SQLiteDBConnection) -> Bool { let type = BookmarkNodeType.folder.rawValue let root = BookmarkRoots.RootID let titleMobile = NSLocalizedString("Mobile Bookmarks", tableName: "Storage", comment: "The title of the folder that contains mobile bookmarks. This should match bookmarks.folder.mobile.label on Android.") let titleMenu = NSLocalizedString("Bookmarks Menu", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the menu. This should match bookmarks.folder.menu.label on Android.") let titleToolbar = NSLocalizedString("Bookmarks Toolbar", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the toolbar. This should match bookmarks.folder.toolbar.label on Android.") let titleUnsorted = NSLocalizedString("Unsorted Bookmarks", tableName: "Storage", comment: "The name of the folder that contains unsorted desktop bookmarks. This should match bookmarks.folder.unfiled.label on Android.") let args: Args = [ root, BookmarkRoots.RootGUID, type, "Root", root, BookmarkRoots.MobileID, BookmarkRoots.MobileFolderGUID, type, titleMobile, root, BookmarkRoots.MenuID, BookmarkRoots.MenuFolderGUID, type, titleMenu, root, BookmarkRoots.ToolbarID, BookmarkRoots.ToolbarFolderGUID, type, titleToolbar, root, BookmarkRoots.UnfiledID, BookmarkRoots.UnfiledFolderGUID, type, titleUnsorted, root, ] let sql = "INSERT INTO bookmarks (id, guid, type, url, title, parent) VALUES " + "(?, ?, ?, NULL, ?, ?), " + // Root "(?, ?, ?, NULL, ?, ?), " + // Mobile "(?, ?, ?, NULL, ?, ?), " + // Menu "(?, ?, ?, NULL, ?, ?), " + // Toolbar "(?, ?, ?, NULL, ?, ?) " // Unsorted return self.run(db, sql: sql, args: args) } func getHistoryTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableHistory) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + // Not null, but the value might be replaced by the server's. "url TEXT UNIQUE, " + // May only be null for deleted records. "title TEXT NOT NULL, " + "server_modified INTEGER, " + // Can be null. Integer milliseconds. "local_modified INTEGER, " + // Can be null. Client clock. In extremis only. "is_deleted TINYINT NOT NULL, " + // Boolean. Locally deleted. "should_upload TINYINT NOT NULL, " + // Boolean. Set when changed or visits added. "domain_id INTEGER REFERENCES \(TableDomains)(id) ON DELETE CASCADE, " + "CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1)" + ")" } func getDomainsTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableDomains) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "domain TEXT NOT NULL UNIQUE, " + "showOnTopSites TINYINT NOT NULL DEFAULT 1" + ")" } func getQueueTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableQueuedTabs) (" + "url TEXT NOT NULL UNIQUE, " + "title TEXT" + ") " } override func create(_ db: SQLiteDBConnection) -> Bool { let favicons = "CREATE TABLE IF NOT EXISTS favicons (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "url TEXT NOT NULL UNIQUE, " + "width INTEGER, " + "height INTEGER, " + "type INTEGER NOT NULL, " + "date REAL NOT NULL" + ") " // Right now we don't need to track per-visit deletions: Sync can't // represent them! See Bug 1157553 Comment 6. // We flip the should_upload flag on the history item when we add a visit. // If we ever want to support logic like not bothering to sync if we added // and then rapidly removed a visit, then we need an 'is_new' flag on each visit. let visits = "CREATE TABLE IF NOT EXISTS visits (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, " + "date REAL NOT NULL, " + // Microseconds since epoch. "type INTEGER NOT NULL, " + "is_local TINYINT NOT NULL, " + // Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. "UNIQUE (siteID, date, type) " + ") " let indexShouldUpload: String if self.supportsPartialIndices { // There's no point tracking rows that are not flagged for upload. indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON \(TableHistory) (should_upload) WHERE should_upload = 1" } else { indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON \(TableHistory) (should_upload)" } let indexSiteIDDate = "CREATE INDEX IF NOT EXISTS \(IndexVisitsSiteIDIsLocalDate) " + "ON \(TableVisits) (siteID, is_local, date)" let faviconSites = "CREATE TABLE IF NOT EXISTS \(TableFaviconSites) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, " + "faviconID INTEGER NOT NULL REFERENCES favicons(id) ON DELETE CASCADE, " + "UNIQUE (siteID, faviconID) " + ") " let widestFavicons = "CREATE VIEW IF NOT EXISTS \(ViewWidestFaviconsForSites) AS " + "SELECT " + "\(TableFaviconSites).siteID AS siteID, " + "favicons.id AS iconID, " + "favicons.url AS iconURL, " + "favicons.date AS iconDate, " + "favicons.type AS iconType, " + "MAX(favicons.width) AS iconWidth " + "FROM \(TableFaviconSites), favicons WHERE " + "\(TableFaviconSites).faviconID = favicons.id " + "GROUP BY siteID " let historyIDsWithIcon = "CREATE VIEW IF NOT EXISTS \(ViewHistoryIDsWithWidestFavicons) AS " + "SELECT history.id AS id, " + "iconID, iconURL, iconDate, iconType, iconWidth " + "FROM history " + "LEFT OUTER JOIN " + "\(ViewWidestFaviconsForSites) ON history.id = \(ViewWidestFaviconsForSites).siteID " let iconForURL = "CREATE VIEW IF NOT EXISTS \(ViewIconForURL) AS " + "SELECT history.url AS url, icons.iconID AS iconID FROM " + "history, \(ViewWidestFaviconsForSites) AS icons WHERE " + "history.id = icons.siteID " let bookmarks = "CREATE TABLE IF NOT EXISTS bookmarks (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + "type TINYINT NOT NULL, " + "url TEXT, " + "parent INTEGER REFERENCES bookmarks(id) NOT NULL, " + "faviconID INTEGER REFERENCES favicons(id) ON DELETE SET NULL, " + "title TEXT" + ") " let queries: [String] = [ getDomainsTableCreationString(), getHistoryTableCreationString(), favicons, visits, bookmarks, faviconSites, indexShouldUpload, indexSiteIDDate, widestFavicons, historyIDsWithIcon, iconForURL, getQueueTableCreationString(), ] return self.run(db, queries: queries) && self.prepopulateRootFolders(db) } } class BrowserSchemaV10: BaseHistoricalBrowserSchema { override var version: Int { return 10 } func prepopulateRootFolders(_ db: SQLiteDBConnection) -> Bool { let type = BookmarkNodeType.folder.rawValue let root = BookmarkRoots.RootID let titleMobile = NSLocalizedString("Mobile Bookmarks", tableName: "Storage", comment: "The title of the folder that contains mobile bookmarks. This should match bookmarks.folder.mobile.label on Android.") let titleMenu = NSLocalizedString("Bookmarks Menu", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the menu. This should match bookmarks.folder.menu.label on Android.") let titleToolbar = NSLocalizedString("Bookmarks Toolbar", tableName: "Storage", comment: "The name of the folder that contains desktop bookmarks in the toolbar. This should match bookmarks.folder.toolbar.label on Android.") let titleUnsorted = NSLocalizedString("Unsorted Bookmarks", tableName: "Storage", comment: "The name of the folder that contains unsorted desktop bookmarks. This should match bookmarks.folder.unfiled.label on Android.") let args: Args = [ root, BookmarkRoots.RootGUID, type, "Root", root, BookmarkRoots.MobileID, BookmarkRoots.MobileFolderGUID, type, titleMobile, root, BookmarkRoots.MenuID, BookmarkRoots.MenuFolderGUID, type, titleMenu, root, BookmarkRoots.ToolbarID, BookmarkRoots.ToolbarFolderGUID, type, titleToolbar, root, BookmarkRoots.UnfiledID, BookmarkRoots.UnfiledFolderGUID, type, titleUnsorted, root, ] let sql = "INSERT INTO bookmarks (id, guid, type, url, title, parent) VALUES " + "(?, ?, ?, NULL, ?, ?), " + // Root "(?, ?, ?, NULL, ?, ?), " + // Mobile "(?, ?, ?, NULL, ?, ?), " + // Menu "(?, ?, ?, NULL, ?, ?), " + // Toolbar "(?, ?, ?, NULL, ?, ?) " // Unsorted return self.run(db, sql: sql, args: args) } func getHistoryTableCreationString(forVersion version: Int = BrowserSchema.DefaultVersion) -> String { return "CREATE TABLE IF NOT EXISTS history (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + // Not null, but the value might be replaced by the server's. "url TEXT UNIQUE, " + // May only be null for deleted records. "title TEXT NOT NULL, " + "server_modified INTEGER, " + // Can be null. Integer milliseconds. "local_modified INTEGER, " + // Can be null. Client clock. In extremis only. "is_deleted TINYINT NOT NULL, " + // Boolean. Locally deleted. "should_upload TINYINT NOT NULL, " + // Boolean. Set when changed or visits added. "domain_id INTEGER REFERENCES \(TableDomains)(id) ON DELETE CASCADE, " + "CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1)" + ")" } func getDomainsTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableDomains) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "domain TEXT NOT NULL UNIQUE, " + "showOnTopSites TINYINT NOT NULL DEFAULT 1" + ")" } func getQueueTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableQueuedTabs) (" + "url TEXT NOT NULL UNIQUE, " + "title TEXT" + ") " } func getBookmarksMirrorTableCreationString() -> String { // The stupid absence of naming conventions here is thanks to pre-Sync Weave. Sorry. // For now we have the simplest possible schema: everything in one. let sql = "CREATE TABLE IF NOT EXISTS \(TableBookmarksMirror) " + // Shared fields. "( id INTEGER PRIMARY KEY AUTOINCREMENT" + ", guid TEXT NOT NULL UNIQUE" + ", type TINYINT NOT NULL" + // Type enum. TODO: BookmarkNodeType needs to be extended. // Record/envelope metadata that'll allow us to do merges. ", server_modified INTEGER NOT NULL" + // Milliseconds. ", is_deleted TINYINT NOT NULL DEFAULT 0" + // Boolean ", hasDupe TINYINT NOT NULL DEFAULT 0" + // Boolean, 0 (false) if deleted. ", parentid TEXT" + // GUID ", parentName TEXT" + // Type-specific fields. These should be NOT NULL in many cases, but we're going // for a sparse schema, so this'll do for now. Enforce these in the application code. ", feedUri TEXT, siteUri TEXT" + // LIVEMARKS ", pos INT" + // SEPARATORS ", title TEXT, description TEXT" + // FOLDERS, BOOKMARKS, QUERIES ", bmkUri TEXT, tags TEXT, keyword TEXT" + // BOOKMARKS, QUERIES ", folderName TEXT, queryId TEXT" + // QUERIES ", CONSTRAINT parentidOrDeleted CHECK (parentid IS NOT NULL OR is_deleted = 1)" + ", CONSTRAINT parentNameOrDeleted CHECK (parentName IS NOT NULL OR is_deleted = 1)" + ")" return sql } /** * We need to explicitly store what's provided by the server, because we can't rely on * referenced child nodes to exist yet! */ func getBookmarksMirrorStructureTableCreationString() -> String { return "CREATE TABLE IF NOT EXISTS \(TableBookmarksMirrorStructure) " + "( parent TEXT NOT NULL REFERENCES \(TableBookmarksMirror)(guid) ON DELETE CASCADE" + ", child TEXT NOT NULL" + // Should be the GUID of a child. ", idx INTEGER NOT NULL" + // Should advance from 0. ")" } override func create(_ db: SQLiteDBConnection) -> Bool { let favicons = "CREATE TABLE IF NOT EXISTS favicons (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "url TEXT NOT NULL UNIQUE, " + "width INTEGER, " + "height INTEGER, " + "type INTEGER NOT NULL, " + "date REAL NOT NULL" + ") " // Right now we don't need to track per-visit deletions: Sync can't // represent them! See Bug 1157553 Comment 6. // We flip the should_upload flag on the history item when we add a visit. // If we ever want to support logic like not bothering to sync if we added // and then rapidly removed a visit, then we need an 'is_new' flag on each visit. let visits = "CREATE TABLE IF NOT EXISTS visits (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, " + "date REAL NOT NULL, " + // Microseconds since epoch. "type INTEGER NOT NULL, " + "is_local TINYINT NOT NULL, " + // Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. "UNIQUE (siteID, date, type) " + ") " let indexShouldUpload: String if self.supportsPartialIndices { // There's no point tracking rows that are not flagged for upload. indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON history (should_upload) WHERE should_upload = 1" } else { indexShouldUpload = "CREATE INDEX IF NOT EXISTS \(IndexHistoryShouldUpload) " + "ON history (should_upload)" } let indexSiteIDDate = "CREATE INDEX IF NOT EXISTS \(IndexVisitsSiteIDIsLocalDate) " + "ON visits (siteID, is_local, date)" let faviconSites = "CREATE TABLE IF NOT EXISTS \(TableFaviconSites) (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, " + "faviconID INTEGER NOT NULL REFERENCES favicons(id) ON DELETE CASCADE, " + "UNIQUE (siteID, faviconID) " + ") " let widestFavicons = "CREATE VIEW IF NOT EXISTS \(ViewWidestFaviconsForSites) AS " + "SELECT " + "\(TableFaviconSites).siteID AS siteID, " + "favicons.id AS iconID, " + "favicons.url AS iconURL, " + "favicons.date AS iconDate, " + "favicons.type AS iconType, " + "MAX(favicons.width) AS iconWidth " + "FROM \(TableFaviconSites), favicons WHERE " + "\(TableFaviconSites).faviconID = favicons.id " + "GROUP BY siteID " let historyIDsWithIcon = "CREATE VIEW IF NOT EXISTS \(ViewHistoryIDsWithWidestFavicons) AS " + "SELECT history.id AS id, " + "iconID, iconURL, iconDate, iconType, iconWidth " + "FROM history " + "LEFT OUTER JOIN " + "\(ViewWidestFaviconsForSites) ON history.id = \(ViewWidestFaviconsForSites).siteID " let iconForURL = "CREATE VIEW IF NOT EXISTS \(ViewIconForURL) AS " + "SELECT history.url AS url, icons.iconID AS iconID FROM " + "history, \(ViewWidestFaviconsForSites) AS icons WHERE " + "history.id = icons.siteID " let bookmarks = "CREATE TABLE IF NOT EXISTS bookmarks (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "guid TEXT NOT NULL UNIQUE, " + "type TINYINT NOT NULL, " + "url TEXT, " + "parent INTEGER REFERENCES bookmarks(id) NOT NULL, " + "faviconID INTEGER REFERENCES favicons(id) ON DELETE SET NULL, " + "title TEXT" + ") " let bookmarksMirror = getBookmarksMirrorTableCreationString() let bookmarksMirrorStructure = getBookmarksMirrorStructureTableCreationString() let indexStructureParentIdx = "CREATE INDEX IF NOT EXISTS \(IndexBookmarksMirrorStructureParentIdx) " + "ON \(TableBookmarksMirrorStructure) (parent, idx)" let queries: [String] = [ getDomainsTableCreationString(), getHistoryTableCreationString(), favicons, visits, bookmarks, bookmarksMirror, bookmarksMirrorStructure, indexStructureParentIdx, faviconSites, indexShouldUpload, indexSiteIDDate, widestFavicons, historyIDsWithIcon, iconForURL, getQueueTableCreationString(), ] return self.run(db, queries: queries) && self.prepopulateRootFolders(db) } } class TestSQLiteHistory: XCTestCase { let files = MockFiles() fileprivate func deleteDatabases() { for v in ["6", "7", "8", "10", "6-data"] { do { try files.remove("browser-v\(v).db") } catch {} } do { try files.remove("browser.db") try files.remove("historysynced.db") } catch {} } override func tearDown() { super.tearDown() self.deleteDatabases() } override func setUp() { super.setUp() // Just in case tearDown didn't run or succeed last time! self.deleteDatabases() } // Test that our visit partitioning for frecency is correct. func testHistoryLocalAndRemoteVisits() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) let siteL = Site(url: "http://url1/", title: "title local only") let siteR = Site(url: "http://url2/", title: "title remote only") let siteB = Site(url: "http://url3/", title: "title local and remote") siteL.guid = "locallocal12" siteR.guid = "remoteremote" siteB.guid = "bothbothboth" let siteVisitL1 = SiteVisit(site: siteL, date: baseInstantInMicros + 1000, type: VisitType.link) let siteVisitL2 = SiteVisit(site: siteL, date: baseInstantInMicros + 2000, type: VisitType.link) let siteVisitR1 = SiteVisit(site: siteR, date: baseInstantInMicros + 1000, type: VisitType.link) let siteVisitR2 = SiteVisit(site: siteR, date: baseInstantInMicros + 2000, type: VisitType.link) let siteVisitR3 = SiteVisit(site: siteR, date: baseInstantInMicros + 3000, type: VisitType.link) let siteVisitBL1 = SiteVisit(site: siteB, date: baseInstantInMicros + 4000, type: VisitType.link) let siteVisitBR1 = SiteVisit(site: siteB, date: baseInstantInMicros + 5000, type: VisitType.link) let deferred = history.clearHistory() >>> { history.addLocalVisit(siteVisitL1) } >>> { history.addLocalVisit(siteVisitL2) } >>> { history.addLocalVisit(siteVisitBL1) } >>> { history.insertOrUpdatePlace(siteL.asPlace(), modified: baseInstantInMillis + 2) } >>> { history.insertOrUpdatePlace(siteR.asPlace(), modified: baseInstantInMillis + 3) } >>> { history.insertOrUpdatePlace(siteB.asPlace(), modified: baseInstantInMillis + 5) } // Do this step twice, so we exercise the dupe-visit handling. >>> { history.storeRemoteVisits([siteVisitR1, siteVisitR2, siteVisitR3], forGUID: siteR.guid!) } >>> { history.storeRemoteVisits([siteVisitR1, siteVisitR2, siteVisitR3], forGUID: siteR.guid!) } >>> { history.storeRemoteVisits([siteVisitBR1], forGUID: siteB.guid!) } >>> { history.getFrecentHistory().getSites(whereURLContains: nil, historyLimit: 3, bookmarksLimit: 0) >>== { (sites: Cursor) -> Success in XCTAssertEqual(3, sites.count) // Two local visits beat a single later remote visit and one later local visit. // Two local visits beat three remote visits. XCTAssertEqual(siteL.guid!, sites[0]!.guid!) XCTAssertEqual(siteB.guid!, sites[1]!.guid!) XCTAssertEqual(siteR.guid!, sites[2]!.guid!) return succeed() } // This marks everything as modified so we can fetch it. >>> history.onRemovedAccount // Now check that we have no duplicate visits. >>> { history.getModifiedHistoryToUpload() >>== { (places) -> Success in if let (_, visits) = places.find({$0.0.guid == siteR.guid!}) { XCTAssertEqual(3, visits.count) } else { XCTFail("Couldn't find site R.") } return succeed() } } } XCTAssertTrue(deferred.value.isSuccess) } func testUpgrades() { let sources: [(Int, Schema)] = [ (6, BrowserSchemaV6()), (7, BrowserSchemaV7()), (8, BrowserSchemaV8()), (10, BrowserSchemaV10()), ] let destination = BrowserSchema() for (version, schema) in sources { var db = BrowserDB(filename: "browser-v\(version).db", schema: schema, files: files) XCTAssertTrue(db.withConnection({ connection -> Int in connection.version }).value.successValue == schema.version, "Creating BrowserSchema at version \(version)") db.forceClose() db = BrowserDB(filename: "browser-v\(version).db", schema: destination, files: files) XCTAssertTrue(db.withConnection({ connection -> Int in connection.version }).value.successValue == destination.version, "Upgrading BrowserSchema from version \(version) to version \(schema.version)") db.forceClose() } } func testUpgradesWithData() { var db = BrowserDB(filename: "browser-v6-data.db", schema: BrowserSchemaV6(), files: files) // Insert some data. let queries = [ "INSERT INTO domains (id, domain) VALUES (1, 'example.com')", "INSERT INTO history (id, guid, url, title, server_modified, local_modified, is_deleted, should_upload, domain_id) VALUES (5, 'guid', 'http://www.example.com', 'title', 5, 10, 0, 1, 1)", "INSERT INTO visits (siteID, date, type, is_local) VALUES (5, 15, 1, 1)", "INSERT INTO favicons (url, width, height, type, date) VALUES ('http://www.example.com/favicon.ico', 10, 10, 1, 20)", "INSERT INTO favicon_sites (siteID, faviconID) VALUES (5, 1)", "INSERT INTO bookmarks (guid, type, url, parent, faviconID, title) VALUES ('guid', 1, 'http://www.example.com', 0, 1, 'title')" ] XCTAssertTrue(db.run(queries).value.isSuccess) // And we can upgrade to the current version. db = BrowserDB(filename: "browser-v6-data.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) let results = history.getSitesByLastVisit(10).value.successValue XCTAssertNotNil(results) XCTAssertEqual(results![0]?.url, "http://www.example.com") db.forceClose() } func testDomainUpgrade() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) let site = Site(url: "http://www.example.com/test1.1", title: "title one") // Insert something with an invalid domain ID. We have to manually do this since domains are usually hidden. let insertDeferred = db.withConnection { connection -> Void in try connection.executeChange("PRAGMA foreign_keys = OFF") let insert = "INSERT INTO \(TableHistory) (guid, url, title, local_modified, is_deleted, should_upload, domain_id) VALUES (?, ?, ?, ?, ?, ?, ?)" let args: Args = [Bytes.generateGUID(), site.url, site.title, Date.now(), 0, 0, -1] try connection.executeChange(insert, withArgs: args) } XCTAssertTrue(insertDeferred.value.isSuccess) // Now insert it again. This should update the domain. history.addLocalVisit(SiteVisit(site: site, date: Date.nowMicroseconds(), type: VisitType.link)).succeeded() // domain_id isn't normally exposed, so we manually query to get it. let resultsDeferred = db.withConnection { connection -> Cursor in let sql = "SELECT domain_id FROM \(TableHistory) WHERE url = ?" let args: Args = [site.url] return connection.executeQuery(sql, factory: { $0[0] as? Int }, withArgs: args) } let results = resultsDeferred.value.successValue! let domain = results[0]! // Unwrap to get the first item from the cursor. XCTAssertNil(domain) } func testDomains() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) let initialGuid = Bytes.generateGUID() let site11 = Site(url: "http://www.example.com/test1.1", title: "title one") let site12 = Site(url: "http://www.example.com/test1.2", title: "title two") let site13 = Place(guid: initialGuid, url: "http://www.example.com/test1.3", title: "title three") let site3 = Site(url: "http://www.example2.com/test1", title: "title three") let expectation = self.expectation(description: "First.") let clearTopSites = "DELETE FROM \(TableCachedTopSites)" let updateTopSites: [(String, Args?)] = [(clearTopSites, nil), (history.getFrecentHistory().updateTopSitesCacheQuery())] func countTopSites() -> Deferred>> { return db.runQuery("SELECT COUNT(*) from \(TableCachedTopSites)", args: nil, factory: { sdrow -> Int in return sdrow[0] as? Int ?? 0 }) } history.clearHistory().bind({ success in return all([history.addLocalVisit(SiteVisit(site: site11, date: Date.nowMicroseconds(), type: VisitType.link)), history.addLocalVisit(SiteVisit(site: site12, date: Date.nowMicroseconds(), type: VisitType.link)), history.addLocalVisit(SiteVisit(site: site3, date: Date.nowMicroseconds(), type: VisitType.link))]) }).bind({ (results: [Maybe<()>]) in return history.insertOrUpdatePlace(site13, modified: Date.nowMicroseconds()) }).bind({ guid -> Success in XCTAssertEqual(guid.successValue!, initialGuid, "Guid is correct") return db.run(updateTopSites) }).bind({ success in XCTAssertTrue(success.isSuccess, "update was successful") return countTopSites() }).bind({ (count: Maybe>) -> Success in XCTAssert(count.successValue![0] == 2, "2 sites returned") return history.removeSiteFromTopSites(site11) }).bind({ success -> Success in XCTAssertTrue(success.isSuccess, "Remove was successful") return db.run(updateTopSites) }).bind({ success -> Deferred>> in XCTAssertTrue(success.isSuccess, "update was successful") return countTopSites() }) .upon({ (count: Maybe>) in XCTAssert(count.successValue![0] == 1, "1 site returned") expectation.fulfill() }) waitForExpectations(timeout: 10.0) { error in return } } func testHistoryIsSynced() { let db = BrowserDB(filename: "historysynced.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) let initialGUID = Bytes.generateGUID() let site = Place(guid: initialGUID, url: "http://www.example.com/test1.3", title: "title") XCTAssertFalse(history.hasSyncedHistory().value.successValue ?? true) XCTAssertTrue(history.insertOrUpdatePlace(site, modified: Date.now()).value.isSuccess) XCTAssertTrue(history.hasSyncedHistory().value.successValue ?? false) } // This is a very basic test. Adds an entry, retrieves it, updates it, // and then clears the database. func testHistoryTable() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) let bookmarks = SQLiteBookmarks(db: db) let site1 = Site(url: "http://url1/", title: "title one") let site1Changed = Site(url: "http://url1/", title: "title one alt") let siteVisit1 = SiteVisit(site: site1, date: Date.nowMicroseconds(), type: VisitType.link) let siteVisit2 = SiteVisit(site: site1Changed, date: Date.nowMicroseconds() + 1000, type: VisitType.bookmark) let site2 = Site(url: "http://url2/", title: "title two") let siteVisit3 = SiteVisit(site: site2, date: Date.nowMicroseconds() + 2000, type: VisitType.link) let expectation = self.expectation(description: "First.") func done() -> Success { expectation.fulfill() return succeed() } func checkSitesByFrecency(_ f: @escaping (Cursor) -> Success) -> () -> Success { return { history.getFrecentHistory().getSites(whereURLContains: nil, historyLimit: 10, bookmarksLimit: 0) >>== f } } func checkSitesByDate(_ f: @escaping (Cursor) -> Success) -> () -> Success { return { history.getSitesByLastVisit(10) >>== f } } func checkSitesWithFilter(_ filter: String, f: @escaping (Cursor) -> Success) -> () -> Success { return { history.getFrecentHistory().getSites(whereURLContains: filter, historyLimit: 10, bookmarksLimit: 0) >>== f } } func checkDeletedCount(_ expected: Int) -> () -> Success { return { history.getDeletedHistoryToUpload() >>== { guids in XCTAssertEqual(expected, guids.count) return succeed() } } } history.clearHistory() >>> { history.addLocalVisit(siteVisit1) } >>> checkSitesByFrecency { (sites: Cursor) -> Success in XCTAssertEqual(1, sites.count) XCTAssertEqual(site1.title, sites[0]!.title) XCTAssertEqual(site1.url, sites[0]!.url) sites.close() return succeed() } >>> { history.addLocalVisit(siteVisit2) } >>> checkSitesByFrecency { (sites: Cursor) -> Success in XCTAssertEqual(1, sites.count) XCTAssertEqual(site1Changed.title, sites[0]!.title) XCTAssertEqual(site1Changed.url, sites[0]!.url) sites.close() return succeed() } >>> { history.addLocalVisit(siteVisit3) } >>> checkSitesByFrecency { (sites: Cursor) -> Success in XCTAssertEqual(2, sites.count) // They're in order of frecency. XCTAssertEqual(site1Changed.title, sites[0]!.title) XCTAssertEqual(site2.title, sites[1]!.title) return succeed() } >>> checkSitesByDate { (sites: Cursor) -> Success in XCTAssertEqual(2, sites.count) // They're in order of date last visited. let first = sites[0]! let second = sites[1]! XCTAssertEqual(site2.title, first.title) XCTAssertEqual(site1Changed.title, second.title) XCTAssertTrue(siteVisit3.date == first.latestVisit!.date) return succeed() } >>> checkSitesWithFilter("two") { (sites: Cursor) -> Success in XCTAssertEqual(1, sites.count) let first = sites[0]! XCTAssertEqual(site2.title, first.title) return succeed() } >>> checkDeletedCount(0) >>> { history.removeHistoryForURL("http://url2/") } >>> checkDeletedCount(1) >>> checkSitesByFrecency { (sites: Cursor) -> Success in XCTAssertEqual(1, sites.count) // They're in order of frecency. XCTAssertEqual(site1Changed.title, sites[0]!.title) return succeed() } >>> { history.clearHistory() } >>> checkDeletedCount(0) >>> checkSitesByDate { (sites: Cursor) -> Success in XCTAssertEqual(0, sites.count) return succeed() } >>> checkSitesByFrecency { (sites: Cursor) -> Success in XCTAssertEqual(0, sites.count) return succeed() } >>> done waitForExpectations(timeout: 10.0) { error in return } } func testFaviconTable() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) let bookmarks = SQLiteBookmarks(db: db) let expectation = self.expectation(description: "First.") func done() -> Success { expectation.fulfill() return succeed() } func updateFavicon() -> Success { let fav = Favicon(url: "http://url2/", date: Date(), type: .icon) fav.id = 1 let site = Site(url: "http://bookmarkedurl/", title: "My Bookmark") return history.addFavicon(fav, forSite: site) >>> succeed } func checkFaviconForBookmarkIsNil() -> Success { return bookmarks.bookmarksByURL("http://bookmarkedurl/".asURL!) >>== { results in XCTAssertEqual(1, results.count) XCTAssertNil(results[0]?.favicon) return succeed() } } func checkFaviconWasSetForBookmark() -> Success { return history.getFaviconsForBookmarkedURL("http://bookmarkedurl/") >>== { results in XCTAssertEqual(1, results.count) if let actualFaviconURL = results[0]??.url { XCTAssertEqual("http://url2/", actualFaviconURL) } return succeed() } } func removeBookmark() -> Success { return bookmarks.testFactory.removeByURL("http://bookmarkedurl/") } func checkFaviconWasRemovedForBookmark() -> Success { return history.getFaviconsForBookmarkedURL("http://bookmarkedurl/") >>== { results in XCTAssertEqual(0, results.count) return succeed() } } history.clearAllFavicons() >>> bookmarks.clearBookmarks >>> { bookmarks.addToMobileBookmarks("http://bookmarkedurl/".asURL!, title: "Title", favicon: nil) } >>> checkFaviconForBookmarkIsNil >>> updateFavicon >>> checkFaviconWasSetForBookmark >>> removeBookmark >>> checkFaviconWasRemovedForBookmark >>> done waitForExpectations(timeout: 10.0) { error in return } } func testTopSitesFrecencyOrder() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) history.setTopSitesCacheSize(20) history.clearTopSitesCache().succeeded() history.clearHistory().succeeded() // Lets create some history. This will create 100 sites that will have 21 local and 21 remote visits populateHistoryForFrecencyCalculations(history, siteCount: 100) // Create a new site thats for an existing domain but a different URL. let site = Site(url: "http://s\(5)ite\(5).com/foo-different-url", title: "A \(5) different url") site.guid = "abc\(5)defhi" history.insertOrUpdatePlace(site.asPlace(), modified: baseInstantInMillis - 20000).succeeded() // Don't give it any remote visits. But give it 100 local visits. This should be the new Topsite! for i in 0...100 { addVisitForSite(site, intoHistory: history, from: .local, atTime: advanceTimestamp(baseInstantInMicros, by: 1000000 * i)) } let expectation = self.expectation(description: "First.") func done() -> Success { expectation.fulfill() return succeed() } func loadCache() -> Success { return history.repopulate(invalidateTopSites: true, invalidateHighlights: true) >>> succeed } func checkTopSitesReturnsResults() -> Success { return history.getTopSitesWithLimit(20) >>== { topSites in XCTAssertEqual(topSites.count, 20) XCTAssertEqual(topSites[0]!.guid, "abc\(5)defhi") return succeed() } } loadCache() >>> checkTopSitesReturnsResults >>> done waitForExpectations(timeout: 10.0) { error in return } } func testTopSitesFiltersGoogle() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) history.setTopSitesCacheSize(20) history.clearTopSitesCache().succeeded() history.clearHistory().succeeded() // Lets create some history. This will create 100 sites that will have 21 local and 21 remote visits populateHistoryForFrecencyCalculations(history, siteCount: 100) func createTopSite(url: String, guid: String) { let site = Site(url: url, title: "Hi") site.guid = guid history.insertOrUpdatePlace(site.asPlace(), modified: baseInstantInMillis - 20000).succeeded() // Don't give it any remote visits. But give it 100 local visits. This should be the new Topsite! for i in 0...100 { addVisitForSite(site, intoHistory: history, from: .local, atTime: advanceTimestamp(baseInstantInMicros, by: 1000000 * i)) } } createTopSite(url: "http://google.com", guid: "abcgoogle") // should not be a topsite createTopSite(url: "http://www.google.com", guid: "abcgoogle1") // should not be a topsite createTopSite(url: "http://google.co.za", guid: "abcgoogleza") // should not be a topsite createTopSite(url: "http://docs.google.com", guid: "docsgoogle") // should be a topsite let expectation = self.expectation(description: "First.") func done() -> Success { expectation.fulfill() return succeed() } func loadCache() -> Success { return history.repopulate(invalidateTopSites: true, invalidateHighlights: true) >>> succeed } func checkTopSitesReturnsResults() -> Success { return history.getTopSitesWithLimit(20) >>== { topSites in XCTAssertEqual(topSites[0]?.guid, "docsgoogle") // google docs should be the first topsite // make sure all other google guids are not in the topsites array topSites.forEach { let guid: String = $0!.guid! // type checking is hard XCTAssertNil(["abcgoogle", "abcgoogle1", "abcgoogleza"].index(of: guid)) } XCTAssertEqual(topSites.count, 20) return succeed() } } loadCache() >>> checkTopSitesReturnsResults >>> done waitForExpectations(timeout: 10.0) { error in return } } func testTopSitesCache() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) history.setTopSitesCacheSize(20) history.clearTopSitesCache().succeeded() history.clearHistory().succeeded() // Make sure that we get back the top sites populateHistoryForFrecencyCalculations(history, siteCount: 100) // Add extra visits to the 5th site to bubble it to the top of the top sites cache let site = Site(url: "http://s\(5)ite\(5).com/foo", title: "A \(5)") site.guid = "abc\(5)def" for i in 0...20 { addVisitForSite(site, intoHistory: history, from: .local, atTime: advanceTimestamp(baseInstantInMicros, by: 1000000 * i)) } let expectation = self.expectation(description: "First.") func done() -> Success { expectation.fulfill() return succeed() } func loadCache() -> Success { return history.repopulate(invalidateTopSites: true, invalidateHighlights: true) >>> succeed } func checkTopSitesReturnsResults() -> Success { return history.getTopSitesWithLimit(20) >>== { topSites in XCTAssertEqual(topSites.count, 20) XCTAssertEqual(topSites[0]!.guid, "abc\(5)def") return succeed() } } func invalidateIfNeededDoesntChangeResults() -> Success { return history.repopulate(invalidateTopSites: true, invalidateHighlights: true) >>> { return history.getTopSitesWithLimit(20) >>== { topSites in XCTAssertEqual(topSites.count, 20) XCTAssertEqual(topSites[0]!.guid, "abc\(5)def") return succeed() } } } func addVisitsToZerothSite() -> Success { let site = Site(url: "http://s\(0)ite\(0).com/foo", title: "A \(0)") site.guid = "abc\(0)def" for i in 0...20 { addVisitForSite(site, intoHistory: history, from: .local, atTime: advanceTimestamp(baseInstantInMicros, by: 1000000 * i)) } return succeed() } func markInvalidation() -> Success { history.setTopSitesNeedsInvalidation() return succeed() } func checkSitesInvalidate() -> Success { history.repopulate(invalidateTopSites: true, invalidateHighlights: true).succeeded() return history.getTopSitesWithLimit(20) >>== { topSites in XCTAssertEqual(topSites.count, 20) XCTAssertEqual(topSites[0]!.guid, "abc\(5)def") XCTAssertEqual(topSites[1]!.guid, "abc\(0)def") return succeed() } } loadCache() >>> checkTopSitesReturnsResults >>> invalidateIfNeededDoesntChangeResults >>> markInvalidation >>> addVisitsToZerothSite >>> checkSitesInvalidate >>> done waitForExpectations(timeout: 10.0) { error in return } } func testPinnedTopSites() { let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) history.setTopSitesCacheSize(20) history.clearTopSitesCache().succeeded() history.clearHistory().succeeded() // add 2 sites to pinned topsite // get pinned site and make sure it exists in the right order // remove pinned sites // make sure pinned sites dont exist // create pinned sites. let site1 = Site(url: "http://s\(1)ite\(1).com/foo", title: "A \(1)") site1.id = 1 site1.guid = "abc\(1)def" addVisitForSite(site1, intoHistory: history, from: .local, atTime: Date.now()) let site2 = Site(url: "http://s\(2)ite\(2).com/foo", title: "A \(2)") site2.id = 2 site2.guid = "abc\(2)def" addVisitForSite(site2, intoHistory: history, from: .local, atTime: Date.now()) let expectation = self.expectation(description: "First.") func done() -> Success { expectation.fulfill() return succeed() } func addPinnedSites() -> Success { return history.addPinnedTopSite(site1) >>== { sleep(1) // Sleep to prevent intermittent issue with sorting on the timestamp return history.addPinnedTopSite(site2) } } func checkPinnedSites() -> Success { return history.getPinnedTopSites() >>== { pinnedSites in XCTAssertEqual(pinnedSites.count, 2) XCTAssertEqual(pinnedSites[0]!.url, site2.url) XCTAssertEqual(pinnedSites[1]!.url, site1.url, "The older pinned site should be last") return succeed() } } func removePinnedSites() -> Success { return history.removeFromPinnedTopSites(site2) >>== { return history.getPinnedTopSites() >>== { pinnedSites in XCTAssertEqual(pinnedSites.count, 1, "There should only be one pinned site") XCTAssertEqual(pinnedSites[0]!.url, site1.url, "Site2 should be the only pin left") return succeed() } } } func dupePinnedSite() -> Success { return history.addPinnedTopSite(site1) >>== { return history.getPinnedTopSites() >>== { pinnedSites in XCTAssertEqual(pinnedSites.count, 1, "There should not be a dupe") XCTAssertEqual(pinnedSites[0]!.url, site1.url, "Site2 should be the only pin left") return succeed() } } } func removeHistory() -> Success { return history.clearHistory() >>== { return history.getPinnedTopSites() >>== { pinnedSites in XCTAssertEqual(pinnedSites.count, 1, "Pinned sites should exist after a history clear") return succeed() } } } addPinnedSites() >>> checkPinnedSites >>> removePinnedSites >>> dupePinnedSite >>> removeHistory >>> done waitForExpectations(timeout: 10.0) { error in return } } } class TestSQLiteHistoryTransactionUpdate: XCTestCase { func testUpdateInTransaction() { let files = MockFiles() let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() let history = SQLiteHistory(db: db, prefs: prefs) history.clearHistory().succeeded() let site = Site(url: "http://site/foo", title: "AA") site.guid = "abcdefghiabc" history.insertOrUpdatePlace(site.asPlace(), modified: 1234567890).succeeded() let ts: MicrosecondTimestamp = baseInstantInMicros let local = SiteVisit(site: site, date: ts, type: VisitType.link) XCTAssertTrue(history.addLocalVisit(local).value.isSuccess) } } class TestSQLiteHistoryFilterSplitting: XCTestCase { let history: SQLiteHistory = { let files = MockFiles() let db = BrowserDB(filename: "browser.db", schema: BrowserSchema(), files: files) let prefs = MockProfilePrefs() return SQLiteHistory(db: db, prefs: prefs) }() func testWithSingleWord() { let (fragment, args) = computeWhereFragmentWithFilter("foo", perWordFragment: "?", perWordArgs: { [$0] }) XCTAssertEqual(fragment, "?") XCTAssert(stringArgsEqual(args, ["foo"])) } func testWithIdenticalWords() { let (fragment, args) = computeWhereFragmentWithFilter("foo fo foo", perWordFragment: "?", perWordArgs: { [$0] }) XCTAssertEqual(fragment, "?") XCTAssert(stringArgsEqual(args, ["foo"])) } func testWithDistinctWords() { let (fragment, args) = computeWhereFragmentWithFilter("foo bar", perWordFragment: "?", perWordArgs: { [$0] }) XCTAssertEqual(fragment, "? AND ?") XCTAssert(stringArgsEqual(args, ["foo", "bar"])) } func testWithDistinctWordsAndWhitespace() { let (fragment, args) = computeWhereFragmentWithFilter(" foo bar ", perWordFragment: "?", perWordArgs: { [$0] }) XCTAssertEqual(fragment, "? AND ?") XCTAssert(stringArgsEqual(args, ["foo", "bar"])) } func testWithSubstrings() { let (fragment, args) = computeWhereFragmentWithFilter("foo bar foobar", perWordFragment: "?", perWordArgs: { [$0] }) XCTAssertEqual(fragment, "?") XCTAssert(stringArgsEqual(args, ["foobar"])) } func testWithSubstringsAndIdenticalWords() { let (fragment, args) = computeWhereFragmentWithFilter("foo bar foobar foobar", perWordFragment: "?", perWordArgs: { [$0] }) XCTAssertEqual(fragment, "?") XCTAssert(stringArgsEqual(args, ["foobar"])) } fileprivate func stringArgsEqual(_ one: Args, _ other: Args) -> Bool { return one.elementsEqual(other, by: { (oneElement: Any?, otherElement: Any?) -> Bool in return (oneElement as! String) == (otherElement as! String) }) } } // MARK - Private Test Helper Methods enum VisitOrigin { case local case remote } private func populateHistoryForFrecencyCalculations(_ history: SQLiteHistory, siteCount count: Int) { for i in 0...count { let site = Site(url: "http://s\(i)ite\(i).com/foo", title: "A \(i)") site.guid = "abc\(i)def" let baseMillis: UInt64 = baseInstantInMillis - 20000 history.insertOrUpdatePlace(site.asPlace(), modified: baseMillis).succeeded() for j in 0...20 { let visitTime = advanceMicrosecondTimestamp(baseInstantInMicros, by: (1000000 * i) + (1000 * j)) addVisitForSite(site, intoHistory: history, from: .local, atTime: visitTime) addVisitForSite(site, intoHistory: history, from: .remote, atTime: visitTime - 100) } } } func addVisitForSite(_ site: Site, intoHistory history: SQLiteHistory, from: VisitOrigin, atTime: MicrosecondTimestamp) { let visit = SiteVisit(site: site, date: atTime, type: VisitType.link) switch from { case .local: history.addLocalVisit(visit).succeeded() case .remote: history.storeRemoteVisits([visit], forGUID: site.guid!).succeeded() } }