CoreDataStack.swift 20 KB

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