Working with Codable and Core Data
Recently, I have been working on implementing a caching mechanism for an iOS app. In order to achieve that, I set the following goals:
-
Leverage the
Codable
protocol to easily parse the JSON response from the web service and create the appropriate model instance. -
Store the required model instances in Core Data.
This task has been an interesting learning experience. So, I decided to go back to one of my sample apps to illustrate how it is possible to make data models support Codable
and work with Core Data.
The model: NSManagedObject and Codable
The sample app I started from has only one simple model, User
, illustrated below:
enum Role: String {
case unknown = "Unknown"
case user = "User"
case owner = "Owner"
case admin = "Admin"
static func get(from: String) -> Role {
if from == user.rawValue {
return .user
} else if from == owner.rawValue {
return .owner
} else if from == admin.rawValue {
return .admin
}
return .unknown
}
}
struct User {
let avatarUrl: String
let username: String
let role: Role
init(avatarUrl: String, username: String, role: Role) {
self.avatarUrl = avatarUrl
self.username = username
self.role = role
}
}
In order to be able to store instance of User
in Core Data, a few changes are required.
Convert from struct to class
The first step to make the User model work with Core Data is to make it inherit from NSMangedObject
. This requires declaring User
as a class, instead of a struct, because NSManagedObject
inherits from NSObject
(which is the ancestor of most Objective-C classes).
Use primitive data types
Using primitive data types makes it easier for model properties to be stored in Core Data. For sake of simplicity, then, we will change role
to be a String
type instead of an enum
.
Declare properties as @NSManaged var
We need to declare all properties that will be stored in Core Data as @NSManaged var
. This is required to allow Core Data to correctly access such properties.
Allow seamless encoding/decoding with Core Data
To make Codable
work with Core Data we need to conform to both Encodable
and Decodable
in a way that allows to correctly interact with the app persistent container. Basically, we need to appropriately implement Encodable
’s encode(to:)
and Decodable
’s init(from:)
. In particular, we need to be able to access the persistent managed object context and correctly insert each entity (NSManagedObject) representing a User into Core Data (more on this in the Parsing the JSON response and storing users in Core Data
section below).
The resulting updated code for the User
model is as follows:
class User: NSManagedObject, Codable {
enum CodingKeys: String, CodingKey {
case avatarUrl = "avatar"
case username
case role
}
// MARK: - Core Data Managed Object
@NSManaged var avatarUrl: String?
@NSManaged var username: String?
@NSManaged var role: String?
// MARK: - Decodable
required convenience init(from decoder: Decoder) throws {
guard let codingUserInfoKeyManagedObjectContext = CodingUserInfoKey.managedObjectContext,
let managedObjectContext = decoder.userInfo[codingUserInfoKeyManagedObjectContext] as? NSManagedObjectContext,
let entity = NSEntityDescription.entity(forEntityName: "User", in: managedObjectContext) else {
fatalError("Failed to decode User")
}
self.init(entity: entity, insertInto: managedObjectContext)
let container = try decoder.container(keyedBy: CodingKeys.self)
self.avatarUrl = try container.decodeIfPresent(String.self, forKey: .avatarUrl)
self.username = try container.decodeIfPresent(String.self, forKey: .username)
self.role = try container.decodeIfPresent(String.self, forKey: .role)
}
// MARK: - Encodable
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(avatarUrl, forKey: .avatarUrl)
try container.encode(username, forKey: .username)
try container.encode(role, forKey: .role)
}
}
The controller: Parsing JSON responses
Now that our updated User
model is ready, let’s look into how we can parse the JSON response from the web service.
Accessing the managed object context
In order to use Core Data to store our User
instances we need to be able to access the persistent container and, in particular, the managed object context (viewContext
) from the Decodable
initializer implementation inside the model:
// MARK: - Decodable
required convenience init(from decoder: Decoder) throws {
// We need to access the managed object context (viewContext) of the persistence container here!
}
Since we can’t change the signature of the above method, and explicitly pass the required managed object context as a parameter, we have to find an alternative way to make the context available.
One way to achieve that is to store the context in the custom dictionary userInfo
property of the Decoder
instance. The userInfo
requires a key of CodingUserInfoKey
type to store the contextual information. To make things easier we will provide a CodingUserInfoKey
extension that conveniently wraps the key name:
public extension CodingUserInfoKey {
// Helper property to retrieve the context
static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")
}
Now, we can easily refer to the key reserved to store the managed object context as CodingUserInfoKey.managedObjectContext
. Because the CodingUserInfoKey
initializer returns an optional, though, we should always make sure to access our CodingUserInfoKey.managedObjectContext
extension in a safe way and avoid using forced unwrapping:
guard let codingUserInfoKeyManagedObjectContext = CodingUserInfoKey.managedObjectContext else {
fatalError("Failed to retrieve managed object context")
}
Parsing the JSON response and storing users in Core Data
The JSON parsing method is part of a controller, UserController
, that will take care of all the logic required for fetching the data representing our users from both the network and Core Data. Here’s the relevant parsing code:
class UserController: UserControllerProtocol {
[...]
private let persistentContainer: NSPersistentContainer
[...]
init(persistentContainer: NSPersistentContainer) {
self.persistentContainer = persistentContainer
}
[...]
}
private extension UserController {
func parse(_ jsonData: Data) -> Bool {
do {
guard let codingUserInfoKeyManagedObjectContext = CodingUserInfoKey.managedObjectContext else {
fatalError("Failed to retrieve context")
}
// Clear storage and save managed object instances
if currentPage == 0 {
clearStorage()
}
// Parse JSON data
let managedObjectContext = persistentContainer.viewContext
let decoder = JSONDecoder()
decoder.userInfo[codingUserInfoKeyManagedObjectContext] = managedObjectContext
_ = try decoder.decode([User].self, from: jsonData)
try managedObjectContext.save()
return true
} catch let error {
print(error)
return false
}
}
[...]
}
Let’s step through the salient points of the above code.
The UserController
requires a NSPersistentContainer
instance to be initialized. This will ensure we can access Core Data persistent container and its managed object context when needed.
As usual, when using Codable
, we create a JSONDecoder
instance inside the parse
method to parse the JSON response. Before the actual parsing, though, we store the managed object context in the decoder userInfo
dictionary using CodingUserInfoKey.managedObjectContext
as the key:
let managedObjectContext = persistentContainer.viewContext
let decoder = JSONDecoder()
decoder.userInfo[codingUserInfoKeyManagedObjectContext] = managedObjectContext
The managed object context instance we just stored in userInfo
will be used by the User
class while performing its decoding task (as described in the Allow seamless encoding/decoding with Core Data
section above). This is the first main difference in having to deal with Codable
and NSManagedObject
, compared to what we usually do when working with Codable
alone.
Next, we proceed to parse the JSON response to retrieve our User
instances as usual:
decoder.decode([User].self, from: jsonData)
While decoding the JSON response, the User
class will take care of correctly initializing the values of its @NSManaged var
properties and make sure that the new User
instances are correctly inserted in Core Data:
class User: NSManagedObject, Codable {
[...]
// MARK: - Core Data Managed Object
@NSManaged var avatarUrl: String?
@NSManaged var username: String?
@NSManaged var role: String?
// MARK: - Decodable
required convenience init(from decoder: Decoder) throws {
guard let codingUserInfoKeyManagedObjectContext = CodingUserInfoKey.managedObjectContext,
let managedObjectContext = decoder.userInfo[codingUserInfoKeyManagedObjectContext] as? NSManagedObjectContext,
let entity = NSEntityDescription.entity(forEntityName: "User", in: managedObjectContext) else {
fatalError("Failed to decode User")
}
self.init(entity: entity, insertInto: managedObjectContext)
let container = try decoder.container(keyedBy: CodingKeys.self)
self.avatarUrl = try container.decodeIfPresent(String.self, forKey: .avatarUrl)
self.username = try container.decodeIfPresent(String.self, forKey: .username)
self.role = try container.decodeIfPresent(String.self, forKey: .role)
}
[...]
}
Now, to finalize our parsing task, we need to make sure that our newly inserted User
instances are saved into the managed object context to be persisted:
try managedObjectContext.save()
This is the second, and last, main difference compared to what we usually do when working with Codable
alone. Once the parse
method is successfully executed all the User
instances retrieved from the JSON response will have been saved and will be accessible in our Core Data persistent storage.
Core Data as a caching mechanism
This new sample app requires only one model, which makes the database structure embarrassingly simple:
The use case for Core Data is rather simple: To allow the app to be used offline (i.e.: when network connectivity is not available). The simplest way to achieve this is to delete, and re-create, Core Data database every time the app has network connection. This is why in the parse(…)
method we call clearStorage()
before actually parsing the JSON response: We want to clear the storage (database) before we start adding the parsed User
instances. The code required to clear the storage is rather simple, as we just need to delete one table:
private extension UserController {
[...]
func clearStorage() {
let managedObjectContext = persistentContainer.viewContext
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: UserController.entityName)
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try managedObjectContext.execute(batchDeleteRequest)
} catch let error as NSError {
print(error)
}
}
[...]
}
In order to retrieve the stored User
instances, the UserController
provides the fetchFromStorage()
method:
private extension UserController {
[...]
func fetchFromStorage() -> [User]? {
let managedObjectContext = persistentContainer.viewContext
let fetchRequest = NSFetchRequest<User>(entityName: UserController.entityName)
let sortDescriptor1 = NSSortDescriptor(key: "role", ascending: true)
let sortDescriptor2 = NSSortDescriptor(key: "username", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor1, sortDescriptor2]
do {
let users = try managedObjectContext.fetch(fetchRequest)
return users
} catch let error {
print(error)
return nil
}
}
[...]
}
Both methods perform their respective task by means of a NSFetchRequest
. With the two above methods implemented, we now have everything we need to successfully interact with Core Data and our User instances.
Conclusion
In this post, I described my personal experience working with Codable
and Core Data. In particular, I focused on how to seamlessly parse JSON responses and store the resulting models in the appropriate database table in Core Data.
The code for the sample app illustrated in this post is available on GitHub.