CoreDataStack.swift 22 KB

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