Combining Core Data with Generics

Combining Core Data with Generics

ยท

6 min read

If you've caught the Combine bug, chances are you are beginning to leverage the framework for all sorts of asynchronous logic in your code base. One such area that I've found myself interacting with a lot lately in this regard is Core Data.

When Apple debuted Combine in iOS 13, the release included some convenient publisher apis for NSManagedObject, mainly in the form of publishers for key-value observation and conformation to the ObservableObject protocol. Despite this, however, there is still a lot to be desired.

Fetching, adding and deleting objects are asynchronous in nature and would fit perfectly into the publisher world of Combine. Luckily with Deferred Futures, we can attach publisher functionality to our Core Data interactions with minimal effort:

func addTodo(context: NSManagedObjectContext, title: String) -> AnyPublisher<Todo, Error> {
    Deferred { [context] in
        Future { promise in
            context.perform {
                let todo = Todo(context: context)
                todo.title = title
                do {
                    try context.save()
                    promise(.success(todo))
                } catch {
                    promise(.failure(error))
                }
            }
        }
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

In a project that spans multiple Core Data entities, however, wrapping this sort of logic can end up in a lot of repetitive code. Let's discover how we can leverage generics to attach publisher functionality to any Core Data object

Identifying common interactions

If we were to familiarize ourselves with any Core Data project, we'd probably identify the following common interactions when it comes to its managed objects:

  • Fetching a list of objects with the ability to filter or sort them
  • Fetching a single existing object
  • Adding a new object
  • Updating an existing object
  • Deleting an existing object

The above interactions might look very similar to an abstracted repository pattern, which ends up being a good name for our generic class:

class CoreDataRepository<Entity: NSManagedObject> {
    private let context: NSManagedObjectContext

    init(context: NSManagedObjectContext) {
        self.context = context
    }
}

Above we've defined a class that has a generic placeholder type conveniently named Entity. We specify that our generic placeholder needs to be a type that inherits NSManagedObject. Through this specification, we will be able to call relevant methods that are associated with an NSManagedObject such as fetch requests.

We also require our class to be instantiated with an NSManagedObjectContext. By following an Inversion of Control (IoC) pattern with our context, we provide consumers of the class with the flexibility to specify the context depending on the circumstance (i.e view context or background context).

Now that we've got the foundation for our generic repository class in place, let's start implementing the interactions we identified earlier in this section.

Fetching a list of objects

Fetching objects in Core Data usually involves creating a fetch request, and optionally providing additional filters or sorting specifications in the form of NSPredicates or NSSortDescriptors:

let request: NSFetchRequest<Todo> = Todo.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Todo.title, ascending: true)]
do {
    let todos = try context.fetch(request)
    return todos
} catch {
    debugPrint("an error occurred \(error.localizedDescription)")
}

If we were to make this functionality more reusable and adjust it to work with our new generic publisher implementation, we could define the method as follows:

class CoreDataRepository<Entity: NSManagedObject> {
    //...

    func fetch(sortDescriptors: [NSSortDescriptor] = [],
               predicate: NSPredicate? = nil) -> AnyPublisher<[Entity], Error> {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    let request = Entity.fetchRequest()
                                        request.sortDescriptors = sortDescriptors
                    request.predicate = predicate
                    do {
                        let results = try context.fetch(request) as! [Entity]
                        promise(.success(results))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }
    //..
}

Requesting a specific object

The Core Data framework provides several methods for querying an existing object. For our repository class, we are going to assume that we would like to fetch an object only if it exists and throw an error otherwise. This is where we can leverage the existingObject method on the NSManagedObjectContext class:

guard let todo = try? context.existingObject(with: id) as? Todo else {
    return
}

Making the above more reusable we can apply it to our generic class through the following:

enum RepositoryError: Error {
    case objectNotFound
}

class CoreDataRepository<Entity: NSManagedObject> {
    // ..

    func object(_ id: NSManagedObjectID) -> AnyPublisher<Entity, Error> {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    guard let entity = try? context.existingObject(with: id) as? Entity else {
                        promise(.failure(RepositoryError.objectNotFound))
                        return
                    }
                    promise(.success(entity))
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }
    // ..
}

Note above we throw a custom error in the event that we fail to find the object. This could help with error management down stream if weโ€™d like to replace the occurrence of the error with a fallback experience in our app.

Adding a new object

When it comes to creating or adding new objects, putting together a generic implementation can be a little tricky. This is because each entity would have it's own respective attributes that are assembled prior to persisting the newly added object:

let todo = Todo(context: context)
todo.title = title
todo.createdAt = Date()
do {
    try context.save()
} catch {
        debugPrint("an error occurred \(error.localizedDescription)")
}

How can put together a function that provides the caller with the ability to apply changes to the object prior to persisting it? This is where we can make use of Swift's convenient in-out parameter:

class CoreDataRepository<Entity: NSManagedObject> {
    // ..
    func add(_ body: @escaping (inout Entity) -> Void) -> AnyPublisher<Entity, Error> {
        Deferred { [context] in
            Future  { promise in
                context.perform {
                    var entity = Entity(context: context)
                    body(&entity)
                    do {
                        try context.save()
                        promise(.success(entity))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .eraseToAnyPublisher()
    }
        //..
}

Updating or deleting an existing object

Updating or deleting an object is a little more straightforward, especially if the expectation is to persist the changes immediately:

class CoreDataRepository<Entity: NSManagedObject> {
    // ..

    func update(_ entity: Entity) -> AnyPublisher<Void, Error> {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    do {
                        try context.save()
                        promise(.success(()))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }

    func delete(_ entity: Entity) -> AnyPublisher<Void, Error> {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    do {
                        context.delete(entity)
                        try context.save()
                        promise(.success(()))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }

    // ..
}

Putting it all together

Now that we've defined the functionality of our generic repository implementation, we can attach CRUD publisher functionality to any object that inherits from the NSManagedObject class:

// create a repo for the relevant entity with the relevant context
let repo = CoreDataRepository<Todo>(context: context)

// add an entity
repo.add { todo in
    todo.title = "Hello Generics"
}
.sink { completion in
    switch completion {
    case .failure(let error):
        debugPrint("an error occurred \(error.localizedDescription)")
    case .finished:
        break
    }
} receiveValue: { todo in
    debugPrint("todo has been added")
}

// fetch entities
repo.fetch(sortDescriptors: [NSSortDescriptor(keyPath: \Todo.title, ascending: true)])
    .replaceError(with: [])
    .sink { todos in
        debugPrint("\(todos.count) todos fetched")
    }

// get an existing object
repo.object(todoId)
    .sink { completion in
        switch completion {
        case .failure(let error):
            debugPrint("an error occurred \(error.localizedDescription)")
        case .finished:
            break
        }
    } receiveValue: { todo in
        debugPrint("hello \(todo.title) object")
    }

// update an entity
todo.title = "updated title"
repo.update(todo)
    .sink { completion in
        switch completion {
        case .failure(let error):
            debugPrint("an error occurred \(error.localizedDescription)")
        case .finished:
            break
        }
    } receiveValue: { _ in
        debugPrint("todo updated")
    }

// delete an entity
repo.delete(todo)
    .sink { completion in
        switch completion {
        case .failure(let error):
            debugPrint("an error occurred \(error.localizedDescription)")
        case .finished:
            break
        }
    } receiveValue: { _ in
        debugPrint("todo deleted")
    }

Conclusion

Generics enable us to write flexible, reusable functionality that can work with any type, serving as a great tool for avoiding duplicate or repetitive code. In this article we learned how we could use generics to attach reusable publisher functionality to any Core Data object.

I hope you enjoyed this article and found it useful. If you have any questions, comments or feedback i'd love to hear them. Contact me or follow me on Twitter

ย