NetNewsWire/Frameworks/Database/Database.swift

489 lines
14 KiB
Swift

//
// Database.swift
// Evergreen
//
// Created by Brent Simmons on 7/20/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSParser
import Data
private let sqlLogging = false
private func logSQL(_ sql: String) {
if sqlLogging {
print("SQL: \(sql)")
}
}
typealias ArticleResultBlock = (Set<Article>) -> Void
final class Database {
fileprivate let queue: RSDatabaseQueue
private let databaseFile: String
fileprivate let statusesManager: StatusesManager
fileprivate let articleCache = ArticlesManager()
fileprivate var articleArrivalCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 3 * 31)!
fileprivate let minimumNumberOfArticles = 10
fileprivate weak var delegate: AccountDelegate?
init(databaseFile: String, delegate: AccountDelegate) {
self.delegate = delegate
self.databaseFile = databaseFile
self.queue = RSDatabaseQueue(filepath: databaseFile, excludeFromBackup: false)
self.statusesManager = StatusesManager(queue: self.queue)
let createStatementsPath = Bundle(for: type(of: self)).path(forResource: "CreateStatements", ofType: "sql")!
let createStatements = try! NSString(contentsOfFile: createStatementsPath, encoding: String.Encoding.utf8.rawValue)
queue.createTables(usingStatements: createStatements as String)
queue.vacuumIfNeeded()
}
// MARK: Fetching Articles
func fetchArticlesForFeed(_ feed: Feed) -> Set<Article> {
var fetchedArticles = Set<Article>()
let feedID = feed.feedID
queue.fetchSync { (database: FMDatabase!) -> Void in
fetchedArticles = self.fetchArticlesForFeedID(feedID, database: database)
}
let articles = articleCache.uniquedArticles(fetchedArticles, statusesManager: statusesManager)
return filteredArticles(articles, feedCounts: [feed.feedID: fetchedArticles.count])
}
func fetchArticlesForFeedAsync(_ feed: Feed, _ resultBlock: @escaping ArticleResultBlock) {
let feedID = feed.feedID
queue.fetch { (database: FMDatabase!) -> Void in
let fetchedArticles = self.fetchArticlesForFeedID(feedID, database: database)
DispatchQueue.main.async() { () -> Void in
let articles = self.articleCache.uniquedArticles(fetchedArticles, statusesManager: self.statusesManager)
let filteredArticles = self.filteredArticles(articles, feedCounts: [feed.feedID: fetchedArticles.count])
resultBlock(filteredArticles)
}
}
}
func feedIDCountDictionariesWithResultSet(_ resultSet: FMResultSet) -> [String: Int] {
var counts = [String: Int]()
while (resultSet.next()) {
if let oneFeedID = resultSet.string(forColumnIndex: 0) {
let count = resultSet.int(forColumnIndex: 1)
counts[oneFeedID] = Int(count)
}
}
return counts
}
func countsForAllFeeds(_ database: FMDatabase) -> [String: Int] {
let sql = "select distinct feedID, count(*) as count from articles group by feedID;"
if let resultSet = database.executeQuery(sql, withArgumentsIn: []) {
return feedIDCountDictionariesWithResultSet(resultSet)
}
return [String: Int]()
}
func countsForFeedIDs(_ feedIDs: [String], _ database: FMDatabase) -> [String: Int] {
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let sql = "select distinct feedID, count(*) from articles where feedID in \(placeholders) group by feedID;"
logSQL(sql)
if let resultSet = database.executeQuery(sql, withArgumentsIn: feedIDs) {
return feedIDCountDictionariesWithResultSet(resultSet)
}
return [String: Int]()
}
func fetchUnreadArticlesForFolder(_ folder: Folder) -> Set<Article> {
return fetchUnreadArticlesForFeedIDs(folder.flattenedFeedIDs())
}
func fetchUnreadArticlesForFeedIDs(_ feedIDs: [String]) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
var fetchedArticles = Set<Article>()
var counts = [String: Int]()
queue.fetchSync { (database: FMDatabase!) -> Void in
counts = self.countsForFeedIDs(feedIDs, database)
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read = 0
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let sql = "select * from articles natural join statuses where feedID in \(placeholders) and read=0;"
logSQL(sql)
if let resultSet = database.executeQuery(sql, withArgumentsIn: feedIDs) {
fetchedArticles = self.articlesWithResultSet(resultSet)
}
}
let articles = articleCache.uniquedArticles(fetchedArticles, statusesManager: statusesManager)
return filteredArticles(articles, feedCounts: counts)
}
typealias UnreadCountCompletionBlock = ([String: Int]) -> Void //feedID: unreadCount
func updateUnreadCounts(for feedIDs: Set<String>, completion: @escaping UnreadCountCompletionBlock) {
queue.fetch { (database: FMDatabase!) -> Void in
var unreadCounts = [String: Int]()
for oneFeedID in feedIDs {
unreadCounts[oneFeedID] = self.unreadCount(oneFeedID, database)
}
DispatchQueue.main.async() {
completion(unreadCounts)
}
}
}
// MARK: Updating Articles
func updateFeedWithParsedFeed(_ feed: Feed, parsedFeed: ParsedFeed, completionHandler: @escaping RSVoidCompletionBlock) {
if parsedFeed.items.isEmpty {
completionHandler()
return
}
let parsedArticlesDictionary = self.articlesDictionary(parsedFeed.items as NSSet) as! [String: ParsedItem]
fetchArticlesForFeedAsync(feed) { (articles) -> Void in
let articlesDictionary = self.articlesDictionary(articles as NSSet) as! [String: Article]
self.updateArticles(articlesDictionary, parsedArticles: parsedArticlesDictionary, feed: feed, completionHandler: completionHandler)
}
}
// MARK: Status
func markArticles(_ articles: NSSet, statusKey: ArticleStatusKey, flag: Bool) {
statusesManager.markArticles(articles as! Set<Article>, statusKey: statusKey, flag: flag)
}
}
// MARK: Private
private extension Database {
// MARK: Saving Articles
func saveUpdatedAndNewArticles(_ articleChanges: Set<NSDictionary>, newArticles: Set<Article>) {
if articleChanges.isEmpty && newArticles.isEmpty {
return
}
statusesManager.assertNoMissingStatuses(newArticles)
articleCache.cacheArticles(newArticles)
let newArticleDictionaries = newArticles.map { (oneArticle) in
return oneArticle.databaseDictionary()
}
queue.update { (database: FMDatabase!) -> Void in
if !articleChanges.isEmpty {
for oneDictionary in articleChanges {
let oneArticleDictionary = oneDictionary.mutableCopy() as! NSMutableDictionary
let articleID = oneArticleDictionary[DatabaseKey.articleID]!
oneArticleDictionary.removeObject(forKey: articleIDKey)
let _ = database.rs_updateRows(with: oneArticleDictionary as [NSObject: AnyObject], whereKey: articleIDKey, equalsValue: articleID, tableName: articlesTableName)
}
}
if !newArticleDictionaries.isEmpty {
for oneNewArticleDictionary in newArticleDictionaries {
let _ = database.rs_insertRow(with: oneNewArticleDictionary as [NSObject: AnyObject], insertType: RSDatabaseInsertOrReplace, tableName: articlesTableName)
}
}
}
}
// MARK: Updating Articles
func updateArticles(_ articles: [String: Article], parsedArticles: [String: ParsedItem], feed: Feed, completionHandler: @escaping RSVoidCompletionBlock) {
statusesManager.ensureStatusesForParsedArticles(Set(parsedArticles.values)) {
let articleChanges = self.updateExistingArticles(articles, parsedArticles)
let newArticles = self.createNewArticles(articles, parsedArticles: parsedArticles, feedID: feed.feedID)
self.saveUpdatedAndNewArticles(articleChanges, newArticles: newArticles)
completionHandler()
}
}
func articlesDictionary(_ articles: NSSet) -> [String: AnyObject] {
var d = [String: AnyObject]()
for oneArticle in articles {
let oneArticleID = (oneArticle as AnyObject).value(forKey: articleIDKey) as! String
d[oneArticleID] = oneArticle as AnyObject
}
return d
}
func updateExistingArticles(_ articles: [String: Article], _ parsedArticles: [String: ParsedItem]) -> Set<NSDictionary> {
var articleChanges = Set<NSDictionary>()
for oneArticle in articles.values {
if let oneParsedArticle = parsedArticles[oneArticle.articleID] {
if let oneArticleChanges = oneArticle.updateWithParsedArticle(oneParsedArticle) {
articleChanges.insert(oneArticleChanges)
}
}
}
return articleChanges
}
// MARK: Creating Articles
func createNewArticlesWithParsedArticles(_ parsedArticles: Set<ParsedItem>, feedID: String) -> Set<Article> {
return Set(parsedArticles.map { Article(account: account, feedID: feedID, parsedArticle: $0) })
}
func articlesWithParsedArticles(_ parsedArticles: Set<ParsedItem>, feedID: String) -> Set<Article> {
var localArticles = Set<Article>()
for oneParsedArticle in parsedArticles {
let oneLocalArticle = Article(account: self.account, feedID: feedID, parsedArticle: oneParsedArticle)
localArticles.insert(oneLocalArticle)
}
return localArticles
}
func createNewArticles(_ existingArticles: [String: Article], parsedArticles: [String: ParsedItem], feedID: String) -> Set<Article> {
let newParsedArticles = parsedArticlesMinusExistingArticles(parsedArticles, existingArticles: existingArticles)
let newArticles = createNewArticlesWithParsedArticles(newParsedArticles, feedID: feedID)
statusesManager.attachCachedUniqueStatuses(newArticles)
return newArticles
}
func parsedArticlesMinusExistingArticles(_ parsedArticles: [String: ParsedItem], existingArticles: [String: Article]) -> Set<ParsedItem> {
var result = Set<ParsedItem>()
for oneParsedArticle in parsedArticles.values {
if let _ = existingArticles[oneParsedArticle.databaseID] {
continue
}
result.insert(oneParsedArticle)
}
return result
}
// MARK: Fetching Articles
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]?) -> Set<Article> {
let sql = "select * from articles natural join statuses where \(whereClause);"
logSQL(sql)
if let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) {
return articlesWithResultSet(resultSet)
}
return Set<Article>()
}
func articlesWithResultSet(_ resultSet: FMResultSet) -> Set<Article> {
var fetchedArticles = Set<Article>()
while (resultSet.next()) {
if let oneArticle = Article(account: self.account, row: resultSet) {
oneArticle.status = ArticleStatus(row: resultSet)
fetchedArticles.insert(oneArticle)
}
}
return fetchedArticles
}
func fetchArticlesForFeedID(_ feedID: String, database: FMDatabase) -> Set<Article> {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject])
}
// MARK: Unread counts
func numberWithCountResultSet(_ resultSet: FMResultSet?) -> Int {
guard let resultSet = resultSet else {
return 0
}
if resultSet.next() {
return Int(resultSet.int(forColumnIndex: 0))
}
return 0
}
func numberWithSQLAndParameters(_ sql: String, parameters: [Any], _ database: FMDatabase) -> Int {
let resultSet = database.executeQuery(sql, withArgumentsIn: parameters)
return numberWithCountResultSet(resultSet)
}
func numberOfArticles(_ feedID: String, _ database: FMDatabase) -> Int {
let sql = "select count(*) from articles where feedID = ?;"
logSQL(sql)
return numberWithSQLAndParameters(sql, parameters: [feedID], database)
}
func unreadCount(_ feedID: String, _ database: FMDatabase) -> Int {
let totalNumberOfArticles = numberOfArticles(feedID, database)
if totalNumberOfArticles <= minimumNumberOfArticles {
return unreadCountIgnoringCutoffDate(feedID, database)
}
return unreadCountRespectingCutoffDate(feedID, database)
}
func unreadCountIgnoringCutoffDate(_ feedID: String, _ database: FMDatabase) -> Int {
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0;"
logSQL(sql)
return numberWithSQLAndParameters(sql, parameters: [feedID], database)
}
func unreadCountRespectingCutoffDate(_ feedID: String, _ database: FMDatabase) -> Int {
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or dateArrived>?);"
logSQL(sql)
return numberWithSQLAndParameters(sql, parameters: [feedID, articleArrivalCutoffDate], database)
}
// MARK: Filtering out old articles
func articleIsOlderThanCutoffDate(_ article: Article) -> Bool {
if let dateArrived = article.status?.dateArrived {
return dateArrived < articleArrivalCutoffDate
}
return false
}
func articleShouldBeSavedForever(_ article: Article) -> Bool {
return article.status.starred
}
func articleShouldAppearToUser(_ article: Article, _ numberOfArticlesInFeed: Int) -> Bool {
if numberOfArticlesInFeed <= minimumNumberOfArticles {
return true
}
return articleShouldBeSavedForever(article) || !articleIsOlderThanCutoffDate(article)
}
private static let minimumNumberOfArticlesInFeed = 10
func filteredArticles(_ articles: Set<Article>, feedCounts: [String: Int]) -> Set<Article> {
var articlesSet = Set<Article>()
for oneArticle in articles {
if let feedCount = feedCounts[oneArticle.feedID], articleShouldAppearToUser(oneArticle, feedCount) {
articlesSet.insert(oneArticle)
}
}
return articlesSet
}
typealias FeedCountCallback = (Int) -> Void
func feedIDsFromArticles(_ articles: Set<Article>) -> Set<String> {
return Set(articles.map { $0.feedID })
}
func deletePossibleOldArticles(_ articles: Set<Article>) {
let feedIDs = feedIDsFromArticles(articles)
if feedIDs.isEmpty {
return
}
}
func numberOfArticlesInFeedID(_ feedID: String, callback: @escaping FeedCountCallback) {
queue.fetch { (database: FMDatabase!) -> Void in
let sql = "select count(*) from articles where feedID = ?;"
logSQL(sql)
var numberOfArticles = -1
if let resultSet = database.executeQuery(sql, withArgumentsIn: [feedID]) {
while (resultSet.next()) {
numberOfArticles = resultSet.long(forColumnIndex: 0)
break
}
}
DispatchQueue.main.async() {
callback(numberOfArticles)
}
}
}
}