CoreDataStack.swift 23 KB

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