NetNewsWire/Frameworks/ArticlesDatabase/ArticlesTable.swift

979 lines
35 KiB
Swift
Raw Normal View History

//
// ArticlesTable.swift
2018-08-29 07:18:24 +02:00
// NetNewsWire
//
// Created by Brent Simmons on 5/9/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSParser
import Articles
final class ArticlesTable: DatabaseTable {
let name: String
2017-09-05 17:53:45 +02:00
private let accountID: String
2019-11-30 06:49:44 +01:00
private let queue: DatabaseQueue
2017-09-05 17:53:45 +02:00
private let statusesTable: StatusesTable
private let authorsLookupTable: DatabaseLookupTable
private let retentionStyle: ArticlesDatabase.RetentionStyle
private var articlesCache = [String: Article]()
2019-02-25 00:34:10 +01:00
private lazy var searchTable: SearchTable = {
return SearchTable(queue: queue, articlesTable: self)
}()
2017-08-31 22:35:48 +02:00
// TODO: update articleCutoffDate as time passes and based on user preferences.
let articleCutoffDate = Date().bySubtracting(days: 90)
2017-08-31 22:35:48 +02:00
private typealias ArticlesFetchMethod = (FMDatabase) -> Set<Article>
init(name: String, accountID: String, queue: DatabaseQueue, retentionStyle: ArticlesDatabase.RetentionStyle) {
self.name = name
2017-09-05 17:53:45 +02:00
self.accountID = accountID
2017-08-27 00:37:15 +02:00
self.queue = queue
self.statusesTable = StatusesTable(queue: queue)
self.retentionStyle = retentionStyle
let authorsTable = AuthorsTable(name: DatabaseTableName.authors)
self.authorsLookupTable = DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors)
}
// MARK: - Fetching Articles for Feed
func fetchArticles(_ webFeedID: String) throws -> Set<Article> {
return try fetchArticles{ self.fetchArticlesForFeedID(webFeedID, withLimits: true, $0) }
}
2017-08-27 00:37:15 +02:00
func fetchArticlesAsync(_ webFeedID: String, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
fetchArticlesAsync({ self.fetchArticlesForFeedID(webFeedID, withLimits: true, $0) }, completion)
}
2017-08-27 00:37:15 +02:00
func fetchArticles(_ webFeedIDs: Set<String>) throws -> Set<Article> {
return try fetchArticles{ self.fetchArticles(webFeedIDs, $0) }
2019-11-22 17:21:30 +01:00
}
func fetchArticlesAsync(_ webFeedIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
fetchArticlesAsync({ self.fetchArticles(webFeedIDs, $0) }, completion)
2019-11-22 17:21:30 +01:00
}
// MARK: - Fetching Articles by articleID
func fetchArticles(articleIDs: Set<String>) throws -> Set<Article> {
return try fetchArticles{ self.fetchArticles(articleIDs: articleIDs, $0) }
}
2017-08-27 00:37:15 +02:00
func fetchArticlesAsync(articleIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
return fetchArticlesAsync({ self.fetchArticles(articleIDs: articleIDs, $0) }, completion)
}
2017-08-27 00:37:15 +02:00
// MARK: - Fetching Unread Articles
func fetchUnreadArticles(_ webFeedIDs: Set<String>) throws -> Set<Article> {
return try fetchArticles{ self.fetchUnreadArticles(webFeedIDs, $0) }
}
func fetchUnreadArticlesAsync(_ webFeedIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
fetchArticlesAsync({ self.fetchUnreadArticles(webFeedIDs, $0) }, completion)
}
// MARK: - Fetching Today Articles
func fetchArticlesSince(_ webFeedIDs: Set<String>, _ cutoffDate: Date) throws -> Set<Article> {
return try fetchArticles{ self.fetchArticlesSince(webFeedIDs, cutoffDate, $0) }
}
2017-09-01 22:31:27 +02:00
func fetchArticlesSinceAsync(_ webFeedIDs: Set<String>, _ cutoffDate: Date, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
fetchArticlesAsync({ self.fetchArticlesSince(webFeedIDs, cutoffDate, $0) }, completion)
}
// MARK: - Fetching Starred Articles
func fetchStarredArticles(_ webFeedIDs: Set<String>) throws -> Set<Article> {
return try fetchArticles{ self.fetchStarredArticles(webFeedIDs, $0) }
}
func fetchStarredArticlesAsync(_ webFeedIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
fetchArticlesAsync({ self.fetchStarredArticles(webFeedIDs, $0) }, completion)
}
// MARK: - Fetching Search Articles
func fetchArticlesMatching(_ searchString: String) throws -> Set<Article> {
var articles: Set<Article> = Set<Article>()
2019-12-16 07:37:45 +01:00
var error: DatabaseError? = nil
queue.runInDatabaseSync { (databaseResult) in
switch databaseResult {
case .success(let database):
articles = self.fetchArticlesMatching(searchString, database)
2019-12-16 07:37:45 +01:00
case .failure(let databaseError):
error = databaseError
}
}
if let error = error {
throw(error)
}
2019-08-31 22:53:47 +02:00
return articles
}
func fetchArticlesMatching(_ searchString: String, _ webFeedIDs: Set<String>) throws -> Set<Article> {
var articles = try fetchArticlesMatching(searchString)
articles = articles.filter{ webFeedIDs.contains($0.webFeedID) }
return articles
}
func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set<String>) throws -> Set<Article> {
var articles = try fetchArticlesMatching(searchString)
2019-08-31 22:53:47 +02:00
articles = articles.filter{ articleIDs.contains($0.articleID) }
return articles
}
func fetchArticlesMatchingAsync(_ searchString: String, _ webFeedIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
fetchArticlesAsync({ self.fetchArticlesMatching(searchString, webFeedIDs, $0) }, completion)
}
func fetchArticlesMatchingWithArticleIDsAsync(_ searchString: String, _ articleIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
2019-12-15 02:01:34 +01:00
fetchArticlesAsync({ self.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs, $0) }, completion)
2019-08-31 22:53:47 +02:00
}
// MARK: - Fetching Articles for Indexer
2019-02-25 00:34:10 +01:00
func fetchArticleSearchInfos(_ articleIDs: Set<String>, in database: FMDatabase) -> Set<ArticleSearchInfo>? {
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let sql = "select articleID, title, contentHTML, contentText, summary, searchRowID from articles where articleID in \(placeholders);";
if let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) {
return resultSet.mapToSet { (row) -> ArticleSearchInfo? in
let articleID = row.string(forColumn: DatabaseKey.articleID)!
let title = row.string(forColumn: DatabaseKey.title)
let contentHTML = row.string(forColumn: DatabaseKey.contentHTML)
let contentText = row.string(forColumn: DatabaseKey.contentText)
let summary = row.string(forColumn: DatabaseKey.summary)
let searchRowIDObject = row.object(forColumnName: DatabaseKey.searchRowID)
var searchRowID: Int? = nil
if searchRowIDObject != nil && !(searchRowIDObject is NSNull) {
searchRowID = Int(row.longLongInt(forColumn: DatabaseKey.searchRowID))
}
return ArticleSearchInfo(articleID: articleID, title: title, contentHTML: contentHTML, contentText: contentText, summary: summary, searchRowID: searchRowID)
}
}
return nil
}
// MARK: - Updating
func update(_ parsedItems: Set<ParsedItem>, _ webFeedID: String, _ completion: @escaping UpdateArticlesCompletionBlock) {
precondition(retentionStyle == .feedBased)
if parsedItems.isEmpty {
callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
// 1. Ensure statuses for all the incoming articles.
// 2. Create incoming articles with parsedItems.
2020-04-19 05:18:32 +02:00
// 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 whats changed.
// 7. Call back with new and updated Articles.
// 8. Delete Articles in database no longer present in the feed.
// 9. Update search index.
self.queue.runInTransaction { (databaseResult) in
func makeDatabaseCalls(_ database: FMDatabase) {
let articleIDs = parsedItems.articleIDs()
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1
assert(statusesDictionary.count == articleIDs.count)
let incomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2
if incomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let fetchedArticles = self.fetchArticlesForFeedID(webFeedID, withLimits: false, database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
self.addArticlesToCache(newArticles)
self.addArticlesToCache(updatedArticles)
// 8. Delete articles no longer in feed.
let articleIDsToDelete = fetchedArticles.articleIDs().filter { !(articleIDs.contains($0)) }
if !articleIDsToDelete.isEmpty {
self.removeArticles(articleIDsToDelete, database)
self.removeArticleIDsFromCache(articleIDsToDelete)
}
// 9. Update search index.
if let newArticles = newArticles {
self.searchTable.indexNewArticles(newArticles, database)
}
if let updatedArticles = updatedArticles {
self.searchTable.indexUpdatedArticles(updatedArticles, database)
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCalls(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
func update(_ webFeedIDsAndItems: [String: Set<ParsedItem>], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) {
precondition(retentionStyle == .syncSystem)
if webFeedIDsAndItems.isEmpty {
callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
// 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)
2017-09-11 15:46:32 +02:00
// 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 whats changed.
// 7. Call back with new and updated Articles.
2019-02-25 00:34:10 +01:00
// 8. Update search index.
self.queue.runInTransaction { (databaseResult) in
func makeDatabaseCalls(_ database: FMDatabase) {
var articleIDs = Set<String>()
for (_, parsedItems) in webFeedIDsAndItems {
articleIDs.formUnion(parsedItems.articleIDs())
}
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1
assert(statusesDictionary.count == articleIDs.count)
let allIncomingArticles = Article.articlesWithWebFeedIDsAndItems(webFeedIDsAndItems, self.accountID, statusesDictionary) //2
if allIncomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let incomingArticles = self.filterIncomingArticles(allIncomingArticles) //3
if incomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let incomingArticleIDs = incomingArticles.articleIDs()
let fetchedArticles = self.fetchArticles(articleIDs: incomingArticleIDs, database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
2019-02-25 00:34:10 +01:00
self.addArticlesToCache(newArticles)
self.addArticlesToCache(updatedArticles)
// 8. Update search index.
if let newArticles = newArticles {
self.searchTable.indexNewArticles(newArticles, database)
}
if let updatedArticles = updatedArticles {
self.searchTable.indexUpdatedArticles(updatedArticles, database)
}
2019-02-25 00:34:10 +01:00
}
switch databaseResult {
case .success(let database):
makeDatabaseCalls(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
2019-02-25 00:34:10 +01:00
}
}
}
2017-09-01 22:31:27 +02:00
// MARK: - Unread Counts
func fetchUnreadCount(_ webFeedIDs: Set<String>, _ since: Date, _ completion: @escaping SingleUnreadCountCompletionBlock) {
// Get unread count for today, for instance.
if webFeedIDs.isEmpty {
completion(.success(0))
return
}
queue.runInDatabase { databaseResult in
func makeDatabaseCalls(_ database: FMDatabase) {
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.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(webFeedIDs) as [Any]
parameters += [since] as [Any]
parameters += [since] as [Any]
let unreadCount = self.numberWithSQLAndParameters(sql, parameters, in: database)
DispatchQueue.main.async {
completion(.success(unreadCount))
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCalls(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
func fetchStarredAndUnreadCount(_ webFeedIDs: Set<String>, _ completion: @escaping SingleUnreadCountCompletionBlock) {
if webFeedIDs.isEmpty {
completion(.success(0))
return
}
queue.runInDatabase { databaseResult in
func makeDatabaseCalls(_ database: FMDatabase) {
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 and starred=1;"
let parameters = Array(webFeedIDs) as [Any]
let unreadCount = self.numberWithSQLAndParameters(sql, parameters, in: database)
DispatchQueue.main.async {
completion(.success(unreadCount))
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCalls(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
// MARK: - Statuses
func fetchUnreadArticleIDsAsync(_ webFeedIDs: Set<String>, _ completion: @escaping ArticleIDsCompletionBlock) {
2019-12-15 02:01:34 +01:00
fetchArticleIDsAsync(.read, false, webFeedIDs, completion)
}
func fetchStarredArticleIDsAsync(_ webFeedIDs: Set<String>, _ completion: @escaping ArticleIDsCompletionBlock) {
2019-12-15 02:01:34 +01:00
fetchArticleIDsAsync(.starred, true, webFeedIDs, completion)
}
func fetchStarredArticleIDs() throws -> Set<String> {
return try statusesTable.fetchStarredArticleIDs()
}
func fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(_ completion: @escaping ArticleIDsCompletionBlock) {
statusesTable.fetchArticleIDsForStatusesWithoutArticlesNewerThan(articleCutoffDate, completion)
}
func mark(_ articles: Set<Article>, _ statusKey: ArticleStatus.Key, _ flag: Bool) throws -> Set<ArticleStatus>? {
var statuses: Set<ArticleStatus>?
2019-12-16 07:37:45 +01:00
var error: DatabaseError?
self.queue.runInTransactionSync { databaseResult in
switch databaseResult {
case .success(let database):
statuses = self.statusesTable.mark(articles.statuses(), statusKey, flag, database)
2019-12-16 07:37:45 +01:00
case .failure(let databaseError):
error = databaseError
}
}
if let error = error {
throw error
}
return statuses
}
2017-11-20 01:28:26 +01:00
func mark(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ completion: @escaping DatabaseCompletionBlock) {
queue.runInTransaction { databaseResult in
switch databaseResult {
case .success(let database):
self.statusesTable.mark(articleIDs, statusKey, flag, database)
DispatchQueue.main.async {
completion(nil)
}
case .failure(let databaseError):
DispatchQueue.main.async {
completion(databaseError)
}
}
}
}
func createStatusesIfNeeded(_ articleIDs: Set<String>, _ completion: @escaping DatabaseCompletionBlock) {
queue.runInTransaction { databaseResult in
switch databaseResult {
case .success(let database):
let _ = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, true, database)
DispatchQueue.main.async {
completion(nil)
}
case .failure(let databaseError):
DispatchQueue.main.async {
completion(databaseError)
}
}
}
}
// MARK: - Indexing
2019-02-25 00:34:10 +01:00
func indexUnindexedArticles() {
queue.runInDatabase { databaseResult in
func makeDatabaseCalls(_ database: FMDatabase) {
let sql = "select articleID from articles where searchRowID is null limit 500;"
guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
return
}
let articleIDs = resultSet.mapToSet{ $0.string(forColumn: DatabaseKey.articleID) }
if articleIDs.isEmpty {
return
}
self.searchTable.ensureIndexedArticles(articleIDs, database)
DispatchQueue.main.async {
self.indexUnindexedArticles()
}
2019-02-25 00:34:10 +01:00
}
if let database = databaseResult.database {
makeDatabaseCalls(database)
2019-02-25 00:34:10 +01:00
}
}
}
// MARK: - Caches
func emptyCaches() {
2019-11-30 06:49:44 +01:00
queue.runInDatabase { _ in
self.articlesCache = [String: Article]()
}
}
// MARK: - Cleanup
/// Delete articles that we wont 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.
2020-04-19 01:59:13 +02:00
/// Repeat for successively more-recent dates.
2020-04-19 05:18:32 +02:00
///
/// Returns `true` if it deleted old articles all the way up to the 90 day cutoff date.
func deleteOldArticles() {
precondition(retentionStyle == .syncSystem)
queue.runInTransaction { databaseResult in
guard let database = databaseResult.database else {
return
}
func deleteOldArticles(cutoffDate: Date) {
let sql = "delete from articles where articleID in (select articleID from articles natural join statuses where dateArrived<? and read=1 and starred=0);"
let parameters = [cutoffDate] as [Any]
database.executeUpdate(sql, withArgumentsIn: parameters)
}
let startTime = Date()
func tooMuchTimeHasPassed() -> 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)
}
}
2020-04-19 01:59:13 +02:00
/// Delete old statuses.
func deleteOldStatuses() {
queue.runInTransaction { databaseResult in
guard let database = databaseResult.database else {
return
}
let sql: String
let cutoffDate: Date
switch self.retentionStyle {
case .syncSystem:
2020-04-19 05:18:32 +02:00
sql = "delete from statuses where dateArrived<? and read=1 and starred=0 and articleID not in (select articleID from articles);"
2020-04-19 01:59:13 +02:00
cutoffDate = Date().bySubtracting(days: 180)
case .feedBased:
2020-04-19 05:18:32 +02:00
sql = "delete from statuses where dateArrived<? and starred=0 and articleID not in (select articleID from articles);"
2020-04-19 01:59:13 +02:00
cutoffDate = Date().bySubtracting(days: 30)
}
let parameters = [cutoffDate] as [Any]
database.executeUpdate(sql, withArgumentsIn: parameters)
}
}
/// Delete articles from feeds that are no longer in the current set of subscribed-to feeds.
/// This deletes from the articles and articleStatuses tables,
/// and, via a trigger, it also deletes from the search index.
func deleteArticlesNotInSubscribedToFeedIDs(_ webFeedIDs: Set<String>) {
if webFeedIDs.isEmpty {
return
}
queue.runInDatabase { databaseResult in
func makeDatabaseCalls(_ database: FMDatabase) {
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
let sql = "select articleID from articles where feedID not in \(placeholders);"
let parameters = Array(webFeedIDs) 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
}
self.removeArticles(articleIDs, database)
self.statusesTable.removeStatuses(articleIDs, database)
}
if let database = databaseResult.database {
makeDatabaseCalls(database)
}
}
}
}
// MARK: - Private
2017-08-27 00:37:15 +02:00
private extension ArticlesTable {
// MARK: - Fetching
2017-08-27 00:37:15 +02:00
private func fetchArticles(_ fetchMethod: @escaping ArticlesFetchMethod) throws -> Set<Article> {
var articles = Set<Article>()
2019-12-16 07:37:45 +01:00
var error: DatabaseError? = nil
queue.runInDatabaseSync { databaseResult in
switch databaseResult {
case .success(let database):
articles = fetchMethod(database)
2019-12-16 07:37:45 +01:00
case .failure(let databaseError):
error = databaseError
}
}
if let error = error {
throw(error)
}
return articles
}
private func fetchArticlesAsync(_ fetchMethod: @escaping ArticlesFetchMethod, _ completion: @escaping ArticleSetResultBlock) {
queue.runInDatabase { databaseResult in
switch databaseResult {
case .success(let database):
let articles = fetchMethod(database)
DispatchQueue.main.async {
completion(.success(articles))
}
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
2017-08-27 00:37:15 +02:00
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
var cachedArticles = Set<Article>()
var fetchedArticles = Set<Article>()
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)
}
2020-04-19 05:18:32 +02:00
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> Set<Article> {
let sql = "select * from articles natural join statuses where \(whereClause);"
return articlesWithSQL(sql, parameters, database)
2017-08-27 22:03:15 +02:00
}
func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set<Article> {
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<Article>()
}
let searchRowIDs = resultSet.mapToSet { $0.longLongInt(forColumnIndex: 0) }
if searchRowIDs.isEmpty {
return Set<Article>()
}
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(searchRowIDs.count))!
2019-03-03 21:11:16 +01:00
let whereClause = "searchRowID in \(placeholders)"
let parameters: [AnyObject] = Array(searchRowIDs) as [AnyObject]
2020-04-19 05:18:32 +02:00
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}
func sqliteSearchString(with searchString: String) -> String {
var s = ""
searchString.enumerateSubstrings(in: searchString.startIndex..<searchString.endIndex, options: .byWords) { (word, range, enclosingRange, stop) in
guard let word = word else {
return
}
s += word
if s != "AND" && s != "OR" {
s += "*"
}
s += " "
}
return s
}
2017-08-27 22:03:15 +02:00
func articlesWithSQL(_ sql: String, _ parameters: [AnyObject], _ database: FMDatabase) -> Set<Article> {
guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
return Set<Article>()
}
return articlesWithResultSet(resultSet, database)
2017-08-27 00:37:15 +02:00
}
func fetchArticleIDsAsync(_ statusKey: ArticleStatus.Key, _ value: Bool, _ webFeedIDs: Set<String>, _ completion: @escaping ArticleIDsCompletionBlock) {
guard !webFeedIDs.isEmpty else {
completion(.success(Set<String>()))
return
}
queue.runInDatabase { databaseResult in
func makeDatabaseCalls(_ database: FMDatabase) {
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
var sql = "select articleID from articles natural join statuses where feedID in \(placeholders) and \(statusKey.rawValue)="
sql += value ? "1" : "0"
sql += ";"
let parameters = Array(webFeedIDs) as [Any]
guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
DispatchQueue.main.async {
completion(.success(Set<String>()))
}
return
}
let articleIDs = resultSet.mapToSet{ $0.string(forColumnIndex: 0) }
DispatchQueue.main.async {
completion(.success(articleIDs))
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCalls(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
func fetchArticles(_ webFeedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
if webFeedIDs.isEmpty {
return Set<Article>()
}
let parameters = webFeedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
let whereClause = "feedID in \(placeholders)"
2020-04-19 05:18:32 +02:00
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}
func fetchUnreadArticles(_ webFeedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
if webFeedIDs.isEmpty {
return Set<Article>()
}
let parameters = webFeedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
let whereClause = "feedID in \(placeholders) and read=0"
2020-04-19 05:18:32 +02:00
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}
func fetchArticlesForFeedID(_ webFeedID: String, withLimits: Bool, _ database: FMDatabase) -> Set<Article> {
2020-04-19 05:18:32 +02:00
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [webFeedID as AnyObject])
}
func fetchArticles(articleIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
if articleIDs.isEmpty {
return Set<Article>()
}
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let whereClause = "articleID in \(placeholders)"
2020-04-19 05:18:32 +02:00
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}
func fetchArticlesSince(_ webFeedIDs: Set<String>, _ cutoffDate: Date, _ database: FMDatabase) -> Set<Article> {
// 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 webFeedIDs.isEmpty {
return Set<Article>()
}
let parameters = webFeedIDs.map { $0 as AnyObject } + [cutoffDate as AnyObject, cutoffDate as AnyObject]
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
let whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?))"
2020-04-19 05:18:32 +02:00
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}
func fetchStarredArticles(_ webFeedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred=1;
if webFeedIDs.isEmpty {
return Set<Article>()
}
let parameters = webFeedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
let whereClause = "feedID in \(placeholders) and starred=1"
2020-04-19 05:18:32 +02:00
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}
func fetchArticlesMatching(_ searchString: String, _ webFeedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
let articles = fetchArticlesMatching(searchString, database)
// TODO: include the feedIDs in the SQL rather than filtering here.
return articles.filter{ webFeedIDs.contains($0.webFeedID) }
}
func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
let articles = fetchArticlesMatching(searchString, database)
// TODO: include the articleIDs in the SQL rather than filtering here.
return articles.filter{ articleIDs.contains($0.articleID) }
}
// MARK: - Saving Parsed Items
2017-09-11 15:46:32 +02:00
func callUpdateArticlesCompletionBlock(_ newArticles: Set<Article>?, _ updatedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesCompletionBlock) {
let newAndUpdatedArticles = NewAndUpdatedArticles(newArticles: newArticles, updatedArticles: updatedArticles)
2017-09-11 15:46:32 +02:00
DispatchQueue.main.async {
completion(.success(newAndUpdatedArticles))
2017-09-11 15:46:32 +02:00
}
}
// MARK: - Saving New Articles
2017-09-11 15:46:32 +02:00
func findNewArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article]) -> Set<Article>? {
let newArticles = Set(incomingArticles.filter { fetchedArticlesDictionary[$0.articleID] == nil })
return newArticles.isEmpty ? nil : newArticles
}
func findAndSaveNewArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set<Article>? { //5
guard let newArticles = findNewArticles(incomingArticles, fetchedArticlesDictionary) else {
return nil
}
self.saveNewArticles(newArticles, database)
return newArticles
}
func saveNewArticles(_ articles: Set<Article>, _ database: FMDatabase) {
saveRelatedObjectsForNewArticles(articles, database)
if let databaseDictionaries = articles.databaseDictionaries() {
insertRows(databaseDictionaries, insertType: .orReplace, in: database)
}
}
func saveRelatedObjectsForNewArticles(_ articles: Set<Article>, _ database: FMDatabase) {
let databaseObjects = articles.databaseObjects()
authorsLookupTable.saveRelatedObjects(for: databaseObjects, in: database)
}
// MARK: - Updating Existing Articles
func articlesWithRelatedObjectChanges<T>(_ comparisonKeyPath: KeyPath<Article, Set<T>?>, _ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article]) -> Set<Article> {
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<T>(_ comparisonKeyPath: KeyPath<Article, Set<T>?>, _ updatedArticles: Set<Article>, _ 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<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
updateRelatedObjects(\Article.authors, updatedArticles, fetchedArticles, authorsLookupTable, database)
}
2017-09-11 15:46:32 +02:00
func findUpdatedArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article]) -> Set<Article>? {
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<Article>, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set<Article>? { //6
guard let updatedArticles = findUpdatedArticles(incomingArticles, fetchedArticlesDictionary) else {
return nil
}
saveUpdatedArticles(Set(updatedArticles), fetchedArticlesDictionary, database)
return updatedArticles
}
func saveUpdatedArticles(_ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database)
for updatedArticle in updatedArticles {
2017-09-09 21:24:30 +02:00
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.
2017-09-14 06:41:01 +02:00
guard let fetchedArticle = fetchedArticles[updatedArticle.articleID] else {
assertionFailure("Expected to find matching fetched article.");
saveNewArticles(Set([updatedArticle]), database)
return
}
2017-09-14 06:41:01 +02:00
guard let changesDictionary = updatedArticle.changesFrom(fetchedArticle), changesDictionary.count > 0 else {
// Not unexpected. There may be no changes.
return
}
updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, matches: updatedArticle.articleID, database: database)
}
func addArticlesToCache(_ articles: Set<Article>?) {
guard let articles = articles else {
return
}
for article in articles {
articlesCache[article.articleID] = article
}
}
func removeArticleIDsFromCache(_ articleIDs: Set<String>) {
for articleID in articleIDs {
articlesCache[articleID] = nil
}
}
func articleIsIgnorable(_ article: Article) -> Bool {
if article.status.starred || !article.status.read {
return false
}
return article.status.dateArrived < articleCutoffDate
}
2017-09-22 03:14:37 +02:00
func filterIncomingArticles(_ articles: Set<Article>) -> Set<Article> {
2017-09-11 15:46:32 +02:00
// Drop Articles that we can ignore.
precondition(retentionStyle == .syncSystem)
return Set(articles.filter{ !articleIsIgnorable($0) })
}
func removeArticles(_ articleIDs: Set<String>, _ database: FMDatabase) {
deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), in: database)
}
2017-08-27 00:37:15 +02:00
}
private extension Set where Element == ParsedItem {
func articleIDs() -> Set<String> {
return Set<String>(map { $0.articleID })
}
}