CoreDataStack.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  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 let maxRetries = 3
  11. private let initializationCoordinator = CoreDataInitializationCoordinator()
  12. private init(inMemory: Bool = false) {
  13. self.inMemory = inMemory
  14. // Initialize persistent container immediately
  15. persistentContainer = NSPersistentContainer(
  16. name: "TrioCoreDataPersistentContainer",
  17. managedObjectModel: Self.managedObjectModel
  18. )
  19. guard let description = persistentContainer.persistentStoreDescriptions.first else {
  20. fatalError("Failed \(DebuggingIdentifiers.failed) to retrieve a persistent store description")
  21. }
  22. if inMemory {
  23. description.url = URL(fileURLWithPath: "/dev/null")
  24. }
  25. // Enable persistent store remote change notifications
  26. /// - Tag: persistentStoreRemoteChange
  27. description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
  28. // Enable persistent history tracking
  29. /// - Tag: persistentHistoryTracking
  30. description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
  31. // Enable lightweight migration
  32. /// - Tag: lightweightMigration
  33. description.shouldMigrateStoreAutomatically = true
  34. description.shouldInferMappingModelAutomatically = true
  35. persistentContainer.viewContext.automaticallyMergesChangesFromParent = false
  36. persistentContainer.viewContext.name = "viewContext"
  37. /// - Tag: viewContextmergePolicy
  38. persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  39. persistentContainer.viewContext.undoManager = nil
  40. persistentContainer.viewContext.shouldDeleteInaccessibleFaults = true
  41. }
  42. deinit {
  43. if let observer = notificationToken {
  44. Foundation.NotificationCenter.default.removeObserver(observer)
  45. }
  46. }
  47. /// A persistent history token used for fetching transactions from the store
  48. /// Save the last token to User defaults
  49. private var lastToken: NSPersistentHistoryToken? {
  50. get {
  51. UserDefaults.standard.lastHistoryToken
  52. }
  53. set {
  54. UserDefaults.standard.lastHistoryToken = newValue
  55. }
  56. }
  57. // Factory method for tests
  58. static func createForTests() async throws -> CoreDataStack {
  59. let stack = CoreDataStack(inMemory: true)
  60. try await stack.initializeStack()
  61. return stack
  62. }
  63. // Used for Canvas Preview
  64. static func preview() async throws -> CoreDataStack {
  65. let stack = CoreDataStack(inMemory: true)
  66. try await stack.initializeStack()
  67. return stack
  68. }
  69. // Shared managed object model
  70. static var managedObjectModel: NSManagedObjectModel = {
  71. let bundle = Bundle(for: CoreDataStack.self)
  72. guard let url = bundle.url(forResource: "TrioCoreDataPersistentContainer", withExtension: "momd") else {
  73. fatalError("Failed \(DebuggingIdentifiers.failed) to locate momd file")
  74. }
  75. guard let model = NSManagedObjectModel(contentsOf: url) else {
  76. fatalError("Failed \(DebuggingIdentifiers.failed) to load momd file")
  77. }
  78. return model
  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 = true
  87. taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  88. taskContext.undoManager = nil
  89. return taskContext
  90. }
  91. func fetchPersistentHistory() async {
  92. do {
  93. try await fetchPersistentHistoryTransactionsAndChanges()
  94. } catch let error as NSError where error.code == NSPersistentHistoryTokenExpiredError {
  95. debug(.coreData, "Persistent history token expired; clearing token and replaying available history.")
  96. await recoverFromExpiredHistoryToken()
  97. } catch {
  98. debug(.coreData, "\(error)")
  99. }
  100. }
  101. /// Recovers the change-merge pipeline after the persistent history token expires.
  102. private func recoverFromExpiredHistoryToken() async {
  103. lastToken = nil
  104. do {
  105. try await fetchPersistentHistoryTransactionsAndChanges()
  106. } catch {
  107. debug(.coreData, "Replay after token reset failed; jumping to current token. \(error)")
  108. let viewContext = persistentContainer.viewContext
  109. let currentToken = persistentContainer.persistentStoreCoordinator
  110. .currentPersistentHistoryToken(fromStores: nil)
  111. lastToken = currentToken
  112. await viewContext.perform {
  113. viewContext.refreshAllObjects()
  114. }
  115. }
  116. }
  117. private func fetchPersistentHistoryTransactionsAndChanges() async throws {
  118. let taskContext = newTaskContext()
  119. taskContext.name = "persistentHistoryContext"
  120. // debug(.coreData,"Start fetching persistent history changes from the store ... \(DebuggingIdentifiers.inProgress)")
  121. try await taskContext.perform {
  122. // Execute the persistent history change since the last transaction
  123. /// - Tag: fetchHistory
  124. let changeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastToken)
  125. let historyResult = try taskContext.execute(changeRequest) as? NSPersistentHistoryResult
  126. if let history = historyResult?.result as? [NSPersistentHistoryTransaction], !history.isEmpty {
  127. self.mergePersistentHistoryChanges(from: history)
  128. return
  129. }
  130. }
  131. }
  132. private func mergePersistentHistoryChanges(from history: [NSPersistentHistoryTransaction]) {
  133. // debug(.coreData,"Received \(history.count) persistent history transactions")
  134. // Update view context with objectIDs from history change request
  135. /// - Tag: mergeChanges
  136. let viewContext = persistentContainer.viewContext
  137. viewContext.perform {
  138. for transaction in history {
  139. viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
  140. self.lastToken = transaction.token
  141. }
  142. }
  143. }
  144. // Clean old Persistent History
  145. /// - Tag: clearHistory
  146. func cleanupPersistentHistoryTokens(before date: Date) async {
  147. let taskContext = newTaskContext()
  148. taskContext.name = "cleanPersistentHistoryTokensContext"
  149. await taskContext.perform {
  150. let deleteHistoryTokensRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date)
  151. do {
  152. try taskContext.execute(deleteHistoryTokensRequest)
  153. debug(.coreData, "\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history from before \(date)")
  154. } catch {
  155. debug(
  156. .coreData,
  157. "\(DebuggingIdentifiers.failed) Failed to delete persistent history from before \(date): \(error)"
  158. )
  159. }
  160. }
  161. }
  162. private func setupPersistentStoreChangeNotifications() {
  163. // Observe Core Data remote change notifications on the queue where the changes were made
  164. notificationToken = Foundation.NotificationCenter.default.addObserver(
  165. forName: .NSPersistentStoreRemoteChange,
  166. object: nil,
  167. queue: nil
  168. ) { _ in
  169. Task {
  170. await self.fetchPersistentHistory()
  171. }
  172. }
  173. debug(.coreData, "Set up persistent store change notifications")
  174. }
  175. /// Loads the persistent stores asynchronously.
  176. ///
  177. /// Converts the synchronous NSPersistentContainer loading process into an async/await compatible
  178. /// function using a continuation.
  179. ///
  180. /// - Throws: Any errors encountered during the loading of persistent stores.
  181. /// - Returns: Void once stores are loaded successfully
  182. private func loadPersistentStores() async throws {
  183. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  184. persistentContainer.loadPersistentStores { storeDescription, error in
  185. if let error = error {
  186. warning(.coreData, "Failed to load persistent stores: \(error)")
  187. continuation.resume(throwing: error)
  188. } else {
  189. debug(.coreData, "Successfully loaded persistent store: \(storeDescription.url?.absoluteString ?? "unknown")")
  190. continuation.resume(returning: ())
  191. }
  192. }
  193. }
  194. }
  195. /// Public entry point for initializing the CoreData stack.
  196. ///
  197. /// Uses the initialization coordinator to ensure initialization happens only once,
  198. /// even with concurrent calls. Subsequent calls will wait for the original initialization
  199. /// to complete.
  200. ///
  201. /// - Throws: Any errors that occur during initialization.
  202. /// - Returns: Void once initialization is complete.
  203. func initializeStack() async throws {
  204. try await initializationCoordinator.ensureInitialized {
  205. try await self.initializeStack(retryCount: 0)
  206. }
  207. }
  208. /// Private implementation of the initialization process with retry capability.
  209. ///
  210. /// Handles the actual initialization work including store loading, verification,
  211. /// notification setup, and error handling with retry logic.
  212. ///
  213. /// - Parameter retryCount: The current retry attempt number, starting at 0.
  214. /// - Throws: CoreDataError or any other error if initialization fails after all retries.
  215. /// - Returns: Void when initialization completes successfully.
  216. private func initializeStack(retryCount: Int) async throws {
  217. do {
  218. // Load stores asynchronously
  219. try await loadPersistentStores()
  220. // Verify the store is loaded
  221. guard persistentContainer.persistentStoreCoordinator.persistentStores.isEmpty == false else {
  222. let error = CoreDataError.storeNotInitializedError(function: #function, file: #file)
  223. throw error
  224. }
  225. setupPersistentStoreChangeNotifications()
  226. debug(.coreData, "Core Data stack initialized successfully")
  227. } catch {
  228. debug(.coreData, "Failed to initialize Core Data stack: \(error)")
  229. // If we still have retries left, try again after a delay
  230. if retryCount < maxRetries {
  231. debug(.coreData, "Retrying initialization (\(retryCount + 1)/\(maxRetries))")
  232. // Wait before retrying
  233. try await Task.sleep(for: .seconds(1))
  234. // Retry the initialization
  235. try await initializeStack(retryCount: retryCount + 1)
  236. } else {
  237. // We've exhausted our retries
  238. debug(.coreData, "Core Data initialization failed after \(maxRetries) attempts")
  239. throw error
  240. }
  241. }
  242. }
  243. }
  244. // MARK: - Delete
  245. extension CoreDataStack {
  246. /// Synchronously delete entry with specified object IDs
  247. /// - Tag: synchronousDelete
  248. func deleteObject(identifiedBy objectID: NSManagedObjectID) async {
  249. let viewContext = persistentContainer.viewContext
  250. debug(.coreData, "Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
  251. await viewContext.perform {
  252. do {
  253. let entryToDelete = viewContext.object(with: objectID)
  254. viewContext.delete(entryToDelete)
  255. guard viewContext.hasChanges else { return }
  256. try viewContext.save()
  257. debug(.coreData, "Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
  258. } catch {
  259. debug(.coreData, "Failed to delete data: \(error)")
  260. }
  261. }
  262. }
  263. /// Asynchronously deletes records for entities
  264. /// - Tag: batchDelete
  265. func batchDeleteOlderThan<T: NSManagedObject>(
  266. _ objectType: T.Type,
  267. dateKey: String,
  268. days: Int,
  269. isPresetKey: String? = nil,
  270. callingFunction: String = #function,
  271. callingClass: String = #fileID
  272. ) async throws {
  273. let taskContext = newTaskContext()
  274. taskContext.name = "deleteContext"
  275. taskContext.transactionAuthor = "batchDelete"
  276. // Get the number of days we want to keep the data
  277. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  278. // Fetch all the objects that are older than the specified days
  279. let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
  280. // Construct the predicate
  281. var predicates: [NSPredicate] = [NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)]
  282. if let isPresetKey = isPresetKey {
  283. predicates.append(NSPredicate(format: "%K == NO", isPresetKey))
  284. }
  285. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
  286. fetchRequest.resultType = .managedObjectIDResultType
  287. do {
  288. // Execute the Fetch Request
  289. let objectIDs = try await taskContext.perform {
  290. try taskContext.fetch(fetchRequest)
  291. }
  292. // Guard check if there are NSManagedObjects older than the specified days
  293. guard !objectIDs.isEmpty else {
  294. // debug(.coreData,"No objects found older than \(days) days.")
  295. return
  296. }
  297. // Execute the Batch Delete
  298. try await taskContext.perform {
  299. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
  300. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  301. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  302. let success = batchDeleteResult.result as? Bool, success
  303. else {
  304. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  305. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  306. }
  307. }
  308. debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
  309. } catch {
  310. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  311. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  312. }
  313. }
  314. func batchDeleteOlderThan<Parent: NSManagedObject, Child: NSManagedObject>(
  315. parentType: Parent.Type,
  316. childType: Child.Type,
  317. dateKey: String,
  318. days: Int,
  319. relationshipKey: String, // The key of the Child Entity that links to the parent Entity
  320. callingFunction: String = #function,
  321. callingClass: String = #fileID
  322. ) async throws {
  323. let taskContext = newTaskContext()
  324. taskContext.name = "deleteContext"
  325. taskContext.transactionAuthor = "batchDelete"
  326. // Get the target date
  327. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  328. // Fetch Parent objects older than the target date
  329. let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
  330. fetchParentRequest.predicate = NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)
  331. fetchParentRequest.resultType = .managedObjectIDResultType
  332. do {
  333. let parentObjectIDs = try await taskContext.perform {
  334. try taskContext.fetch(fetchParentRequest)
  335. }
  336. guard !parentObjectIDs.isEmpty else {
  337. // debug(.coreData,"No \(parentType) objects found older than \(days) days.")
  338. return
  339. }
  340. // Fetch Child objects related to the fetched Parent objects
  341. let fetchChildRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: childType))
  342. fetchChildRequest.predicate = NSPredicate(format: "ANY %K IN %@", relationshipKey, parentObjectIDs)
  343. fetchChildRequest.resultType = .managedObjectIDResultType
  344. let childObjectIDs = try await taskContext.perform {
  345. try taskContext.fetch(fetchChildRequest)
  346. }
  347. guard !childObjectIDs.isEmpty else {
  348. // debug(.coreData,"No \(childType) objects found related to \(parentType) objects older than \(days) days.")
  349. return
  350. }
  351. // Execute the batch delete for Child objects
  352. try await taskContext.perform {
  353. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: childObjectIDs)
  354. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  355. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  356. let success = batchDeleteResult.result as? Bool, success
  357. else {
  358. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  359. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  360. }
  361. }
  362. debug(
  363. .coreData,
  364. "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
  365. )
  366. } catch {
  367. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  368. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  369. }
  370. }
  371. }
  372. // MARK: - Fetch Requests
  373. extension CoreDataStack {
  374. // Fetch in background thread
  375. /// - Tag: backgroundFetch
  376. func fetchEntities<T: NSManagedObject>(
  377. ofType type: T.Type,
  378. onContext context: NSManagedObjectContext,
  379. predicate: NSPredicate,
  380. key: String,
  381. ascending: Bool,
  382. fetchLimit: Int? = nil,
  383. batchSize: Int? = nil,
  384. propertiesToFetch: [String]? = nil,
  385. callingFunction: String = #function,
  386. callingClass: String = #fileID
  387. ) throws -> [Any] {
  388. let request = NSFetchRequest<NSFetchRequestResult>(entityName: String(describing: type))
  389. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  390. request.predicate = predicate
  391. if let limit = fetchLimit {
  392. request.fetchLimit = limit
  393. }
  394. if let batchSize = batchSize {
  395. request.fetchBatchSize = batchSize
  396. }
  397. if let propertiesToFetch = propertiesToFetch {
  398. request.propertiesToFetch = propertiesToFetch
  399. request.resultType = .dictionaryResultType
  400. } else {
  401. request.resultType = .managedObjectResultType
  402. }
  403. context.name = "fetchContext"
  404. context.transactionAuthor = "fetchEntities"
  405. /// 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
  406. return try context.performAndWait {
  407. do {
  408. if propertiesToFetch != nil {
  409. return try context.fetch(request) as? [[String: Any]] ?? []
  410. } else {
  411. return try context.fetch(request) as? [T] ?? []
  412. }
  413. } catch let error as NSError {
  414. throw CoreDataError.fetchError(
  415. function: callingFunction,
  416. file: callingClass
  417. )
  418. }
  419. }
  420. }
  421. // Fetch Async
  422. func fetchEntitiesAsync<T: NSManagedObject>(
  423. ofType type: T.Type,
  424. onContext context: NSManagedObjectContext,
  425. predicate: NSPredicate,
  426. key: String,
  427. ascending: Bool,
  428. fetchLimit: Int? = nil,
  429. batchSize: Int? = nil,
  430. propertiesToFetch: [String]? = nil,
  431. relationshipKeyPathsForPrefetching: [String]? = nil,
  432. callingFunction: String = #function,
  433. callingClass: String = #fileID
  434. ) async throws -> Any {
  435. let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
  436. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  437. request.predicate = predicate
  438. if let limit = fetchLimit {
  439. request.fetchLimit = limit
  440. }
  441. if let batchSize = batchSize {
  442. request.fetchBatchSize = batchSize
  443. }
  444. if let propertiesToFetch = propertiesToFetch {
  445. request.propertiesToFetch = propertiesToFetch
  446. request.resultType = .dictionaryResultType
  447. } else {
  448. request.resultType = .managedObjectResultType
  449. }
  450. if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
  451. request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
  452. }
  453. context.name = "fetchContext"
  454. context.transactionAuthor = "fetchEntities"
  455. return try await context.perform {
  456. do {
  457. if propertiesToFetch != nil {
  458. return try context.fetch(request) as? [[String: Any]] ?? []
  459. } else {
  460. return try context.fetch(request) as? [T] ?? []
  461. }
  462. } catch let error as NSError {
  463. throw CoreDataError.unexpectedError(
  464. error: error,
  465. function: callingFunction,
  466. file: callingClass
  467. )
  468. }
  469. }
  470. }
  471. // Get NSManagedObject
  472. func getNSManagedObject<T: NSManagedObject>(
  473. with ids: [NSManagedObjectID],
  474. context: NSManagedObjectContext,
  475. callingFunction: String = #function,
  476. callingClass: String = #fileID
  477. ) async throws -> [T] {
  478. try await context.perform {
  479. var objects = [T]()
  480. do {
  481. for id in ids {
  482. if let object = try context.existingObject(with: id) as? T {
  483. objects.append(object)
  484. }
  485. }
  486. return objects
  487. } catch {
  488. throw CoreDataError.fetchError(
  489. function: callingFunction,
  490. file: callingClass
  491. )
  492. }
  493. }
  494. }
  495. }
  496. // MARK: - Save
  497. /// This function is used when terminating the App to ensure any unsaved changes on the view context made their way to the persistent container
  498. extension CoreDataStack {
  499. func save() {
  500. let context = persistentContainer.viewContext
  501. guard context.hasChanges else { return }
  502. do {
  503. try context.save()
  504. } catch {
  505. debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)")
  506. }
  507. }
  508. }
  509. extension NSManagedObjectContext {
  510. // takes a context as a parameter to be executed either on the main thread or on a background thread
  511. /// - Tag: save
  512. func saveContext(
  513. onContext: NSManagedObjectContext,
  514. callingFunction: String = #function,
  515. callingClass: String = #fileID
  516. ) throws {
  517. do {
  518. guard onContext.hasChanges else { return }
  519. try onContext.save()
  520. debug(
  521. .coreData,
  522. "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
  523. )
  524. } catch let error as NSError {
  525. debug(
  526. .coreData,
  527. "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
  528. )
  529. throw error
  530. }
  531. }
  532. }