CoreDataStack.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import CoreData
  2. import Foundation
  3. import OSLog
  4. class CoreDataStack: ObservableObject {
  5. static let shared = CoreDataStack()
  6. static let identifier = "CoreDataStack"
  7. private var notificationToken: NSObjectProtocol?
  8. private let inMemory: Bool
  9. private init(inMemory: Bool = false) {
  10. self.inMemory = inMemory
  11. // Observe Core Data remote change notifications on the queue where the changes were made
  12. notificationToken = Foundation.NotificationCenter.default.addObserver(
  13. forName: .NSPersistentStoreRemoteChange,
  14. object: nil,
  15. queue: nil
  16. ) { _ in
  17. Task {
  18. await self.fetchPersistentHistory()
  19. }
  20. }
  21. }
  22. deinit {
  23. if let observer = notificationToken {
  24. Foundation.NotificationCenter.default.removeObserver(observer)
  25. }
  26. }
  27. /// A persistent history token used for fetching transactions from the store
  28. /// Save the last token to User defaults
  29. private var lastToken: NSPersistentHistoryToken? {
  30. get {
  31. UserDefaults.standard.lastHistoryToken
  32. }
  33. set {
  34. UserDefaults.standard.lastHistoryToken = newValue
  35. }
  36. }
  37. // Factory method for tests
  38. static func createForTests() -> CoreDataStack {
  39. CoreDataStack(inMemory: true)
  40. }
  41. // Used for Canvas Preview
  42. static var preview: CoreDataStack = {
  43. let stack = CoreDataStack(inMemory: true)
  44. let context = stack.persistentContainer.viewContext
  45. let pumpHistory = PumpEventStored.makePreviewEvents(count: 10, provider: stack)
  46. return stack
  47. }()
  48. /// A persistent container to set up the Core Data Stack
  49. lazy var persistentContainer: NSPersistentContainer = {
  50. let container = NSPersistentContainer(name: "TrioCoreDataPersistentContainer")
  51. guard let description = container.persistentStoreDescriptions.first else {
  52. fatalError("Failed \(DebuggingIdentifiers.failed) to retrieve a persistent store description")
  53. }
  54. if inMemory {
  55. description.url = URL(fileURLWithPath: "/dev/null")
  56. }
  57. // Enable persistent store remote change notifications
  58. /// - Tag: persistentStoreRemoteChange
  59. description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
  60. // Enable persistent history tracking
  61. /// - Tag: persistentHistoryTracking
  62. description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
  63. // Enable lightweight migration
  64. /// - Tag: lightweightMigration
  65. description.shouldMigrateStoreAutomatically = true
  66. description.shouldInferMappingModelAutomatically = true
  67. container.loadPersistentStores { _, error in
  68. if let error = error as NSError? {
  69. fatalError("Unresolved Error \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)")
  70. }
  71. }
  72. container.viewContext.automaticallyMergesChangesFromParent = false
  73. container.viewContext.name = "viewContext"
  74. /// - Tag: viewContextmergePolicy
  75. container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  76. container.viewContext.undoManager = nil
  77. container.viewContext.shouldDeleteInaccessibleFaults = true
  78. return container
  79. }()
  80. /// Creates and configures a private queue context
  81. func newTaskContext() -> NSManagedObjectContext {
  82. // Create a private queue context
  83. /// - Tag: newBackgroundContext
  84. let taskContext = persistentContainer.newBackgroundContext()
  85. /// ensure that the background contexts stay in sync with the main context
  86. taskContext.automaticallyMergesChangesFromParent = false
  87. taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  88. taskContext.undoManager = nil
  89. return taskContext
  90. }
  91. func fetchPersistentHistory() async {
  92. do {
  93. try await fetchPersistentHistoryTransactionsAndChanges()
  94. } catch {
  95. debugPrint("\(error.localizedDescription)")
  96. }
  97. }
  98. private func fetchPersistentHistoryTransactionsAndChanges() async throws {
  99. let taskContext = newTaskContext()
  100. taskContext.name = "persistentHistoryContext"
  101. // debugPrint("Start fetching persistent history changes from the store ... \(DebuggingIdentifiers.inProgress)")
  102. try await taskContext.perform {
  103. // Execute the persistent history change since the last transaction
  104. /// - Tag: fetchHistory
  105. let changeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastToken)
  106. let historyResult = try taskContext.execute(changeRequest) as? NSPersistentHistoryResult
  107. if let history = historyResult?.result as? [NSPersistentHistoryTransaction], !history.isEmpty {
  108. self.mergePersistentHistoryChanges(from: history)
  109. return
  110. }
  111. }
  112. }
  113. private func mergePersistentHistoryChanges(from history: [NSPersistentHistoryTransaction]) {
  114. // debugPrint("Received \(history.count) persistent history transactions")
  115. // Update view context with objectIDs from history change request
  116. /// - Tag: mergeChanges
  117. let viewContext = persistentContainer.viewContext
  118. viewContext.perform {
  119. for transaction in history {
  120. viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
  121. self.lastToken = transaction.token
  122. }
  123. }
  124. }
  125. // Clean old Persistent History
  126. /// - Tag: clearHistory
  127. func cleanupPersistentHistoryTokens(before date: Date) async {
  128. let taskContext = newTaskContext()
  129. taskContext.name = "cleanPersistentHistoryTokensContext"
  130. await taskContext.perform {
  131. let deleteHistoryTokensRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date)
  132. do {
  133. try taskContext.execute(deleteHistoryTokensRequest)
  134. debugPrint("\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history before \(date)")
  135. } catch {
  136. debugPrint(
  137. "\(DebuggingIdentifiers.failed) Failed to delete persistent history before \(date): \(error.localizedDescription)"
  138. )
  139. }
  140. }
  141. }
  142. }
  143. // MARK: - Delete
  144. extension CoreDataStack {
  145. /// Synchronously delete entry with specified object IDs
  146. /// - Tag: synchronousDelete
  147. func deleteObject(identifiedBy objectID: NSManagedObjectID) async {
  148. let viewContext = persistentContainer.viewContext
  149. debugPrint("Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
  150. await viewContext.perform {
  151. do {
  152. let entryToDelete = viewContext.object(with: objectID)
  153. viewContext.delete(entryToDelete)
  154. guard viewContext.hasChanges else { return }
  155. try viewContext.save()
  156. debugPrint("Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
  157. } catch {
  158. debugPrint("Failed to delete data: \(error.localizedDescription)")
  159. }
  160. }
  161. }
  162. /// Asynchronously deletes records for entities
  163. /// - Tag: batchDelete
  164. func batchDeleteOlderThan<T: NSManagedObject>(
  165. _ objectType: T.Type,
  166. dateKey: String,
  167. days: Int,
  168. isPresetKey: String? = nil
  169. ) async throws {
  170. let taskContext = newTaskContext()
  171. taskContext.name = "deleteContext"
  172. taskContext.transactionAuthor = "batchDelete"
  173. // Get the number of days we want to keep the data
  174. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  175. // Fetch all the objects that are older than the specified days
  176. let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
  177. // Construct the predicate
  178. var predicates: [NSPredicate] = [NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)]
  179. if let isPresetKey = isPresetKey {
  180. predicates.append(NSPredicate(format: "%K == NO", isPresetKey))
  181. }
  182. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
  183. fetchRequest.resultType = .managedObjectIDResultType
  184. do {
  185. // Execute the Fetch Request
  186. let objectIDs = try await taskContext.perform {
  187. try taskContext.fetch(fetchRequest)
  188. }
  189. // Guard check if there are NSManagedObjects older than the specified days
  190. guard !objectIDs.isEmpty else {
  191. // debugPrint("No objects found older than \(days) days.")
  192. return
  193. }
  194. // Execute the Batch Delete
  195. try await taskContext.perform {
  196. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
  197. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  198. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  199. let success = batchDeleteResult.result as? Bool, success
  200. else {
  201. debugPrint("Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  202. throw CoreDataError.batchDeleteError
  203. }
  204. }
  205. debugPrint("Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
  206. } catch {
  207. debugPrint("Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
  208. throw CoreDataError.batchDeleteError
  209. }
  210. }
  211. func batchDeleteOlderThan<Parent: NSManagedObject, Child: NSManagedObject>(
  212. parentType: Parent.Type,
  213. childType: Child.Type,
  214. dateKey: String,
  215. days: Int,
  216. relationshipKey: String // The key of the Child Entity that links to the parent Entity
  217. ) async throws {
  218. let taskContext = newTaskContext()
  219. taskContext.name = "deleteContext"
  220. taskContext.transactionAuthor = "batchDelete"
  221. // Get the target date
  222. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  223. // Fetch Parent objects older than the target date
  224. let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
  225. fetchParentRequest.predicate = NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)
  226. fetchParentRequest.resultType = .managedObjectIDResultType
  227. do {
  228. let parentObjectIDs = try await taskContext.perform {
  229. try taskContext.fetch(fetchParentRequest)
  230. }
  231. guard !parentObjectIDs.isEmpty else {
  232. // debugPrint("No \(parentType) objects found older than \(days) days.")
  233. return
  234. }
  235. // Fetch Child objects related to the fetched Parent objects
  236. let fetchChildRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: childType))
  237. fetchChildRequest.predicate = NSPredicate(format: "ANY %K IN %@", relationshipKey, parentObjectIDs)
  238. fetchChildRequest.resultType = .managedObjectIDResultType
  239. let childObjectIDs = try await taskContext.perform {
  240. try taskContext.fetch(fetchChildRequest)
  241. }
  242. guard !childObjectIDs.isEmpty else {
  243. // debugPrint("No \(childType) objects found related to \(parentType) objects older than \(days) days.")
  244. return
  245. }
  246. // Execute the batch delete for Child objects
  247. try await taskContext.perform {
  248. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: childObjectIDs)
  249. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  250. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  251. let success = batchDeleteResult.result as? Bool, success
  252. else {
  253. debugPrint("Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  254. throw CoreDataError.batchDeleteError
  255. }
  256. }
  257. debugPrint(
  258. "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
  259. )
  260. } catch {
  261. debugPrint("Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
  262. throw CoreDataError.batchDeleteError
  263. }
  264. }
  265. }
  266. // MARK: - Fetch Requests
  267. extension CoreDataStack {
  268. // Fetch in background thread
  269. /// - Tag: backgroundFetch
  270. func fetchEntities<T: NSManagedObject>(
  271. ofType type: T.Type,
  272. onContext context: NSManagedObjectContext,
  273. predicate: NSPredicate,
  274. key: String,
  275. ascending: Bool,
  276. fetchLimit: Int? = nil,
  277. batchSize: Int? = nil,
  278. propertiesToFetch: [String]? = nil,
  279. callingFunction: String = #function,
  280. callingClass: String = #fileID
  281. ) -> [Any] {
  282. let request = NSFetchRequest<NSFetchRequestResult>(entityName: String(describing: type))
  283. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  284. request.predicate = predicate
  285. if let limit = fetchLimit {
  286. request.fetchLimit = limit
  287. }
  288. if let batchSize = batchSize {
  289. request.fetchBatchSize = batchSize
  290. }
  291. if let propertiesToFetch = propertiesToFetch {
  292. request.propertiesToFetch = propertiesToFetch
  293. request.resultType = .dictionaryResultType
  294. } else {
  295. request.resultType = .managedObjectResultType
  296. }
  297. context.name = "fetchContext"
  298. context.transactionAuthor = "fetchEntities"
  299. /// we need to ensure that the fetch immediately returns a value as long as the whole app does not use the async await pattern, otherwise we could perform this asynchronously with backgroundContext.perform and not block the thread
  300. return context.performAndWait {
  301. do {
  302. if propertiesToFetch != nil {
  303. return try context.fetch(request) as? [[String: Any]] ?? []
  304. } else {
  305. return try context.fetch(request) as? [T] ?? []
  306. }
  307. } catch let error as NSError {
  308. debugPrint(
  309. "Fetching \(T.self) in \(callingFunction) from \(callingClass): \(DebuggingIdentifiers.failed) \(error) on Thread: \(Thread.current)"
  310. )
  311. return []
  312. }
  313. }
  314. }
  315. // Fetch Async
  316. func fetchEntitiesAsync<T: NSManagedObject>(
  317. ofType type: T.Type,
  318. onContext context: NSManagedObjectContext,
  319. predicate: NSPredicate,
  320. key: String,
  321. ascending: Bool,
  322. fetchLimit: Int? = nil,
  323. batchSize: Int? = nil,
  324. propertiesToFetch: [String]? = nil,
  325. relationshipKeyPathsForPrefetching: [String]? = nil,
  326. callingFunction: String = #function,
  327. callingClass: String = #fileID
  328. ) async -> Any {
  329. let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
  330. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  331. request.predicate = predicate
  332. if let limit = fetchLimit {
  333. request.fetchLimit = limit
  334. }
  335. if let batchSize = batchSize {
  336. request.fetchBatchSize = batchSize
  337. }
  338. if let propertiesToFetch = propertiesToFetch {
  339. request.propertiesToFetch = propertiesToFetch
  340. request.resultType = .dictionaryResultType
  341. } else {
  342. request.resultType = .managedObjectResultType
  343. }
  344. if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
  345. request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
  346. }
  347. context.name = "fetchContext"
  348. context.transactionAuthor = "fetchEntities"
  349. return await context.perform {
  350. do {
  351. if propertiesToFetch != nil {
  352. return try context.fetch(request) as? [[String: Any]] ?? []
  353. } else {
  354. return try context.fetch(request) as? [T] ?? []
  355. }
  356. } catch let error as NSError {
  357. debugPrint(
  358. "Fetching \(T.self) in \(callingFunction) from \(callingClass): \(DebuggingIdentifiers.failed) \(error) on Thread: \(Thread.current)"
  359. )
  360. return []
  361. }
  362. }
  363. }
  364. // Get NSManagedObject
  365. func getNSManagedObject<T: NSManagedObject>(
  366. with ids: [NSManagedObjectID],
  367. context: NSManagedObjectContext
  368. ) async -> [T] {
  369. await context.perform {
  370. var objects = [T]()
  371. do {
  372. for id in ids {
  373. if let object = try context.existingObject(with: id) as? T {
  374. objects.append(object)
  375. }
  376. }
  377. } catch {
  378. debugPrint("Failed to fetch objects: \(error.localizedDescription)")
  379. }
  380. return objects
  381. }
  382. }
  383. }
  384. // MARK: - Save
  385. /// This function is used when terminating the App to ensure any unsaved changes on the view context made their way to the persistent container
  386. extension CoreDataStack {
  387. func save() {
  388. let context = persistentContainer.viewContext
  389. guard context.hasChanges else { return }
  390. do {
  391. try context.save()
  392. } catch {
  393. debugPrint("Error saving context \(DebuggingIdentifiers.failed): \(error)")
  394. }
  395. }
  396. }
  397. extension NSManagedObjectContext {
  398. // takes a context as a parameter to be executed either on the main thread or on a background thread
  399. /// - Tag: save
  400. func saveContext(
  401. onContext: NSManagedObjectContext,
  402. callingFunction: String = #function,
  403. callingClass: String = #fileID
  404. ) throws {
  405. do {
  406. guard onContext.hasChanges else { return }
  407. try onContext.save()
  408. // debugPrint(
  409. // "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
  410. // )
  411. } catch let error as NSError {
  412. debugPrint(
  413. "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
  414. )
  415. throw error
  416. }
  417. }
  418. }