JSONCache
JSONCache is a thin layer on top of Core Data that seamlessly consumes, caches and produces JSON data.
- Automatically creates Core Data objects from JSON data, or merges JSON data into objects that already exist.
- Automatically maps 1:1 and 1:N relationships based on inferred knowledge of your Core Data model.
- If necessary, automatically maps between
snake_case
in JSON key names andcamelCase
in Core Data attribute names. - Generates JSON on demand, both from
NSManagedObject
instances, and from anystruct
that adopts theJSONifiable
protocol. - Operates on background threads to avoid interfering with your app’s responsiveness.
Read the API documentation for the complete picture.
Content
Show, don’t tell
Consuming JSON
Say your backend produces JSON like this:
{
"bands": [
{
"name": "Japan",
"formed": 1974,
"disbanded": 1991,
"hiatus": "1982-1989",
"description": "Initially a glam-inspired group [...]"
},
...
],
"band_members": [
{
"id": "David Sylvian in Japan",
"musician": "David Sylvian",
"band": "Japan",
"instruments": "Lead vocals, keyboards, guitar",
"joined": 1974,
"left": 1991
},
...
],
"musicians": [
{
"name": "David Sylvian",
"born": 1958,
"instruments": "Vocals, guitar, keyboards"
},
...
],
"albums": [
{
"name": "Gentlemen Take Polaroids",
"band": "Japan",
"released": "1980-10-24T00:00:00Z",
"label": "Virgin"
},
...
]
}
To cache this data in your app, you create a suitable Core Data model:
And with only a few lines of code, the JSON data is safely persisted in Core Data on your device, relationships and all:
import JSONCache
...
let jsonObject = try! JSONSerialization.jsonObject(with: jsonData) as! [String: Any]
let bands = jsonObject["bands"] as! [[String: Any]]
let bandMembers = jsonObject["band_members"] as! [[String: Any]]
let musicians = jsonObject["musicians"] as! [[String: Any]]
let albums = jsonObject["albums"] as! [[String: Any]]
JSONCache.casing = .snake_case
JSONCache.dateFormat = .iso8601WithSeparators
JSONCache.bootstrap(withModelName: "Bands") { result in
switch result {
case .success:
JSONCache.stageChanges(withDictionaries: bands, forEntityWithName: "Band")
JSONCache.stageChanges(withDictionaries: bandMembers, forEntityWithName: "BandMember")
JSONCache.stageChanges(withDictionaries: musicians, forEntityWithName: "Musician")
JSONCache.stageChanges(withDictionaries: albums, forEntityWithName: "Album")
JSONCache.applyChanges { result in
switch result {
case .success:
print("Data all nicely tucked in")
case .failure(let error):
print("An error occurred: \(error)")
}
}
case .failure(let error):
print("An error occurred: \(error)")
}
}
If you receive additional data at a later stage it’s even simpler:
let albums = jsonObject["albums"] as! [[String: Any]]
JSONCache.stageChanges(withDictionaries: albums, forEntityWithName: "Album")
JSONCache.applyChanges { result in
switch result {
case .success:
print("Data all nicely tucked in")
case .failure(let error):
print("An error occurred: \(error)")
}
}
Producing JSON
If your app allows producing as well as consuming data, you can
generate JSON directly from NSManagedObject
instances:
switch JSONCache.fetchObject(ofType: "Band", withId: "Japan") {
case .success(let japan):
var japan = japan as! Band
japan.otherNames = "Rain Tree Crow"
ServerProxy.update(band: japan.toJSONDictionary()) { result in
switch result {
case .success:
switch JSONCache.save() {
case .success:
print("Japan as Rain Tree Crow all nicely tucked in")
case .failure(let error):
print("An error occurred: \(error)")
}
case .failure(let error):
print("An error occurred: \(error)")
}
}
case .failure(let error):
print("An error occurred: \(error)")
}
To create and persist new objects to the backend, you can either create the NSManagedObject
instance first and then use it to generate JSON for the backend, or if you prefer to wait until the record is safely persisted on the backend, you can generate JSON from any struct
that adopts the JSONifiable
protocol:
struct BandInfo: JSONifiable {
var name: String
var bandDescription: String
var formed: Int
var disbanded: Int?
var hiatus: Int?
var otherNames: String?
}
let u2Info = BandInfo(name: "U2", bandDescription: "Dublin boys", formed: 1976, disbanded: nil, hiatus: nil, otherNames: "Passengers")
ServerProxy.save(band: u2Info.toJSONDictionary()) { result in
switch result {
case .success:
u2 = NSEntityDescription.insertNewObject(forEntityName: "Band" into: JSONCache.mainContext)!
u2.setAttributes(fromDictionary: u2Info)
switch JSONCache.save() { result in
case .success:
print("U2 all nicely tucked in")
case .failure(let error)
print("An error occurred: \(error)")
}
case .failure(let error):
print("An error occurred: \(error)")
}
NOTE: I created the
JSONifiable
protocol beforeCodable
came along, and the documentation reflects this. I have not yet tried using JSONCache withCodable
, but from what I understand, it should work just fine. However, you may have to write a bit of extra code to get thefromJSONDictionary
/toJSONDictionary
duality that JSONCache offers for free. That said, I’m open to replacingJSONifiable
withCodable
in a future release if and when I realise that it makes better sense.
Avoiding the pyramid of doom
As is seen in the examples above, the asynchronous nature of backend calls and many Core Data operations can result in quite an indented pyramid of doom. To avoid this, overloaded bootstrap()
and applyChanges()
implementations are available that return a ResultPromise
instead of taking a closure, thus facilitating fluid sequencing of computations that produce either a Result
(then
) or a ResultPromise
(thenAsync
):
let promise = JSONCache.bootstrap(withModelName: "Bands")
.then { JSONCache.fetchObject(ofType: "Band", withId: "Japan") }
.thenAsync { object in
let japan = object as! Band
japan.otherNames = "Rain Tree Crow"
return ServerProxy.update(band: japan.toJSONDictionary())
}
.then { JSONCache.save() }
promise.await { result in
switch result {
case .success:
print("Japan as Rain Tree Crow all nicely tucked in")
case .failure(let error):
print("An error occurred: \(error)")
}
}
But do tell
Key conversion
When consuming JSON, JSON keys are converted to Core Data entity attribute names. Conversely, when producing JSON, Core Data entity attribute names (or struct
properties) are converted to JSON keys.
Key conversion consists of two steps:
- Convert between
snake_case
andcamelCase
as needed. - Qualify or dequalify reserved words as needed.
Case conversion
The JSONCache.casing
configuration parameter tells JSONCache whether the JSON data uses .snake_case
or .camelCase
in key names. Case conversion is only done if the JSON casing is .snake_case
:
attribute_name
becomesattributeName
when consuming JSON.attributeName
becomesattribute_name
when producing JSON.
Qualifying reserved words
Some words, such as description
, collide with reserved Cocoa names and may not be used as attribute names in Core Data entities. When reserved words are received as keys in JSON data, they are qualified. Specifically, the name of the Core Data entity that will hold the data is prefixed onto the reserved word. If for instance the entity name is EntityName
:
description
becomesentityNameDescription
when consuming JSON.entityNameDescription
becomesdescription
when producing JSON.
JSONCache only supports qualifying (prefixing) reserved words with the name of the corresponding entity. Thus, care must be taken when naming Core Data entity attributes whose JSON counterparts represent reserved words.
Currently, JSONCache only supports qualifying the reserved word description
. More words may be added in the future.
Date conversion
JSONCache supports the following JSON date formats:
- ISO 8601 with separators:
2000-08-22T12:28:00Z
- ISO 8601 without separators:
20000822T122800Z
- Seconds since 00:00 on 1 Jan 1970 as a double precision value:
966947280.0
Use the JSONCache.dateFormat
configuration parameter to tell JSONCache which format to expect and/or produce.
Relationship mapping
How to …
In order for JSONCache to automatically map a 1:1 or a 1:N relationship, you essentially only need to tell it one thing: The primary key of the object on the ‘1: end’ of the relationship. You do this in either of two ways:
- Use the name
id
for the primary key. (See Figure 1.) - Create a User Info key named
JC.isIdentifier
for the primary key attribute and assign it the valuetrue
orYES
(orTRUE
oryes
; both are case insensitive). (See Figure 2.)
Figure 1: Marking an entity’s primary key by naming it id
.
Figure 2: Marking an entity’s primary key by creating a User Info key named JC.isIdentifier
and setting it to true
.
The primary key must be unique within an entity, but not across entities.
But how …?
When JSONCache instantiates or updates an NSManagedObject
instance from a JSON dictionary, it does so by inspecting the
NSAttributeDescription
s of the NSEntityDescription
that describes the underlying entity, and assigns each attribute the corresponding value from the JSON dictionary.
In a second pass, JSONCache inspects the NSRelationshipDescription
s of the entity, and for each relationship that is not toMany
, it looks up the NSEntityDescription
of the destination object. Through a JSONCache extension method on NSEntityDescription
, it obtains the primary key of the destination entity. It already knows the primary key value of the destination object from the JSON dictionary, so now it has all the information it needs in order to look it up, either in the set of new objects created from the JSON data, or in Core Data if it already has been persisted. Once it has a reference to the destination object, it hooks up the relationship and moves on to the next item.
Consider the following JSON records:
Band
{
"name": "Japan",
"formed": 1974,
"disbanded": 1991,
"hiatus": "1982-1989",
"description": "Initially a glam-inspired group [...]",
"other_names": "Rain Tree Crow"
}
Album
{
"name": "Tin Drum",
"band": "Japan",
"released": "1981-11-13T00:00:00Z",
"label": "Virgin"
}
JSONCache first creates two NSManagedObject
instances, one for the band Japan, and one for the Tin Drum album, each holding all the attributes from the corresponding JSON record. Then, in the second pass, JSONCache looks at relationships. The Band
entity participates in 2 relationships, albums
and members
(see the ER diagram above), both of which are toMany
, so it does nothing. The Album
entity participates in 1 relationship, band
, which is not
toMany
. Through the NSRelationshipDescription
describing the band
relationship, JSONCache finds that the destination entity is of type Band
, and through the NSEntityDescription
describing the destination entity, it finds that it has the primary key name
. Now JSONCache has all the information it needs. It looks up a Band
object whose name
value is the same as the band
value in the Album
dictionary for the Tin Drum album (i.e., ‘Japan’), and finally hooks up the relationship.
Installation
You can install JSONCache using either CocoaPods or Carthage.
CocoaPods
pod 'JSONCache'
Carthage
github "andersblehr/JSONCache" ~> 1.0
Compatibility
- Swift 5.x
- macOS 10.12 or later
- iOS 10.0 or later
- watchOS 3.0 or later
- tvOS 10.0 or later
License
JSONCache is released under the MIT license. See the LICENSE file for details.