diff --git a/DB/.gitignore b/DB/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/DB/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/DB/Package.swift b/DB/Package.swift new file mode 100644 index 0000000..c058a0a --- /dev/null +++ b/DB/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.3 + +import PackageDescription + +let package = Package( + name: "DB", + platforms: [ + .iOS(.v14), + .macOS(.v11) + ], + products: [ + .library( + name: "DB", + targets: ["DB"]) + ], + dependencies: [ + .package(name: "GRDB", url: "https://github.com/groue/GRDB.swift.git", .upToNextMajor(from: "5.0.0-beta.10")), + .package(path: "Mastodon") + ], + targets: [ + .target( + name: "DB", + dependencies: ["GRDB", "Mastodon"]), + .testTarget( + name: "DBTests", + dependencies: ["DB"]) + ] +) diff --git a/ServiceLayer/Sources/ServiceLayer/Database/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift similarity index 56% rename from ServiceLayer/Sources/ServiceLayer/Database/ContentDatabase.swift rename to DB/Sources/DB/Content/ContentDatabase.swift index 63bac37..8933363 100644 --- a/ServiceLayer/Sources/ServiceLayer/Database/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -5,12 +5,11 @@ import Combine import GRDB import Mastodon -// swiftlint:disable file_length -struct ContentDatabase { +public struct ContentDatabase { private let databaseQueue: DatabaseQueue - init(identityID: UUID, environment: AppEnvironment) throws { - if environment.inMemoryContent { + public init(identityID: UUID, inMemory: Bool) throws { + if inMemory { databaseQueue = DatabaseQueue() } else { databaseQueue = try DatabaseQueue(path: try Self.fileURL(identityID: identityID).path) @@ -20,7 +19,7 @@ struct ContentDatabase { } } -extension ContentDatabase { +public extension ContentDatabase { static func delete(forIdentityID identityID: UUID) throws { try FileManager.default.removeItem(at: try fileURL(identityID: identityID)) } @@ -287,274 +286,3 @@ private extension ContentDatabase { } // swiftlint:enable function_body_length } - -extension Account: FetchableRecord, PersistableRecord { - public static func databaseJSONDecoder(for column: String) -> JSONDecoder { - APIDecoder() - } - - public static func databaseJSONEncoder(for column: String) -> JSONEncoder { - APIEncoder() - } -} - -private struct TimelineStatusJoin: Codable, FetchableRecord, PersistableRecord { - let timelineId: String - let statusId: String - - static let status = belongsTo(StoredStatus.self) -} - -extension Timeline: FetchableRecord, PersistableRecord { - enum Columns: String, ColumnExpression { - case id, listTitle - } - - public init(row: Row) { - switch (row[Columns.id] as String, row[Columns.listTitle] as String?) { - case (Timeline.home.id, _): - self = .home - case (Timeline.local.id, _): - self = .local - case (Timeline.federated.id, _): - self = .federated - case (let id, .some(let title)): - self = .list(MastodonList(id: id, title: title)) - default: - self = .tag(row[Columns.id]) - } - } - - public func encode(to container: inout PersistenceContainer) { - container[Columns.id] = id - - if case let .list(list) = self { - container[Columns.listTitle] = list.title - } - } -} - -private extension Timeline { - static let statusJoins = hasMany(TimelineStatusJoin.self) - static let statuses = hasMany( - StoredStatus.self, - through: statusJoins, - using: TimelineStatusJoin.status) - .order(Column("createdAt").desc) - - var statuses: QueryInterfaceRequest { - request(for: Self.statuses).statusResultRequest - } -} - -private struct StatusContextJoin: Codable, FetchableRecord, PersistableRecord { - enum Section: String, Codable { - case ancestors - case descendants - } - - 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) - } -} - -extension Filter: FetchableRecord, PersistableRecord { - public static func databaseJSONDecoder(for column: String) -> JSONDecoder { - APIDecoder() - } - - public static func databaseJSONEncoder(for column: String) -> JSONEncoder { - APIEncoder() - } -} - -struct StoredStatus: Codable, Hashable { - let id: String - let uri: String - let createdAt: Date - let accountId: String - let content: HTML - let visibility: Status.Visibility - let sensitive: Bool - let spoilerText: String - let mediaAttachments: [Attachment] - let mentions: [Mention] - let tags: [Tag] - let emojis: [Emoji] - let reblogsCount: Int - let favouritesCount: Int - let repliesCount: Int - let application: Application? - let url: URL? - let inReplyToId: String? - let inReplyToAccountId: String? - let reblogId: String? - let poll: Poll? - let card: Card? - let language: String? - let text: String? - let favourited: Bool - let reblogged: Bool - let muted: Bool - let bookmarked: Bool - let pinned: Bool? -} - -private extension StoredStatus { - static let account = belongsTo(Account.self, key: "account") - static let reblogAccount = hasOne(Account.self, through: Self.reblog, using: Self.account, key: "reblogAccount") - static let reblog = belongsTo(StoredStatus.self, key: "reblog") - - var account: QueryInterfaceRequest { - request(for: Self.account) - } - - var reblogAccount: QueryInterfaceRequest { - request(for: Self.reblogAccount) - } - - var reblog: QueryInterfaceRequest { - request(for: Self.reblog) - } - - init(status: Status) { - id = status.id - uri = status.uri - createdAt = status.createdAt - accountId = status.account.id - content = status.content - visibility = status.visibility - sensitive = status.sensitive - spoilerText = status.spoilerText - mediaAttachments = status.mediaAttachments - mentions = status.mentions - tags = status.tags - emojis = status.emojis - reblogsCount = status.reblogsCount - favouritesCount = status.favouritesCount - repliesCount = status.repliesCount - application = status.application - url = status.url - inReplyToId = status.inReplyToId - inReplyToAccountId = status.inReplyToAccountId - reblogId = status.reblog?.id - poll = status.poll - card = status.card - language = status.language - text = status.text - favourited = status.favourited - reblogged = status.reblogged - muted = status.muted - bookmarked = status.bookmarked - pinned = status.pinned - } -} - -extension StoredStatus: FetchableRecord, PersistableRecord { - static func databaseJSONDecoder(for column: String) -> JSONDecoder { - APIDecoder() - } - - static func databaseJSONEncoder(for column: String) -> JSONEncoder { - APIEncoder() - } -} - -struct StatusResult: Codable, Hashable, FetchableRecord { - let account: Account - let status: StoredStatus - let reblogAccount: Account? - let reblog: StoredStatus? -} - -private extension Status { - func save(_ db: Database) throws { - try account.save(db) - - if let reblog = reblog { - try reblog.account.save(db) - try StoredStatus(status: reblog).save(db) - } - - try StoredStatus(status: self).save(db) - } - - convenience init(statusResult: StatusResult) { - var reblog: Status? - - if let reblogResult = statusResult.reblog, let reblogAccount = statusResult.reblogAccount { - reblog = Status(storedStatus: reblogResult, account: reblogAccount, reblog: nil) - } - - self.init(storedStatus: statusResult.status, account: statusResult.account, reblog: reblog) - } - - convenience init(storedStatus: StoredStatus, account: Account, reblog: Status?) { - self.init( - id: storedStatus.id, - uri: storedStatus.uri, - createdAt: storedStatus.createdAt, - account: account, - content: storedStatus.content, - visibility: storedStatus.visibility, - sensitive: storedStatus.sensitive, - spoilerText: storedStatus.spoilerText, - mediaAttachments: storedStatus.mediaAttachments, - mentions: storedStatus.mentions, - tags: storedStatus.tags, - emojis: storedStatus.emojis, - reblogsCount: storedStatus.reblogsCount, - favouritesCount: storedStatus.favouritesCount, - repliesCount: storedStatus.repliesCount, - application: storedStatus.application, - url: storedStatus.url, - inReplyToId: storedStatus.inReplyToId, - inReplyToAccountId: storedStatus.inReplyToAccountId, - reblog: reblog, - poll: storedStatus.poll, - card: storedStatus.card, - language: storedStatus.language, - text: storedStatus.text, - favourited: storedStatus.favourited, - reblogged: storedStatus.reblogged, - muted: storedStatus.muted, - bookmarked: storedStatus.bookmarked, - pinned: storedStatus.pinned) - } -} -// swiftlint:enable file_length diff --git a/DB/Sources/DB/Content/StatusContextJoin.swift b/DB/Sources/DB/Content/StatusContextJoin.swift new file mode 100644 index 0000000..b8c7f14 --- /dev/null +++ b/DB/Sources/DB/Content/StatusContextJoin.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct StatusContextJoin: Codable, FetchableRecord, PersistableRecord { + enum Section: String, Codable { + case ancestors + case descendants + } + + let parentId: String + let statusId: String + let section: Section + let index: Int + + static let status = belongsTo(StoredStatus.self, using: ForeignKey([Column("statusId")])) +} diff --git a/DB/Sources/DB/Content/StatusResult.swift b/DB/Sources/DB/Content/StatusResult.swift new file mode 100644 index 0000000..f028eaf --- /dev/null +++ b/DB/Sources/DB/Content/StatusResult.swift @@ -0,0 +1,21 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct StatusResult: Codable, Hashable, FetchableRecord { + let account: Account + let status: StoredStatus + let reblogAccount: Account? + let reblog: StoredStatus? +} + +extension QueryInterfaceRequest where RowDecoder == StoredStatus { + var statusResultRequest: QueryInterfaceRequest { + including(required: StoredStatus.account) + .including(optional: StoredStatus.reblogAccount) + .including(optional: StoredStatus.reblog) + .asRequest(of: StatusResult.self) + } +} diff --git a/DB/Sources/DB/Content/StoredStatus.swift b/DB/Sources/DB/Content/StoredStatus.swift new file mode 100644 index 0000000..50ed866 --- /dev/null +++ b/DB/Sources/DB/Content/StoredStatus.swift @@ -0,0 +1,117 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct StoredStatus: Codable, Hashable { + let id: String + let uri: String + let createdAt: Date + let accountId: String + let content: HTML + let visibility: Status.Visibility + let sensitive: Bool + let spoilerText: String + let mediaAttachments: [Attachment] + let mentions: [Mention] + let tags: [Tag] + let emojis: [Emoji] + let reblogsCount: Int + let favouritesCount: Int + let repliesCount: Int + let application: Application? + let url: URL? + let inReplyToId: String? + let inReplyToAccountId: String? + let reblogId: String? + let poll: Poll? + let card: Card? + let language: String? + let text: String? + let favourited: Bool + let reblogged: Bool + let muted: Bool + let bookmarked: Bool + let pinned: Bool? +} + +extension StoredStatus: FetchableRecord, PersistableRecord { + static func databaseJSONDecoder(for column: String) -> JSONDecoder { + APIDecoder() + } + + static func databaseJSONEncoder(for column: String) -> JSONEncoder { + APIEncoder() + } +} + +extension StoredStatus { + static let account = belongsTo(Account.self, key: "account") + static let reblogAccount = hasOne(Account.self, through: Self.reblog, using: Self.account, key: "reblogAccount") + static let reblog = belongsTo(StoredStatus.self, key: "reblog") + 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 account: QueryInterfaceRequest { + request(for: Self.account) + } + + var reblogAccount: QueryInterfaceRequest { + request(for: Self.reblogAccount) + } + + var reblog: QueryInterfaceRequest { + request(for: Self.reblog) + } + + var ancestors: QueryInterfaceRequest { + request(for: Self.ancestors).statusResultRequest + } + + var descendants: QueryInterfaceRequest { + request(for: Self.descendants).statusResultRequest + } + + init(status: Status) { + id = status.id + uri = status.uri + createdAt = status.createdAt + accountId = status.account.id + content = status.content + visibility = status.visibility + sensitive = status.sensitive + spoilerText = status.spoilerText + mediaAttachments = status.mediaAttachments + mentions = status.mentions + tags = status.tags + emojis = status.emojis + reblogsCount = status.reblogsCount + favouritesCount = status.favouritesCount + repliesCount = status.repliesCount + application = status.application + url = status.url + inReplyToId = status.inReplyToId + inReplyToAccountId = status.inReplyToAccountId + reblogId = status.reblog?.id + poll = status.poll + card = status.card + language = status.language + text = status.text + favourited = status.favourited + reblogged = status.reblogged + muted = status.muted + bookmarked = status.bookmarked + pinned = status.pinned + } +} diff --git a/DB/Sources/DB/Content/TimelineStatusJoin.swift b/DB/Sources/DB/Content/TimelineStatusJoin.swift new file mode 100644 index 0000000..a5ed846 --- /dev/null +++ b/DB/Sources/DB/Content/TimelineStatusJoin.swift @@ -0,0 +1,11 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct TimelineStatusJoin: Codable, FetchableRecord, PersistableRecord { + let timelineId: String + let statusId: String + + static let status = belongsTo(StoredStatus.self) +} diff --git a/DB/Sources/DB/Entities/Identity.swift b/DB/Sources/DB/Entities/Identity.swift new file mode 100644 index 0000000..06f7d9e --- /dev/null +++ b/DB/Sources/DB/Entities/Identity.swift @@ -0,0 +1,71 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Mastodon + +public struct Identity: Codable, Hashable, Identifiable { + public let id: UUID + public let url: URL + public let lastUsedAt: Date + public let preferences: Identity.Preferences + public let instance: Identity.Instance? + public let account: Identity.Account? + public let lastRegisteredDeviceToken: String? + public let pushSubscriptionAlerts: PushSubscription.Alerts +} + +public extension Identity { + struct Instance: Codable, Hashable { + public let uri: String + public let streamingAPI: URL + public let title: String + public let thumbnail: URL? + } + + struct Account: Codable, Hashable { + public let id: String + public let identityID: UUID + public let username: String + public let displayName: String + public let url: URL + public let avatar: URL + public let avatarStatic: URL + public let header: URL + public let headerStatic: URL + public let emojis: [Emoji] + } + + struct Preferences: Codable, Hashable { + @DecodableDefault.True public var useServerPostingReadingPreferences + @DecodableDefault.StatusVisibilityPublic public var postingDefaultVisibility: Status.Visibility + @DecodableDefault.False public var postingDefaultSensitive + public var postingDefaultLanguage: String? + @DecodableDefault.ExpandMediaDefault public var readingExpandMedia: Mastodon.Preferences.ExpandMedia + @DecodableDefault.False public var readingExpandSpoilers + } + + var handle: String { + if let account = account, let host = account.url.host { + return account.url.lastPathComponent + "@" + host + } + + return instance?.title ?? url.host ?? url.absoluteString + } + + var image: URL? { account?.avatar ?? instance?.thumbnail } +} + +public extension Identity.Preferences { + func updated(from serverPreferences: Preferences) -> Self { + var mutable = self + + if useServerPostingReadingPreferences { + mutable.postingDefaultVisibility = serverPreferences.postingDefaultVisibility + mutable.postingDefaultSensitive = serverPreferences.postingDefaultSensitive + mutable.readingExpandMedia = serverPreferences.readingExpandMedia + mutable.readingExpandSpoilers = serverPreferences.readingExpandSpoilers + } + + return mutable + } +} diff --git a/DB/Sources/DB/Entities/IdentityFixture.swift b/DB/Sources/DB/Entities/IdentityFixture.swift new file mode 100644 index 0000000..855d6a9 --- /dev/null +++ b/DB/Sources/DB/Entities/IdentityFixture.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Mastodon + +public struct IdentityFixture { + public let id: UUID + public let instanceURL: URL + public let instance: Instance? + public let account: Account? + + public init(id: UUID, instanceURL: URL, instance: Instance?, account: Account?) { + self.id = id + self.instanceURL = instanceURL + self.instance = instance + self.account = account + } +} diff --git a/DB/Sources/DB/Extensions/Account+Extensions.swift b/DB/Sources/DB/Extensions/Account+Extensions.swift new file mode 100644 index 0000000..448fb46 --- /dev/null +++ b/DB/Sources/DB/Extensions/Account+Extensions.swift @@ -0,0 +1,15 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +extension Account: FetchableRecord, PersistableRecord { + public static func databaseJSONDecoder(for column: String) -> JSONDecoder { + APIDecoder() + } + + public static func databaseJSONEncoder(for column: String) -> JSONEncoder { + APIEncoder() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Extensions/FileManager+Extensions.swift b/DB/Sources/DB/Extensions/FileManager+Extensions.swift similarity index 92% rename from ServiceLayer/Sources/ServiceLayer/Extensions/FileManager+Extensions.swift rename to DB/Sources/DB/Extensions/FileManager+Extensions.swift index d7168ff..127ae8a 100644 --- a/ServiceLayer/Sources/ServiceLayer/Extensions/FileManager+Extensions.swift +++ b/DB/Sources/DB/Extensions/FileManager+Extensions.swift @@ -1,9 +1,4 @@ -// -// File.swift -// -// -// Created by Justin Mazzocchi on 9/2/20. -// +// Copyright © 2020 Metabolist. All rights reserved. import Foundation diff --git a/DB/Sources/DB/Extensions/Filter+Extensions.swift b/DB/Sources/DB/Extensions/Filter+Extensions.swift new file mode 100644 index 0000000..7606ec9 --- /dev/null +++ b/DB/Sources/DB/Extensions/Filter+Extensions.swift @@ -0,0 +1,15 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +extension Filter: FetchableRecord, PersistableRecord { + public static func databaseJSONDecoder(for column: String) -> JSONDecoder { + APIDecoder() + } + + public static func databaseJSONEncoder(for column: String) -> JSONEncoder { + APIEncoder() + } +} diff --git a/DB/Sources/DB/Extensions/Identity+Internal.swift b/DB/Sources/DB/Extensions/Identity+Internal.swift new file mode 100644 index 0000000..fe7b565 --- /dev/null +++ b/DB/Sources/DB/Extensions/Identity+Internal.swift @@ -0,0 +1,22 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +extension Identity { + init(result: IdentityResult) { + self.init( + id: result.identity.id, + url: result.identity.url, + lastUsedAt: result.identity.lastUsedAt, + preferences: result.identity.preferences, + instance: result.instance, + account: result.account, + lastRegisteredDeviceToken: result.identity.lastRegisteredDeviceToken, + pushSubscriptionAlerts: result.pushSubscriptionAlerts) + } +} + +extension Identity.Instance: FetchableRecord, PersistableRecord {} + +extension Identity.Account: FetchableRecord, PersistableRecord {} diff --git a/DB/Sources/DB/Extensions/Status+ Extensions.swift b/DB/Sources/DB/Extensions/Status+ Extensions.swift new file mode 100644 index 0000000..5cbacc2 --- /dev/null +++ b/DB/Sources/DB/Extensions/Status+ Extensions.swift @@ -0,0 +1,61 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +extension Status { + func save(_ db: Database) throws { + try account.save(db) + + if let reblog = reblog { + try reblog.account.save(db) + try StoredStatus(status: reblog).save(db) + } + + try StoredStatus(status: self).save(db) + } + + convenience init(statusResult: StatusResult) { + var reblog: Status? + + if let reblogResult = statusResult.reblog, let reblogAccount = statusResult.reblogAccount { + reblog = Status(storedStatus: reblogResult, account: reblogAccount, reblog: nil) + } + + self.init(storedStatus: statusResult.status, account: statusResult.account, reblog: reblog) + } + + convenience init(storedStatus: StoredStatus, account: Account, reblog: Status?) { + self.init( + id: storedStatus.id, + uri: storedStatus.uri, + createdAt: storedStatus.createdAt, + account: account, + content: storedStatus.content, + visibility: storedStatus.visibility, + sensitive: storedStatus.sensitive, + spoilerText: storedStatus.spoilerText, + mediaAttachments: storedStatus.mediaAttachments, + mentions: storedStatus.mentions, + tags: storedStatus.tags, + emojis: storedStatus.emojis, + reblogsCount: storedStatus.reblogsCount, + favouritesCount: storedStatus.favouritesCount, + repliesCount: storedStatus.repliesCount, + application: storedStatus.application, + url: storedStatus.url, + inReplyToId: storedStatus.inReplyToId, + inReplyToAccountId: storedStatus.inReplyToAccountId, + reblog: reblog, + poll: storedStatus.poll, + card: storedStatus.card, + language: storedStatus.language, + text: storedStatus.text, + favourited: storedStatus.favourited, + reblogged: storedStatus.reblogged, + muted: storedStatus.muted, + bookmarked: storedStatus.bookmarked, + pinned: storedStatus.pinned) + } +} diff --git a/DB/Sources/DB/Extensions/Timeline+Extensions.swift b/DB/Sources/DB/Extensions/Timeline+Extensions.swift new file mode 100644 index 0000000..cf4a9a9 --- /dev/null +++ b/DB/Sources/DB/Extensions/Timeline+Extensions.swift @@ -0,0 +1,47 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +extension Timeline: FetchableRecord, PersistableRecord { + enum Columns: String, ColumnExpression { + case id, listTitle + } + + public init(row: Row) { + switch (row[Columns.id] as String, row[Columns.listTitle] as String?) { + case (Timeline.home.id, _): + self = .home + case (Timeline.local.id, _): + self = .local + case (Timeline.federated.id, _): + self = .federated + case (let id, .some(let title)): + self = .list(MastodonList(id: id, title: title)) + default: + self = .tag(row[Columns.id]) + } + } + + public func encode(to container: inout PersistenceContainer) { + container[Columns.id] = id + + if case let .list(list) = self { + container[Columns.listTitle] = list.title + } + } +} + +extension Timeline { + static let statusJoins = hasMany(TimelineStatusJoin.self) + static let statuses = hasMany( + StoredStatus.self, + through: statusJoins, + using: TimelineStatusJoin.status) + .order(Column("createdAt").desc) + + var statuses: QueryInterfaceRequest { + request(for: Self.statuses).statusResultRequest + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Database/IdentityDatabase.swift b/DB/Sources/DB/Identity/IdentityDatabase.swift similarity index 83% rename from ServiceLayer/Sources/ServiceLayer/Database/IdentityDatabase.swift rename to DB/Sources/DB/Identity/IdentityDatabase.swift index d1f5ff4..847f291 100644 --- a/ServiceLayer/Sources/ServiceLayer/Database/IdentityDatabase.swift +++ b/DB/Sources/DB/Identity/IdentityDatabase.swift @@ -5,15 +5,15 @@ import Combine import GRDB import Mastodon -enum IdentityDatabaseError: Error { +public enum IdentityDatabaseError: Error { case identityNotFound } -struct IdentityDatabase { +public struct IdentityDatabase { private let databaseQueue: DatabaseQueue - init(environment: AppEnvironment) throws { - if environment.inMemoryContent { + public init(inMemory: Bool, fixture: IdentityFixture?) throws { + if inMemory { databaseQueue = DatabaseQueue() } else { let databaseURL = try FileManager.default.databaseDirectoryURL().appendingPathComponent("Identities.sqlite") @@ -23,13 +23,13 @@ struct IdentityDatabase { try Self.migrate(databaseQueue) - if let fixture = environment.identityFixture { + if let fixture = fixture { try populate(fixture: fixture) } } } -extension IdentityDatabase { +public extension IdentityDatabase { func createIdentity(id: UUID, url: URL) -> AnyPublisher { databaseQueue.writePublisher( updates: StoredIdentity( @@ -235,7 +235,7 @@ private extension IdentityDatabase { try migrator.migrate(writer) } - func populate(fixture: AppEnvironment.IdentityFixture) throws { + func populate(fixture: IdentityFixture) throws { _ = createIdentity(id: fixture.id, url: fixture.instanceURL) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } @@ -253,51 +253,3 @@ private extension IdentityDatabase { } } } - -private struct StoredIdentity: Codable, Hashable, FetchableRecord, PersistableRecord { - let id: UUID - let url: URL - let lastUsedAt: Date - let preferences: Identity.Preferences - let instanceURI: String? - let lastRegisteredDeviceToken: String? - let pushSubscriptionAlerts: PushSubscription.Alerts -} - -extension StoredIdentity { - static let instance = belongsTo(Identity.Instance.self, key: "instance") - static let account = hasOne(Identity.Account.self, key: "account") - - var instance: QueryInterfaceRequest { - request(for: Self.instance) - } - - var account: QueryInterfaceRequest { - request(for: Self.account) - } -} - -private struct IdentityResult: Codable, Hashable, FetchableRecord { - let identity: StoredIdentity - let instance: Identity.Instance? - let account: Identity.Account? - let pushSubscriptionAlerts: PushSubscription.Alerts -} - -private extension Identity { - init(result: IdentityResult) { - self.init( - id: result.identity.id, - url: result.identity.url, - lastUsedAt: result.identity.lastUsedAt, - preferences: result.identity.preferences, - instance: result.instance, - account: result.account, - lastRegisteredDeviceToken: result.identity.lastRegisteredDeviceToken, - pushSubscriptionAlerts: result.pushSubscriptionAlerts) - } -} - -extension Identity.Instance: FetchableRecord, PersistableRecord {} - -extension Identity.Account: FetchableRecord, PersistableRecord {} diff --git a/DB/Sources/DB/Identity/IdentityResult.swift b/DB/Sources/DB/Identity/IdentityResult.swift new file mode 100644 index 0000000..f5e51cd --- /dev/null +++ b/DB/Sources/DB/Identity/IdentityResult.swift @@ -0,0 +1,12 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct IdentityResult: Codable, Hashable, FetchableRecord { + let identity: StoredIdentity + let instance: Identity.Instance? + let account: Identity.Account? + let pushSubscriptionAlerts: PushSubscription.Alerts +} diff --git a/DB/Sources/DB/Identity/StoredIdentity.swift b/DB/Sources/DB/Identity/StoredIdentity.swift new file mode 100644 index 0000000..415f813 --- /dev/null +++ b/DB/Sources/DB/Identity/StoredIdentity.swift @@ -0,0 +1,28 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct StoredIdentity: Codable, Hashable, FetchableRecord, PersistableRecord { + let id: UUID + let url: URL + let lastUsedAt: Date + let preferences: Identity.Preferences + let instanceURI: String? + let lastRegisteredDeviceToken: String? + let pushSubscriptionAlerts: PushSubscription.Alerts +} + +extension StoredIdentity { + static let instance = belongsTo(Identity.Instance.self, key: "instance") + static let account = hasOne(Identity.Account.self, key: "account") + + var instance: QueryInterfaceRequest { + request(for: Self.instance) + } + + var account: QueryInterfaceRequest { + request(for: Self.account) + } +} diff --git a/DB/Tests/DBTests/DBTests.swift b/DB/Tests/DBTests/DBTests.swift new file mode 100644 index 0000000..7394c0f --- /dev/null +++ b/DB/Tests/DBTests/DBTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import DB + +final class DBTests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index e662c19..c5cc533 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = ""; }; D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = ""; }; @@ -176,6 +177,7 @@ isa = PBXGroup; children = ( D0C7D45224F76169001EBDBB /* Assets.xcassets */, + D085C3BB25008DEC008A6C5E /* DB */, D0C7D46824F76169001EBDBB /* Extensions */, D0666A7924C7745A00F3F04B /* Frameworks */, D0BFDAF524FC7C5300C86618 /* HTTP */, diff --git a/ServiceLayer/Package.swift b/ServiceLayer/Package.swift index 645e0bc..5cf3294 100644 --- a/ServiceLayer/Package.swift +++ b/ServiceLayer/Package.swift @@ -18,13 +18,13 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")), - .package(name: "GRDB", url: "https://github.com/groue/GRDB.swift.git", .upToNextMajor(from: "5.0.0-beta.10")), + .package(path: "DB"), .package(path: "Mastodon") ], targets: [ .target( name: "ServiceLayer", - dependencies: ["GRDB", "Mastodon"]), + dependencies: ["DB"]), .target( name: "ServiceLayerMocks", dependencies: ["ServiceLayer", .product(name: "MastodonStubs", package: "Mastodon")]), diff --git a/ServiceLayer/Sources/ServiceLayer/AllIdentitiesService.swift b/ServiceLayer/Sources/ServiceLayer/AllIdentitiesService.swift index d4f33ea..8074b16 100644 --- a/ServiceLayer/Sources/ServiceLayer/AllIdentitiesService.swift +++ b/ServiceLayer/Sources/ServiceLayer/AllIdentitiesService.swift @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +import DB import Foundation import Combine import Mastodon @@ -11,7 +12,8 @@ public struct AllIdentitiesService { private let environment: AppEnvironment public init(environment: AppEnvironment) throws { - self.identityDatabase = try IdentityDatabase(environment: environment) + self.identityDatabase = try IdentityDatabase(inMemory: environment.inMemoryContent, + fixture: environment.identityFixture) self.environment = environment mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation() diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift b/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift index c4b98a5..18ccb07 100644 --- a/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift +++ b/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +import DB import Foundation import HTTP import Mastodon @@ -32,20 +33,6 @@ public struct AppEnvironment { } public extension AppEnvironment { - struct IdentityFixture { - public let id: UUID - public let instanceURL: URL - public let instance: Instance? - public let account: Account? - - public init(id: UUID, instanceURL: URL, instance: Instance?, account: Account?) { - self.id = id - self.instanceURL = instanceURL - self.instance = instance - self.account = account - } - } - static func live(userNotificationCenter: UNUserNotificationCenter) -> Self { Self( session: Session(configuration: .default), diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/Identity.swift b/ServiceLayer/Sources/ServiceLayer/Entities/Identity.swift index 06f7d9e..a775f57 100644 --- a/ServiceLayer/Sources/ServiceLayer/Entities/Identity.swift +++ b/ServiceLayer/Sources/ServiceLayer/Entities/Identity.swift @@ -1,71 +1,5 @@ // Copyright © 2020 Metabolist. All rights reserved. -import Foundation -import Mastodon +import DB -public struct Identity: Codable, Hashable, Identifiable { - public let id: UUID - public let url: URL - public let lastUsedAt: Date - public let preferences: Identity.Preferences - public let instance: Identity.Instance? - public let account: Identity.Account? - public let lastRegisteredDeviceToken: String? - public let pushSubscriptionAlerts: PushSubscription.Alerts -} - -public extension Identity { - struct Instance: Codable, Hashable { - public let uri: String - public let streamingAPI: URL - public let title: String - public let thumbnail: URL? - } - - struct Account: Codable, Hashable { - public let id: String - public let identityID: UUID - public let username: String - public let displayName: String - public let url: URL - public let avatar: URL - public let avatarStatic: URL - public let header: URL - public let headerStatic: URL - public let emojis: [Emoji] - } - - struct Preferences: Codable, Hashable { - @DecodableDefault.True public var useServerPostingReadingPreferences - @DecodableDefault.StatusVisibilityPublic public var postingDefaultVisibility: Status.Visibility - @DecodableDefault.False public var postingDefaultSensitive - public var postingDefaultLanguage: String? - @DecodableDefault.ExpandMediaDefault public var readingExpandMedia: Mastodon.Preferences.ExpandMedia - @DecodableDefault.False public var readingExpandSpoilers - } - - var handle: String { - if let account = account, let host = account.url.host { - return account.url.lastPathComponent + "@" + host - } - - return instance?.title ?? url.host ?? url.absoluteString - } - - var image: URL? { account?.avatar ?? instance?.thumbnail } -} - -public extension Identity.Preferences { - func updated(from serverPreferences: Preferences) -> Self { - var mutable = self - - if useServerPostingReadingPreferences { - mutable.postingDefaultVisibility = serverPreferences.postingDefaultVisibility - mutable.postingDefaultSensitive = serverPreferences.postingDefaultSensitive - mutable.readingExpandMedia = serverPreferences.readingExpandMedia - mutable.readingExpandSpoilers = serverPreferences.readingExpandSpoilers - } - - return mutable - } -} +public typealias Identity = DB.Identity diff --git a/ServiceLayer/Sources/ServiceLayer/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/IdentityService.swift index 862ff27..7ac7c01 100644 --- a/ServiceLayer/Sources/ServiceLayer/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/IdentityService.swift @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +import DB import Foundation import Combine import Mastodon @@ -39,7 +40,7 @@ public class IdentityService { networkClient.instanceURL = identity.url networkClient.accessToken = try? secretsService.item(.accessToken) - contentDatabase = try ContentDatabase(identityID: identityID, environment: environment) + contentDatabase = try ContentDatabase(identityID: identityID, inMemory: environment.inMemoryContent) observation.catch { [weak self] error -> Empty in self?.observationErrorsInput.send(error) diff --git a/ServiceLayer/Sources/ServiceLayer/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/StatusListService.swift index beffcfd..2ce0123 100644 --- a/ServiceLayer/Sources/ServiceLayer/StatusListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/StatusListService.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Combine +import DB import Foundation import Mastodon diff --git a/ServiceLayer/Sources/ServiceLayer/StatusService.swift b/ServiceLayer/Sources/ServiceLayer/StatusService.swift index 6be66fa..cef4b26 100644 --- a/ServiceLayer/Sources/ServiceLayer/StatusService.swift +++ b/ServiceLayer/Sources/ServiceLayer/StatusService.swift @@ -2,6 +2,7 @@ import Foundation import Combine +import DB import Mastodon public struct StatusService { diff --git a/ServiceLayer/Sources/ServiceLayerMocks/MockAppEnvironment.swift b/ServiceLayer/Sources/ServiceLayerMocks/MockAppEnvironment.swift index ee39981..dd088cb 100644 --- a/ServiceLayer/Sources/ServiceLayerMocks/MockAppEnvironment.swift +++ b/ServiceLayer/Sources/ServiceLayerMocks/MockAppEnvironment.swift @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +import DB import Foundation import HTTP import ServiceLayer