// // File.swift // // // Created by Brent Simmons on 3/10/24. // import Foundation import FMDB import Database import Articles import Parser final class ArticlesTable { let name = DatabaseTableName.articles private let accountID: String private let retentionStyle: ArticlesDatabase.RetentionStyle private var articlesCache = [String: Article]() private let statusesTable = StatusesTable() private let authorsTable = AuthorsTable() private let searchTable = SearchTable() private lazy var authorsLookupTable: DatabaseLookupTable = { DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors) }() // TODO: update articleCutoffDate as time passes and based on user preferences. private let articleCutoffDate = Date().bySubtracting(days: 90) private typealias ArticlesFetchMethod = (FMDatabase) -> Set
init(accountID: String, retentionStyle: ArticlesDatabase.RetentionStyle) { self.accountID = accountID self.retentionStyle = retentionStyle } // MARK: - Fetching Articles func articles(feedID: String, database: FMDatabase) -> Set
{ fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject]) } func articles(feedIDs: Set, database: FMDatabase) -> Set
{ // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0 if feedIDs.isEmpty { return Set
() } let parameters = feedIDs.map { $0 as AnyObject } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let whereClause = "feedID in \(placeholders)" return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } func articles(articleIDs: Set, database: FMDatabase) -> Set
{ if articleIDs.isEmpty { return Set
() } let parameters = articleIDs.map { $0 as AnyObject } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))! let whereClause = "articleID in \(placeholders)" return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } func unreadArticles(feedIDs: Set, limit: Int?, database: FMDatabase) -> Set
{ // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0 if feedIDs.isEmpty { return Set
() } let parameters = feedIDs.map { $0 as AnyObject } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! var whereClause = "feedID in \(placeholders) and read=0" if let limit = limit { whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)") } return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } func todayArticles(feedIDs: Set, cutoffDate: Date, limit: Int?, database: FMDatabase) -> Set
{ fetchArticlesSince(feedIDs: feedIDs, cutoffDate: cutoffDate, limit: limit, database: database) } func starredArticles(feedIDs: Set, limit: Int?, database: FMDatabase) -> Set
{ // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred=1; if feedIDs.isEmpty { return Set
() } let parameters = feedIDs.map { $0 as AnyObject } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! var whereClause = "feedID in \(placeholders) and starred=1" if let limit = limit { whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)") } return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } func articlesMatching(searchString: String, feedIDs: Set, database: FMDatabase) -> Set
{ let articles = fetchArticlesMatching(searchString, database) // TODO: include the feedIDs in the SQL rather than filtering here. return articles.filter{ feedIDs.contains($0.feedID) } } func articlesMatching(searchString: String, articleIDs: Set, database: FMDatabase) -> Set
{ let articles = fetchArticlesMatching(searchString, database) // TODO: include the articleIDs in the SQL rather than filtering here. return articles.filter{ articleIDs.contains($0.articleID) } } // MARK: - Unread Counts func allUnreadCounts(database: FMDatabase) -> UnreadCountDictionary { var unreadCountDictionary = UnreadCountDictionary() let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 group by feedID;" guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else { return unreadCountDictionary } while resultSet.next() { let unreadCount = resultSet.long(forColumnIndex: 1) if let feedID = resultSet.string(forColumnIndex: 0) { unreadCountDictionary[feedID] = unreadCount } } resultSet.close() return unreadCountDictionary } func unreadCount(feedID: String, database: FMDatabase) -> Int? { let sql = "select count(*) from articles natural join statuses where feedID=? and read=0;" let unreadCount = database.count(sql: sql, parameters: [feedID], tableName: name) return unreadCount } // Unread count for starred articles in feedIDs. func starredAndUnreadCount(feedIDs: Set, database: FMDatabase) -> Int? { if feedIDs.isEmpty { return 0 } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 and starred=1;" let parameters = Array(feedIDs) as [Any] let unreadCount = database.count(sql: sql, parameters: parameters, tableName: name) return unreadCount } func unreadCounts(feedIDs: Set, database: FMDatabase) -> UnreadCountDictionary { var unreadCountDictionary = UnreadCountDictionary() let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let sql = "select distinct feedID, count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 group by feedID;" let parameters = Array(feedIDs) as [Any] guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { return unreadCountDictionary } while resultSet.next() { let unreadCount = resultSet.long(forColumnIndex: 1) if let feedID = resultSet.string(forColumnIndex: 0) { unreadCountDictionary[feedID] = unreadCount } } resultSet.close() return unreadCountDictionary } func unreadCount(feedIDs: Set, since: Date, database: FMDatabase) -> Int? { if feedIDs.isEmpty { return 0 } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?)) and read=0;" var parameters = [Any]() parameters += Array(feedIDs) as [Any] parameters += [since] as [Any] parameters += [since] as [Any] let unreadCount = database.count(sql: sql, parameters: parameters, tableName: name) return unreadCount } // MARK: - Saving, Updating, and Deleting Articles /// Update articles and save new ones — for feed-based systems (local and iCloud). func update(parsedItems: Set, feedID: String, deleteOlder: Bool, database: FMDatabase) -> ArticleChanges { precondition(retentionStyle == .feedBased) if parsedItems.isEmpty { return ArticleChanges() } // 1. Ensure statuses for all the incoming articles. // 2. Create incoming articles with parsedItems. // 3. [Deleted - this step is no longer needed] // 4. Fetch all articles for the feed. // 5. Create array of Articles not in database and save them. // 6. Create array of updated Articles and save what’s changed. // 7. Call back with new and updated Articles. // 8. Delete Articles in database no longer present in the feed. // 9. Update search index. let articleIDs = parsedItems.articleIDs() let (statusesDictionary, _) = statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1 assert(statusesDictionary.count == articleIDs.count) let incomingArticles = Article.articlesWithParsedItems(parsedItems, feedID, accountID, statusesDictionary) //2 if incomingArticles.isEmpty { return ArticleChanges() } let fetchedArticles = articles(feedID: feedID, database: database) //4 let fetchedArticlesDictionary = fetchedArticles.dictionary() let newArticles = findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 let updatedArticles = findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 // Articles to delete are 1) not starred and 2) older than 30 days and 3) no longer in feed. let articlesToDelete: Set
if deleteOlder { let cutoffDate = Date().bySubtracting(days: 30) articlesToDelete = fetchedArticles.filter { (article) -> Bool in return !article.status.starred && article.status.dateArrived < cutoffDate && !articleIDs.contains(article.articleID) } } else { articlesToDelete = Set
() } addArticlesToCache(newArticles) addArticlesToCache(updatedArticles) // 8. Delete articles no longer in feed. let articleIDsToDelete = articlesToDelete.articleIDs() if !articleIDsToDelete.isEmpty { removeArticles(articleIDsToDelete, database) removeArticleIDsFromCache(articleIDsToDelete) } // 9. Update search index. if let newArticles = newArticles { searchTable.indexNewArticles(newArticles, database) } if let updatedArticles = updatedArticles { searchTable.indexUpdatedArticles(updatedArticles, database) } let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: articlesToDelete) return articleChanges } /// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.). func update(feedIDsAndItems: [String: Set], read: Bool, database: FMDatabase) -> ArticleChanges { precondition(retentionStyle == .syncSystem) if feedIDsAndItems.isEmpty { return ArticleChanges() } // 1. Ensure statuses for all the incoming articles. // 2. Create incoming articles with parsedItems. // 3. Ignore incoming articles that are (!starred and read and really old) // 4. Fetch all articles for the feed. // 5. Create array of Articles not in database and save them. // 6. Create array of updated Articles and save what’s changed. // 7. Call back with new and updated Articles. // 8. Update search index. var articleIDs = Set() for (_, parsedItems) in feedIDsAndItems { articleIDs.formUnion(parsedItems.articleIDs()) } let (statusesDictionary, _) = statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1 assert(statusesDictionary.count == articleIDs.count) let allIncomingArticles = Article.articlesWithFeedIDsAndItems(feedIDsAndItems, accountID, statusesDictionary) //2 if allIncomingArticles.isEmpty { return ArticleChanges() } let incomingArticles = filterIncomingArticles(allIncomingArticles) //3 if incomingArticles.isEmpty { return ArticleChanges() } let incomingArticleIDs = incomingArticles.articleIDs() let fetchedArticles = articles(articleIDs: incomingArticleIDs, database: database) //4 let fetchedArticlesDictionary = fetchedArticles.dictionary() let newArticles = findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 let updatedArticles = findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 addArticlesToCache(newArticles) addArticlesToCache(updatedArticles) // 8. Update search index. if let newArticles = newArticles { searchTable.indexNewArticles(newArticles, database) } if let updatedArticles = updatedArticles { searchTable.indexUpdatedArticles(updatedArticles, database) } let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: nil) return articleChanges } /// Delete articles func delete(articleIDs: Set, database: FMDatabase) { database.deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name) } // MARK: - Status /// Fetch the articleIDs of unread articles. func unreadArticleIDs(database: FMDatabase) -> Set? { statusesTable.articleIDs(key: .read, value: false, database: database) } func starredArticleIDs(database: FMDatabase) -> Set? { statusesTable.articleIDs(key: .starred, value: true, database: database) } func articleIDsForStatusesWithoutArticlesNewerThanCutoffDate(database: FMDatabase) -> Set? { statusesTable.articleIDsForStatusesWithoutArticlesNewerThan(cutoffDate: articleCutoffDate, database: database) } func mark(articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, database: FMDatabase) -> Set? { let statuses = statusesTable.mark(articles.statuses(), statusKey, flag, database) return statuses } func mark(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool, database: FMDatabase) { statusesTable.mark(articleIDs, statusKey, flag, database) } /// Create statuses for specified articleIDs. For existing statuses, don’t do anything. /// For newly-created statuses, mark them as read and not-starred. func createStatusesIfNeeded(articleIDs: Set, database: FMDatabase) { statusesTable.ensureStatusesForArticleIDs(articleIDs, true, database) } // MARK: - Indexing /// Returns true if it indexed >0 articles. Keep calling until it returns false. func indexUnindexedArticles(database: FMDatabase) -> Bool { let sql = "select articleID from articles where searchRowID is null limit 500;" guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else { return false } let articleIDs = resultSet.mapToSet{ $0.string(forColumn: DatabaseKey.articleID) } if articleIDs.isEmpty { return false } searchTable.ensureIndexedArticles(articleIDs: articleIDs, database: database) return true } // MARK: - Caches func emptyCaches() { articlesCache = [String: Article]() } // MARK: - Cleanup /// Delete articles that we won’t show in the UI any longer /// — their arrival date is before our 90-day recency window; /// they are read; they are not starred. /// /// Because deleting articles might block the database for too long, /// we do this in a careful way: delete articles older than a year, /// check to see how much time has passed, then decide whether or not to continue. /// Repeat for successively more-recent dates. func deleteOldArticles(database: FMDatabase) { precondition(retentionStyle == .syncSystem) func deleteOldArticles(cutoffDate: Date) { let sql = "delete from articles where articleID in (select articleID from articles natural join statuses where dateArrived Bool { let timeElapsed = Date().timeIntervalSince(startTime) return timeElapsed > 2.0 } let dayIntervals = [365, 300, 225, 150] for dayInterval in dayIntervals { deleteOldArticles(cutoffDate: startTime.bySubtracting(days: dayInterval)) if tooMuchTimeHasPassed() { return } } deleteOldArticles(cutoffDate: self.articleCutoffDate) } func deleteArticlesNotInSubscribedToFeedIDs(_ feedIDs: Set, database: FMDatabase) { if feedIDs.isEmpty { return } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let sql = "select articleID from articles where feedID not in \(placeholders);" let parameters = Array(feedIDs) as [Any] guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { return } let articleIDs = resultSet.mapToSet{ $0.string(forColumn: DatabaseKey.articleID) } if articleIDs.isEmpty { return } removeArticles(articleIDs, database) statusesTable.removeStatuses(articleIDs, database) } func deleteOldStatuses(database: FMDatabase) { let sql: String let cutoffDate: Date switch self.retentionStyle { case .syncSystem: sql = "delete from statuses where dateArrived Set
{ let sql = "select * from articles natural join statuses where \(whereClause);" return articlesWithSQL(sql, parameters, database) } func articlesWithSQL(_ sql: String, _ parameters: [AnyObject], _ database: FMDatabase) -> Set
{ guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { return Set
() } return articlesWithResultSet(resultSet, database) } func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set
{ var cachedArticles = Set
() var fetchedArticles = Set
() while resultSet.next() { guard let articleID = resultSet.string(forColumn: DatabaseKey.articleID) else { assertionFailure("Expected articleID.") continue } if let article = articlesCache[articleID] { cachedArticles.insert(article) continue } // The resultSet is a result of a JOIN query with the statuses table, // so we can get the statuses at the same time and avoid additional database lookups. guard let status = statusesTable.statusWithRow(resultSet, articleID: articleID) else { assertionFailure("Expected status.") continue } guard let article = Article(accountID: accountID, row: resultSet, status: status) else { continue } fetchedArticles.insert(article) } resultSet.close() if fetchedArticles.isEmpty { return cachedArticles } // Fetch authors for non-cached articles. (Articles from the cache already have authors.) let fetchedArticleIDs = fetchedArticles.articleIDs() let authorsMap = authorsLookupTable.fetchRelatedObjects(for: fetchedArticleIDs, in: database) let articlesWithFetchedAuthors = fetchedArticles.map { (article) -> Article in if let authors = authorsMap?.authors(for: article.articleID) { return article.byAdding(authors) } return article } // Add fetchedArticles to cache, now that they have attached authors. for article in articlesWithFetchedAuthors { articlesCache[article.articleID] = article } return cachedArticles.union(articlesWithFetchedAuthors) } func fetchArticlesSince(feedIDs: Set, cutoffDate: Date, limit: Int?, database: FMDatabase) -> Set
{ // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and (datePublished > ? || (datePublished is null and dateArrived > ?) // // datePublished may be nil, so we fall back to dateArrived. if feedIDs.isEmpty { return Set
() } let parameters = feedIDs.map { $0 as AnyObject } + [cutoffDate as AnyObject, cutoffDate as AnyObject] let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! var whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?))" if let limit = limit { whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)") } return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set
{ let sql = "select rowid from search where search match ?;" let sqlSearchString = sqliteSearchString(with: searchString) let searchStringParameters = [sqlSearchString] guard let resultSet = database.executeQuery(sql, withArgumentsIn: searchStringParameters) else { return Set
() } let searchRowIDs = resultSet.mapToSet { $0.longLongInt(forColumnIndex: 0) } if searchRowIDs.isEmpty { return Set
() } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(searchRowIDs.count))! let whereClause = "searchRowID in \(placeholders)" let parameters: [AnyObject] = Array(searchRowIDs) as [AnyObject] return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } func sqliteSearchString(with searchString: String) -> String { var s = "" searchString.enumerateSubstrings(in: searchString.startIndex.., _ database: FMDatabase) { database.deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name) } // MARK: - Cache func addArticlesToCache(_ articles: Set
?) { guard let articles = articles else { return } for article in articles { articlesCache[article.articleID] = article } } func removeArticleIDsFromCache(_ articleIDs: Set) { for articleID in articleIDs { articlesCache[articleID] = nil } } // MARK: - Saving New Articles func findNewArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article]) -> Set
? { let newArticles = Set(incomingArticles.filter { fetchedArticlesDictionary[$0.articleID] == nil }) return newArticles.isEmpty ? nil : newArticles } func findAndSaveNewArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set
? { //5 guard let newArticles = findNewArticles(incomingArticles, fetchedArticlesDictionary) else { return nil } saveNewArticles(newArticles, database) return newArticles } func saveNewArticles(_ articles: Set
, _ database: FMDatabase) { saveRelatedObjectsForNewArticles(articles, database) if let databaseDictionaries = articles.databaseDictionaries() { database.insertRows(databaseDictionaries, insertType: .orReplace, tableName: name) } } func saveRelatedObjectsForNewArticles(_ articles: Set
, _ database: FMDatabase) { let databaseObjects = articles.databaseObjects() authorsLookupTable.saveRelatedObjects(for: databaseObjects, in: database) } // MARK: - Updating Existing Articles func articlesWithRelatedObjectChanges(_ comparisonKeyPath: KeyPath?>, _ updatedArticles: Set
, _ fetchedArticles: [String: Article]) -> Set
{ return updatedArticles.filter{ (updatedArticle) -> Bool in if let fetchedArticle = fetchedArticles[updatedArticle.articleID] { return updatedArticle[keyPath: comparisonKeyPath] != fetchedArticle[keyPath: comparisonKeyPath] } assertionFailure("Expected to find matching fetched article."); return true } } func updateRelatedObjects(_ comparisonKeyPath: KeyPath?>, _ updatedArticles: Set
, _ fetchedArticles: [String: Article], _ lookupTable: DatabaseLookupTable, _ database: FMDatabase) { let articlesWithChanges = articlesWithRelatedObjectChanges(comparisonKeyPath, updatedArticles, fetchedArticles) if !articlesWithChanges.isEmpty { lookupTable.saveRelatedObjects(for: articlesWithChanges.databaseObjects(), in: database) } } func saveUpdatedRelatedObjects(_ updatedArticles: Set
, _ fetchedArticles: [String: Article], _ database: FMDatabase) { updateRelatedObjects(\Article.authors, updatedArticles, fetchedArticles, authorsLookupTable, database) } func findUpdatedArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article]) -> Set
? { let updatedArticles = incomingArticles.filter{ (incomingArticle) -> Bool in //6 if let existingArticle = fetchedArticlesDictionary[incomingArticle.articleID] { if existingArticle != incomingArticle { return true } } return false } return updatedArticles.isEmpty ? nil : updatedArticles } func findAndSaveUpdatedArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set
? { //6 guard let updatedArticles = findUpdatedArticles(incomingArticles, fetchedArticlesDictionary) else { return nil } saveUpdatedArticles(Set(updatedArticles), fetchedArticlesDictionary, database) return updatedArticles } func saveUpdatedArticles(_ updatedArticles: Set
, _ fetchedArticles: [String: Article], _ database: FMDatabase) { saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database) for updatedArticle in updatedArticles { saveUpdatedArticle(updatedArticle, fetchedArticles, database) } } func saveUpdatedArticle(_ updatedArticle: Article, _ fetchedArticles: [String: Article], _ database: FMDatabase) { // Only update exactly what has changed in the Article (if anything). // Untested theory: this gets us better performance and less database fragmentation. guard let fetchedArticle = fetchedArticles[updatedArticle.articleID] else { assertionFailure("Expected to find matching fetched article."); saveNewArticles(Set([updatedArticle]), database) return } guard let changesDictionary = updatedArticle.changesFrom(fetchedArticle), changesDictionary.count > 0 else { // Not unexpected. There may be no changes. return } database.updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, equals: updatedArticle.articleID, tableName: name) } func articleIsIgnorable(_ article: Article) -> Bool { if article.status.starred || !article.status.read { return false } return article.status.dateArrived < articleCutoffDate } func filterIncomingArticles(_ articles: Set
) -> Set
{ // Drop Articles that we can ignore. precondition(retentionStyle == .syncSystem) return Set(articles.filter{ !articleIsIgnorable($0) }) } } private extension Set where Element == ParsedItem { func articleIDs() -> Set { Set(map { $0.articleID }) } }