diff --git a/Frameworks/Data/Article.swift b/Frameworks/Data/Article.swift index ed5dff3f0..d8ae01422 100644 --- a/Frameworks/Data/Article.swift +++ b/Frameworks/Data/Article.swift @@ -12,7 +12,7 @@ public final class Article: Hashable { weak var account: Account? - public let databaseID: String + public let articleID: String public let feedID: String // Likely a URL, but not necessarily public let uniqueID: String // Unique per feed (RSS guid, for example) public var title: String? diff --git a/Frameworks/Data/Author.swift b/Frameworks/Data/Author.swift index 7f354b06f..658a09922 100644 --- a/Frameworks/Data/Author.swift +++ b/Frameworks/Data/Author.swift @@ -18,7 +18,7 @@ public struct Author: Hashable { public let emailAddress: String? public let hashValue: Int - public init?(databaseID: String?, name: String?, url: String?, avatarURL: String?, emailAddress: String?) { + public init?(authorID: String?, name: String?, url: String?, avatarURL: String?, emailAddress: String?) { if name == nil && url == nil && emailAddress == nil { return nil @@ -34,11 +34,11 @@ public struct Author: Hashable { s += emailAddress ?? "" self.hashValue = s.hashValue - if let databaseID = databaseID { - self.databaseID = databaseID + if let authorID = authorID { + self.authorID = authorID } else { - self.databaseID = databaseIDWithString(s) + self.authorID = databaseIDWithString(s) } } diff --git a/Frameworks/Database/AttachmentsTable.swift b/Frameworks/Database/AttachmentsTable.swift index f7a39b4d3..08149d152 100644 --- a/Frameworks/Database/AttachmentsTable.swift +++ b/Frameworks/Database/AttachmentsTable.swift @@ -10,244 +10,34 @@ import Foundation import RSDatabase import Data -// Attachments are treated as atomic. -// If an attachment in a feed changes any of its values, -// it’s actually saved as a new attachment and the old one is deleted. -// (This is rare compared to an article in a feed changing its text, for instance.) -// -// Article -> Attachment is one-to-many. -// Attachment -> Article is one-to-one. -// A given attachment can be owned by one and only one Article. -// An attachment with the same exact values (except for articleID) might exist. -// (That would be quite rare. But it’s by design.) -// -// All the functions here must be called only from inside the database serial queue. -// (The serial queue makes locking unnecessary.) -// -// Attachments are cached, for the lifetime of the app run, once fetched or saved. -// Because: -// * They don’t take up much space. -// * It seriously cuts down on the number of database reads and writes. -// -// CREATE TABLE if not EXISTS attachments(databaseID TEXT NOT NULL PRIMARY KEY, articleID TEXT NOT NULL, url TEXT NOT NULL, mimeType TEXT, title TEXT, sizeInBytes INTEGER, durationInSeconds INTEGER); - -final class AttachmentsTable: DatabaseTable { +struct AttachmentsTable: DatabaseTable { let name: String - private let cacheByArticleID = ObjectCache(keyPathForID: \Attachment.articleID) - private let cacheByDatabaseID = ObjectCache(keyPathForID: \Attachment.databaseID) + let databaseIDKey = DatabaseKey.attachmentID + private let cache = DatabaseObjectCache() init(name: String) { self.name = name } - - private var cachedAttachments = [String: Attachment]() // Attachment.databaseID key - private var cachedAttachmentsByArticle = [String: Set]() // Article.databaseID key - private var articlesWithNoAttachments = Set() // Article.databaseID - - func fetchAttachmentsForArticles(_ articles: Set
, database: FMDatabase) { - - } - - func saveAttachmentsForArticles(_ articles: Set
, database: FMDatabase) { - - // This is complex and overly long because it’s optimized for fewest database hits. - - var articlesWithPossiblyAllAttachmentsDeleted = Set
() - var attachmentsToSave = Set() - var attachmentsToDelete = Set() - - func reconcileAttachments(incomingAttachments: Set, existingAttachments: Set) { - - for oneIncomingAttachment in incomingAttachments { // Add some. - if !existingAttachments.contains(oneIncomingAttachment) { - attachmentsToSave.insert(oneIncomingAttachment) - } - } - for oneExistingAttachment in existingAttachments { // Delete some. - if !incomingAttachments.contains(oneExistingAttachment) { - attachmentsToDelete.insert(oneExistingAttachment) - } - } - } - - for oneArticle in articles { - - if let oneAttachments = oneArticle.attachments, !oneAttachments.isEmpty { - - // If it matches the cache, then do nothing. - if let oneCachedAttachments = cachedAttachmentsByArticle(oneArticle.databaseID) { - if oneCachedAttachments == oneAttachments { - continue - } - - // There is a cache and it doesn’t match. - reconcileAttachments(incomingAttachments: oneAttachments, existingAttachments: oneCachedAttachments) - } - - else { // no cache, but article has attachments - - if let resultSet = table.selectRowsWhere(key: DatabaseKey.articleID, equals: oneArticle.databaseID, in: database) { - let existingAttachments = attachmentsWithResultSet(resultSet) - if existingAttachments != oneAttachments { // Don’t match? - reconcileAttachments(incomingAttachments: oneAttachments, existingAttachments: existingAttachments) - } - } - else { - // Nothing in database. Just save. - attachmentsToSave.formUnion(oneAttachments) - } - } - - cacheAttachmentsForArticle(oneArticle) - } - else { - // No attachments: might need to delete them all from database - if !articlesWithNoAttachments.contains(oneArticle.databaseID) { - articlesWithPossiblyAllAttachmentsDeleted.insert(oneArticle) - uncacheAttachmentsForArticle(oneArticle) - } - } - } - - deleteAttachmentsForArticles(articlesWithPossiblyAllAttachmentsDeleted, database) - deleteAttachments(attachmentsToDelete, database) - saveAttachments(attachmentsToSave, database) + + // MARK: DatabaseTable Methods + + func objectWithRow(_ row: FMResultSet) -> DatabaseObject? { + + return attachmentWithRow(row) as DatabaseObject } } private extension AttachmentsTable { - func deleteAttachmentsForArticles(_ articles: Set
, _ database: FMDatabase) { - - if articles.isEmpty { - return - } - articles.forEach { uncacheAttachmentsForArticle($0) } - - let articleIDs = articles.map { $0.databaseID } - deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: articlesIDs, in: database) - } - - func deleteAttachments(_ attachments: Set, _ database: FMDatabase) { - - if attachments.isEmpty { - return - } - let databaseIDs = attachments.map { $0.databaseID } - deleteRowsWhere(key: DatabaseKey.databaseID, equalsAnyValue: databaseIDs, in: database) - } - - func saveAttachments(_ attachments: Set, _ database: FMDatabase) { - - if attachments.isEmpty { - return - } - - - } - - func addCachedAttachmentsToArticle(_ article: Article) { - - if let _ = article.attachments { - return - } - - if let attachments = cachedAttachmentsByArticle[article.databaseID] { - article.attachments = attachments - } - } - - func fetchAttachmentsForArticle(_ article: Article, database: FMDatabase) { - - if articlesWithNoAttachments.contains(article.databaseID) { - return - } - addCachedAttachmentsToArticle(article) - if let _ = article.attachments { - return - } - - - - - } - - func uncacheAttachmentsForArticle(_ article: Article) { - - assert(article.attachments == nil || article.attachments.isEmpty) - articlesWithNoAttachments.insert(article.databaseID) - cachedAttachmentsByArticle[article.databaseID] = nil - } - - func cacheAttachmentsForArticle(_ article: Article) { - - guard let attachments = article.attachments, !attachments.isEmpty else { - assertionFailure("article.attachments must not be empty") - } - - articlesWithNoAttachments.remove(article.databaseID) - cachedAttachmentsByArticle[article.databaseID] = attachments - cacheAttachment(attachments) - } - - func cachedAttachmentForDatabaseID(_ databaseID: String) -> Attachment? { - - return cachedAttachments[databaseID] - } - - func cacheAttachments(_ attachments: Set) { - - attachments.forEach { cacheAttachment($) } - } - - func cacheAttachment(_ attachment: Attachment) { - - cachedAttachments[attachment.databaseID] = attachment - } - - func uncacheAttachments(_ attachments: Set) { - - attachments.removeO - attachments.forEach { uncacheAttachment($0) } - } - - func uncacheAttachment(_ attachment: Attachment) { - - cachedAttachments[attachment.databaseID] = nil - } - - func saveAttachmentsForArticle(_ article: Article, database: FMDatabase) { - - if let attachments = article.attachments { - - } - else { - if articlesWithNoAttachments.contains(article.databaseID) { - return - } - - articlesWithNoAttachments.insert(article.databaseID) - cachedAttachmentsByArticle[article.databaseID] = nil - - deleteAttachmentsForArticleID(article.databaseID) - } - - } - - func attachmentsWithResultSet(_ resultSet: FMResultSet) -> Set { - - return resultSet.mapToSet(attachmentWithRow) - } - func attachmentWithRow(_ row: FMResultSet) -> Attachment? { - let databaseID = row.string(forColumn: DatabaseKey.databaseID) - if let cachedAttachment = cachedAttachmentForDatabaseID(databaseID) { + let attachmentID = row.string(forColumn: DatabaseKey.attachmentID) + if let cachedAttachment = cache(attachmentID) { return cachedAttachment } - return Attachment(databaseID: databaseID, row: row) + return Attachment(attachmentID: attachmentID, row: row) } } diff --git a/Frameworks/Database/AuthorsTable.swift b/Frameworks/Database/AuthorsTable.swift index cfa4a6b10..ffac66c0d 100644 --- a/Frameworks/Database/AuthorsTable.swift +++ b/Frameworks/Database/AuthorsTable.swift @@ -13,14 +13,15 @@ import Data // article->authors is a many-to-many relationship. // There’s a lookup table relating authorID and articleID. // -// CREATE TABLE if not EXISTS authors (databaseID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT); +// CREATE TABLE if not EXISTS authors (authorID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT); // CREATE TABLE if not EXISTS authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID)); struct AuthorsTable: DatabaseTable { let name: String - private let cache = ObjectCache(keyPathForID: \Author.databaseID) + let databaseIDKey = DatabaseKey.authorID + private let cache = DatabaseObjectCache() init(name: String) { @@ -29,98 +30,29 @@ struct AuthorsTable: DatabaseTable { // MARK: DatabaseTable Methods - func fetchObjectsWithIDs(_ databaseIDs: Set, in database: FMDatabase) -> [DatabaseObject] { - + func objectWithRow(_ row: FMResultSet) -> DatabaseObject? { + return authorWithRow(row) as DatabaseObject } - - func save(_ objects: [DatabaseObject], in database: FMDatabase) { - <#code#> - } - } private extension AuthorsTable { - func attachCachedAuthors(_ articles: Set
) { - - for article in articles { - if let authors = articleIDToAuthorsCache[article.databaseID] { - article.authors = Array(authors) - } - } - } - - func articlesNeedingAuthors(_ articles: Set
) -> Set
{ - - // If article.authors is nil and article is not known to have zero authors, include it in the set. - let articlesWithNoAuthors = articles.withNilProperty(\Article.authors) - return Set(articlesWithNoAuthors.filter { !articleIDsWithNoAuthors.contains($0.databaseID) }) - } - - func fetchAuthorsForArticleIDs(_ articleIDs: Set, _ database: FMDatabase) -> [String: Set]? { - - let lookupTableDictionary = authorsLookupTable.fetchLookupTableDictionary(articleIDs, database) - let authorIDs = authorsLookupTable.primaryIDsInLookupTableDictionary(lookupTableDictionary) - if authorIDs.isEmpty { - return nil - } - - guard let resultSet = selectRowsWhere(key: DatabaseKey.databaseID, inValues: Array(authorIDs), in: database) else { - return nil - } - - let authors = authorsWithResultSet(resultSet) - if authors.isEmpty { - return nil - } - - return authorTableWithLookupValues(lookupValues) - } - - func authorTableWithLookupValues(_ lookupValues: Set) -> [String: Set] { - - var authorTable = [String: Set]() - - for lookupValue in lookupValues { - - let authorID = lookupValue.primaryID - guard let author = cache[authorID] else { - continue - } - - let articleID = lookupValue.foreignID - if authorTable[articleID] == nil { - authorTable[articleID] = Set([author]) - } - else { - authorTable[articleID]!.insert(author) - } - } - - return authorTable - } - - func authorsWithResultSet(_ resultSet: FMResultSet) -> Set { - - return resultSet.mapToSet(authorWithRow) - } - func authorWithRow(_ row: FMResultSet) -> Author? { - guard let databaseID = row.string(forColumn: DatabaseKey.databaseID) else { + guard let authorID = row.string(forColumn: DatabaseKey.authorID) else { return nil } - if let cachedAuthor = cache[databaseID] { + if let cachedAuthor = cache[authorID] { return cachedAuthor } - guard let author = Author(databaseID: databaseID, row: row) else { + guard let author = Author(authorID: authorID, row: row) else { return nil } - cache[databaseID] = author + cache[authorID] = author return author } } diff --git a/Frameworks/Database/Constants.swift b/Frameworks/Database/Constants.swift index 1a643b679..22fa5b7e5 100644 --- a/Frameworks/Database/Constants.swift +++ b/Frameworks/Database/Constants.swift @@ -12,10 +12,11 @@ public struct DatabaseTableName { static let articles = "articles" static let authors = "authors" - static let authorsLookup = "authorLookup" + static let authorsLookup = "authorsLookup" static let statuses = "statuses" static let tags = "tags" static let attachments = "attachments" + static let attachmentsLookup = "attachmentsLookup" } public struct DatabaseKey { @@ -49,6 +50,7 @@ public struct DatabaseKey { static let dateArrived = "dateArrived" // Attachment + static let attachmentID = "attachmentID" static let mimeType = "mimeType" static let sizeInBytes = "sizeInBytes" static let durationInSeconds = "durationInSeconds" diff --git a/Frameworks/Database/CreateStatements.sql b/Frameworks/Database/CreateStatements.sql index 0e0d0ece3..1d6be490f 100644 --- a/Frameworks/Database/CreateStatements.sql +++ b/Frameworks/Database/CreateStatements.sql @@ -2,16 +2,15 @@ CREATE TABLE if not EXISTS articles (articleID TEXT NOT NULL PRIMARY KEY, feedID CREATE TABLE if not EXISTS statuses (articleID TEXT NOT NULL PRIMARY KEY, read BOOL NOT NULL DEFAULT 0, starred BOOL NOT NULL DEFAULT 0, userDeleted BOOL NOT NULL DEFAULT 0, dateArrived DATE NOT NULL DEFAULT 0, accountInfo BLOB); -CREATE TABLE if not EXISTS authors (databaseID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT); -CREATE TABLE if not EXISTS authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID)); +CREATE TABLE if not EXISTS authors (authorID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT); +CREATE TABLE if not EXISTS authorsLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID)); CREATE TABLE if not EXISTS tags(tagName TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(tagName, articleID)); -CREATE TABLE if not EXISTS attachments(databaseID TEXT NOT NULL PRIMARY KEY, articleID TEXT NOT NULL, url TEXT NOT NULL, mimeType TEXT, title TEXT, sizeInBytes INTEGER, durationInSeconds INTEGER); +CREATE TABLE if not EXISTS attachments(attachmentID TEXT NOT NULL PRIMARY KEY, url TEXT NOT NULL, mimeType TEXT, title TEXT, sizeInBytes INTEGER, durationInSeconds INTEGER); +CREATE TABLE if not EXISTS attachmentsLookup(attachmentID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(attachmentID, articleID)); CREATE INDEX if not EXISTS articles_feedID_index on articles (feedID); CREATE INDEX if not EXISTS tags_tagName_index on tags (tagName COLLATE NOCASE); -CREATE INDEX if not EXISTS attachments_articleID_index on attachments (articleID); - diff --git a/Frameworks/Database/Database.swift b/Frameworks/Database/Database.swift index 30e396c6b..fb77c17dd 100644 --- a/Frameworks/Database/Database.swift +++ b/Frameworks/Database/Database.swift @@ -27,10 +27,9 @@ final class Database { private let queue: RSDatabaseQueue private let databaseFile: String private let articlesTable: ArticlesTable - private let authorsTable: AuthorsTable - private let authorsLookupTable: DatabaseLookupTable - private let attachmentsTable: AttachmentsTable private let statusesTable: StatusesTable + private let authorsLookupTable: DatabaseLookupTable + private let attachmentsLookupTable: DatabaseLookupTable private let tagsLookupTable: DatabaseLookupTable private var articleArrivalCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 3 * 31)! private let minimumNumberOfArticles = 10 @@ -43,15 +42,17 @@ final class Database { self.queue = RSDatabaseQueue(filepath: databaseFile, excludeFromBackup: false) self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, queue: queue) - self.attachmentsTable = AttachmentsTable(name: DatabaseTableName.attachments) self.statusesTable = StatusesTable(name: DatabaseTableName.statuses) - self.authorsTable = AuthorsTable(name: DatabaseTableName.authors) + let authorsTable = AuthorsTable(name: DatabaseTableName.authors) self.authorsLookupTable = DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors) let tagsTable = TagsTable(name: DatabaseTableName.tags) self.tagsLookupTable = DatabaseLookupTable(name: DatabaseTableName.tags, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.tagName, relatedTable: tagsTable, relationshipName: RelationshipName.tags) + let attachmentsTable = AttachmentsTable(name: DatabaseTableName.attachments) + self.attachmentsLookupTable = DatabaseLookupTable(name: DatabaseTableName.tags, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.tagName, relatedTable: tagsTable, relationshipName: RelationshipName.tags) + 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) diff --git a/Frameworks/Database/Extensions/Article+Database.swift b/Frameworks/Database/Extensions/Article+Database.swift index da741dfe8..00c87571c 100644 --- a/Frameworks/Database/Extensions/Article+Database.swift +++ b/Frameworks/Database/Extensions/Article+Database.swift @@ -49,6 +49,15 @@ extension Article { } } +extension Article: DatabaseObject { + + var databaseID: String { + get { + return articleID + } + } +} + extension Set where Element == Article { func withNilProperty(_ keyPath: KeyPath) -> Set
{ diff --git a/Frameworks/Database/Extensions/Author+Database.swift b/Frameworks/Database/Extensions/Author+Database.swift index 86d2db4d5..10d794800 100644 --- a/Frameworks/Database/Extensions/Author+Database.swift +++ b/Frameworks/Database/Extensions/Author+Database.swift @@ -23,7 +23,7 @@ extension Author { } } -extension Author: DatabaseObject { +public extension Author: DatabaseObject { var databaseID: String { get { diff --git a/Frameworks/Database/StatusesTable.swift b/Frameworks/Database/StatusesTable.swift index 5055ad852..1c0aa970a 100644 --- a/Frameworks/Database/StatusesTable.swift +++ b/Frameworks/Database/StatusesTable.swift @@ -18,7 +18,7 @@ import Data final class StatusesTable: DatabaseTable { let name: String - private let cache = ObjectCache(keyPathForID: \ArticleStatus.articleID) + private let cache = DatabaseObjectCache() init(name: String) { diff --git a/Frameworks/Database/TagsTable.swift b/Frameworks/Database/TagsTable.swift index 8ddcd636a..9e9a4f073 100644 --- a/Frameworks/Database/TagsTable.swift +++ b/Frameworks/Database/TagsTable.swift @@ -20,7 +20,7 @@ import Data struct TagsTable: DatabaseTable { let name: String - + let databaseIDKey = DatabaseKey.tagName init(name: String) { self.name = name @@ -34,6 +34,11 @@ struct TagsTable: DatabaseTable { return databaseIDs.map{ $0 as DatabaseObject } } + func objectWithRow(_ row: FMResultSet) -> DatabaseObject? { + + return nil //unused + } + func save(_ objects: [DatabaseObject], in database: FMDatabase) { // Nothing to do, since tags are saved in the lookup table, not in a separate table. diff --git a/Frameworks/RSDatabase/DatabaseTable.swift b/Frameworks/RSDatabase/DatabaseTable.swift index 6303e45fa..4aa758736 100644 --- a/Frameworks/RSDatabase/DatabaseTable.swift +++ b/Frameworks/RSDatabase/DatabaseTable.swift @@ -11,13 +11,32 @@ import Foundation public protocol DatabaseTable { var name: String { get } + var databaseIDKey: String { get} func fetchObjectsWithIDs(_ databaseIDs: Set, in database: FMDatabase) -> [DatabaseObject] + func objectsWithResultSet(_ resultSet: FMResultSet) -> [DatabaseObject] + func objectWithRow(_ row: FMResultSet) -> DatabaseObject? + func save(_ objects: [DatabaseObject], in database: FMDatabase) } public extension DatabaseTable { + // MARK: Default implementations + + func fetchObjectsWithIDs(_ databaseIDs: Set, in database: FMDatabase) -> [DatabaseObject] { + + guard let resultSet = selectRowsWhere(key: databaseIDKey, inValues: Array(databaseIDs), in: database) else { + return [DatabaseObject]() + } + return objectsWithResultSet(resultSet) + } + + func objectsWithResultSet(_ resultSet: FMResultSet) -> [DatabaseObject] { + + return resultSet.flatMap(objectWithRow) + } + // MARK: Fetching public func selectRowsWhere(key: String, equals value: Any, in database: FMDatabase) -> FMResultSet? { diff --git a/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift b/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift index 2e2f920ff..75bd24ffd 100644 --- a/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift +++ b/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift @@ -31,7 +31,7 @@ public final class DatabaseLookupTable { self.cache = DatabaseLookupTableCache(relationshipName) } - public func attachRelationships(to objects: [DatabaseObject], in database: FMDatabase) { + public func attachRelatedObjects(to objects: [DatabaseObject], in database: FMDatabase) { let objectsThatMayHaveRelatedObjects = cache.objectsThatMayHaveRelatedObjects(objects) if objectsThatMayHaveRelatedObjects.isEmpty { @@ -54,7 +54,7 @@ public final class DatabaseLookupTable { cache.update(with: objectsNeedingFetching) } - public func saveRelationships(for objects: [DatabaseObject], in database: FMDatabase) { + public func saveRelatedObjects(for objects: [DatabaseObject], in database: FMDatabase) { var objectsWithNoRelationships = [DatabaseObject]() var objectsWithRelationships = [DatabaseObject]()