Re-iterating over Core Data Manager


I like my Core Data Manager - I really do. When you write something that is so adaptable to your workflow and multiple projects life becomes some simple.

People are constantly writing about using Core Data and being overwhelmed, looking for good resources, and then simply being redirected by other people to use Realm or MySQL.

Obviously you need to assess your own situation and use what is best for you and your needs

But once I had my CoreDataManager.swift written, I've been using it a lot. It handles everything I need it to, and it makes approaching persistence easy when making new apps.

One thing I poorly wrote in my original public version was error handling. I think at the time I wasn't ready to go all-in on fleshing it out to handle errors other than using a closure.

However, I've done some work on it, and thought it's always nice to see an updated version for anyone out there that might like using it - or at least a starting point for your own version.

Errors

This is what my CoreDataError enum looks like. It is very basic, and tries not to complicate it with too many outputs.

enum CoreDataError: Error {
 case unableToFetch
 case errorSyncing
 case noCloudFunctionality
 case unableToSave
 case persistentStoreError(error: Error)
}

Localisation

As always, it is always good practice to have localisation in your app - even in errors.

extension CoreDataError: LocalizedError {
 var errorDescription: String? {
  switch self {
   case .unableToFetch:
    return NSLocalizedString(
     "Unable to fetch data for this type",
     comment: "Error message when unable to fetch data for a certain entity"
    )

   case .errorSyncing:
    return NSLocalizedString(
     "There was an error syncing with the cloud",
     comment: "Error message when unable to sync with cloud services"
    )

   case .noCloudFunctionality:
    return NSLocalizedString(
     "Cloud services not available for the user",
     comment: "Error message when cloud services are not available for the user"
    )

   case .unableToSave:
    return NSLocalizedString(
     "Unable to save data",
     comment: "Error message when unable to save data to Core Data"
    )

   case .persistentStoreError(let error):
    return NSLocalizedString(
     "Error accessing persistent store: \(error.localizedDescription)",
     comment: "Error message when there's an error accessing the persistent store"
    )
  }
 }
}

CoreDataManager

Changes

Completion handler removal

As mentioned before, there is no more saveError or completion for save errors. Everything is handled using a CoreDataError.

Though I do like the completion handling for readability, I thought defining the errors and having my comments for what the error is was a better approach.

I do still use the completion handling for the create method so I can assign the new data to the object.

Better nil checkers

Previously I was using a lot of if let and trying to handle the else portions of the checking.

But I've moved into using the guard-ing of checks, and if they fail do the issue then, otherwise continue.

I think this method is easier to read when working through the steps of how it works, opposed to reading within the {}.

Better `deleteAll` usage

I have this method really for debugging, but I do allow users to have access to resetting their apps.

But previously I had to fetch all the types, then pass it into the deleteAll method, and if I wanted only a subset I was doing a .filter on the fetched results before passing it into the deletion.

And if that run-on sentence isn't clear enough - it was messy.

I already have a powerful fetch method, why not leverage that and pass in an optional NSPredicate if I want to.

Now I can delete objects with a name starting with "TEST" or favourites older than "30 days".

Async

Some of the cloud methods are now async which makes them more future proof.

Simply just frees up the system and can run concurrently instead of blocking the system.

New code

Here is the full manager, as well as a link to the gist revision.

final class CoreDataManager {

 // -- singleton initalisation
 static let shared = CoreDataManager()

 var container: NSPersistentContainer

 // -- managed object context
 var managedObjectContext: NSManagedObjectContext {
  container.viewContext
 }

 // -- initialiser
 private init() {

  // -- get the model
  container = NSPersistentCloudKitContainer(name: "AttendanceModel")

  // -- get the description
  guard let description = container.persistentStoreDescriptions.first else {
   fatalError("###\(#function): Failed to retrieve a persistent store description.")
  }

  // -- listen to changes from cloudkit
  description.setOption(
   true as NSNumber,
   forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
  )

  container.loadPersistentStores { storeDescription, error in
   if let error = error as NSError? {
    fatalError("Unresolved error \(error), \(error.userInfo)")
   }
   storeDescription.shouldInferMappingModelAutomatically = false
  }

  managedObjectContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  managedObjectContext.automaticallyMergesChangesFromParent = true
 }


 // MARK: - CRUD operations (create / fetch / update / delete)

 /// Creates a new managed object of the specified type and executes the
 /// completion block with the new object.
 /// - Parameters:
 ///   - objectType: The type of the managed object to create.
 ///   - completion: A block that takes the new managed object as its parameter.
 func create<T: NSManagedObject>(_ objectType: T.Type, completion: (T) -> Void) throws {
  let newObject = NSEntityDescription.insertNewObject(
   forEntityName: String(describing: objectType),
   into: managedObjectContext
  ) as! T
  completion(newObject)
  try save()
 }

 /// Fetches a list of `objectType` objects from the Core Data store.
 /// - Parameters:
 ///   - objectType: The type of object to fetch.
 ///   - predicate: An optional predicate for filtering the results.
 ///   - sortDescriptors: An optional list of sort descriptors for sorting the results.
 /// - Returns: An array of `objectType` objects.
 func fetch<T: NSManagedObject>(_ objectType: T.Type,
           predicate: NSPredicate? = nil,
           sortDescriptors: [NSSortDescriptor]? = nil) throws -> [T] {
  let fetchRequest = NSFetchRequest<T>(entityName: String(describing: objectType))
  fetchRequest.predicate = predicate
  fetchRequest.sortDescriptors = sortDescriptors
  do {
   let objects = try managedObjectContext.fetch(fetchRequest)
   return objects
  } catch {
   throw CoreDataError.unableToFetch
  }
 }

 /// Saves the managed object context.
 /// - Parameter completion: A completion block that is called after the save is
 /// complete. The block takes an optional Error as its only argument.
 func save() throws {
  guard managedObjectContext.hasChanges else { return }
  do {
   try managedObjectContext.save()
  } catch {
   managedObjectContext.rollback()
   throw CoreDataError.unableToSave
  }
 }

 /// Deletes an NSManagedObject from the managed object context.
 /// - Parameters:
 ///   - object: The NSManagedObject to delete.
 ///   - completion: A closure to be called after the delete operation is completed.
 /// If the delete operation was successful, the error parameter is nil. If the
 /// delete operation failed, the error parameter contains the error that occurred.
 func delete<T: NSManagedObject>(_ object: T) throws {
  managedObjectContext.delete(object)
  try save()
 }

 /// Deletes all objects of a given type that match the specified predicate.
 /// - Parameters:
 ///   - objectType: The type of object to delete.
 ///   - predicate: An optional predicate to limit the objects that will be deleted. If `nil`, all objects of the
 ///   given type will be deleted.
 /// - Throws: An error if any of the objects could not be deleted.
 func deleteAll<T: NSManagedObject>(_ objectType: T.Type, predicate: NSPredicate? = nil) throws {
  let objects = try fetch(objectType, predicate: predicate)
  for object in objects {
   try delete(object)
  }
 }



 // MARK: - iCloud functionality

 /// Determines if the user is logged into an Apple ID and using iCloud.
 /// - Returns: A boolean indicating if the user is logged into an Apple ID and using iCloud.
 func canUseCloudFunctionality() -> Bool {
  isLoggedIntoAppleID() && isUsingiCloud()
 }

 /// Determines whether the user is currently using iCloud or not.
 /// - Returns: `true` if the user is using iCloud, `false` otherwise.
 private func isUsingiCloud() -> Bool {
  let iCloudAccountStatus = FileManager.default.ubiquityIdentityToken
  return iCloudAccountStatus != nil
 }

 /// Determines whether the user is logged into an Apple ID.
 /// - Remark:
 ///  - `available`: user is logged in to an iCloud account and iCloud is enabled
 ///  - `noAccount`: user is not logged in to an iCloud account or iCloud is not enabled
 ///  - `restricted`: the device is in a restricted or parental controlled environment
 ///  - `couldNotDetermine`: an error occurred trying to determine the user's iCloud account status
 ///  - `temporarilyUnavailable`: user’s iCloud account is available, but isn’t ready
 /// to support CloudKit operation
 /// - Returns: A boolean value indicating whether the user is logged into an Apple ID.
 private func isLoggedIntoAppleID() -> Bool {
  let container = CKContainer.default()
  var status: Bool = false
  container.accountStatus { (accountStatus, error) in
   switch accountStatus {
    case .available:
     status = true
    case .noAccount,
      .restricted,
      .couldNotDetermine,
      .temporarilyUnavailable:
     status = false
    @unknown default:
     status = false
   }
  }
  return status
 }

 /// Syncs the local data store with the cloud data store.
 /// - Parameter pull: A boolean value indicating whether to pull data from the
 /// cloud (true) or push data to the cloud (false).
 func syncWithCloud(pull: Bool) async throws {
  if canUseCloudFunctionality() {
   try await container.viewContext.perform {
    do {
     if pull {
      self.managedObjectContext.refreshAllObjects()
     } else {
      try self.managedObjectContext.save()
     }
    } catch {
     throw CoreDataError.errorSyncing
    }
   }
  } else {
   throw CoreDataError.noCloudFunctionality
  }
 }

 /// Forces a sync with the cloud.
 /// - Parameter pull: A boolean value indicating whether to pull data from the cloud.
 func forceSyncWithCloud(pull: Bool) async throws {
  if canUseCloudFunctionality() {
   try await container.viewContext.perform {
    do {
     self.managedObjectContext.reset()
     if pull {
      self.managedObjectContext.refreshAllObjects()
     } else {
      try self.managedObjectContext.save()
     }
    } catch {
     throw CoreDataError.errorSyncing
    }
   }
  }
 }


    /// This function toggles cloud sync for Core Data.
    /// - Parameters:
    ///   - isEnabled: A boolean value indicating whether cloud sync should be enabled or disabled.
    /// - Throws: CoreDataError.noCloudFunctionality or CoreDataError.persistentStoreError
 func toggleCloudSync(isEnabled: Bool) throws {
  guard canUseCloudFunctionality() else {
   throw CoreDataError.noCloudFunctionality
  }
  guard let description = container.persistentStoreDescriptions.first else {
   return
  }
  description.setOption(
   isEnabled as NSNumber,
   forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
  )
  do {
   try container.persistentStoreCoordinator.performAndWait {
    try container.persistentStoreCoordinator.remove(
     container.persistentStoreCoordinator.persistentStores.first!
    )
    try container.persistentStoreCoordinator.addPersistentStore(
     ofType: NSSQLiteStoreType,
     configurationName: nil,
     at: description.url,
     options: description.options
    )
   }
  } catch {
   throw CoreDataError.persistentStoreError(error: error)
  }
 }
}

Future versions

I guess the only real next iterations would consist of renaming the fetch method to update so I could simply remember CRUD and know the method to call.

But a part from that cosmetic change, it would be whatever changes happen to CoreData after Apple decide to make it more Swift-like.

And now that I've made it work for me, and the expansions of @FetchRequest and the like for SwiftUI, I'm 100% confident they'll re-write the entire backend. It's the only way it goes - put in the effort to make something work for you, then once you're done they'll re-write to to work for everyone.

🤷🏻‍♂️


Help me write and make more!

You can help me continue to provide valuable content like this. If you found this article helpful, please consider supporting me.

Coffee Pizza Dinner