Attach authors.

This commit is contained in:
Brent Simmons 2017-08-06 12:37:47 -07:00
parent c30e7eeb99
commit 4503f771da
12 changed files with 276 additions and 61 deletions

View File

@ -238,15 +238,7 @@ private extension AttachmentsTable {
func attachmentsWithResultSet(_ resultSet: FMResultSet) -> Set<Attachment> {
var attachments = Set<Attachment>()
while (resultSet.next()) {
if let oneAttachment = attachmentWithRow(resultSet) {
attachments.insert(oneAttachment)
}
}
return attachments
return resultSet.mapToSet(attachmentWithRow)
}
func attachmentWithRow(_ row: FMResultSet) -> Attachment? {

View File

@ -10,11 +10,21 @@ import Foundation
import RSDatabase
import Data
// article->authors is a many-to-many relationship.
// Theres 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 authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID));
final class AuthorsTable: DatabaseTable {
let name: String
let queue: RSDatabaseQueue
private let cache = ObjectCache<Author>(keyPathForID: \Author.databaseID)
private var articleIDToAuthorsCache = [String: Set<Author>]()
private var articleIDsWithNoAuthors = Set<String>()
private let authorsLookupTable = LookupTable(name: DatabaseTableName.authorsLookup, primaryKey: DatabaseKey.authorID, foreignKey: DatabaseKey.articleID)
init(name: String, queue: RSDatabaseQueue) {
@ -22,12 +32,108 @@ final class AuthorsTable: DatabaseTable {
self.queue = queue
}
func authorWithRow(_ row: FMResultSet) -> Author? {
func attachAuthors(_ articles: Set<Article>, _ database: FMDatabase) {
// Since:
// 1. anything to do with an FMResultSet runs inside the database serial queue, and
// 2. the cache is referenced only within this method,
// this is safe.
attachCachedAuthors(articles)
let articlesNeedingAuthors = articlesMissingAuthors(articles)
if articlesNeedingAuthors.isEmpty {
return
}
let articleIDs = Set(articlesNeedingAuthors.map { $0.databaseID })
let authorTable = fetchAuthorsForArticleIDs(articleIDs, database)
for article in articlesNeedingAuthors {
let articleID = article.databaseID
if let authors = authorTable?[articleID] {
articleIDsWithNoAuthors.remove(articleID)
article.authors = Array(authors)
}
else {
articleIDsWithNoAuthors.insert(articleID)
}
}
}
}
private extension AuthorsTable {
func attachCachedAuthors(_ articles: Set<Article>) {
for article in articles {
if let authors = articleIDToAuthorsCache[article.databaseID] {
article.authors = Array(authors)
}
}
}
func articlesMissingAuthors(_ articles: Set<Article>) -> Set<Article> {
return articles.filter{ (article) -> Bool in
if let _ = article.authors {
return false
}
if articleIDsWithNoAuthors.contains(article.databaseID) {
return false
}
return true
}
}
func fetchAuthorsForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) -> [String: Set<Author>]? {
let lookupValues = authorsLookupTable.fetchLookupValues(articleIDs, database: database)
let authorIDs = Set(lookupValues.map { $0.primaryID })
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<LookupValue>) -> [String: Set<Author>] {
var authorTable = [String: Set<Author>]()
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<Author> {
return resultSet.mapToSet(authorWithRow)
}
func authorWithRow(_ row: FMResultSet) -> Author? {
guard let databaseID = row.string(forColumn: DatabaseKey.databaseID) else {
return nil
@ -37,7 +143,7 @@ final class AuthorsTable: DatabaseTable {
return cachedAuthor
}
guard let author = Author(row: row) else {
guard let author = Author(databaseID: databaseID, row: row) else {
return nil
}
@ -45,4 +151,3 @@ final class AuthorsTable: DatabaseTable {
return author
}
}

View File

@ -12,6 +12,7 @@ public struct DatabaseTableName {
static let articles = "articles"
static let authors = "authors"
static let authorsLookup = "authorLookup"
static let statuses = "statuses"
static let tags = "tags"
static let attachments = "attachments"
@ -56,6 +57,7 @@ public struct DatabaseKey {
static let tagName = "tagName"
// Author
static let authorID = "authorID"
static let name = "name"
static let avatarURL = "avatarURL"
static let emailAddress = "emailAddress"

View File

@ -65,7 +65,7 @@ final class Database {
fetchedArticles = self.fetchArticlesForFeedID(feedID, database: database)
}
let articles = articleCache.uniquedArticles(fetchedArticles, statusesManager: statusesManager)
let articles = articleCache.uniquedArticles(fetchedArticles, statusesTable: statusesTable)
return filteredArticles(articles, feedCounts: [feed.feedID: fetchedArticles.count])
}
@ -79,7 +79,7 @@ final class Database {
DispatchQueue.main.async() { () -> Void in
let articles = self.articleCache.uniquedArticles(fetchedArticles, statusesManager: self.statusesManager)
let articles = self.articleCache.uniquedArticles(fetchedArticles, statusesTable: self.statusesTable)
let filteredArticles = self.filteredArticles(articles, feedCounts: [feed.feedID: fetchedArticles.count])
resultBlock(filteredArticles)
}
@ -155,7 +155,7 @@ final class Database {
}
}
let articles = articleCache.uniquedArticles(fetchedArticles, statusesManager: statusesManager)
let articles = articleCache.uniquedArticles(fetchedArticles, statusesTable: statusesTable)
return filteredArticles(articles, feedCounts: counts)
}
@ -199,7 +199,7 @@ final class Database {
func markArticles(_ articles: NSSet, statusKey: ArticleStatusKey, flag: Bool) {
statusesManager.markArticles(articles as! Set<Article>, statusKey: statusKey, flag: flag)
statusesTable.markArticles(articles as! Set<Article>, statusKey: statusKey, flag: flag)
}
}
@ -215,7 +215,7 @@ private extension Database {
return
}
statusesManager.assertNoMissingStatuses(newArticles)
statusesTable.assertNoMissingStatuses(newArticles)
articleCache.cacheArticles(newArticles)
let newArticleDictionaries = newArticles.map { (oneArticle) in
@ -249,7 +249,7 @@ private extension Database {
func updateArticles(_ articles: [String: Article], parsedArticles: [String: ParsedItem], feed: Feed, completionHandler: @escaping RSVoidCompletionBlock) {
statusesManager.ensureStatusesForParsedArticles(Set(parsedArticles.values)) {
statusesTable.ensureStatusesForParsedArticles(Set(parsedArticles.values)) {
let articleChanges = self.updateExistingArticles(articles, parsedArticles)
let newArticles = self.createNewArticles(articles, parsedArticles: parsedArticles, feedID: feed.feedID)
@ -309,7 +309,7 @@ private extension Database {
let newParsedArticles = parsedArticlesMinusExistingArticles(parsedArticles, existingArticles: existingArticles)
let newArticles = createNewArticlesWithParsedArticles(newParsedArticles, feedID: feedID)
statusesManager.attachCachedUniqueStatuses(newArticles)
statusesTable.attachCachedUniqueStatuses(newArticles)
return newArticles
}
@ -337,24 +337,16 @@ private extension Database {
logSQL(sql)
if let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) {
return articlesWithResultSet(resultSet)
return articlesWithResultSet(resultSet, database)
}
return Set<Article>()
}
func articlesWithResultSet(_ resultSet: FMResultSet) -> Set<Article> {
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
var fetchedArticles = Set<Article>()
let fetchedArticles = resultSet.mapToSet { Article(account: self.account, row: $0) }
while (resultSet.next()) {
if let oneArticle = Article(account: self.account, row: resultSet) {
fetchedArticles.insert(oneArticle)
}
}
resultSet.close()
statusesTable.attachStatuses(fetchedArticles, database)
authorsTable.attachAuthors(fetchedArticles, database)
tagsTable.attachTags(fetchedArticles, database)

View File

@ -164,10 +164,10 @@
84E156E91F0AB80500F8CC05 /* Database.swift */,
845580661F0AEBCD003CCFA1 /* Constants.swift */,
84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */,
84F20F8E1F180D8700D8E682 /* AuthorsTable.swift */,
840405CE1F1A963700DF0296 /* AttachmentsTable.swift */,
84E156ED1F0AB81400F8CC05 /* StatusesTable.swift */,
84F20F8E1F180D8700D8E682 /* AuthorsTable.swift */,
84BB4BA81F11A32800858766 /* TagsTable.swift */,
840405CE1F1A963700DF0296 /* AttachmentsTable.swift */,
8461462A1F0AC44100870CB3 /* Extensions */,
84E156EF1F0AB81F00F8CC05 /* CreateStatements.sql */,
84E156E81F0AB75600F8CC05 /* Info.plist */,

View File

@ -12,12 +12,8 @@ import Data
extension ArticleStatus {
convenience init?(row: FMResultSet) {
convenience init?(articleID: String, row: FMResultSet) {
let articleID = row.string(forColumn: DatabaseKey.articleID)
if (articleID == nil) {
return nil
}
let read = row.bool(forColumn: DatabaseKey.read)
let starred = row.bool(forColumn: DatabaseKey.starred)
let userDeleted = row.bool(forColumn: DatabaseKey.userDeleted)
@ -29,7 +25,7 @@ extension ArticleStatus {
let accountInfoPlist = accountInfoWithRow(row)
self.init(articleID: articleID!, read: read, starred: starred, userDeleted: userDeleted, dateArrived: dateArrived!, accountInfo: accountInfoPlist)
self.init(articleID: articleID, read: read, starred: starred, userDeleted: userDeleted, dateArrived: dateArrived!, accountInfo: accountInfoPlist)
}
func databaseDictionary() -> NSDictionary {

View File

@ -12,9 +12,8 @@ import RSDatabase
extension Author {
init?(row: FMResultSet) {
init?(databaseID: String, row: FMResultSet) {
let databaseID = row.string(forColumn: DatabaseKey.databaseID)
let name = row.string(forColumn: DatabaseKey.name)
let url = row.string(forColumn: DatabaseKey.url)
let avatarURL = row.string(forColumn: DatabaseKey.avatarURL)

View File

@ -11,6 +11,10 @@ import RSCore
import RSDatabase
import Data
// Article->ArticleStatus is a to-one relationship.
//
// 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);
final class StatusesTable: DatabaseTable {
let name: String
@ -122,30 +126,35 @@ private extension StatusesTable {
func fetchAndCacheStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) {
let statuses = fetchStatusesForArticleIDs(articleIDs, database)
cache.addObjectsNotCached(Array(statuses))
if let statuses = fetchStatusesForArticleIDs(articleIDs, database) {
cache.addObjectsNotCached(Array(statuses))
}
}
func fetchStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) -> Set<ArticleStatus> {
func fetchStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) -> Set<ArticleStatus>? {
if !articleIDs.isEmpty, let resultSet = selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) {
return articleStatusesWithResultSet(resultSet)
guard let resultSet = selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) else {
return nil
}
return Set<ArticleStatus>()
return articleStatusesWithResultSet(resultSet)
}
func articleStatusesWithResultSet(_ resultSet: FMResultSet) -> Set<ArticleStatus> {
var statuses = Set<ArticleStatus>()
while(resultSet.next()) {
if let oneArticleStatus = ArticleStatus(row: resultSet) {
statuses.insert(oneArticleStatus)
}
return resultSet.mapToSet(articleStatusWithRow)
}
func articleStatusWithRow(_ row: FMResultSet) -> ArticleStatus? {
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
return nil
}
return statuses
if let cachedStatus = cache[articleID] {
return cachedStatus
}
let status = ArticleStatus(articleID: articleID, row: row)
cache[articleID] = status
return status
}
// MARK: Updating

View File

@ -10,9 +10,15 @@ import Foundation
import RSDatabase
import Data
// Article->tags is a many-to-many relationship.
// Since a tag is just a simple string, the tags table and the lookup table are the same table.
//
// Tags  and the non-existence of tags are cached, once fetched, for the lifetime of the run.
// This uses some extra memory but cuts way down on the amount of database time spent
// maintaining the tags table.
//
// CREATE TABLE if not EXISTS tags(tagName TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(tagName, articleID));
// CREATE INDEX if not EXISTS tags_tagName_index on tags (tagName COLLATE NOCASE);
typealias TagNameSet = Set<String>

View File

@ -27,6 +27,9 @@ public extension DatabaseTable {
public func selectRowsWhere(key: String, inValues values: [Any], in database: FMDatabase) -> FMResultSet? {
if values.isEmpty {
return nil
}
return database.rs_selectRowsWhereKey(key, inValues: values, tableName: name)
}
@ -37,7 +40,6 @@ public extension DatabaseTable {
if values.isEmpty {
return
}
database.rs_deleteRowsWhereKey(key, inValues: values, tableName: name)
}
@ -75,5 +77,38 @@ public extension DatabaseTable {
let resultSet = database.executeQuery(sql, withArgumentsIn: parameters)
return numberWithCountResultSet(resultSet)
}
// MARK: Mapping
func mapResultSet<T>(_ resultSet: FMResultSet, _ callback: (_ resultSet: FMResultSet) -> T?) -> [T] {
var objects = [T]()
while resultSet.next() {
if let obj = callback(resultSet) {
objects += [obj]
}
}
return objects
}
}
public extension FMResultSet {
public func flatMap<T>(_ callback: (_ row: FMResultSet) -> T?) -> [T] {
var objects = [T]()
while next() {
if let obj = callback(self) {
objects += [obj]
}
}
close()
return objects
}
public func mapToSet<T>(_ callback: (_ row: FMResultSet) -> T?) -> Set<T> {
return Set(flatMap(callback))
}
}

View File

@ -35,6 +35,8 @@
84419B061B5ABFF700C26BB2 /* FMResultSet+RSExtras.m in Sources */ = {isa = PBXBuildFile; fileRef = 84419B041B5ABFF700C26BB2 /* FMResultSet+RSExtras.m */; };
844D97411F2D32F300CEDDEA /* ObjectCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844D97401F2D32F300CEDDEA /* ObjectCache.swift */; };
849BF8C61C94FB8E0071D1DA /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 849BF8C51C94FB8E0071D1DA /* libsqlite3.tbd */; };
84ABC1D11F364B07000DCC55 /* LookupTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ABC1D01F364B07000DCC55 /* LookupTable.swift */; };
84ABC1D21F364B07000DCC55 /* LookupTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ABC1D01F364B07000DCC55 /* LookupTable.swift */; };
84DDF1961C94FC45005E6CF5 /* FMDatabase.h in Headers */ = {isa = PBXBuildFile; fileRef = 84DDF18B1C94FC45005E6CF5 /* FMDatabase.h */; settings = {ATTRIBUTES = (Public, ); }; };
84DDF1971C94FC45005E6CF5 /* FMDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = 84DDF18C1C94FC45005E6CF5 /* FMDatabase.m */; };
84DDF1981C94FC45005E6CF5 /* FMDatabaseAdditions.h in Headers */ = {isa = PBXBuildFile; fileRef = 84DDF18D1C94FC45005E6CF5 /* FMDatabaseAdditions.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -71,6 +73,7 @@
84419B041B5ABFF700C26BB2 /* FMResultSet+RSExtras.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "FMResultSet+RSExtras.m"; path = "RSDatabase/FMResultSet+RSExtras.m"; sourceTree = "<group>"; };
844D97401F2D32F300CEDDEA /* ObjectCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ObjectCache.swift; path = RSDatabase/ObjectCache.swift; sourceTree = "<group>"; };
849BF8C51C94FB8E0071D1DA /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; };
84ABC1D01F364B07000DCC55 /* LookupTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LookupTable.swift; path = RSDatabase/LookupTable.swift; sourceTree = "<group>"; };
84DDF18B1C94FC45005E6CF5 /* FMDatabase.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FMDatabase.h; sourceTree = "<group>"; };
84DDF18C1C94FC45005E6CF5 /* FMDatabase.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FMDatabase.m; sourceTree = "<group>"; };
84DDF18D1C94FC45005E6CF5 /* FMDatabaseAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FMDatabaseAdditions.h; sourceTree = "<group>"; };
@ -156,6 +159,7 @@
84419AD81B5ABD7400C26BB2 /* NSString+RSDatabase.h */,
84419AD91B5ABD7400C26BB2 /* NSString+RSDatabase.m */,
840405DA1F1C158C00DF0296 /* DatabaseTable.swift */,
84ABC1D01F364B07000DCC55 /* LookupTable.swift */,
844D97401F2D32F300CEDDEA /* ObjectCache.swift */,
84DDF18A1C94FC45005E6CF5 /* FMDB */,
84F22C5A1B52E0D9000060CE /* Info.plist */,
@ -350,6 +354,7 @@
files = (
8400AC001E0CFC0700AA7C57 /* RSDatabaseQueue.m in Sources */,
8400AC061E0CFC0700AA7C57 /* NSString+RSDatabase.m in Sources */,
84ABC1D21F364B07000DCC55 /* LookupTable.swift in Sources */,
8400AC0C1E0CFC3100AA7C57 /* FMResultSet.m in Sources */,
840405DC1F1C15EA00DF0296 /* DatabaseTable.swift in Sources */,
8400AC021E0CFC0700AA7C57 /* FMDatabase+RSExtras.m in Sources */,
@ -367,6 +372,7 @@
84419AD71B5ABD6D00C26BB2 /* FMDatabase+RSExtras.m in Sources */,
84419ADB1B5ABD7400C26BB2 /* NSString+RSDatabase.m in Sources */,
840405DB1F1C158C00DF0296 /* DatabaseTable.swift in Sources */,
84ABC1D11F364B07000DCC55 /* LookupTable.swift in Sources */,
84DDF1971C94FC45005E6CF5 /* FMDatabase.m in Sources */,
84DDF1A01C94FC45005E6CF5 /* FMResultSet.m in Sources */,
84419B061B5ABFF700C26BB2 /* FMResultSet+RSExtras.m in Sources */,

View File

@ -0,0 +1,73 @@
//
// LookupTable.swift
// RSDatabase
//
// Created by Brent Simmons on 8/5/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
//
import Foundation
// Implement a lookup table for a many-to-many relationship.
// Example: CREATE TABLE if not EXISTS authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID));
// authorID is primaryKey; articleID is foreignKey.
public struct LookupTable {
let name: String
let primaryKey: String
let foreignKey: String
public init(name: String, primaryKey: String, foreignKey: String) {
self.name = name
self.primaryKey = primaryKey
self.foreignKey = foreignKey
}
public func fetchLookupValues(_ foreignIDs: Set<String>, database: FMDatabase) -> Set<LookupValue> {
guard let resultSet = database.rs_selectRowsWhereKey(foreignKey, inValues: Array(foreignIDs), tableName: name) else {
return Set<LookupValue>()
}
return lookupValuesWithResultSet(resultSet)
}
}
private extension LookupTable {
func lookupValuesWithResultSet(_ resultSet: FMResultSet) -> Set<LookupValue> {
return resultSet.mapToSet(lookupValueWithRow)
}
func lookupValueWithRow(_ resultSet: FMResultSet) -> LookupValue? {
guard let primaryID = resultSet.string(forColumn: primaryKey) else {
return nil
}
guard let foreignID = resultSet.string(forColumn: foreignKey) else {
return nil
}
return LookupValue(primaryID: primaryID, foreignID: foreignID)
}
}
public struct LookupValue: Hashable {
public let primaryID: String
public let foreignID: String
public let hashValue: Int
init(primaryID: String, foreignID: String) {
self.primaryID = primaryID
self.foreignID = foreignID
self.hashValue = "\(primaryID)\(foreignID)".hashValue
}
static public func ==(lhs: LookupValue, rhs: LookupValue) -> Bool {
return lhs.primaryID == rhs.primaryID && lhs.foreignID == rhs.foreignID
}
}