/* 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 import XCGLogger import Deferred private let log = Logger.syncLogger public let TopSiteCacheSize: Int32 = 16 class NoSuchRecordError: MaybeErrorType { let guid: GUID init(guid: GUID) { self.guid = guid } var description: String { return "No such record: \(guid)." } } private var ignoredSchemes = ["about"] public func isIgnoredURL(_ url: URL) -> Bool { guard let scheme = url.scheme else { return false } if let _ = ignoredSchemes.index(of: scheme) { return true } if url.host == "localhost" { return true } return false } public func isIgnoredURL(_ url: String) -> Bool { if let url = URL(string: url) { return isIgnoredURL(url) } return false } /* // Here's the Swift equivalent of the below. func simulatedFrecency(now: MicrosecondTimestamp, then: MicrosecondTimestamp, visitCount: Int) -> Double { let ageMicroseconds = (now - then) let ageDays = Double(ageMicroseconds) / 86400000000.0 // In SQL the .0 does the coercion. let f = 100 * 225 / ((ageSeconds * ageSeconds) + 225) return Double(visitCount) * max(1.0, f) } */ // The constants in these functions were arrived at by utterly unscientific experimentation. func getRemoteFrecencySQL() -> String { let visitCountExpression = "remoteVisitCount" let now = Date.nowMicroseconds() let microsecondsPerDay = 86_400_000_000.0 // 1000 * 1000 * 60 * 60 * 24 let ageDays = "((\(now) - remoteVisitDate) / \(microsecondsPerDay))" return "\(visitCountExpression) * max(1, 100 * 110 / (\(ageDays) * \(ageDays) + 110))" } func getLocalFrecencySQL() -> String { let visitCountExpression = "((2 + localVisitCount) * (2 + localVisitCount))" let now = Date.nowMicroseconds() let microsecondsPerDay = 86_400_000_000.0 // 1000 * 1000 * 60 * 60 * 24 let ageDays = "((\(now) - localVisitDate) / \(microsecondsPerDay))" return "\(visitCountExpression) * max(2, 100 * 225 / (\(ageDays) * \(ageDays) + 225))" } fileprivate func computeWordsWithFilter(_ filter: String) -> [String] { // Split filter on whitespace. let words = filter.components(separatedBy: .whitespaces) // Remove substrings and duplicates. // TODO: this can probably be improved. return words.enumerated().filter({ (index: Int, word: String) in if word.isEmpty { return false } for i in words.indices where i != index { if words[i].range(of: word) != nil && (words[i].count != word.count || i < index) { return false } } return true }).map({ $0.1 }) } /** * Take input like "foo bar" and a template fragment and produce output like * * ((x.y LIKE ?) OR (x.z LIKE ?)) AND ((x.y LIKE ?) OR (x.z LIKE ?)) * * with args ["foo", "foo", "bar", "bar"]. */ internal func computeWhereFragmentWithFilter(_ filter: String, perWordFragment: String, perWordArgs: (String) -> Args) -> (fragment: String, args: Args) { precondition(!filter.isEmpty) let words = computeWordsWithFilter(filter) return computeWhereFragmentForWords(words, perWordFragment: perWordFragment, perWordArgs: perWordArgs) } internal func computeWhereFragmentForWords(_ words: [String], perWordFragment: String, perWordArgs: (String) -> Args) -> (fragment: String, args: Args) { assert(!words.isEmpty) let fragment = Array(repeating: perWordFragment, count: words.count).joined(separator: " AND ") let args = words.flatMap(perWordArgs) return (fragment, args) } extension SDRow { func getTimestamp(_ column: String) -> Timestamp? { return (self[column] as? NSNumber)?.uint64Value } func getBoolean(_ column: String) -> Bool { if let val = self[column] as? Int { return val != 0 } return false } } /** * The sqlite-backed implementation of the history protocol. */ open class SQLiteHistory { let db: BrowserDB let favicons: SQLiteFavicons let prefs: Prefs let clearTopSitesQuery: (String, Args?) = ("DELETE FROM \(TableCachedTopSites)", nil) required public init(db: BrowserDB, prefs: Prefs) { self.db = db self.favicons = SQLiteFavicons(db: self.db) self.prefs = prefs } } private let topSitesQuery = "SELECT \(TableCachedTopSites).*, \(TablePageMetadata).provider_name FROM \(TableCachedTopSites) LEFT OUTER JOIN \(TablePageMetadata) ON \(TableCachedTopSites).url = \(TablePageMetadata).site_url ORDER BY frecencies DESC LIMIT (?)" /** * The init for this will perform the heaviest part of the frecency query * and create a temporary table that can be queried quickly. Currently this accounts for * >75% of the query time. * The scope/lifetime of this object is important as the data is 'frozen' until a new instance is created. */ fileprivate struct SQLiteFrecentHistory : FrecentHistory { private let db: BrowserDB private let prefs: Prefs init(db: BrowserDB, prefs: Prefs) { self.db = db self.prefs = prefs let empty = "DELETE FROM \(TempTableAwesomebarBookmarks)" let create = "CREATE TEMP TABLE IF NOT EXISTS \(TempTableAwesomebarBookmarks) (" + "guid TEXT, url TEXT, title TEXT, description TEXT, " + "faviconID INT, visitDate DATE)" let insert = "INSERT INTO \(TempTableAwesomebarBookmarks) " + "SELECT b.guid AS guid, b.url AS url, b.title AS title, " + "b.description AS description, b.faviconID AS faviconID, " + "h.visitDate AS visitDate " + "FROM \(ViewAwesomebarBookmarks) b " + "LEFT JOIN \(ViewHistoryVisits) h ON b.url = h.url" let _ = db.transaction { connection in try connection.executeChange(create) try connection.executeChange(empty) try connection.executeChange(insert) } } func getSites(whereURLContains filter: String?, historyLimit limit: Int, bookmarksLimit: Int) -> Deferred>> { let factory = SQLiteHistory.basicHistoryColumnFactory let params = FrecencyQueryParams.urlCompletion(bookmarksLimit: bookmarksLimit, whereURLContains: filter ?? "", groupClause: "GROUP BY historyID ") let (query, args) = getFrecencyQuery(historyLimit: limit, params: params) return db.runQuery(query, args: args, factory: factory) } func updateTopSitesCacheQuery() -> (String, Args?) { let limit = Int(prefs.intForKey(PrefsKeys.KeyTopSitesCacheSize) ?? TopSiteCacheSize) let (whereData, groupBy) = topSiteClauses() let params = FrecencyQueryParams.topSites(groupClause: groupBy, whereData: whereData) let (frecencyQuery, args) = getFrecencyQuery(historyLimit: limit, params: params) // We must project, because we get bookmarks in these results. let insertQuery = [ "WITH siteFrecency AS (\(frecencyQuery))", "INSERT INTO \(TableCachedTopSites)", "SELECT historyID, url, title, guid, domain_id, domain,", "localVisitDate, remoteVisitDate, localVisitCount, remoteVisitCount,", "iconID, iconURL, iconDate, iconType, iconWidth, frecencies", "FROM (SELECT * FROM", "siteFrecency LEFT JOIN view_history_id_favicon ON", "siteFrecency.historyID = view_history_id_favicon.id)" ].joined(separator: " ") // @TODO: remove the LEFT JOIN to fill in the icon columns, it is just there because the code has been doing this historically. // Those favicon data columns are not used, and view_history_id_favicon appears to only contain null data. return (insertQuery, args) } private func topSiteClauses() -> (String, String) { let whereData = "(\(TableDomains).showOnTopSites IS 1) AND (\(TableDomains).domain NOT LIKE 'r.%') AND (\(TableDomains).domain NOT LIKE 'google.%') " let groupBy = "GROUP BY domain_id " return (whereData, groupBy) } enum FrecencyQueryParams { case urlCompletion(bookmarksLimit: Int, whereURLContains: String, groupClause: String) case topSites(groupClause: String, whereData: String) } private func getFrecencyQuery(historyLimit: Int, params: FrecencyQueryParams) -> (String, Args?) { let bookmarksLimit: Int let groupClause: String let whereData: String? let urlFilter: String? switch params { case let .urlCompletion(bmLimit, filter, group): bookmarksLimit = bmLimit urlFilter = filter groupClause = group whereData = nil case let .topSites(group, whereArg): bookmarksLimit = 0 urlFilter = nil whereData = whereArg groupClause = group } let includeBookmarks = bookmarksLimit > 0 let localFrecencySQL = getLocalFrecencySQL() let remoteFrecencySQL = getRemoteFrecencySQL() let sixMonthsInMicroseconds: UInt64 = 15_724_800_000_000 // 182 * 1000 * 1000 * 60 * 60 * 24 let sixMonthsAgo = Date.nowMicroseconds() - sixMonthsInMicroseconds let args: Args let whereClause: String let whereFragment = (whereData == nil) ? "" : " AND (\(whereData!))" if let urlFilter = urlFilter?.trimmingCharacters(in: .whitespaces), !urlFilter.isEmpty { let perWordFragment = "((url LIKE ?) OR (title LIKE ?))" let perWordArgs: (String) -> Args = { ["%\($0)%", "%\($0)%"] } let (filterFragment, filterArgs) = computeWhereFragmentWithFilter(urlFilter, perWordFragment: perWordFragment, perWordArgs: perWordArgs) // No deleted item has a URL, so there is no need to explicitly add that here. whereClause = "WHERE (\(filterFragment))\(whereFragment)" if includeBookmarks { // We'll need them twice: once to filter history, and once to filter bookmarks. args = filterArgs + filterArgs } else { args = filterArgs } } else { whereClause = " WHERE (\(TableHistory).is_deleted = 0)\(whereFragment)" args = [] } // Innermost: grab history items and basic visit/domain metadata. var ungroupedSQL = "SELECT \(TableHistory).id AS historyID, \(TableHistory).url AS url, \(TableHistory).title AS title, \(TableHistory).guid AS guid, domain_id, domain" + ", COALESCE(max(case \(TableVisits).is_local when 1 then \(TableVisits).date else 0 end), 0) AS localVisitDate" + ", COALESCE(max(case \(TableVisits).is_local when 0 then \(TableVisits).date else 0 end), 0) AS remoteVisitDate" + ", COALESCE(sum(\(TableVisits).is_local), 0) AS localVisitCount" + ", COALESCE(sum(case \(TableVisits).is_local when 1 then 0 else 1 end), 0) AS remoteVisitCount" + " FROM \(TableHistory) " + "INNER JOIN \(TableDomains) ON \(TableDomains).id = \(TableHistory).domain_id " + "INNER JOIN \(TableVisits) ON \(TableVisits).siteID = \(TableHistory).id " if includeBookmarks { ungroupedSQL.append("LEFT JOIN \(ViewAllBookmarks) on \(ViewAllBookmarks).url = \(TableHistory).url ") } ungroupedSQL.append(whereClause.replacingOccurrences(of: "url", with: "\(TableHistory).url").replacingOccurrences(of: "title", with: "\(TableHistory).title")) if includeBookmarks { ungroupedSQL.append(" AND \(ViewAllBookmarks).url IS NULL") } ungroupedSQL.append(" GROUP BY historyID") // Next: limit to only those that have been visited at all within the last six months. // (Don't do that in the innermost: we want to get the full count, even if some visits are older.) // Discard all but the 1000 most frecent. // Compute and return the frecency for all 1000 URLs. let frecenciedSQL = "SELECT *, (\(localFrecencySQL) + \(remoteFrecencySQL)) AS frecency" + " FROM (" + ungroupedSQL + ")" + " WHERE (" + "((localVisitCount > 0) OR (remoteVisitCount > 0)) AND " + // Eliminate dead rows from coalescing. "((localVisitDate > \(sixMonthsAgo)) OR (remoteVisitDate > \(sixMonthsAgo)))" + // Exclude really old items. ") ORDER BY frecency DESC" + " LIMIT 1000" // Don't even look at a huge set. This avoids work. // Next: merge by domain and select the URL with the max frecency of a domain, ordering by that sum frecency and reducing to a (typically much lower) limit. // NOTE: When using GROUP BY we need to be explicit about which URL to use when grouping. By using "max(frecency)" the result row // for that domain will contain the projected URL corresponding to the history item with the max frecency, https://sqlite.org/lang_select.html#resultset // This is the behavior we want in order to ensure that the most popular URL for a domain is used for the top sites tile. // TODO: make is_bookmarked here accurate by joining against ViewAllBookmarks. // TODO: ensure that the same URL doesn't appear twice in the list, either from duplicate // bookmarks or from being in both bookmarks and history. let historySQL = [ "SELECT historyID, url, title, guid, domain_id, domain,", "max(localVisitDate) AS localVisitDate,", "max(remoteVisitDate) AS remoteVisitDate,", "sum(localVisitCount) AS localVisitCount,", "sum(remoteVisitCount) AS remoteVisitCount,", "max(frecency) AS maxFrecency,", "sum(frecency) AS frecencies,", "0 AS is_bookmarked", "FROM (", frecenciedSQL, ") ", groupClause, "ORDER BY frecencies DESC", "LIMIT \(historyLimit)", ].joined(separator: " ") let bookmarksSQL = [ "SELECT NULL AS historyID, url, title, guid, NULL AS domain_id, NULL AS domain,", "visitDate AS localVisitDate, 0 AS remoteVisitDate, 0 AS localVisitCount,", "0 AS remoteVisitCount,", "visitDate AS frecencies,", // Fake this for ordering purposes. "0 as maxFrecency,", // Need this column for UNION "1 AS is_bookmarked", "FROM \(TempTableAwesomebarBookmarks)", whereClause, // The columns match, so we can reuse this. "GROUP BY url", "ORDER BY visitDate DESC LIMIT \(bookmarksLimit)", ].joined(separator: " ") if !includeBookmarks { return (historySQL, args) } let allSQL = "SELECT * FROM (SELECT * FROM (\(historySQL)) UNION SELECT * FROM (\(bookmarksSQL))) ORDER BY is_bookmarked DESC, frecencies DESC" return (allSQL, args) } } extension SQLiteHistory: BrowserHistory { public func removeSiteFromTopSites(_ site: Site) -> Success { if let host = (site.url as String).asURL?.normalizedHost { return self.removeHostFromTopSites(host) } return deferMaybe(DatabaseError(description: "Invalid url for site \(site.url)")) } public func removeFromPinnedTopSites(_ site: Site) -> Success { guard let host = (site.url as String).asURL?.normalizedHost else { return deferMaybe(DatabaseError(description: "Invalid url for site \(site.url)")) } //do a fuzzy delete so dupes can be removed let query: (String, Args?) = ("DELETE FROM \(TablePinnedTopSites) where domain = ?", [host]) return db.run([query]) >>== { return self.db.run([("UPDATE \(TableDomains) set showOnTopSites = 1 WHERE domain = ?", [host])]) } } public func getPinnedTopSites() -> Deferred>> { let sql = "SELECT * from \(TablePinnedTopSites) " + "LEFT OUTER JOIN view_history_id_favicon on historyID = view_history_id_favicon.id ORDER BY pinDate DESC" return db.runQuery(sql, args: [], factory: SQLiteHistory.iconHistoryMetadataColumnFactory) } public func addPinnedTopSite(_ site: Site) -> Success { // needs test let now = Date.now() guard let guid = site.guid, let host = (site.url as String).asURL?.normalizedHost else { return deferMaybe(DatabaseError(description: "Invalid site \(site.url)")) } let args: Args = [site.url, now, site.title, site.id, guid, host] let arglist = BrowserDB.varlist(args.count) // Prevent the pinned site from being used in topsite calculations // We dont have to worry about this when removing a pin because the assumption is that a user probably doesnt want it being recommended as a topsite either return self.removeHostFromTopSites(host) >>== { return self.db.run([("INSERT OR REPLACE INTO \(TablePinnedTopSites)(url, pinDate, title, historyID, guid, domain) VALUES \(arglist)", args)]) } } public func removeHostFromTopSites(_ host: String) -> Success { return db.run([("UPDATE \(TableDomains) set showOnTopSites = 0 WHERE domain = ?", [host])]) } public func removeHistoryForURL(_ url: String) -> Success { let visitArgs: Args = [url] let deleteVisits = "DELETE FROM \(TableVisits) WHERE siteID = (SELECT id FROM \(TableHistory) WHERE url = ?)" let markArgs: Args = [Date.nowNumber(), url] let markDeleted = "UPDATE \(TableHistory) SET url = NULL, is_deleted = 1, should_upload = 1, local_modified = ? WHERE url = ?" //return db.run([(sql: String, args: Args?)]) let command = [(sql: deleteVisits, args: visitArgs), (sql: markDeleted, args: markArgs), self.favicons.getCleanupFaviconsQuery()] as [(sql: String, args: Args?)] return db.run(command) } // Note: clearing history isn't really a sane concept in the presence of Sync. // This method should be split to do something else. // Bug 1162778. public func clearHistory() -> Success { return self.db.run([ ("DELETE FROM \(TableVisits)", nil), ("DELETE FROM \(TableHistory)", nil), ("DELETE FROM \(TableDomains)", nil), ("DELETE FROM \(TablePageMetadata)", nil), self.favicons.getCleanupFaviconsQuery() ]) // We've probably deleted a lot of stuff. Vacuum now to recover the space. >>> effect(self.db.vacuum) } func recordVisitedSite(_ site: Site) -> Success { // Don't store visits to sites with about: protocols if isIgnoredURL(site.url as String) { return deferMaybe(IgnoredSiteError()) } return db.withConnection { conn -> Void in let now = Date.now() if self.updateSite(site, atTime: now, withConnection: conn) > 0 { return } // Insert instead. if self.insertSite(site, atTime: now, withConnection: conn) > 0 { return } let err = DatabaseError(description: "Unable to update or insert site; Invalid key returned") log.error("recordVisitedSite(_:) encountered an error: \(err.localizedDescription)") throw err } } func updateSite(_ site: Site, atTime time: Timestamp, withConnection conn: SQLiteDBConnection) -> Int { // We know we're adding a new visit, so we'll need to upload this record. // If we ever switch to per-visit change flags, this should turn into a CASE statement like // CASE WHEN title IS ? THEN max(should_upload, 1) ELSE should_upload END // so that we don't flag this as changed unless the title changed. // // Note that we will never match against a deleted item, because deleted items have no URL, // so we don't need to unset is_deleted here. guard let host = (site.url as String).asURL?.normalizedHost else { return 0 } let update = "UPDATE \(TableHistory) SET title = ?, local_modified = ?, should_upload = 1, domain_id = (SELECT id FROM \(TableDomains) where domain = ?) WHERE url = ?" let updateArgs: Args? = [site.title, time, host, site.url] if Logger.logPII { log.debug("Setting title to \(site.title) for URL \(site.url)") } do { try conn.executeChange(update, withArgs: updateArgs) return conn.numberOfRowsModified } catch let error as NSError { log.warning("Update failed with error: \(error.localizedDescription)") return 0 } } fileprivate func insertSite(_ site: Site, atTime time: Timestamp, withConnection conn: SQLiteDBConnection) -> Int { if let host = (site.url as String).asURL?.normalizedHost { do { try conn.executeChange("INSERT OR IGNORE INTO \(TableDomains) (domain) VALUES (?)", withArgs: [host]) } catch let error as NSError { log.warning("Domain insertion failed with \(error.localizedDescription)") return 0 } let insert = "INSERT INTO \(TableHistory) " + "(guid, url, title, local_modified, is_deleted, should_upload, domain_id) " + "SELECT ?, ?, ?, ?, 0, 1, id FROM \(TableDomains) WHERE domain = ?" let insertArgs: Args? = [site.guid ?? Bytes.generateGUID(), site.url, site.title, time, host] do { try conn.executeChange(insert, withArgs: insertArgs) } catch let error as NSError { log.warning("Site insertion failed with \(error.localizedDescription)") return 0 } return 1 } if Logger.logPII { log.warning("Invalid URL \(site.url). Not stored in history.") } return 0 } // TODO: thread siteID into this to avoid the need to do the lookup. func addLocalVisitForExistingSite(_ visit: SiteVisit) -> Success { return db.withConnection { conn -> Void in // INSERT OR IGNORE because we *might* have a clock error that causes a timestamp // collision with an existing visit, and it would really suck to error out for that reason. let insert = "INSERT OR IGNORE INTO \(TableVisits) (siteID, date, type, is_local) VALUES (" + "(SELECT id FROM \(TableHistory) WHERE url = ?), ?, ?, 1)" let realDate = visit.date let insertArgs: Args? = [visit.site.url, realDate, visit.type.rawValue] try conn.executeChange(insert, withArgs: insertArgs) } } public func addLocalVisit(_ visit: SiteVisit) -> Success { return recordVisitedSite(visit.site) >>> { self.addLocalVisitForExistingSite(visit) } } public func getFrecentHistory() -> FrecentHistory { return SQLiteFrecentHistory(db: db, prefs: prefs) } public func getTopSitesWithLimit(_ limit: Int) -> Deferred>> { return self.db.runQuery(topSitesQuery, args: [limit], factory: SQLiteHistory.iconHistoryMetadataColumnFactory) } public func setTopSitesNeedsInvalidation() { prefs.setBool(false, forKey: PrefsKeys.KeyTopSitesCacheIsValid) } public func setTopSitesCacheSize(_ size: Int32) { let oldValue = prefs.intForKey(PrefsKeys.KeyTopSitesCacheSize) ?? 0 if oldValue != size { prefs.setInt(size, forKey: PrefsKeys.KeyTopSitesCacheSize) setTopSitesNeedsInvalidation() } } public func refreshTopSitesQuery() -> [(String, Args?)] { return [clearTopSitesQuery, getFrecentHistory().updateTopSitesCacheQuery()] } public func clearTopSitesCache() -> Success { return self.db.run([clearTopSitesQuery]) >>> { self.prefs.removeObjectForKey(PrefsKeys.KeyTopSitesCacheIsValid) return succeed() } } public func getSitesByLastVisit(_ limit: Int) -> Deferred>> { return self.getFilteredSitesByVisitDateWithLimit(limit, whereURLContains: nil, includeIcon: true) } fileprivate func topSiteClauses() -> (String, String) { let whereData = "(\(TableDomains).showOnTopSites IS 1) AND (\(TableDomains).domain NOT LIKE 'r.%') AND (\(TableDomains).domain NOT LIKE 'google.%') " let groupBy = "GROUP BY domain_id " return (whereData, groupBy) } fileprivate func computeWordsWithFilter(_ filter: String) -> [String] { // Split filter on whitespace. let words = filter.components(separatedBy: CharacterSet.whitespaces) // Remove substrings and duplicates. // TODO: this can probably be improved. return words.enumerated().filter({ (index: Int, word: String) in if word.isEmpty { return false } for i in words.indices where i != index { if words[i].range(of: word) != nil && (words[i].characters.count != word.characters.count || i < index) { return false } } return true }).map({ $0.1 }) } /** * Take input like "foo bar" and a template fragment and produce output like * * ((x.y LIKE ?) OR (x.z LIKE ?)) AND ((x.y LIKE ?) OR (x.z LIKE ?)) * * with args ["foo", "foo", "bar", "bar"]. */ internal func computeWhereFragmentWithFilter(_ filter: String, perWordFragment: String, perWordArgs: (String) -> Args) -> (fragment: String, args: Args) { precondition(!filter.isEmpty) let words = computeWordsWithFilter(filter) return self.computeWhereFragmentForWords(words, perWordFragment: perWordFragment, perWordArgs: perWordArgs) } internal func computeWhereFragmentForWords(_ words: [String], perWordFragment: String, perWordArgs: (String) -> Args) -> (fragment: String, args: Args) { assert(!words.isEmpty) let fragment = Array(repeating: perWordFragment, count: words.count).joined(separator: " AND ") let args = words.flatMap(perWordArgs) return (fragment, args) } fileprivate func getFilteredSitesByVisitDateWithLimit(_ limit: Int, whereURLContains filter: String? = nil, includeIcon: Bool = true) -> Deferred>> { let args: Args? let whereClause: String if let filter = filter?.trimmingCharacters(in: CharacterSet.whitespaces), !filter.isEmpty { let perWordFragment = "((\(TableHistory).url LIKE ?) OR (\(TableHistory).title LIKE ?))" let perWordArgs: (String) -> Args = { ["%\($0)%", "%\($0)%"] } let (filterFragment, filterArgs) = computeWhereFragmentWithFilter(filter, perWordFragment: perWordFragment, perWordArgs: perWordArgs) // No deleted item has a URL, so there is no need to explicitly add that here. whereClause = "WHERE (\(filterFragment))" args = filterArgs } else { whereClause = "WHERE (\(TableHistory).is_deleted = 0)" args = [] } let sql = [ "SELECT", "history.id AS historyID, history.url, title, guid, domain_id, domain,", "COALESCE(MAX(CASE visits.is_local WHEN 1 THEN visits.date ELSE 0 END), 0) AS localVisitDate,", "COALESCE(MAX(CASE visits.is_local WHEN 0 THEN visits.date ELSE 0 END), 0) AS remoteVisitDate,", "COALESCE(COUNT(visits.is_local), 0) AS visitCount", includeIcon ? ", iconID, iconURL, iconDate, iconType, iconWidth" : "", "FROM", "history", "INNER JOIN domains ON domains.id = history.domain_id", "INNER JOIN visits ON visits.siteID = history.id", includeIcon ? "LEFT OUTER JOIN view_history_id_favicon ON view_history_id_favicon.id = history.id" : "", whereClause, "GROUP BY historyID", "HAVING COUNT(visits.is_local) > 0", "ORDER BY MAX(localVisitDate, remoteVisitDate) DESC", "LIMIT \(limit)", ].joined(separator: " ") let factory = includeIcon ? SQLiteHistory.iconHistoryColumnFactory : SQLiteHistory.basicHistoryColumnFactory return db.runQuery(sql, args: args, factory: factory) } } extension SQLiteHistory: Favicons { // These two getter functions are only exposed for testing purposes (and aren't part of the public interface). func getFaviconsForURL(_ url: String) -> Deferred>> { let sql = "SELECT iconID AS id, iconURL AS url, iconDate AS date, iconType AS type, iconWidth AS width FROM " + "\(ViewWidestFaviconsForSites), \(TableHistory) WHERE " + "\(TableHistory).id = siteID AND \(TableHistory).url = ?" let args: Args = [url] return db.runQuery(sql, args: args, factory: SQLiteHistory.iconColumnFactory) } func getFaviconsForBookmarkedURL(_ url: String) -> Deferred>> { let sql = "SELECT " + " \(TableFavicons).id AS id" + ", \(TableFavicons).url AS url" + ", \(TableFavicons).date AS date" + ", \(TableFavicons).type AS type" + ", \(TableFavicons).width AS width" + " FROM \(TableFavicons), \(ViewBookmarksLocalOnMirror) AS bm" + " WHERE bm.faviconID = \(TableFavicons).id AND bm.bmkUri IS ?" let args: Args = [url] return db.runQuery(sql, args: args, factory: SQLiteHistory.iconColumnFactory) } public func getSitesForURLs(_ urls: [String]) -> Deferred>> { let inExpression = urls.joined(separator: "\",\"") let sql = "SELECT \(TableHistory).id AS historyID, \(TableHistory).url AS url, title, guid, iconID, iconURL, iconDate, iconType, iconWidth FROM " + "\(ViewWidestFaviconsForSites), \(TableHistory) WHERE " + "\(TableHistory).id = siteID AND \(TableHistory).url IN (\"\(inExpression)\")" let args: Args = [] return db.runQuery(sql, args: args, factory: SQLiteHistory.iconHistoryColumnFactory) } public func clearAllFavicons() -> Success { return db.transaction { conn -> Void in try conn.executeChange("DELETE FROM \(TableFaviconSites)") try conn.executeChange("DELETE FROM \(TableFavicons)") } } public func addFavicon(_ icon: Favicon) -> Deferred> { return self.favicons.insertOrUpdateFavicon(icon) } /** * This method assumes that the site has already been recorded * in the history table. */ public func addFavicon(_ icon: Favicon, forSite site: Site) -> Deferred> { if Logger.logPII { log.verbose("Adding favicon \(icon.url) for site \(site.url).") } func doChange(_ query: String, args: Args?) -> Deferred> { return db.withConnection { conn -> Int in // Blind! We don't see failure here. let id = self.favicons.insertOrUpdateFaviconInTransaction(icon, conn: conn) // Now set up the mapping. try conn.executeChange(query, withArgs: args) // Try to update the favicon ID column in each bookmarks table. There can be // multiple bookmarks with a particular URI, and a mirror bookmark can be // locally changed, so either or both of these statements can update multiple rows. if let id = id { icon.id = id try? conn.executeChange("UPDATE \(TableBookmarksLocal) SET faviconID = ? WHERE bmkUri = ?", withArgs: [id, site.url]) try? conn.executeChange("UPDATE \(TableBookmarksMirror) SET faviconID = ? WHERE bmkUri = ?", withArgs: [id, site.url]) return id } let err = DatabaseError(description: "Error adding favicon. ID = 0") log.error("addFavicon(_:, forSite:) encountered an error: \(err.localizedDescription)") throw err } } let siteSubselect = "(SELECT id FROM \(TableHistory) WHERE url = ?)" let iconSubselect = "(SELECT id FROM \(TableFavicons) WHERE url = ?)" let insertOrIgnore = "INSERT OR IGNORE INTO \(TableFaviconSites)(siteID, faviconID) VALUES " if let iconID = icon.id { // Easy! if let siteID = site.id { // So easy! let args: Args? = [siteID, iconID] return doChange("\(insertOrIgnore) (?, ?)", args: args) } // Nearly easy. let args: Args? = [site.url, iconID] return doChange("\(insertOrIgnore) (\(siteSubselect), ?)", args: args) } // Sigh. if let siteID = site.id { let args: Args? = [siteID, icon.url] return doChange("\(insertOrIgnore) (?, \(iconSubselect))", args: args) } // The worst. let args: Args? = [site.url, icon.url] return doChange("\(insertOrIgnore) (\(siteSubselect), \(iconSubselect))", args: args) } } extension SQLiteHistory: SyncableHistory { /** * TODO: * When we replace an existing row, we want to create a deleted row with the old * GUID and switch the new one in -- if the old record has escaped to a Sync server, * we want to delete it so that we don't have two records with the same URL on the server. * We will know if it's been uploaded because it'll have a server_modified time. */ public func ensurePlaceWithURL(_ url: String, hasGUID guid: GUID) -> Success { let args: Args = [guid, url, guid] // The additional IS NOT is to ensure that we don't do a write for no reason. return db.run("UPDATE \(TableHistory) SET guid = ? WHERE url = ? AND guid IS NOT ?", withArgs: args) } public func deleteByGUID(_ guid: GUID, deletedAt: Timestamp) -> Success { let args: Args = [guid] // This relies on ON DELETE CASCADE to remove visits. return db.run("DELETE FROM \(TableHistory) WHERE guid = ?", withArgs: args) } // Fails on non-existence. fileprivate func getSiteIDForGUID(_ guid: GUID) -> Deferred> { let args: Args = [guid] let query = "SELECT id FROM history WHERE guid = ?" let factory: (SDRow) -> Int = { return $0["id"] as! Int } return db.runQuery(query, args: args, factory: factory) >>== { cursor in if cursor.count == 0 { return deferMaybe(NoSuchRecordError(guid: guid)) } return deferMaybe(cursor[0]!) } } public func storeRemoteVisits(_ visits: [Visit], forGUID guid: GUID) -> Success { return self.getSiteIDForGUID(guid) >>== { (siteID: Int) -> Success in let visitArgs = visits.map { (visit: Visit) -> Args in let realDate = visit.date let isLocal = 0 let args: Args = [siteID, realDate, visit.type.rawValue, isLocal] return args } // Magic happens here. The INSERT OR IGNORE relies on the multi-column uniqueness // constraint on `visits`: we allow only one row for (siteID, date, type), so if a // local visit already exists, this silently keeps it. End result? Any new remote // visits are added with only one query, keeping any existing rows. return self.db.bulkInsert(TableVisits, op: .InsertOrIgnore, columns: ["siteID", "date", "type", "is_local"], values: visitArgs) } } fileprivate struct HistoryMetadata { let id: Int let serverModified: Timestamp? let localModified: Timestamp? let isDeleted: Bool let shouldUpload: Bool let title: String } fileprivate func metadataForGUID(_ guid: GUID) -> Deferred> { let select = "SELECT id, server_modified, local_modified, is_deleted, should_upload, title FROM \(TableHistory) WHERE guid = ?" let args: Args = [guid] let factory = { (row: SDRow) -> HistoryMetadata in return HistoryMetadata( id: row["id"] as! Int, serverModified: row.getTimestamp("server_modified"), localModified: row.getTimestamp("local_modified"), isDeleted: row.getBoolean("is_deleted"), shouldUpload: row.getBoolean("should_upload"), title: row["title"] as! String ) } return db.runQuery(select, args: args, factory: factory) >>== { cursor in return deferMaybe(cursor[0]) } } public func insertOrUpdatePlace(_ place: Place, modified: Timestamp) -> Deferred> { // One of these things will be true here. // 0. The item is new. // (a) We have a local place with the same URL but a different GUID. // (b) We have never visited this place locally. // In either case, reconcile and proceed. // 1. The remote place is not modified when compared to our mirror of it. This // can occur when we redownload after a partial failure. // (a) And it's not modified locally, either. Nothing to do. Ideally we // will short-circuit so we don't need to update visits. (TODO) // (b) It's modified locally. Don't overwrite anything; let the upload happen. // 2. The remote place is modified (either title or visits). // (a) And it's not locally modified. Update the local entry. // (b) And it's locally modified. Preserve the title of whichever was modified last. // N.B., this is the only instance where we compare two timestamps to see // which one wins. // We use this throughout. let serverModified = modified // Check to see if our modified time is unchanged, if the record exists locally, etc. let insertWithMetadata = { (metadata: HistoryMetadata?) -> Deferred> in if let metadata = metadata { // The item exists locally (perhaps originally with a different GUID). if metadata.serverModified == modified { log.verbose("History item \(place.guid) is unchanged; skipping insert-or-update.") return deferMaybe(place.guid) } // Otherwise, the server record must have changed since we last saw it. if metadata.shouldUpload { // Uh oh, it changed locally. // This might well just be a visit change, but we can't tell. Usually this conflict is harmless. log.debug("Warning: history item \(place.guid) changed both locally and remotely. Comparing timestamps from different clocks!") if let localModified = metadata.localModified, localModified > modified { log.debug("Local changes overriding remote.") // Update server modified time only. (Though it'll be overwritten again after a successful upload.) let update = "UPDATE \(TableHistory) SET server_modified = ? WHERE id = ?" let args: Args = [serverModified, metadata.id] return self.db.run(update, withArgs: args) >>> always(place.guid) } log.verbose("Remote changes overriding local.") // Fall through. } // The record didn't change locally. Update it. log.verbose("Updating local history item for guid \(place.guid).") let update = "UPDATE \(TableHistory) SET title = ?, server_modified = ?, is_deleted = 0 WHERE id = ?" let args: Args = [place.title, serverModified, metadata.id] return self.db.run(update, withArgs: args) >>> always(place.guid) } // The record doesn't exist locally. Insert it. log.verbose("Inserting remote history item for guid \(place.guid).") if let host = place.url.asURL?.normalizedHost { if Logger.logPII { log.debug("Inserting: \(place.url).") } let insertDomain = "INSERT OR IGNORE INTO \(TableDomains) (domain) VALUES (?)" let insertHistory = "INSERT INTO \(TableHistory) (guid, url, title, server_modified, is_deleted, should_upload, domain_id) " + "SELECT ?, ?, ?, ?, 0, 0, id FROM \(TableDomains) where domain = ?" return self.db.run([ (insertDomain, [host]), (insertHistory, [place.guid, place.url, place.title, serverModified, host]) ]) >>> always(place.guid) } else { // This is a URL with no domain. Insert it directly. if Logger.logPII { log.debug("Inserting: \(place.url) with no domain.") } let insertHistory = "INSERT INTO \(TableHistory) (guid, url, title, server_modified, is_deleted, should_upload, domain_id) " + "VALUES (?, ?, ?, ?, 0, 0, NULL)" return self.db.run([ (insertHistory, [place.guid, place.url, place.title, serverModified]) ]) >>> always(place.guid) } } // Make sure that we only need to compare GUIDs by pre-merging on URL. return self.ensurePlaceWithURL(place.url, hasGUID: place.guid) >>> { self.metadataForGUID(place.guid) >>== insertWithMetadata } } public func getDeletedHistoryToUpload() -> Deferred> { // Use the partial index on should_upload to make this nice and quick. let sql = "SELECT guid FROM \(TableHistory) WHERE \(TableHistory).should_upload = 1 AND \(TableHistory).is_deleted = 1" let f: (SDRow) -> String = { $0["guid"] as! String } return self.db.runQuery(sql, args: nil, factory: f) >>== { deferMaybe($0.asArray()) } } public func getModifiedHistoryToUpload() -> Deferred> { // What we want to do: find all history items that are flagged for upload, then find a number of recent visits for each item. // This was originally all in a single SQL query but was seperated into two to save some memory when returning back the cursor. return getModifiedHistory(limit: 1000) >>== { self.attachVisitsTo(places: $0, visitLimit: 20) } } private func getModifiedHistory(limit: Int) -> Deferred> { let sql = "SELECT id, guid, url, title " + "FROM \(TableHistory) " + "WHERE should_upload = 1 AND NOT is_deleted = 1 " + "ORDER BY id " + "LIMIT ?" var places = [Int: Place]() let placeFactory: (SDRow) -> Void = { row in let id = row["id"] as! Int let guid = row["guid"] as! String let url = row["url"] as! String let title = row["title"] as! String places[id] = Place(guid: guid, url: url, title: title) } let args: Args = [limit] return db.runQuery(sql, args: args, factory: placeFactory) >>> { deferMaybe(places) } } private func attachVisitsTo(places: [Int: Place], visitLimit: Int) -> Deferred> { // A difficulty here: we don't want to fetch *all* visits, only some number of the most recent. // (It's not enough to only get new ones, because the server record should contain more.) // // That's the greatest-N-per-group problem. We used to do this in SQL, joining // the visits table to a subselect of visits table, however, ran into OOM issues (Bug 1417034) // // Now, we want a more dumb approach with no joins in SQL and doing group-by-site and limit-to-N in swift. // // We do this in a single query, rather than the N+1 that desktop takes. // // We then need to flatten the cursor. We do that by collecting // places as a side-effect of the factory, producing visits as a result, and merging in memory. // Turn our lazy collection of integers into a comma-seperated string for the IN clause. let historyIDs = Array(places.keys) let sql = "SELECT siteID, date AS visitDate, type AS visitType " + "FROM \(TableVisits) " + "WHERE siteID IN (\(historyIDs.map(String.init).joined(separator: ","))) " + "ORDER BY siteID DESC, date DESC" // We want to get a tuple Visit and Place here. We can either have an explicit tuple factory // or we use an identity function, and make do without creating extra data structures. // Since we have a known Out Of Memory issue here, let's avoid extra data structures. let rowIdentity: (SDRow) -> SDRow = { $0 } // We'll need to runQueryUnsafe so we get a LiveSQLiteCursor, i.e. we don't get the cursor // contents into memory all at once. return db.runQueryUnsafe(sql, args: nil, factory: rowIdentity) { (cursor: Cursor) -> [Int: [Visit]] in // Accumulate a mapping of site IDs to list of visits. Each list should be shorter than visitLimit. // Seed our accumulator with empty lists since we already know which IDs we will be fetching. var visits = [Int: [Visit]]() historyIDs.forEach { visits[$0] = [] } // We need to iterate through these explicitly, without relying on the // factory. for row in cursor.makeIterator() { guard let row = row, cursor.status == .success else { throw NSError(domain: "mozilla", code: 0, userInfo: [NSLocalizedDescriptionKey: cursor.statusMessage]) } guard let id = row["siteID"] as? Int, let existingCount = visits[id]?.count, existingCount < visitLimit else { continue } guard let date = row.getTimestamp("visitDate"), let visitType = row["visitType"] as? Int, let type = VisitType(rawValue: visitType) else { continue } let visit = Visit(date: date, type: type) // Append the visits in descending date order, so we only get the // most recent top N. visits[id]?.append(visit) } return visits } >>== { visits in // Join up the places map we received as input with our visits map. let placesAndVisits: [(Place, [Visit])] = places.flatMap { id, place in guard let visitsList = visits[id], !visitsList.isEmpty else { return nil } return (place, visitsList) } let recentVisitCount = placesAndVisits.reduce(0) { $0 + $1.1.count } log.info("Attaching \(placesAndVisits.count) places to \(recentVisitCount) most recent visits") return deferMaybe(placesAndVisits) } } public func markAsDeleted(_ guids: [GUID]) -> Success { if guids.isEmpty { return succeed() } log.debug("Wiping \(guids.count) deleted GUIDs.") return self.db.run(chunk(guids, by: BrowserDB.MaxVariableNumber).flatMap(markAsDeletedStatementForGUIDs)) } fileprivate func markAsDeletedStatementForGUIDs(_ guids: ArraySlice) -> (String, Args?) { // We deliberately don't limit this to records marked as should_upload, just // in case a coding error leaves records with is_deleted=1 but not flagged for // upload -- this will catch those and throw them away. let inClause = BrowserDB.varlist(guids.count) let sql = "DELETE FROM \(TableHistory) WHERE is_deleted = 1 AND guid IN \(inClause)" let args: Args = guids.map { $0 } return (sql, args) } public func markAsSynchronized(_ guids: [GUID], modified: Timestamp) -> Deferred> { if guids.isEmpty { return deferMaybe(modified) } log.debug("Marking \(guids.count) GUIDs as synchronized. Returning timestamp \(modified).") return self.db.run(chunk(guids, by: BrowserDB.MaxVariableNumber).flatMap { chunk in return markAsSynchronizedStatementForGUIDs(chunk, modified: modified) }) >>> always(modified) } fileprivate func markAsSynchronizedStatementForGUIDs(_ guids: ArraySlice, modified: Timestamp) -> (String, Args?) { let inClause = BrowserDB.varlist(guids.count) let sql = "UPDATE \(TableHistory) SET " + "should_upload = 0, server_modified = \(modified) " + "WHERE guid IN \(inClause)" let args: Args = guids.map { $0 } return (sql, args) } public func doneApplyingRecordsAfterDownload() -> Success { self.db.checkpoint() return succeed() } public func doneUpdatingMetadataAfterUpload() -> Success { self.db.checkpoint() return succeed() } } extension SQLiteHistory { // Returns a deferred `true` if there are rows in the DB that have a server_modified time. // Because we clear this when we reset or remove the account, and never set server_modified // without syncing, the presence of matching rows directly indicates that a deletion // would be synced to the server. public func hasSyncedHistory() -> Deferred> { return self.db.queryReturnsResults("SELECT 1 FROM \(TableHistory) WHERE server_modified IS NOT NULL LIMIT 1") } } extension SQLiteHistory: ResettableSyncStorage { // We don't drop deletions when we reset -- we might need to upload a deleted item // that never made it to the server. public func resetClient() -> Success { let flag = "UPDATE \(TableHistory) SET should_upload = 1, server_modified = NULL" return self.db.run(flag) } } extension SQLiteHistory: AccountRemovalDelegate { public func onRemovedAccount() -> Success { log.info("Clearing history metadata and deleted items after account removal.") let discard = "DELETE FROM \(TableHistory) WHERE is_deleted = 1" return self.db.run(discard) >>> self.resetClient } }