CoreDataStack.swift 22 KB

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