Introduction

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()
}

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:

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).

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()
}
}

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 its 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)")
}

We can put together a function that provides the caller with the ability to apply changes to the object prior to persisting it using 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

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

// 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.