Tusker/Tusker/CoreData/MastodonCachePersistentStor...

342 lines
14 KiB
Swift

//
// MastodonCachePersistentStore.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
import Combine
import OSLog
import Sentry
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
class MastodonCachePersistentStore: NSPersistentContainer {
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
return context
}()
private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
return context
}()
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
// would need to audit existing uses to make sure everything happens on the main thread
// and when updating things on the background context would need to switch to main, refetch, and then publish
let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>()
let relationshipSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
if transient {
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
let storeDescription = NSPersistentStoreDescription()
storeDescription.type = NSInMemoryStoreType
persistentStoreDescriptions = [storeDescription]
} else {
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
}
loadPersistentStores { (description, error) in
if let error = error {
logger.error("Unable to load persistent store: \(String(describing: error), privacy: .public)")
fatalError("Unable to load persistent store")
}
}
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
}
func save(context: NSManagedObjectContext) {
guard context.hasChanges else {
return
}
do {
try context.save()
} catch let error as NSError {
logger.error("Unable to save managed object context: \(String(describing: error), privacy: .public)")
let crumb = Breadcrumb(level: .fatal, category: "PersistentStore")
// note: NSDetailedErrorsKey == "NSDetailedErrorsKey" != "NSDetailedErrors"
if let detailed = error.userInfo["NSDetailedErrors"] as? [NSError] {
crumb.data = [
"errors": detailed.compactMap { error -> [String: Any?]? in
guard let object = error.userInfo[NSValidationObjectErrorKey] as? NSManagedObject else {
return nil
}
return [
"entity": object.entity.name,
"key": error.userInfo[NSValidationKeyErrorKey],
"value": error.userInfo[NSValidationValueErrorKey],
"message": error.localizedDescription,
]
}
]
}
SentrySDK.addBreadcrumb(crumb: crumb)
fatalError("Unable to save managed object context: \(String(describing: error))")
}
}
func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? {
let context = context ?? viewContext
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let status = result.first {
return status
} else {
return nil
}
}
@discardableResult
private func upsert(status: Status, context: NSManagedObjectContext) -> StatusMO {
if let statusMO = self.status(for: status.id, in: context) {
statusMO.updateFrom(apiStatus: status, container: self)
return statusMO
} else {
return StatusMO(apiStatus: status, container: self, context: context)
}
}
func addOrUpdate(status: Status, context: NSManagedObjectContext? = nil, completion: ((StatusMO) -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
let statusMO = self.upsert(status: status, context: context)
self.save(context: context)
completion?(statusMO)
self.statusSubject.send(status.id)
}
}
@MainActor
func addOrUpdateOnViewContext(status: Status) -> StatusMO {
let statusMO = self.upsert(status: status, context: viewContext)
self.save(context: viewContext)
statusSubject.send(status.id)
return statusMO
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform {
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
self.save(context: self.backgroundContext)
statuses.forEach { self.statusSubject.send($0.id) }
completion?()
}
}
func addAll(statuses: [Status]) async {
return await withCheckedContinuation { continuation in
addAll(statuses: statuses) {
continuation.resume()
}
}
}
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
let context = context ?? viewContext
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let account = result.first {
return account
} else {
return nil
}
}
@discardableResult
private func upsert(account: Account, in context: NSManagedObjectContext) -> AccountMO {
if let accountMO = self.account(for: account.id, in: context) {
accountMO.updateFrom(apiAccount: account, container: self)
return accountMO
} else {
return AccountMO(apiAccount: account, container: self, context: context)
}
}
func addOrUpdate(account: Account, in context: NSManagedObjectContext? = nil, completion: ((AccountMO) -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
let accountMO = self.upsert(account: account, in: context)
self.save(context: context)
completion?(accountMO)
self.accountSubject.send(account.id)
}
}
func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? {
let context = context ?? viewContext
let request: NSFetchRequest<RelationshipMO> = RelationshipMO.fetchRequest()
request.predicate = NSPredicate(format: "accountID = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let relationship = result.first {
return relationship
} else {
return nil
}
}
@discardableResult
private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: context) {
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
return relationshipMO
} else {
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: context)
return relationshipMO
}
}
func addOrUpdate(relationship: Relationship, in context: NSManagedObjectContext? = nil, completion: ((RelationshipMO) -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
let relationshipMO = self.upsert(relationship: relationship, in: context)
self.save(context: context)
completion?(relationshipMO)
self.relationshipSubject.send(relationship.id)
}
}
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
accounts.forEach { self.upsert(account: $0, in: context) }
self.save(context: context)
completion?()
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform {
let statuses = notifications.compactMap { $0.status }
// filter out mentions, otherwise we would double increment the reference count of those accounts
// since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext)
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func performBatchUpdates(_ block: @escaping (_ context: NSManagedObjectContext, _ addAccounts: ([Account]) -> Void, _ addStatuses: ([Status]) -> Void) -> Void, completion: (() -> Void)? = nil) {
backgroundContext.perform {
var updatedAccounts = [String]()
var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
updatedStatuses.append(contentsOf: statuses.map { $0.id })
})
updatedAccounts.forEach(self.accountSubject.send)
updatedStatuses.forEach(self.statusSubject.send)
self.save(context: self.backgroundContext)
completion?()
}
}
func updateFollowedHashtags(_ hashtags: [Hashtag], completion: @escaping (Result<[FollowedHashtag], Error>) -> Void) {
viewContext.perform {
do {
var all = try self.viewContext.fetch(FollowedHashtag.fetchRequest())
let toDelete = all.filter { existing in !hashtags.contains(where: { $0.name == existing.name }) }.map(\.objectID)
if !toDelete.isEmpty {
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
}
for hashtag in hashtags where !all.contains(where: { $0.name == hashtag.name}) {
let mo = FollowedHashtag(hashtag: hashtag, context: self.viewContext)
all.append(mo)
}
self.save(context: self.viewContext)
completion(.success(all))
} catch {
completion(.failure(error))
}
}
}
func hasFollowedHashtag(_ hashtag: Hashtag) -> Bool {
do {
let req = FollowedHashtag.fetchRequest(name: name)
return try viewContext.count(for: req) > 0
} catch {
return false
}
}
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
if changes.instances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
}
private func hasChangedSavedHashtagsOrInstances(_ notification: Foundation.Notification) -> (hashtags: Bool, instances: Bool) {
var changes: (hashtags: Bool, instances: Bool) = (false, false)
if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> {
for object in inserted {
if object is SavedHashtag {
changes.hashtags = true
} else if object is SavedInstance {
changes.instances = true
}
if changes.hashtags && changes.instances {
return changes
}
}
}
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> {
for object in deleted {
if object is SavedHashtag {
changes.hashtags = true
} else if object is SavedInstance {
changes.instances = true
}
if changes.hashtags && changes.instances {
return changes
}
}
}
return changes
}
}