CoreDataStack.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  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. debug(.coreData, "\(error.localizedDescription)")
  111. }
  112. }
  113. private func fetchPersistentHistoryTransactionsAndChanges() async throws {
  114. let taskContext = newTaskContext()
  115. taskContext.name = "persistentHistoryContext"
  116. // debug(.coreData,"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. // debug(.coreData,"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. debug(.coreData, "\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history before \(date)")
  150. } catch {
  151. debug(
  152. .coreData,
  153. "\(DebuggingIdentifiers.failed) Failed to delete persistent history before \(date): \(error.localizedDescription)"
  154. )
  155. }
  156. }
  157. }
  158. func initializeStack() throws {
  159. // Force initialization of persistent container
  160. let container = persistentContainer
  161. // Verify the store is loaded
  162. guard container.persistentStoreCoordinator.persistentStores.isEmpty == false else {
  163. throw CoreDataError.storeNotInitializedError(function: #function, file: #file)
  164. }
  165. }
  166. }
  167. // MARK: - Delete
  168. extension CoreDataStack {
  169. /// Synchronously delete entry with specified object IDs
  170. /// - Tag: synchronousDelete
  171. func deleteObject(identifiedBy objectID: NSManagedObjectID) async {
  172. let viewContext = persistentContainer.viewContext
  173. debug(.coreData, "Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
  174. await viewContext.perform {
  175. do {
  176. let entryToDelete = viewContext.object(with: objectID)
  177. viewContext.delete(entryToDelete)
  178. guard viewContext.hasChanges else { return }
  179. try viewContext.save()
  180. debug(.coreData, "Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
  181. } catch {
  182. debug(.coreData, "Failed to delete data: \(error.localizedDescription)")
  183. }
  184. }
  185. }
  186. /// Asynchronously deletes records for entities
  187. /// - Tag: batchDelete
  188. func batchDeleteOlderThan<T: NSManagedObject>(
  189. _ objectType: T.Type,
  190. dateKey: String,
  191. days: Int,
  192. isPresetKey: String? = nil,
  193. callingFunction: String = #function,
  194. callingClass: String = #fileID
  195. ) async throws {
  196. let taskContext = newTaskContext()
  197. taskContext.name = "deleteContext"
  198. taskContext.transactionAuthor = "batchDelete"
  199. // Get the number of days we want to keep the data
  200. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  201. // Fetch all the objects that are older than the specified days
  202. let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
  203. // Construct the predicate
  204. var predicates: [NSPredicate] = [NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)]
  205. if let isPresetKey = isPresetKey {
  206. predicates.append(NSPredicate(format: "%K == NO", isPresetKey))
  207. }
  208. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
  209. fetchRequest.resultType = .managedObjectIDResultType
  210. do {
  211. // Execute the Fetch Request
  212. let objectIDs = try await taskContext.perform {
  213. try taskContext.fetch(fetchRequest)
  214. }
  215. // Guard check if there are NSManagedObjects older than the specified days
  216. guard !objectIDs.isEmpty else {
  217. // debug(.coreData,"No objects found older than \(days) days.")
  218. return
  219. }
  220. // Execute the Batch Delete
  221. try await taskContext.perform {
  222. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
  223. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  224. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  225. let success = batchDeleteResult.result as? Bool, success
  226. else {
  227. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  228. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  229. }
  230. }
  231. debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
  232. } catch {
  233. debug(.coreData, "Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
  234. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  235. }
  236. }
  237. func batchDeleteOlderThan<Parent: NSManagedObject, Child: NSManagedObject>(
  238. parentType: Parent.Type,
  239. childType: Child.Type,
  240. dateKey: String,
  241. days: Int,
  242. relationshipKey: String, // The key of the Child Entity that links to the parent Entity
  243. callingFunction: String = #function,
  244. callingClass: String = #fileID
  245. ) async throws {
  246. let taskContext = newTaskContext()
  247. taskContext.name = "deleteContext"
  248. taskContext.transactionAuthor = "batchDelete"
  249. // Get the target date
  250. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  251. // Fetch Parent objects older than the target date
  252. let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
  253. fetchParentRequest.predicate = NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)
  254. fetchParentRequest.resultType = .managedObjectIDResultType
  255. do {
  256. let parentObjectIDs = try await taskContext.perform {
  257. try taskContext.fetch(fetchParentRequest)
  258. }
  259. guard !parentObjectIDs.isEmpty else {
  260. // debug(.coreData,"No \(parentType) objects found older than \(days) days.")
  261. return
  262. }
  263. // Fetch Child objects related to the fetched Parent objects
  264. let fetchChildRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: childType))
  265. fetchChildRequest.predicate = NSPredicate(format: "ANY %K IN %@", relationshipKey, parentObjectIDs)
  266. fetchChildRequest.resultType = .managedObjectIDResultType
  267. let childObjectIDs = try await taskContext.perform {
  268. try taskContext.fetch(fetchChildRequest)
  269. }
  270. guard !childObjectIDs.isEmpty else {
  271. // debug(.coreData,"No \(childType) objects found related to \(parentType) objects older than \(days) days.")
  272. return
  273. }
  274. // Execute the batch delete for Child objects
  275. try await taskContext.perform {
  276. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: childObjectIDs)
  277. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  278. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  279. let success = batchDeleteResult.result as? Bool, success
  280. else {
  281. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  282. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  283. }
  284. }
  285. debug(
  286. .coreData,
  287. "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
  288. )
  289. } catch {
  290. debug(.coreData, "Failed to fetch or delete data: \(error.localizedDescription) \(DebuggingIdentifiers.failed)")
  291. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  292. }
  293. }
  294. }
  295. // MARK: - Fetch Requests
  296. extension CoreDataStack {
  297. // Fetch in background thread
  298. /// - Tag: backgroundFetch
  299. func fetchEntities<T: NSManagedObject>(
  300. ofType type: T.Type,
  301. onContext context: NSManagedObjectContext,
  302. predicate: NSPredicate,
  303. key: String,
  304. ascending: Bool,
  305. fetchLimit: Int? = nil,
  306. batchSize: Int? = nil,
  307. propertiesToFetch: [String]? = nil,
  308. callingFunction: String = #function,
  309. callingClass: String = #fileID
  310. ) throws -> [Any] {
  311. let request = NSFetchRequest<NSFetchRequestResult>(entityName: String(describing: type))
  312. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  313. request.predicate = predicate
  314. if let limit = fetchLimit {
  315. request.fetchLimit = limit
  316. }
  317. if let batchSize = batchSize {
  318. request.fetchBatchSize = batchSize
  319. }
  320. if let propertiesToFetch = propertiesToFetch {
  321. request.propertiesToFetch = propertiesToFetch
  322. request.resultType = .dictionaryResultType
  323. } else {
  324. request.resultType = .managedObjectResultType
  325. }
  326. context.name = "fetchContext"
  327. context.transactionAuthor = "fetchEntities"
  328. /// 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
  329. return try context.performAndWait {
  330. do {
  331. if propertiesToFetch != nil {
  332. return try context.fetch(request) as? [[String: Any]] ?? []
  333. } else {
  334. return try context.fetch(request) as? [T] ?? []
  335. }
  336. } catch let error as NSError {
  337. throw CoreDataError.fetchError(
  338. function: callingFunction,
  339. file: callingClass
  340. )
  341. }
  342. }
  343. }
  344. // Fetch Async
  345. func fetchEntitiesAsync<T: NSManagedObject>(
  346. ofType type: T.Type,
  347. onContext context: NSManagedObjectContext,
  348. predicate: NSPredicate,
  349. key: String,
  350. ascending: Bool,
  351. fetchLimit: Int? = nil,
  352. batchSize: Int? = nil,
  353. propertiesToFetch: [String]? = nil,
  354. relationshipKeyPathsForPrefetching: [String]? = nil,
  355. callingFunction: String = #function,
  356. callingClass: String = #fileID
  357. ) async throws -> Any {
  358. let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
  359. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  360. request.predicate = predicate
  361. if let limit = fetchLimit {
  362. request.fetchLimit = limit
  363. }
  364. if let batchSize = batchSize {
  365. request.fetchBatchSize = batchSize
  366. }
  367. if let propertiesToFetch = propertiesToFetch {
  368. request.propertiesToFetch = propertiesToFetch
  369. request.resultType = .dictionaryResultType
  370. } else {
  371. request.resultType = .managedObjectResultType
  372. }
  373. if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
  374. request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
  375. }
  376. context.name = "fetchContext"
  377. context.transactionAuthor = "fetchEntities"
  378. return try await context.perform {
  379. do {
  380. if propertiesToFetch != nil {
  381. return try context.fetch(request) as? [[String: Any]] ?? []
  382. } else {
  383. return try context.fetch(request) as? [T] ?? []
  384. }
  385. } catch let error as NSError {
  386. throw CoreDataError.unexpectedError(
  387. error: error,
  388. function: callingFunction,
  389. file: callingClass
  390. )
  391. }
  392. }
  393. }
  394. // Get NSManagedObject
  395. func getNSManagedObject<T: NSManagedObject>(
  396. with ids: [NSManagedObjectID],
  397. context: NSManagedObjectContext,
  398. callingFunction: String = #function,
  399. callingClass: String = #fileID
  400. ) async throws -> [T] {
  401. try await context.perform {
  402. var objects = [T]()
  403. do {
  404. for id in ids {
  405. if let object = try context.existingObject(with: id) as? T {
  406. objects.append(object)
  407. }
  408. }
  409. return objects
  410. } catch {
  411. throw CoreDataError.fetchError(
  412. function: callingFunction,
  413. file: callingClass
  414. )
  415. }
  416. }
  417. }
  418. }
  419. // MARK: - Save
  420. /// This function is used when terminating the App to ensure any unsaved changes on the view context made their way to the persistent container
  421. extension CoreDataStack {
  422. func save() {
  423. let context = persistentContainer.viewContext
  424. guard context.hasChanges else { return }
  425. do {
  426. try context.save()
  427. } catch {
  428. debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)")
  429. }
  430. }
  431. }
  432. extension NSManagedObjectContext {
  433. // takes a context as a parameter to be executed either on the main thread or on a background thread
  434. /// - Tag: save
  435. func saveContext(
  436. onContext: NSManagedObjectContext,
  437. callingFunction: String = #function,
  438. callingClass: String = #fileID
  439. ) throws {
  440. do {
  441. guard onContext.hasChanges else { return }
  442. try onContext.save()
  443. // debug(.coreData,
  444. // "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
  445. // )
  446. } catch let error as NSError {
  447. debug(
  448. .coreData,
  449. "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
  450. )
  451. throw error
  452. }
  453. }
  454. }