CoreDataStack.swift 20 KB

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