CoreDataStack.swift 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import OSLog
  5. class CoreDataStack: ObservableObject {
  6. static let shared = CoreDataStack()
  7. static let identifier = "CoreDataStack"
  8. private var notificationToken: NSObjectProtocol?
  9. private let inMemory: Bool
  10. /// Debounces `NSPersistentStoreRemoteChange` bursts into a single history fetch + merge.
  11. /// Lossless, since `fetchHistory(after: lastToken)` drains all accumulated transactions.
  12. private let remoteChangeSubject = PassthroughSubject<Void, Never>()
  13. private var remoteChangeCancellable: AnyCancellable?
  14. private let historyQueue = DispatchQueue(label: "CoreDataStack.history", qos: .utility)
  15. let persistentContainer: NSPersistentContainer
  16. private let maxRetries = 3
  17. private let initializationCoordinator = CoreDataInitializationCoordinator()
  18. /// Emits the set of changed object IDs from each batch of persistent history transactions.
  19. /// Sourced from persistent history, so — unlike `NSManagedObjectContextDidSave` — it also
  20. /// covers `NSBatchInsertRequest`/`NSBatchDeleteRequest` and cross-process changes. App-side
  21. /// observers subscribe via `entityChangePublisher` and filter with `filteredByEntityName(_:)`.
  22. private let entityChangeSubject = PassthroughSubject<Set<NSManagedObjectID>, Never>()
  23. var entityChangePublisher: AnyPublisher<Set<NSManagedObjectID>, Never> {
  24. entityChangeSubject.eraseToAnyPublisher()
  25. }
  26. private init(inMemory: Bool = false) {
  27. self.inMemory = inMemory
  28. // Initialize persistent container immediately
  29. persistentContainer = NSPersistentContainer(
  30. name: "TrioCoreDataPersistentContainer",
  31. managedObjectModel: Self.managedObjectModel
  32. )
  33. guard let description = persistentContainer.persistentStoreDescriptions.first else {
  34. fatalError("Failed \(DebuggingIdentifiers.failed) to retrieve a persistent store description")
  35. }
  36. if inMemory {
  37. description.url = URL(fileURLWithPath: "/dev/null")
  38. }
  39. // Enable persistent store remote change notifications
  40. /// - Tag: persistentStoreRemoteChange
  41. description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
  42. // Enable persistent history tracking
  43. /// - Tag: persistentHistoryTracking
  44. description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
  45. // Enable lightweight migration
  46. /// - Tag: lightweightMigration
  47. description.shouldMigrateStoreAutomatically = true
  48. description.shouldInferMappingModelAutomatically = true
  49. persistentContainer.viewContext.automaticallyMergesChangesFromParent = false
  50. persistentContainer.viewContext.name = "viewContext"
  51. /// - Tag: viewContextmergePolicy
  52. persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  53. persistentContainer.viewContext.undoManager = nil
  54. persistentContainer.viewContext.shouldDeleteInaccessibleFaults = true
  55. }
  56. deinit {
  57. if let observer = notificationToken {
  58. Foundation.NotificationCenter.default.removeObserver(observer)
  59. }
  60. }
  61. /// A persistent history token used for fetching transactions from the store
  62. /// Save the last token to User defaults
  63. private var lastToken: NSPersistentHistoryToken? {
  64. get {
  65. UserDefaults.standard.lastHistoryToken
  66. }
  67. set {
  68. UserDefaults.standard.lastHistoryToken = newValue
  69. }
  70. }
  71. // Factory method for tests
  72. static func createForTests() async throws -> CoreDataStack {
  73. let stack = CoreDataStack(inMemory: true)
  74. try await stack.initializeStack()
  75. return stack
  76. }
  77. // Used for Canvas Preview
  78. static func preview() async throws -> CoreDataStack {
  79. let stack = CoreDataStack(inMemory: true)
  80. try await stack.initializeStack()
  81. return stack
  82. }
  83. // Shared managed object model
  84. static var managedObjectModel: NSManagedObjectModel = {
  85. let bundle = Bundle(for: CoreDataStack.self)
  86. guard let url = bundle.url(forResource: "TrioCoreDataPersistentContainer", withExtension: "momd") else {
  87. fatalError("Failed \(DebuggingIdentifiers.failed) to locate momd file")
  88. }
  89. guard let model = NSManagedObjectModel(contentsOf: url) else {
  90. fatalError("Failed \(DebuggingIdentifiers.failed) to load momd file")
  91. }
  92. return model
  93. }()
  94. /// Creates and configures a private queue context
  95. func newTaskContext() -> NSManagedObjectContext {
  96. // Create a private queue context
  97. /// - Tag: newBackgroundContext
  98. let taskContext = persistentContainer.newBackgroundContext()
  99. taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  100. taskContext.undoManager = nil
  101. return taskContext
  102. }
  103. func fetchPersistentHistory() async {
  104. do {
  105. try await fetchPersistentHistoryTransactionsAndChanges()
  106. } catch let error as NSError where error.code == NSPersistentHistoryTokenExpiredError {
  107. debug(.coreData, "Persistent history token expired; clearing token and replaying available history.")
  108. await recoverFromExpiredHistoryToken()
  109. } catch {
  110. debug(.coreData, "\(error)")
  111. }
  112. }
  113. /// Recovers the change-merge pipeline after the persistent history token expires.
  114. private func recoverFromExpiredHistoryToken() async {
  115. lastToken = nil
  116. do {
  117. try await fetchPersistentHistoryTransactionsAndChanges()
  118. } catch {
  119. debug(.coreData, "Replay after token reset failed; jumping to current token. \(error)")
  120. let viewContext = persistentContainer.viewContext
  121. let currentToken = persistentContainer.persistentStoreCoordinator
  122. .currentPersistentHistoryToken(fromStores: nil)
  123. lastToken = currentToken
  124. await viewContext.perform {
  125. viewContext.refreshAllObjects()
  126. }
  127. }
  128. }
  129. private func fetchPersistentHistoryTransactionsAndChanges() async throws {
  130. let taskContext = newTaskContext()
  131. taskContext.name = "persistentHistoryContext"
  132. // debug(.coreData,"Start fetching persistent history changes from the store ... \(DebuggingIdentifiers.inProgress)")
  133. try await taskContext.perform {
  134. // Execute the persistent history change since the last transaction
  135. /// - Tag: fetchHistory
  136. let changeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastToken)
  137. let historyResult = try taskContext.execute(changeRequest) as? NSPersistentHistoryResult
  138. if let history = historyResult?.result as? [NSPersistentHistoryTransaction], !history.isEmpty {
  139. self.mergePersistentHistoryChanges(from: history)
  140. return
  141. }
  142. }
  143. }
  144. private func mergePersistentHistoryChanges(from history: [NSPersistentHistoryTransaction]) {
  145. // debug(.coreData,"Received \(history.count) persistent history transactions")
  146. // Update view context with objectIDs from history change request
  147. /// - Tag: mergeChanges
  148. let viewContext = persistentContainer.viewContext
  149. let changedObjectIDs = Set(history.flatMap { $0.changes ?? [] }.map(\.changedObjectID))
  150. viewContext.perform {
  151. for transaction in history {
  152. viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
  153. self.lastToken = transaction.token
  154. }
  155. // Notify app-side observers (services) about which objects changed. This history-sourced
  156. // change feed replaces the hand-rolled changedObjectsOnManagedObjectContextDidSavePublisher.
  157. if !changedObjectIDs.isEmpty {
  158. self.entityChangeSubject.send(changedObjectIDs)
  159. }
  160. }
  161. }
  162. // Clean old Persistent History
  163. /// - Tag: clearHistory
  164. func cleanupPersistentHistoryTokens(before date: Date) async {
  165. let taskContext = newTaskContext()
  166. taskContext.name = "cleanPersistentHistoryTokensContext"
  167. await taskContext.perform {
  168. let deleteHistoryTokensRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date)
  169. do {
  170. try taskContext.execute(deleteHistoryTokensRequest)
  171. debug(.coreData, "\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history from before \(date)")
  172. } catch {
  173. debug(
  174. .coreData,
  175. "\(DebuggingIdentifiers.failed) Failed to delete persistent history from before \(date): \(error)"
  176. )
  177. }
  178. }
  179. }
  180. private func setupPersistentStoreChangeNotifications() {
  181. // Observe Core Data remote change notifications on the queue where the changes were made
  182. notificationToken = Foundation.NotificationCenter.default.addObserver(
  183. forName: .NSPersistentStoreRemoteChange,
  184. object: nil,
  185. queue: nil
  186. ) { [weak self] _ in
  187. // Just signal here; fetching happens in the debounced pipeline below. Notifications
  188. // arrive on arbitrary threads, so serialize the sends via `historyQueue`.
  189. guard let self else { return }
  190. self.historyQueue.async { self.remoteChangeSubject.send() }
  191. }
  192. remoteChangeCancellable = remoteChangeSubject
  193. .debounce(for: .milliseconds(200), scheduler: historyQueue)
  194. .sink { [weak self] in
  195. Task { await self?.fetchPersistentHistory() }
  196. }
  197. debug(.coreData, "Set up persistent store change notifications")
  198. }
  199. /// Loads the persistent stores asynchronously.
  200. ///
  201. /// Converts the synchronous NSPersistentContainer loading process into an async/await compatible
  202. /// function using a continuation.
  203. ///
  204. /// - Throws: Any errors encountered during the loading of persistent stores.
  205. /// - Returns: Void once stores are loaded successfully
  206. private func loadPersistentStores() async throws {
  207. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  208. persistentContainer.loadPersistentStores { storeDescription, error in
  209. if let error = error {
  210. warning(.coreData, "Failed to load persistent stores: \(error)")
  211. continuation.resume(throwing: error)
  212. } else {
  213. debug(.coreData, "Successfully loaded persistent store: \(storeDescription.url?.absoluteString ?? "unknown")")
  214. continuation.resume(returning: ())
  215. }
  216. }
  217. }
  218. }
  219. /// Public entry point for initializing the CoreData stack.
  220. ///
  221. /// Uses the initialization coordinator to ensure initialization happens only once,
  222. /// even with concurrent calls. Subsequent calls will wait for the original initialization
  223. /// to complete.
  224. ///
  225. /// - Throws: Any errors that occur during initialization.
  226. /// - Returns: Void once initialization is complete.
  227. func initializeStack() async throws {
  228. try await initializationCoordinator.ensureInitialized {
  229. try await self.initializeStack(retryCount: 0)
  230. }
  231. }
  232. /// Private implementation of the initialization process with retry capability.
  233. ///
  234. /// Handles the actual initialization work including store loading, verification,
  235. /// notification setup, and error handling with retry logic.
  236. ///
  237. /// - Parameter retryCount: The current retry attempt number, starting at 0.
  238. /// - Throws: CoreDataError or any other error if initialization fails after all retries.
  239. /// - Returns: Void when initialization completes successfully.
  240. private func initializeStack(retryCount: Int) async throws {
  241. do {
  242. // Load stores asynchronously
  243. try await loadPersistentStores()
  244. // Verify the store is loaded
  245. guard persistentContainer.persistentStoreCoordinator.persistentStores.isEmpty == false else {
  246. let error = CoreDataError.storeNotInitializedError(function: #function, file: #file)
  247. throw error
  248. }
  249. setupPersistentStoreChangeNotifications()
  250. debug(.coreData, "Core Data stack initialized successfully")
  251. } catch {
  252. debug(.coreData, "Failed to initialize Core Data stack: \(error)")
  253. // If we still have retries left, try again after a delay
  254. if retryCount < maxRetries {
  255. debug(.coreData, "Retrying initialization (\(retryCount + 1)/\(maxRetries))")
  256. // Wait before retrying
  257. try await Task.sleep(for: .seconds(1))
  258. // Retry the initialization
  259. try await initializeStack(retryCount: retryCount + 1)
  260. } else {
  261. // We've exhausted our retries
  262. debug(.coreData, "Core Data initialization failed after \(maxRetries) attempts")
  263. throw error
  264. }
  265. }
  266. }
  267. }
  268. // MARK: - Delete
  269. extension CoreDataStack {
  270. /// Synchronously delete entry with specified object IDs
  271. /// - Tag: synchronousDelete
  272. func deleteObject(identifiedBy objectID: NSManagedObjectID) async {
  273. let viewContext = persistentContainer.viewContext
  274. debug(.coreData, "Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
  275. await viewContext.perform {
  276. do {
  277. let entryToDelete = viewContext.object(with: objectID)
  278. viewContext.delete(entryToDelete)
  279. guard viewContext.hasChanges else { return }
  280. try viewContext.save()
  281. debug(.coreData, "Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
  282. } catch {
  283. debug(.coreData, "Failed to delete data: \(error)")
  284. }
  285. }
  286. }
  287. /// Asynchronously deletes records for entities
  288. /// - Tag: batchDelete
  289. func batchDeleteOlderThan<T: NSManagedObject>(
  290. _ objectType: T.Type,
  291. dateKey: String,
  292. days: Int,
  293. isPresetKey: String? = nil,
  294. callingFunction: String = #function,
  295. callingClass: String = #fileID
  296. ) async throws {
  297. let taskContext = newTaskContext()
  298. taskContext.name = "deleteContext"
  299. taskContext.transactionAuthor = "batchDelete"
  300. // Get the number of days we want to keep the data
  301. guard let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else {
  302. throw CoreDataError.validationError(function: callingFunction, file: callingClass)
  303. }
  304. // Fetch all the objects that are older than the specified days
  305. let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
  306. // Construct the predicate
  307. var predicates: [NSPredicate] = [NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)]
  308. if let isPresetKey = isPresetKey {
  309. predicates.append(NSPredicate(format: "%K == NO", isPresetKey))
  310. }
  311. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
  312. fetchRequest.resultType = .managedObjectIDResultType
  313. do {
  314. // Execute the Fetch Request
  315. let objectIDs = try await taskContext.perform {
  316. try taskContext.fetch(fetchRequest)
  317. }
  318. // Guard check if there are NSManagedObjects older than the specified days
  319. guard !objectIDs.isEmpty else {
  320. // debug(.coreData,"No objects found older than \(days) days.")
  321. return
  322. }
  323. // Execute the Batch Delete
  324. try await taskContext.perform {
  325. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
  326. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  327. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  328. let success = batchDeleteResult.result as? Bool, success
  329. else {
  330. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  331. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  332. }
  333. }
  334. debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
  335. } catch {
  336. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  337. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  338. }
  339. }
  340. func batchDeleteOlderThan<Parent: NSManagedObject, Child: NSManagedObject>(
  341. parentType: Parent.Type,
  342. childType: Child.Type,
  343. dateKey: String,
  344. days: Int,
  345. relationshipKey: String, // The key of the Child Entity that links to the parent Entity
  346. callingFunction: String = #function,
  347. callingClass: String = #fileID
  348. ) async throws {
  349. let taskContext = newTaskContext()
  350. taskContext.name = "deleteContext"
  351. taskContext.transactionAuthor = "batchDelete"
  352. // Get the target date
  353. guard let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else {
  354. throw CoreDataError.validationError(function: callingFunction, file: callingClass)
  355. }
  356. // Fetch Parent objects older than the target date
  357. let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
  358. fetchParentRequest.predicate = NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)
  359. fetchParentRequest.resultType = .managedObjectIDResultType
  360. do {
  361. let parentObjectIDs = try await taskContext.perform {
  362. try taskContext.fetch(fetchParentRequest)
  363. }
  364. guard !parentObjectIDs.isEmpty else {
  365. // debug(.coreData,"No \(parentType) objects found older than \(days) days.")
  366. return
  367. }
  368. // Fetch Child objects related to the fetched Parent objects
  369. let fetchChildRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: childType))
  370. fetchChildRequest.predicate = NSPredicate(format: "ANY %K IN %@", relationshipKey, parentObjectIDs)
  371. fetchChildRequest.resultType = .managedObjectIDResultType
  372. let childObjectIDs = try await taskContext.perform {
  373. try taskContext.fetch(fetchChildRequest)
  374. }
  375. guard !childObjectIDs.isEmpty else {
  376. // debug(.coreData,"No \(childType) objects found related to \(parentType) objects older than \(days) days.")
  377. return
  378. }
  379. // Execute the batch delete for Child objects
  380. try await taskContext.perform {
  381. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: childObjectIDs)
  382. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  383. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  384. let success = batchDeleteResult.result as? Bool, success
  385. else {
  386. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  387. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  388. }
  389. }
  390. debug(
  391. .coreData,
  392. "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
  393. )
  394. } catch {
  395. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  396. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  397. }
  398. }
  399. }
  400. // MARK: - Fetch Requests
  401. extension CoreDataStack {
  402. // Fetch in background thread
  403. /// - Tag: backgroundFetch
  404. func fetchEntities<T: NSManagedObject>(
  405. ofType type: T.Type,
  406. onContext context: NSManagedObjectContext,
  407. predicate: NSPredicate,
  408. key: String,
  409. ascending: Bool,
  410. fetchLimit: Int? = nil,
  411. batchSize: Int? = nil,
  412. propertiesToFetch: [String]? = nil,
  413. callingFunction: String = #function,
  414. callingClass: String = #fileID
  415. ) throws -> [Any] {
  416. let request = NSFetchRequest<NSFetchRequestResult>(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. /// 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
  432. return try context.performAndWait {
  433. do {
  434. if propertiesToFetch != nil {
  435. return try context.fetch(request) as? [[String: Any]] ?? []
  436. } else {
  437. return try context.fetch(request) as? [T] ?? []
  438. }
  439. } catch let error as NSError {
  440. throw CoreDataError.fetchError(
  441. function: callingFunction,
  442. file: callingClass
  443. )
  444. }
  445. }
  446. }
  447. // Fetch Async
  448. func fetchEntitiesAsync<T: NSManagedObject>(
  449. ofType type: T.Type,
  450. onContext context: NSManagedObjectContext,
  451. predicate: NSPredicate,
  452. key: String,
  453. ascending: Bool,
  454. fetchLimit: Int? = nil,
  455. batchSize: Int? = nil,
  456. propertiesToFetch: [String]? = nil,
  457. relationshipKeyPathsForPrefetching: [String]? = nil,
  458. callingFunction: String = #function,
  459. callingClass: String = #fileID
  460. ) async throws -> Any {
  461. let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
  462. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  463. request.predicate = predicate
  464. if let limit = fetchLimit {
  465. request.fetchLimit = limit
  466. }
  467. if let batchSize = batchSize {
  468. request.fetchBatchSize = batchSize
  469. }
  470. if let propertiesToFetch = propertiesToFetch {
  471. request.propertiesToFetch = propertiesToFetch
  472. request.resultType = .dictionaryResultType
  473. } else {
  474. request.resultType = .managedObjectResultType
  475. }
  476. if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
  477. request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
  478. }
  479. return try await context.perform {
  480. do {
  481. if propertiesToFetch != nil {
  482. return try context.fetch(request) as? [[String: Any]] ?? []
  483. } else {
  484. return try context.fetch(request) as? [T] ?? []
  485. }
  486. } catch let error as NSError {
  487. throw CoreDataError.unexpectedError(
  488. error: error,
  489. function: callingFunction,
  490. file: callingClass
  491. )
  492. }
  493. }
  494. }
  495. // Get NSManagedObject
  496. func getNSManagedObject<T: NSManagedObject>(
  497. with ids: [NSManagedObjectID],
  498. context: NSManagedObjectContext,
  499. callingFunction: String = #function,
  500. callingClass: String = #fileID
  501. ) async throws -> [T] {
  502. try await context.perform {
  503. var objects = [T]()
  504. do {
  505. for id in ids {
  506. if let object = try context.existingObject(with: id) as? T {
  507. objects.append(object)
  508. }
  509. }
  510. return objects
  511. } catch {
  512. throw CoreDataError.fetchError(
  513. function: callingFunction,
  514. file: callingClass
  515. )
  516. }
  517. }
  518. }
  519. }
  520. // MARK: - Save
  521. /// This function is used when terminating the App to ensure any unsaved changes on the view context made their way to the persistent container
  522. extension CoreDataStack {
  523. func save() {
  524. let context = persistentContainer.viewContext
  525. guard context.hasChanges else { return }
  526. do {
  527. try context.save()
  528. } catch {
  529. debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)")
  530. }
  531. }
  532. }
  533. extension NSManagedObjectContext {
  534. // takes a context as a parameter to be executed either on the main thread or on a background thread
  535. /// - Tag: save
  536. func saveContext(
  537. onContext: NSManagedObjectContext,
  538. callingFunction: String = #function,
  539. callingClass: String = #fileID
  540. ) throws {
  541. do {
  542. guard onContext.hasChanges else { return }
  543. try onContext.save()
  544. debug(
  545. .coreData,
  546. "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
  547. )
  548. } catch let error as NSError {
  549. debug(
  550. .coreData,
  551. "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
  552. )
  553. throw error
  554. }
  555. }
  556. }