Hi Carmen,
I'm almost finished Day 61 and I definitely ran into the same thing you have.
I'm planning to post a Twitter thread documenting some turning points and key discoveries, but let me post here what I hope will be helpful to your specific question.
The code you have works well enough to write to Core Data when only one entity is involved, Users, but as you've found, it gets sticky when trying to populate the Friends entity (and Tags entity if you choose to set that up later).
What I learned:
- Just like you can decode JSON directly into basic Swift structures you define yourself, you can decode JSON directly into Core Data objects. I found this to be an elegant solution. Here is what my
NSManagedObject
subclass looks like for the CDUser
entity (where I'm keeping my user data):
//
// CDUser+CoreDataClass.swift
// Friends
//
// Created by Russell Gordon on 2020-12-27.
//
//
import Foundation
import CoreData
@objc(CDUser)
public class CDUser: NSManagedObject, Decodable {
// What properties to decode
enum CodingKeys: CodingKey {
case id
case isActive
case name
case age
case company
case email
case address
case about
case registered
case tags
case friends
}
required convenience public init(from decoder: Decoder) throws {
// Attempt to extract the object
guard let context = decoder.userInfo[.context] as? NSManagedObjectContext else {
fatalError("NSManagedObjectContext is missing")
}
// Actually create an instance of CDUser and tie it to the current managed object context
self.init(context: context)
// Decode JSON...
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
isActive = try container.decode(Bool.self, forKey: .isActive)
name = try container.decode(String.self, forKey: .name)
age = try container.decode(Int16.self, forKey: .age)
company = try container.decode(String.self, forKey: .company)
email = try container.decode(String.self, forKey: .email)
address = try container.decode(String.self, forKey: .address)
let untrimmedAbout = try container.decode(String.self, forKey: .about)
about = untrimmedAbout.trimmingCharacters(in: CharacterSet(charactersIn: "\r\n"))
// Decode the date
// SEE: https://developer.apple.com/documentation/foundation/dateformatter#overview
// SEE: https://developer.apple.com/documentation/foundation/iso8601dateformatter
// SEE: https://makclass.com/posts/24-a-quick-example-on-iso8601dateformatter-and-sorted-by-function
let dateAsString = try container.decode(String.self, forKey: .registered)
let dateFormatter = ISO8601DateFormatter()
registered = dateFormatter.date(from: dateAsString) ?? Date()
// Decode all the tags and tie to this entity
let tagsAsStrings = try container.decode([String].self, forKey: .tags)
var tags: Set<CDTag> = Set()
for tag in tagsAsStrings {
let newTag = CDTag(context: context)
newTag.name = tag
// Associate each tag in the set with this user
newTag.tagOrigin = self
tags.insert(newTag)
}
tag = tags as NSSet
// print("Count of tags for \(name!) is \(tag!.count)")
// Decode all the friends and tie to this entity
let friends = try container.decode(Set<CDFriend>.self, forKey: .friends)
for friend in friends {
// Associate each friend in the set with this user
friend.friendOrigin = self
}
friend = friends as NSSet
// print("Count of friends for \(name!) is \(friend!.count)")
}
}
Here's what my extension to JSONDecoder
looks like:
//
// JSONDecoder.swift
// Friends
//
// Created by Russell Gordon on 2020-12-27.
//
import CoreData
import Foundation
// See: https://stackoverflow.com/a/52698618
// See: https://www.donnywals.com/using-codable-with-core-data-and-nsmanagedobject/
// Required to decode straight from JSON into Core Data managed objects
extension CodingUserInfoKey {
static let context = CodingUserInfoKey(rawValue: "context")!
}
extension JSONDecoder {
convenience init(context: NSManagedObjectContext) {
self.init()
self.userInfo[.context] = context!
}
}
Here is how my entities are set up: , , .
Now... on a related note...
- You might have noticed some
print
statements that I'd commented out in my CDUser
subclass. Decoding JSON happens asynchronously. Until I realized what was happening, I was quite perplexed. Sometimes I would get 100 user objects in Core Data... sometimes 99! Sometimes a user would have 3 friends, sometimes that same user would have 5 friends! And... about 2/3 of the time, my app would crash with (just for fun) different error messages. After a lot of debugging and reading online, I now gather that updating a Core Data store with a lot of records should happen on a background thread. Apple has notes about concurrency here.
Here is what that looked like in practice for me:
// 1. Prepare a URLRequest to send our encoded data as JSON
let url = URL(string: "https://www.hackingwithswift.com/samples/friendface.json")
var request = URLRequest(url: url!)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "GET"
// 2. Run the request and process the response
URLSession.shared.dataTask(with: request) { data, response, error in
// handle the result here – attempt to unwrap optional data provided by task
guard let unwrappedData = data else {
// Show the error message
print("No data in response: \(error?.localizedDescription ?? "Unknown error")")
return
}
// Now decode from JSON directly into Core Data managed objects
// Do this on a background thread to avoid concurrency issues
// SEE: https://stackoverflow.com/questions/49454965/how-to-save-to-managed-object-context-in-a-background-thread-in-core-data
// SEE: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/Concurrency.html
let parent = PersistenceController.shared.container.viewContext
let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
childContext.parent = parent
// Set merge policy for this context (so friends that exist on multiple users don't cause conflicts, for example)
// Set the merge policy for duplicate entries – ensure that duplicate entries get merged into a single unique record
// "To help resolve this, Core Data gives us constraints: we can make one attribute constrained so that it must always be unique. We can then go ahead and make as many objects as we want, unique or otherwise, but as soon as we ask Core Data to save those objects it will resolve duplicates so that only one piece of data gets written."
childContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
childContext.performAndWait {
// ⭐️Decode directly into Core Data objects⭐️
let decoder = JSONDecoder(context: childContext)
if let decodedData = try? decoder.decode([CDUser].self, from: unwrappedData) {
print("There were \(decodedData.count) users placed into Core Data")
} else {
print("Could not decode from JSON into Core Data")
}
// Required to actually write the changes to Core Data
do {
// Save Core Data objects
try childContext.save()
} catch {
fatalError("Failure to save context: \(error)")
}
}
}.resume()
Finally, I should note that I'm working with the iOS 14 "full" SwiftUI app lifecycle.
Largely taken directly from the sample code in the iOS App Core Data template from Xcode 12, here are the contents of my CDPersistence.swift
file:
//
// Persistence.swift
// CoreDataTest
//
// Created by Russell Gordon on 2020-12-27.
//
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newUser = User.example
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Friends")
// Set merge policy
// Set the merge policy for duplicate entries – ensure that duplicate entries get merged into a single unique record
// "To help resolve this, Core Data gives us constraints: we can make one attribute constrained so that it must always be unique. We can then go ahead and make as many objects as we want, unique or otherwise, but as soon as we ask Core Data to save those objects it will resolve duplicates so that only one piece of data gets written."
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
That is what allows for the following key line just before decoding directly into Core Data objects – where we get a reference to the managed object context:
let parent = PersistenceController.shared.container.viewContext
Carmen, I hope I'm not oversharing and that this helps.
Good luck!