CoreDataStack.swift 20 KB

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