From 630738137581fb7e0d293266c52c0b9652c42e7b Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Tue, 1 Sep 2020 19:39:06 -0700 Subject: [PATCH] Improve context data modeling --- .../Database/ContentDatabase.swift | 217 ++++++++++-------- .../Entities/TransientStatusCollection.swift | 11 - .../Status List Services/ContextService.swift | 24 +- .../TimelineService.swift | 5 +- .../Sources/ServiceLayer/StatusService.swift | 2 +- 5 files changed, 135 insertions(+), 124 deletions(-) delete mode 100644 ServiceLayer/Sources/ServiceLayer/Entities/TransientStatusCollection.swift diff --git a/ServiceLayer/Sources/ServiceLayer/Database/ContentDatabase.swift b/ServiceLayer/Sources/ServiceLayer/Database/ContentDatabase.swift index 0700c81..d83333b 100644 --- a/ServiceLayer/Sources/ServiceLayer/Database/ContentDatabase.swift +++ b/ServiceLayer/Sources/ServiceLayer/Database/ContentDatabase.swift @@ -24,21 +24,52 @@ struct ContentDatabase { } try Self.migrate(databaseQueue) - try Self.createTemporaryTables(databaseQueue) } } extension ContentDatabase { - func insert(statuses: [Status], collection: StatusCollection? = nil) -> AnyPublisher { + func insert(statuses: [Status], timeline: Timeline? = nil) -> AnyPublisher { databaseQueue.writePublisher { - try collection?.save($0) + try timeline?.save($0) for status in statuses { for component in status.storedComponents() { try component.save($0) } - try collection?.joinRecord(status: status).save($0) + if let timeline = timeline { + try TimelineStatusJoin(timelineId: timeline.id, statusId: status.id).save($0) + } + } + } + .ignoreOutput() + .eraseToAnyPublisher() + } + + func insert(context: Context, parentID: String) -> AnyPublisher { + databaseQueue.writePublisher { + for status in context.ancestors + context.descendants { + for component in status.storedComponents() { + try component.save($0) + } + } + + for (section, statuses) in [(StatusContextJoin.Section.ancestors, context.ancestors), + (StatusContextJoin.Section.descendants, context.descendants)] { + for (index, status) in statuses.enumerated() { + try StatusContextJoin( + parentId: parentID, + statusId: status.id, + section: section, + index: index) + .save($0) + } + + try StatusContextJoin.filter( + Column("parentId") == parentID + && Column("section") == section.rawValue + && Column("index") >= statuses.count) + .deleteAll($0) } } .ignoreOutput() @@ -93,11 +124,28 @@ extension ContentDatabase { .eraseToAnyPublisher() } - func statusesObservation(collection: StatusCollection) -> AnyPublisher<[Status], Error> { - ValueObservation.tracking(collection.fetch) + func statusesObservation(timeline: Timeline) -> AnyPublisher<[[Status]], Error> { + ValueObservation.tracking(timeline.statuses.fetchAll) + .removeDuplicates() + .publisher(in: databaseQueue) + .map { [$0.map(Status.init(statusResult:))] } + .eraseToAnyPublisher() + } + + func contextObservation(parentID: String) -> AnyPublisher<[[Status]], Error> { + ValueObservation.tracking { db -> [[StatusResult]] in + guard let parent = try StoredStatus.filter(Column("id") == parentID).statusResultRequest.fetchOne(db) else { + return [[]] + } + + let ancestors = try parent.status.ancestors.fetchAll(db) + let descendants = try parent.status.descendants.fetchAll(db) + + return [ancestors, [parent], descendants] + } .removeDuplicates() .publisher(in: databaseQueue) - .map { $0.map(Status.init(statusResult:)) } + .map { $0.map { $0.map(Status.init(statusResult:)) } } .eraseToAnyPublisher() } @@ -209,6 +257,21 @@ private extension ContentDatabase { t.primaryKey(["timelineId", "statusId"], onConflict: .replace) } + try db.create(table: "statusContextJoin", ifNotExists: true) { t in + t.column("parentId", .text) + .indexed() + .notNull() + .references("storedStatus", column: "id", onDelete: .cascade, onUpdate: .cascade) + t.column("statusId", .text) + .indexed() + .notNull() + .references("storedStatus", column: "id", onDelete: .cascade, onUpdate: .cascade) + t.column("section", .text).notNull() + t.column("index", .integer).notNull() + + t.primaryKey(["parentId", "statusId"], onConflict: .replace) + } + try db.create(table: "filter", ifNotExists: true) { t in t.column("id", .text).notNull().primaryKey(onConflict: .replace) t.column("phrase", .text).notNull() @@ -222,23 +285,6 @@ private extension ContentDatabase { try migrator.migrate(writer) } // swiftlint:enable function_body_length - - static func createTemporaryTables(_ writer: DatabaseWriter) throws { - try writer.write { database in - try database.create(table: "transientStatusCollection", temporary: true, ifNotExists: true) { t in - t.column("id", .text).notNull().primaryKey(onConflict: .replace) - } - - try database.create(table: "transientStatusCollectionElement", temporary: true, ifNotExists: true) { t in - t.column("transientStatusCollectionId", .text) - .notNull() - .references("transientStatusCollection", column: "id", onDelete: .cascade, onUpdate: .cascade) - t.column("statusId", .text).notNull() - - t.primaryKey(["transientStatusCollectionId", "statusId"], onConflict: .replace) - } - } - } } extension Account: FetchableRecord, PersistableRecord { @@ -251,13 +297,6 @@ extension Account: FetchableRecord, PersistableRecord { } } -public protocol StatusCollection: FetchableRecord, PersistableRecord { - var id: String { get } - var fetch: (Database) throws -> [StatusResult] { get } - - func joinRecord(status: Status) -> PersistableRecord -} - private struct TimelineStatusJoin: Codable, FetchableRecord, PersistableRecord { let timelineId: String let statusId: String @@ -265,7 +304,7 @@ private struct TimelineStatusJoin: Codable, FetchableRecord, PersistableRecord { static let status = belongsTo(StoredStatus.self) } -extension Timeline: StatusCollection { +extension Timeline: FetchableRecord, PersistableRecord { enum Columns: String, ColumnExpression { case id, listTitle } @@ -292,34 +331,64 @@ extension Timeline: StatusCollection { container[Columns.listTitle] = list.title } } - - public var fetch: (Database) throws -> [StatusResult] { - statuses - .including(required: StoredStatus.account) - .including(optional: StoredStatus.reblogAccount) - .including(optional: StoredStatus.reblog) - .asRequest(of: StatusResult.self) - .fetchAll - } - - public func joinRecord(status: Status) -> PersistableRecord { - TimelineStatusJoin(timelineId: id, statusId: status.id) - } } private extension Timeline { static let statusJoins = hasMany(TimelineStatusJoin.self) + static let statuses = hasMany( + StoredStatus.self, + through: statusJoins, + using: TimelineStatusJoin.status) + .order(Column("createdAt").desc) - static let statuses = hasMany(StoredStatus.self, - through: statusJoins, - using: TimelineStatusJoin.status).order(Column("createdAt").desc) + var statuses: QueryInterfaceRequest { + request(for: Self.statuses).statusResultRequest + } +} - var statusJoins: QueryInterfaceRequest { - request(for: Self.statusJoins) +private struct StatusContextJoin: Codable, FetchableRecord, PersistableRecord { + enum Section: String, Codable { + case ancestors + case descendants } - var statuses: QueryInterfaceRequest { - request(for: Self.statuses) + let parentId: String + let statusId: String + let section: Section + let index: Int + + static let status = belongsTo(StoredStatus.self, using: ForeignKey([Column("statusId")])) +} + +private extension StoredStatus { + static let ancestorJoins = hasMany(StatusContextJoin.self, using: ForeignKey([Column("parentID")])) + .filter(Column("section") == StatusContextJoin.Section.ancestors.rawValue) + .order(Column("index")) + static let descendantJoins = hasMany(StatusContextJoin.self, using: ForeignKey([Column("parentID")])) + .filter(Column("section") == StatusContextJoin.Section.descendants.rawValue) + .order(Column("index")) + static let ancestors = hasMany(StoredStatus.self, + through: ancestorJoins, + using: StatusContextJoin.status) + static let descendants = hasMany(StoredStatus.self, + through: descendantJoins, + using: StatusContextJoin.status) + + var ancestors: QueryInterfaceRequest { + request(for: Self.ancestors).statusResultRequest + } + + var descendants: QueryInterfaceRequest { + request(for: Self.descendants).statusResultRequest + } +} + +private extension QueryInterfaceRequest where RowDecoder == StoredStatus { + var statusResultRequest: QueryInterfaceRequest { + including(required: StoredStatus.account) + .including(optional: StoredStatus.reblogAccount) + .including(optional: StoredStatus.reblog) + .asRequest(of: StatusResult.self) } } @@ -333,43 +402,7 @@ extension Filter: FetchableRecord, PersistableRecord { } } -private struct TransientStatusCollectionElement: Codable, FetchableRecord, PersistableRecord { - let transientStatusCollectionId: String - let statusId: String - - static let status = belongsTo(StoredStatus.self, key: "statusId") -} - -extension TransientStatusCollection: StatusCollection { - public var fetch: (Database) throws -> [StatusResult] { - { - try StatusResult.fetchAll( - $0, - StoredStatus.filter( - try elements - .fetchAll($0) - .map(\.statusId) - .contains(Column("id"))) - .including(required: StoredStatus.account) - .including(optional: StoredStatus.reblogAccount) - .including(optional: StoredStatus.reblog)) - } - } - - public func joinRecord(status: Status) -> PersistableRecord { - TransientStatusCollectionElement(transientStatusCollectionId: id, statusId: status.id) - } -} - -private extension TransientStatusCollection { - static let elements = hasMany(TransientStatusCollectionElement.self) - - var elements: QueryInterfaceRequest { - request(for: Self.elements) - } -} - -private struct StoredStatus: Codable, Hashable { +struct StoredStatus: Codable, Hashable { let id: String let uri: String let createdAt: Date @@ -461,11 +494,11 @@ extension StoredStatus: FetchableRecord, PersistableRecord { } } -public struct StatusResult: Codable, Hashable, FetchableRecord { +struct StatusResult: Codable, Hashable, FetchableRecord { let account: Account - fileprivate let status: StoredStatus + let status: StoredStatus let reblogAccount: Account? - fileprivate let reblog: StoredStatus? + let reblog: StoredStatus? } private extension Status { diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/TransientStatusCollection.swift b/ServiceLayer/Sources/ServiceLayer/Entities/TransientStatusCollection.swift deleted file mode 100644 index 693f7c5..0000000 --- a/ServiceLayer/Sources/ServiceLayer/Entities/TransientStatusCollection.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Foundation - -public struct TransientStatusCollection: Codable { - public let id: String - - public init(id: String) { - self.id = id - } -} diff --git a/ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift b/ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift index 1a68f63..9c691b0 100644 --- a/ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Status List Services/ContextService.swift @@ -12,24 +12,12 @@ public struct ContextService { private let context = CurrentValueSubject(Context(ancestors: [], descendants: [])) private let networkClient: APIClient private let contentDatabase: ContentDatabase - private let collection: TransientStatusCollection init(status: Status, networkClient: APIClient, contentDatabase: ContentDatabase) { self.status = status self.networkClient = networkClient self.contentDatabase = contentDatabase - collection = TransientStatusCollection(id: "context-\(status.id)") - statusSections = contentDatabase.statusesObservation(collection: collection) - .combineLatest(context.setFailureType(to: Error.self)) - .map { statuses, context in - [ - context.ancestors.map { a in statuses.first { $0.id == a.id } ?? a }, - [statuses.first { $0.id == status.id } ?? status], - context.descendants.map { d in statuses.first { $0.id == d.id } ?? d } - ] - } - .removeDuplicates() - .eraseToAnyPublisher() + statusSections = contentDatabase.contextObservation(parentID: status.id) } } @@ -69,12 +57,14 @@ extension ContextService: StatusListService { public func request(maxID: String?, minID: String?) -> AnyPublisher { Publishers.Merge( networkClient.request(StatusEndpoint.status(id: status.id)) - .map { ([$0], collection) } - .flatMap(contentDatabase.insert(statuses:collection:)), + .map { ([$0], nil) } + .flatMap(contentDatabase.insert(statuses:timeline:)) + .eraseToAnyPublisher(), networkClient.request(ContextEndpoint.context(id: status.id)) .handleEvents(receiveOutput: context.send) - .map { ($0.ancestors + $0.descendants, collection) } - .flatMap(contentDatabase.insert(statuses:collection:))) + .map { ($0, status.id) } + .flatMap(contentDatabase.insert(context:parentID:)) + .eraseToAnyPublisher()) .eraseToAnyPublisher() } diff --git a/ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift b/ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift index 98443c1..dd24497 100644 --- a/ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Status List Services/TimelineService.swift @@ -15,8 +15,7 @@ struct TimelineService { self.timeline = timeline self.networkClient = networkClient self.contentDatabase = contentDatabase - statusSections = contentDatabase.statusesObservation(collection: timeline) - .map { [$0] } + statusSections = contentDatabase.statusesObservation(timeline: timeline) .eraseToAnyPublisher() } } @@ -29,7 +28,7 @@ extension TimelineService: StatusListService { func request(maxID: String?, minID: String?) -> AnyPublisher { networkClient.request(Paged(timeline.endpoint, maxID: maxID, minID: minID)) .map { ($0, timeline) } - .flatMap(contentDatabase.insert(statuses:collection:)) + .flatMap(contentDatabase.insert(statuses:timeline:)) .eraseToAnyPublisher() } diff --git a/ServiceLayer/Sources/ServiceLayer/StatusService.swift b/ServiceLayer/Sources/ServiceLayer/StatusService.swift index 1d0247e..c4380d6 100644 --- a/ServiceLayer/Sources/ServiceLayer/StatusService.swift +++ b/ServiceLayer/Sources/ServiceLayer/StatusService.swift @@ -22,7 +22,7 @@ public extension StatusService { ? StatusEndpoint.unfavourite(id: status.id) : StatusEndpoint.favourite(id: status.id)) .map { ([$0], nil) } - .flatMap(contentDatabase.insert(statuses:collection:)) + .flatMap(contentDatabase.insert(statuses:timeline:)) .eraseToAnyPublisher() } }