CoreDataStack.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  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 {
  95. debug(.coreData, "\(error)")
  96. }
  97. }
  98. private func fetchPersistentHistoryTransactionsAndChanges() async throws {
  99. let taskContext = newTaskContext()
  100. taskContext.name = "persistentHistoryContext"
  101. // debug(.coreData,"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. // debug(.coreData,"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. debug(.coreData, "\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history from before \(date)")
  135. } catch {
  136. debug(
  137. .coreData,
  138. "\(DebuggingIdentifiers.failed) Failed to delete persistent history from before \(date): \(error)"
  139. )
  140. }
  141. }
  142. }
  143. private func setupPersistentStoreChangeNotifications() {
  144. // Observe Core Data remote change notifications on the queue where the changes were made
  145. notificationToken = Foundation.NotificationCenter.default.addObserver(
  146. forName: .NSPersistentStoreRemoteChange,
  147. object: nil,
  148. queue: nil
  149. ) { _ in
  150. Task {
  151. await self.fetchPersistentHistory()
  152. }
  153. }
  154. debug(.coreData, "Set up persistent store change notifications")
  155. }
  156. /// Loads the persistent stores asynchronously.
  157. ///
  158. /// Converts the synchronous NSPersistentContainer loading process into an async/await compatible
  159. /// function using a continuation.
  160. ///
  161. /// - Throws: Any errors encountered during the loading of persistent stores.
  162. /// - Returns: Void once stores are loaded successfully
  163. private func loadPersistentStores() async throws {
  164. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  165. persistentContainer.loadPersistentStores { storeDescription, error in
  166. if let error = error {
  167. warning(.coreData, "Failed to load persistent stores: \(error)")
  168. continuation.resume(throwing: error)
  169. } else {
  170. debug(.coreData, "Successfully loaded persistent store: \(storeDescription.url?.absoluteString ?? "unknown")")
  171. continuation.resume(returning: ())
  172. }
  173. }
  174. }
  175. }
  176. /// Public entry point for initializing the CoreData stack.
  177. ///
  178. /// Uses the initialization coordinator to ensure initialization happens only once,
  179. /// even with concurrent calls. Subsequent calls will wait for the original initialization
  180. /// to complete.
  181. ///
  182. /// - Throws: Any errors that occur during initialization.
  183. /// - Returns: Void once initialization is complete.
  184. func initializeStack() async throws {
  185. try await initializationCoordinator.ensureInitialized {
  186. try await self.initializeStack(retryCount: 0)
  187. }
  188. }
  189. /// Private implementation of the initialization process with retry capability.
  190. ///
  191. /// Handles the actual initialization work including store loading, verification,
  192. /// notification setup, and error handling with retry logic.
  193. ///
  194. /// - Parameter retryCount: The current retry attempt number, starting at 0.
  195. /// - Throws: CoreDataError or any other error if initialization fails after all retries.
  196. /// - Returns: Void when initialization completes successfully.
  197. private func initializeStack(retryCount: Int) async throws {
  198. do {
  199. // Load stores asynchronously
  200. try await loadPersistentStores()
  201. // Verify the store is loaded
  202. guard persistentContainer.persistentStoreCoordinator.persistentStores.isEmpty == false else {
  203. let error = CoreDataError.storeNotInitializedError(function: #function, file: #file)
  204. throw error
  205. }
  206. setupPersistentStoreChangeNotifications()
  207. debug(.coreData, "Core Data stack initialized successfully")
  208. } catch {
  209. debug(.coreData, "Failed to initialize Core Data stack: \(error)")
  210. // If we still have retries left, try again after a delay
  211. if retryCount < maxRetries {
  212. debug(.coreData, "Retrying initialization (\(retryCount + 1)/\(maxRetries))")
  213. // Wait before retrying
  214. try await Task.sleep(for: .seconds(1))
  215. // Retry the initialization
  216. try await initializeStack(retryCount: retryCount + 1)
  217. } else {
  218. // We've exhausted our retries
  219. debug(.coreData, "Core Data initialization failed after \(maxRetries) attempts")
  220. throw error
  221. }
  222. }
  223. }
  224. }
  225. // MARK: - Delete
  226. extension CoreDataStack {
  227. /// Synchronously delete entry with specified object IDs
  228. /// - Tag: synchronousDelete
  229. func deleteObject(identifiedBy objectID: NSManagedObjectID) async {
  230. let viewContext = persistentContainer.viewContext
  231. debug(.coreData, "Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
  232. await viewContext.perform {
  233. do {
  234. let entryToDelete = viewContext.object(with: objectID)
  235. viewContext.delete(entryToDelete)
  236. guard viewContext.hasChanges else { return }
  237. try viewContext.save()
  238. debug(.coreData, "Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
  239. } catch {
  240. debug(.coreData, "Failed to delete data: \(error)")
  241. }
  242. }
  243. }
  244. /// Asynchronously deletes records for entities
  245. /// - Tag: batchDelete
  246. func batchDeleteOlderThan<T: NSManagedObject>(
  247. _ objectType: T.Type,
  248. dateKey: String,
  249. days: Int,
  250. isPresetKey: String? = nil,
  251. callingFunction: String = #function,
  252. callingClass: String = #fileID
  253. ) async throws {
  254. let taskContext = newTaskContext()
  255. taskContext.name = "deleteContext"
  256. taskContext.transactionAuthor = "batchDelete"
  257. // Get the number of days we want to keep the data
  258. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  259. // Fetch all the objects that are older than the specified days
  260. let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
  261. // Construct the predicate
  262. var predicates: [NSPredicate] = [NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)]
  263. if let isPresetKey = isPresetKey {
  264. predicates.append(NSPredicate(format: "%K == NO", isPresetKey))
  265. }
  266. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
  267. fetchRequest.resultType = .managedObjectIDResultType
  268. do {
  269. // Execute the Fetch Request
  270. let objectIDs = try await taskContext.perform {
  271. try taskContext.fetch(fetchRequest)
  272. }
  273. // Guard check if there are NSManagedObjects older than the specified days
  274. guard !objectIDs.isEmpty else {
  275. // debug(.coreData,"No objects found older than \(days) days.")
  276. return
  277. }
  278. // Execute the Batch Delete
  279. try await taskContext.perform {
  280. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
  281. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  282. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  283. let success = batchDeleteResult.result as? Bool, success
  284. else {
  285. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  286. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  287. }
  288. }
  289. debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
  290. } catch {
  291. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  292. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  293. }
  294. }
  295. func batchDeleteOlderThan<Parent: NSManagedObject, Child: NSManagedObject>(
  296. parentType: Parent.Type,
  297. childType: Child.Type,
  298. dateKey: String,
  299. days: Int,
  300. relationshipKey: String, // The key of the Child Entity that links to the parent Entity
  301. callingFunction: String = #function,
  302. callingClass: String = #fileID
  303. ) async throws {
  304. let taskContext = newTaskContext()
  305. taskContext.name = "deleteContext"
  306. taskContext.transactionAuthor = "batchDelete"
  307. // Get the target date
  308. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  309. // Fetch Parent objects older than the target date
  310. let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
  311. fetchParentRequest.predicate = NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)
  312. fetchParentRequest.resultType = .managedObjectIDResultType
  313. do {
  314. let parentObjectIDs = try await taskContext.perform {
  315. try taskContext.fetch(fetchParentRequest)
  316. }
  317. guard !parentObjectIDs.isEmpty else {
  318. // debug(.coreData,"No \(parentType) objects found older than \(days) days.")
  319. return
  320. }
  321. // Fetch Child objects related to the fetched Parent objects
  322. let fetchChildRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: childType))
  323. fetchChildRequest.predicate = NSPredicate(format: "ANY %K IN %@", relationshipKey, parentObjectIDs)
  324. fetchChildRequest.resultType = .managedObjectIDResultType
  325. let childObjectIDs = try await taskContext.perform {
  326. try taskContext.fetch(fetchChildRequest)
  327. }
  328. guard !childObjectIDs.isEmpty else {
  329. // debug(.coreData,"No \(childType) objects found related to \(parentType) objects older than \(days) days.")
  330. return
  331. }
  332. // Execute the batch delete for Child objects
  333. try await taskContext.perform {
  334. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: childObjectIDs)
  335. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  336. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  337. let success = batchDeleteResult.result as? Bool, success
  338. else {
  339. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  340. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  341. }
  342. }
  343. debug(
  344. .coreData,
  345. "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
  346. )
  347. } catch {
  348. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  349. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  350. }
  351. }
  352. }
  353. // MARK: - Fetch Requests
  354. extension CoreDataStack {
  355. // Fetch in background thread
  356. /// - Tag: backgroundFetch
  357. func fetchEntities<T: NSManagedObject>(
  358. ofType type: T.Type,
  359. onContext context: NSManagedObjectContext,
  360. predicate: NSPredicate,
  361. key: String,
  362. ascending: Bool,
  363. fetchLimit: Int? = nil,
  364. batchSize: Int? = nil,
  365. propertiesToFetch: [String]? = nil,
  366. callingFunction: String = #function,
  367. callingClass: String = #fileID
  368. ) throws -> [Any] {
  369. let request = NSFetchRequest<NSFetchRequestResult>(entityName: String(describing: type))
  370. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  371. request.predicate = predicate
  372. if let limit = fetchLimit {
  373. request.fetchLimit = limit
  374. }
  375. if let batchSize = batchSize {
  376. request.fetchBatchSize = batchSize
  377. }
  378. if let propertiesToFetch = propertiesToFetch {
  379. request.propertiesToFetch = propertiesToFetch
  380. request.resultType = .dictionaryResultType
  381. } else {
  382. request.resultType = .managedObjectResultType
  383. }
  384. context.name = "fetchContext"
  385. context.transactionAuthor = "fetchEntities"
  386. /// 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
  387. return try context.performAndWait {
  388. do {
  389. if propertiesToFetch != nil {
  390. return try context.fetch(request) as? [[String: Any]] ?? []
  391. } else {
  392. return try context.fetch(request) as? [T] ?? []
  393. }
  394. } catch let error as NSError {
  395. throw CoreDataError.fetchError(
  396. function: callingFunction,
  397. file: callingClass
  398. )
  399. }
  400. }
  401. }
  402. // Fetch Async
  403. func fetchEntitiesAsync<T: NSManagedObject>(
  404. ofType type: T.Type,
  405. onContext context: NSManagedObjectContext,
  406. predicate: NSPredicate,
  407. key: String,
  408. ascending: Bool,
  409. fetchLimit: Int? = nil,
  410. batchSize: Int? = nil,
  411. propertiesToFetch: [String]? = nil,
  412. relationshipKeyPathsForPrefetching: [String]? = nil,
  413. callingFunction: String = #function,
  414. callingClass: String = #fileID
  415. ) async throws -> Any {
  416. let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
  417. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  418. request.predicate = predicate
  419. if let limit = fetchLimit {
  420. request.fetchLimit = limit
  421. }
  422. if let batchSize = batchSize {
  423. request.fetchBatchSize = batchSize
  424. }
  425. if let propertiesToFetch = propertiesToFetch {
  426. request.propertiesToFetch = propertiesToFetch
  427. request.resultType = .dictionaryResultType
  428. } else {
  429. request.resultType = .managedObjectResultType
  430. }
  431. if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
  432. request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
  433. }
  434. context.name = "fetchContext"
  435. context.transactionAuthor = "fetchEntities"
  436. return try await context.perform {
  437. do {
  438. if propertiesToFetch != nil {
  439. return try context.fetch(request) as? [[String: Any]] ?? []
  440. } else {
  441. return try context.fetch(request) as? [T] ?? []
  442. }
  443. } catch let error as NSError {
  444. throw CoreDataError.unexpectedError(
  445. error: error,
  446. function: callingFunction,
  447. file: callingClass
  448. )
  449. }
  450. }
  451. }
  452. // Get NSManagedObject
  453. func getNSManagedObject<T: NSManagedObject>(
  454. with ids: [NSManagedObjectID],
  455. context: NSManagedObjectContext,
  456. callingFunction: String = #function,
  457. callingClass: String = #fileID
  458. ) async throws -> [T] {
  459. try await context.perform {
  460. var objects = [T]()
  461. do {
  462. for id in ids {
  463. if let object = try context.existingObject(with: id) as? T {
  464. objects.append(object)
  465. }
  466. }
  467. return objects
  468. } catch {
  469. throw CoreDataError.fetchError(
  470. function: callingFunction,
  471. file: callingClass
  472. )
  473. }
  474. }
  475. }
  476. }
  477. // MARK: - Save
  478. /// This function is used when terminating the App to ensure any unsaved changes on the view context made their way to the persistent container
  479. extension CoreDataStack {
  480. func save() {
  481. let context = persistentContainer.viewContext
  482. guard context.hasChanges else { return }
  483. do {
  484. try context.save()
  485. } catch {
  486. debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)")
  487. }
  488. }
  489. }
  490. extension NSManagedObjectContext {
  491. // takes a context as a parameter to be executed either on the main thread or on a background thread
  492. /// - Tag: save
  493. func saveContext(
  494. onContext: NSManagedObjectContext,
  495. callingFunction: String = #function,
  496. callingClass: String = #fileID
  497. ) throws {
  498. do {
  499. guard onContext.hasChanges else { return }
  500. try onContext.save()
  501. debug(
  502. .coreData,
  503. "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
  504. )
  505. } catch let error as NSError {
  506. debug(
  507. .coreData,
  508. "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
  509. )
  510. throw error
  511. }
  512. }
  513. }