This repository has been archived by the owner. It is now read-only.
Permalink
Cannot retrieve contributors at this time
1757 lines (1512 sloc)
70.4 KB
| /* 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 | |
| -- Root | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Mobile | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Menu | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Toolbar | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Unsorted | |
| (?, ?, ?, NULL, ?, ?) | |
| """ | |
| return self.run(db, sql: sql, args: args) | |
| } | |
| func CreateHistoryTable() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS history ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| -- Not null, but the value might be replaced by the server's. | |
| guid TEXT NOT NULL UNIQUE, | |
| -- May only be null for deleted records. | |
| url TEXT UNIQUE, | |
| title TEXT NOT NULL, | |
| -- Can be null. Integer milliseconds. | |
| server_modified INTEGER, | |
| -- Can be null. Client clock. In extremis only. | |
| local_modified INTEGER, | |
| -- Boolean. Locally deleted. | |
| is_deleted TINYINT NOT NULL, | |
| -- Boolean. Set when changed or visits added. | |
| should_upload TINYINT NOT NULL, | |
| domain_id INTEGER REFERENCES domains(id) ON DELETE CASCADE, | |
| CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1) | |
| ) | |
| """ | |
| return sql | |
| } | |
| func CreateDomainsTable() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS domains ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| domain TEXT NOT NULL UNIQUE, | |
| showOnTopSites TINYINT NOT NULL DEFAULT 1 | |
| ) | |
| """ | |
| return sql | |
| } | |
| func CreateQueueTable() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS queue ( | |
| url TEXT NOT NULL UNIQUE, | |
| title TEXT | |
| ) | |
| """ | |
| return sql | |
| } | |
| override func create(_ db: SQLiteDBConnection) -> Bool { | |
| let visits = """ | |
| CREATE TABLE IF NOT EXISTS visits ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, | |
| -- Microseconds since epoch. | |
| date REAL NOT NULL, | |
| type INTEGER NOT NULL, | |
| -- Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. | |
| is_local TINYINT NOT NULL, | |
| 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 idx_history_should_upload ON history (should_upload) WHERE should_upload = 1" | |
| } else { | |
| indexShouldUpload = | |
| "CREATE INDEX IF NOT EXISTS idx_history_should_upload ON history (should_upload)" | |
| } | |
| let indexSiteIDDate = | |
| "CREATE INDEX IF NOT EXISTS idx_visits_siteID_is_local_date ON visits (siteID, is_local, date)" | |
| let faviconSites = """ | |
| CREATE TABLE IF NOT EXISTS favicon_sites ( | |
| 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 view_favicons_widest AS | |
| SELECT | |
| favicon_sites.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 favicon_sites, favicons | |
| WHERE favicon_sites.faviconID = favicons.id | |
| GROUP BY siteID | |
| """ | |
| let historyIDsWithIcon = """ | |
| CREATE VIEW IF NOT EXISTS view_history_id_favicon AS | |
| SELECT history.id AS id, iconID, iconURL, iconDate, iconType, iconWidth | |
| FROM history LEFT OUTER JOIN view_favicons_widest ON | |
| history.id = view_favicons_widest.siteID | |
| """ | |
| let iconForURL = """ | |
| CREATE VIEW IF NOT EXISTS view_icon_for_url AS | |
| SELECT history.url AS url, icons.iconID AS iconID | |
| FROM history, view_favicons_widest 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 = [ | |
| // 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 | |
| -- Root | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Mobile | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Menu | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Toolbar | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Unsorted | |
| (?, ?, ?, NULL, ?, ?) | |
| """ | |
| return self.run(db, sql: sql, args: args) | |
| } | |
| func getHistoryTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS history ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| -- Not null, but the value might be replaced by the server's. | |
| guid TEXT NOT NULL UNIQUE, | |
| -- May only be null for deleted records. | |
| url TEXT UNIQUE, | |
| title TEXT NOT NULL, | |
| -- Can be null. Integer milliseconds. | |
| server_modified INTEGER, | |
| -- Can be null. Client clock. In extremis only. | |
| local_modified INTEGER, | |
| -- Boolean. Locally deleted. | |
| is_deleted TINYINT NOT NULL, | |
| -- Boolean. Set when changed or visits added. | |
| should_upload TINYINT NOT NULL, | |
| domain_id INTEGER REFERENCES domains(id) ON DELETE CASCADE, | |
| CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1) | |
| ) | |
| """ | |
| return sql | |
| } | |
| func getDomainsTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS domains ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| domain TEXT NOT NULL UNIQUE, | |
| showOnTopSites TINYINT NOT NULL DEFAULT 1 | |
| ) | |
| """ | |
| return sql | |
| } | |
| func getQueueTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS queue ( | |
| url TEXT NOT NULL UNIQUE, | |
| title TEXT | |
| ) | |
| """ | |
| return sql | |
| } | |
| 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 visits ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| siteID INTEGER NOT NULL REFERENCES history(id) ON DELETE CASCADE, | |
| -- Microseconds since epoch. | |
| date REAL NOT NULL, | |
| type INTEGER NOT NULL, | |
| -- Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. | |
| is_local TINYINT NOT NULL, | |
| 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 idx_history_should_upload ON history (should_upload) WHERE should_upload = 1" | |
| } else { | |
| indexShouldUpload = | |
| "CREATE INDEX IF NOT EXISTS idx_history_should_upload ON history (should_upload)" | |
| } | |
| let indexSiteIDDate = | |
| "CREATE INDEX IF NOT EXISTS idx_visits_siteID_is_local_date ON visits (siteID, is_local, date)" | |
| let faviconSites = """ | |
| CREATE TABLE IF NOT EXISTS favicon_sites ( | |
| 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 view_favicons_widest AS | |
| SELECT | |
| favicon_sites.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 favicon_sites, favicons | |
| WHERE favicon_sites.faviconID = favicons.id | |
| GROUP BY siteID | |
| """ | |
| let historyIDsWithIcon = """ | |
| CREATE VIEW IF NOT EXISTS view_history_id_favicon AS | |
| SELECT history.id AS id, iconID, iconURL, iconDate, iconType, iconWidth | |
| FROM history LEFT OUTER JOIN view_favicons_widest ON | |
| history.id = view_favicons_widest.siteID | |
| """ | |
| let iconForURL = """ | |
| CREATE VIEW IF NOT EXISTS view_icon_for_url AS | |
| SELECT history.url AS url, icons.iconID AS iconID | |
| FROM history, view_favicons_widest 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 = [ | |
| // 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 | |
| -- Root | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Mobile | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Menu | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Toolbar | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Unsorted | |
| (?, ?, ?, NULL, ?, ?) | |
| """ | |
| return self.run(db, sql: sql, args: args) | |
| } | |
| func getHistoryTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS history ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| -- Not null, but the value might be replaced by the server's. | |
| guid TEXT NOT NULL UNIQUE, | |
| -- May only be null for deleted records. | |
| url TEXT UNIQUE, | |
| title TEXT NOT NULL, | |
| -- Can be null. Integer milliseconds. | |
| server_modified INTEGER, | |
| -- Can be null. Client clock. In extremis only. | |
| local_modified INTEGER, | |
| -- Boolean. Locally deleted. | |
| is_deleted TINYINT NOT NULL, | |
| -- Boolean. Set when changed or visits added. | |
| should_upload TINYINT NOT NULL, | |
| domain_id INTEGER REFERENCES domains(id) ON DELETE CASCADE, | |
| CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1) | |
| ) | |
| """ | |
| return sql | |
| } | |
| func getDomainsTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS domains ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| domain TEXT NOT NULL UNIQUE, | |
| showOnTopSites TINYINT NOT NULL DEFAULT 1 | |
| ) | |
| """ | |
| return sql | |
| } | |
| func getQueueTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS queue ( | |
| url TEXT NOT NULL UNIQUE, | |
| title TEXT | |
| ) | |
| """ | |
| return sql | |
| } | |
| 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, | |
| -- Microseconds since epoch. | |
| date REAL NOT NULL, | |
| type INTEGER NOT NULL, | |
| -- Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. | |
| is_local TINYINT NOT NULL, | |
| 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 idx_history_should_upload ON history (should_upload) WHERE should_upload = 1" | |
| } else { | |
| indexShouldUpload = | |
| "CREATE INDEX IF NOT EXISTS idx_history_should_upload ON history (should_upload)" | |
| } | |
| let indexSiteIDDate = | |
| "CREATE INDEX IF NOT EXISTS idx_visits_siteID_is_local_date ON visits (siteID, is_local, date)" | |
| let faviconSites = """ | |
| CREATE TABLE IF NOT EXISTS favicon_sites ( | |
| 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 view_favicons_widest AS | |
| SELECT | |
| favicon_sites.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 favicon_sites, favicons | |
| WHERE favicon_sites.faviconID = favicons.id | |
| GROUP BY siteID | |
| """ | |
| let historyIDsWithIcon = """ | |
| CREATE VIEW IF NOT EXISTS view_history_id_favicon AS | |
| SELECT history.id AS id, iconID, iconURL, iconDate, iconType, iconWidth | |
| FROM history LEFT OUTER JOIN view_favicons_widest ON | |
| history.id = view_favicons_widest.siteID | |
| """ | |
| let iconForURL = """ | |
| CREATE VIEW IF NOT EXISTS view_icon_for_url AS | |
| SELECT history.url AS url, icons.iconID AS iconID | |
| FROM history, view_favicons_widest 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 | |
| -- Root | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Mobile | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Menu | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Toolbar | |
| (?, ?, ?, NULL, ?, ?), | |
| -- Unsorted | |
| (?, ?, ?, NULL, ?, ?) | |
| """ | |
| return self.run(db, sql: sql, args: args) | |
| } | |
| func getHistoryTableCreationString(forVersion version: Int = BrowserSchema.DefaultVersion) -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS history ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| -- Not null, but the value might be replaced by the server's. | |
| guid TEXT NOT NULL UNIQUE, | |
| -- May only be null for deleted records. | |
| url TEXT UNIQUE, | |
| title TEXT NOT NULL, | |
| -- Can be null. Integer milliseconds. | |
| server_modified INTEGER, | |
| -- Can be null. Client clock. In extremis only. | |
| local_modified INTEGER, | |
| -- Boolean. Locally deleted. | |
| is_deleted TINYINT NOT NULL, | |
| -- Boolean. Set when changed or visits added. | |
| should_upload TINYINT NOT NULL, | |
| domain_id INTEGER REFERENCES domains(id) ON DELETE CASCADE, | |
| CONSTRAINT urlOrDeleted CHECK (url IS NOT NULL OR is_deleted = 1) | |
| ) | |
| """ | |
| return sql | |
| } | |
| func getDomainsTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS domains ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| domain TEXT NOT NULL UNIQUE, | |
| showOnTopSites TINYINT NOT NULL DEFAULT 1 | |
| ) | |
| """ | |
| return sql | |
| } | |
| func getQueueTableCreationString() -> String { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS queue ( | |
| url TEXT NOT NULL UNIQUE, | |
| title TEXT | |
| ) | |
| """ | |
| return sql | |
| } | |
| 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 bookmarksMirror | |
| -- Shared fields. | |
| ( id INTEGER PRIMARY KEY AUTOINCREMENT | |
| , guid TEXT NOT NULL UNIQUE | |
| -- Type enum. TODO: BookmarkNodeType needs to be extended. | |
| , type TINYINT NOT NULL | |
| -- Record/envelope metadata that'll allow us to do merges. | |
| -- Milliseconds. | |
| , server_modified INTEGER NOT NULL | |
| -- Boolean | |
| , is_deleted TINYINT NOT NULL DEFAULT 0 | |
| -- Boolean, 0 (false) if deleted. | |
| , hasDupe TINYINT NOT NULL DEFAULT 0 | |
| -- GUID | |
| , parentid TEXT | |
| , 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. | |
| -- LIVEMARKS | |
| , feedUri TEXT, siteUri TEXT | |
| -- SEPARATORS | |
| , pos INT | |
| -- FOLDERS, BOOKMARKS, QUERIES | |
| , title TEXT, description TEXT | |
| -- BOOKMARKS, QUERIES | |
| , bmkUri TEXT, tags TEXT, keyword TEXT | |
| -- QUERIES | |
| , folderName TEXT, queryId TEXT | |
| , 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 { | |
| let sql = """ | |
| CREATE TABLE IF NOT EXISTS bookmarksMirrorStructure ( | |
| parent TEXT NOT NULL REFERENCES bookmarksMirror(guid) ON DELETE CASCADE, | |
| -- Should be the GUID of a child. | |
| child TEXT NOT NULL, | |
| -- Should advance from 0. | |
| idx INTEGER NOT NULL | |
| ) | |
| """ | |
| return sql | |
| } | |
| 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, | |
| -- Microseconds since epoch. | |
| date REAL NOT NULL, | |
| type INTEGER NOT NULL, | |
| -- Some visits are local. Some are remote ('mirrored'). This boolean flag is the split. | |
| is_local TINYINT NOT NULL, | |
| 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 idx_history_should_upload ON history (should_upload) WHERE should_upload = 1" | |
| } else { | |
| indexShouldUpload = | |
| "CREATE INDEX IF NOT EXISTS idx_history_should_upload ON history (should_upload)" | |
| } | |
| let indexSiteIDDate = | |
| "CREATE INDEX IF NOT EXISTS idx_visits_siteID_is_local_date ON visits (siteID, is_local, date)" | |
| let faviconSites = """ | |
| CREATE TABLE IF NOT EXISTS favicon_sites ( | |
| 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 view_favicons_widest AS | |
| SELECT | |
| favicon_sites.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 favicon_sites, favicons | |
| WHERE favicon_sites.faviconID = favicons.id | |
| GROUP BY siteID | |
| """ | |
| let historyIDsWithIcon = """ | |
| CREATE VIEW IF NOT EXISTS view_history_id_favicon AS | |
| SELECT history.id AS id, iconID, iconURL, iconDate, iconType, iconWidth | |
| FROM history LEFT OUTER JOIN view_favicons_widest ON | |
| history.id = view_favicons_widest.siteID | |
| """ | |
| let iconForURL = """ | |
| CREATE VIEW IF NOT EXISTS view_icon_for_url AS | |
| SELECT history.url AS url, icons.iconID AS iconID | |
| FROM history, view_favicons_widest 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 idx_bookmarksMirrorStructure_parent_idx ON bookmarksMirrorStructure (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 history (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<Int?> in | |
| let sql = "SELECT domain_id FROM history 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 cached_top_sites" | |
| let updateTopSites: [(String, Args?)] = [(clearTopSites, nil), (history.getFrecentHistory().updateTopSitesCacheQuery())] | |
| func countTopSites() -> Deferred<Maybe<Cursor<Int>>> { | |
| return db.runQuery("SELECT count(*) FROM cached_top_sites", 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<Cursor<Int>>) -> 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<Maybe<Cursor<Int>>> in | |
| XCTAssertTrue(success.isSuccess, "update was successful") | |
| return countTopSites() | |
| }) | |
| .upon({ (count: Maybe<Cursor<Int>>) 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<Site>) -> Success) -> () -> Success { | |
| return { | |
| history.getFrecentHistory().getSites(whereURLContains: nil, historyLimit: 10, bookmarksLimit: 0) | |
| >>== f | |
| } | |
| } | |
| func checkSitesByDate(_ f: @escaping (Cursor<Site>) -> Success) -> () -> Success { | |
| return { | |
| history.getSitesByLastVisit(10) | |
| >>== f | |
| } | |
| } | |
| func checkSitesWithFilter(_ filter: String, f: @escaping (Cursor<Site>) -> 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<Site>) -> 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<Site>) -> 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<Site>) -> Success in | |
| XCTAssertEqual(0, sites.count) | |
| return succeed() | |
| } | |
| >>> checkSitesByFrecency { (sites: Cursor<Site>) -> 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()) | |
| 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() | |
| } | |
| } |