CoreDataStack.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  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. viewContext.perform {
  145. for transaction in history {
  146. viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
  147. self.lastToken = transaction.token
  148. }
  149. }
  150. // Notify app-side observers (services) about which objects changed. This history-sourced
  151. // change feed replaces the hand-rolled changedObjectsOnManagedObjectContextDidSavePublisher.
  152. let changedObjectIDs = Set(history.flatMap { $0.changes ?? [] }.map(\.changedObjectID))
  153. if !changedObjectIDs.isEmpty {
  154. entityChangeSubject.send(changedObjectIDs)
  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. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  291. // Fetch all the objects that are older than the specified days
  292. let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
  293. // Construct the predicate
  294. var predicates: [NSPredicate] = [NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)]
  295. if let isPresetKey = isPresetKey {
  296. predicates.append(NSPredicate(format: "%K == NO", isPresetKey))
  297. }
  298. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
  299. fetchRequest.resultType = .managedObjectIDResultType
  300. do {
  301. // Execute the Fetch Request
  302. let objectIDs = try await taskContext.perform {
  303. try taskContext.fetch(fetchRequest)
  304. }
  305. // Guard check if there are NSManagedObjects older than the specified days
  306. guard !objectIDs.isEmpty else {
  307. // debug(.coreData,"No objects found older than \(days) days.")
  308. return
  309. }
  310. // Execute the Batch Delete
  311. try await taskContext.perform {
  312. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
  313. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  314. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  315. let success = batchDeleteResult.result as? Bool, success
  316. else {
  317. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  318. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  319. }
  320. }
  321. debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
  322. } catch {
  323. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  324. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  325. }
  326. }
  327. func batchDeleteOlderThan<Parent: NSManagedObject, Child: NSManagedObject>(
  328. parentType: Parent.Type,
  329. childType: Child.Type,
  330. dateKey: String,
  331. days: Int,
  332. relationshipKey: String, // The key of the Child Entity that links to the parent Entity
  333. callingFunction: String = #function,
  334. callingClass: String = #fileID
  335. ) async throws {
  336. let taskContext = newTaskContext()
  337. taskContext.name = "deleteContext"
  338. taskContext.transactionAuthor = "batchDelete"
  339. // Get the target date
  340. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  341. // Fetch Parent objects older than the target date
  342. let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
  343. fetchParentRequest.predicate = NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)
  344. fetchParentRequest.resultType = .managedObjectIDResultType
  345. do {
  346. let parentObjectIDs = try await taskContext.perform {
  347. try taskContext.fetch(fetchParentRequest)
  348. }
  349. guard !parentObjectIDs.isEmpty else {
  350. // debug(.coreData,"No \(parentType) objects found older than \(days) days.")
  351. return
  352. }
  353. // Fetch Child objects related to the fetched Parent objects
  354. let fetchChildRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: childType))
  355. fetchChildRequest.predicate = NSPredicate(format: "ANY %K IN %@", relationshipKey, parentObjectIDs)
  356. fetchChildRequest.resultType = .managedObjectIDResultType
  357. let childObjectIDs = try await taskContext.perform {
  358. try taskContext.fetch(fetchChildRequest)
  359. }
  360. guard !childObjectIDs.isEmpty else {
  361. // debug(.coreData,"No \(childType) objects found related to \(parentType) objects older than \(days) days.")
  362. return
  363. }
  364. // Execute the batch delete for Child objects
  365. try await taskContext.perform {
  366. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: childObjectIDs)
  367. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  368. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  369. let success = batchDeleteResult.result as? Bool, success
  370. else {
  371. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  372. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  373. }
  374. }
  375. debug(
  376. .coreData,
  377. "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
  378. )
  379. } catch {
  380. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  381. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  382. }
  383. }
  384. }
  385. // MARK: - Fetch Requests
  386. extension CoreDataStack {
  387. // Fetch in background thread
  388. /// - Tag: backgroundFetch
  389. func fetchEntities<T: NSManagedObject>(
  390. ofType type: T.Type,
  391. onContext context: NSManagedObjectContext,
  392. predicate: NSPredicate,
  393. key: String,
  394. ascending: Bool,
  395. fetchLimit: Int? = nil,
  396. batchSize: Int? = nil,
  397. propertiesToFetch: [String]? = nil,
  398. callingFunction: String = #function,
  399. callingClass: String = #fileID
  400. ) throws -> [Any] {
  401. let request = NSFetchRequest<NSFetchRequestResult>(entityName: String(describing: type))
  402. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  403. request.predicate = predicate
  404. if let limit = fetchLimit {
  405. request.fetchLimit = limit
  406. }
  407. if let batchSize = batchSize {
  408. request.fetchBatchSize = batchSize
  409. }
  410. if let propertiesToFetch = propertiesToFetch {
  411. request.propertiesToFetch = propertiesToFetch
  412. request.resultType = .dictionaryResultType
  413. } else {
  414. request.resultType = .managedObjectResultType
  415. }
  416. /// 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
  417. return try context.performAndWait {
  418. do {
  419. if propertiesToFetch != nil {
  420. return try context.fetch(request) as? [[String: Any]] ?? []
  421. } else {
  422. return try context.fetch(request) as? [T] ?? []
  423. }
  424. } catch let error as NSError {
  425. throw CoreDataError.fetchError(
  426. function: callingFunction,
  427. file: callingClass
  428. )
  429. }
  430. }
  431. }
  432. // Fetch Async
  433. func fetchEntitiesAsync<T: NSManagedObject>(
  434. ofType type: T.Type,
  435. onContext context: NSManagedObjectContext,
  436. predicate: NSPredicate,
  437. key: String,
  438. ascending: Bool,
  439. fetchLimit: Int? = nil,
  440. batchSize: Int? = nil,
  441. propertiesToFetch: [String]? = nil,
  442. relationshipKeyPathsForPrefetching: [String]? = nil,
  443. callingFunction: String = #function,
  444. callingClass: String = #fileID
  445. ) async throws -> Any {
  446. let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
  447. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  448. request.predicate = predicate
  449. if let limit = fetchLimit {
  450. request.fetchLimit = limit
  451. }
  452. if let batchSize = batchSize {
  453. request.fetchBatchSize = batchSize
  454. }
  455. if let propertiesToFetch = propertiesToFetch {
  456. request.propertiesToFetch = propertiesToFetch
  457. request.resultType = .dictionaryResultType
  458. } else {
  459. request.resultType = .managedObjectResultType
  460. }
  461. if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
  462. request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
  463. }
  464. return try await context.perform {
  465. do {
  466. if propertiesToFetch != nil {
  467. return try context.fetch(request) as? [[String: Any]] ?? []
  468. } else {
  469. return try context.fetch(request) as? [T] ?? []
  470. }
  471. } catch let error as NSError {
  472. throw CoreDataError.unexpectedError(
  473. error: error,
  474. function: callingFunction,
  475. file: callingClass
  476. )
  477. }
  478. }
  479. }
  480. // Get NSManagedObject
  481. func getNSManagedObject<T: NSManagedObject>(
  482. with ids: [NSManagedObjectID],
  483. context: NSManagedObjectContext,
  484. callingFunction: String = #function,
  485. callingClass: String = #fileID
  486. ) async throws -> [T] {
  487. try await context.perform {
  488. var objects = [T]()
  489. do {
  490. for id in ids {
  491. if let object = try context.existingObject(with: id) as? T {
  492. objects.append(object)
  493. }
  494. }
  495. return objects
  496. } catch {
  497. throw CoreDataError.fetchError(
  498. function: callingFunction,
  499. file: callingClass
  500. )
  501. }
  502. }
  503. }
  504. }
  505. // MARK: - Save
  506. /// This function is used when terminating the App to ensure any unsaved changes on the view context made their way to the persistent container
  507. extension CoreDataStack {
  508. func save() {
  509. let context = persistentContainer.viewContext
  510. guard context.hasChanges else { return }
  511. do {
  512. try context.save()
  513. } catch {
  514. debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)")
  515. }
  516. }
  517. }
  518. extension NSManagedObjectContext {
  519. // takes a context as a parameter to be executed either on the main thread or on a background thread
  520. /// - Tag: save
  521. func saveContext(
  522. onContext: NSManagedObjectContext,
  523. callingFunction: String = #function,
  524. callingClass: String = #fileID
  525. ) throws {
  526. do {
  527. guard onContext.hasChanges else { return }
  528. try onContext.save()
  529. debug(
  530. .coreData,
  531. "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
  532. )
  533. } catch let error as NSError {
  534. debug(
  535. .coreData,
  536. "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
  537. )
  538. throw error
  539. }
  540. }
  541. }