CoreDataStack.swift 25 KB

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