Search code examples
iosxcodeswiftuiswiftdata

JSON to SwiftData Decode of JSON Ends in Error at ObservationRegistrar


I'm struggling with a SwiftUI app for iOS17 and SwiftData. I am attempting to download data from a JSON source and store the data in SwiftData. I can download and decode the data into a Swift struct, but have not been able to do so with a SwiftData @Model class. The code below includes both the struct and SwiftData procedures. The SwiftData classes are SDTopLevel and SDFuelStation, the structs are TopLevelX and FuelStationX. The button in FuelStationListView calls either loadDataSD or loadDataX. The URL is correct and contains a demo key.

struct FuelStationListView: View {

    @Environment(\.modelContext) var context
    @Query(sort: \SDFuelStation.stationName) var fuelStations: [SDFuelStation]

    @State private var sdTopLevel: SDTopLevel?
    @State private var topLevel: TopLevelX?

    var body: some View {
        NavigationStack {
            Button("Fetch") {
                Task {
                    await loadDataX()//works
                    //await loadDataSD()//does not work
                }
            }
            List {
                ForEach(fuelStations) { fuelStation in
                    Text(fuelStation.stationName)
                }
            }
            .navigationTitle("Fuel Stations")
        }//nav
    }//body

    func loadDataSD() async {

        guard let url = URL(string: "https://developer.nrel.gov/api/alt-fuel-stations/v1.json?api_key=DEMO_KEY&limit=10") else {
            print("Invalid URL")
            return
        }

        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            guard (response as? HTTPURLResponse)?.statusCode == 200 else {
                print(response)
                return
            }
        
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let decodedResponse = try decoder.decode(SDTopLevel.self, from: data)
            print(decodedResponse)
        
            sdTopLevel = decodedResponse
            print("sdTopLevel.fuelStations.count is \(sdTopLevel?.fuelStations.count ?? 1000)")

        } catch {
            print("Invalid Data")
        }

    }//load data

    func loadDataX() async {

        guard let url = URL(string: "https://developer.nrel.gov/api/alt-fuel-stations/v1.json?api_key=DEMO_KEY&limit=10") else {
            print("Invalid URL")
            return
        }

        do {
        
            let (data, response) = try await URLSession.shared.data(from: url)
            guard (response as? HTTPURLResponse)?.statusCode == 200 else {
                print(response)
                return
            }
        
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let decodedResponse = try decoder.decode(TopLevelX.self, from: data)
        
            self.topLevel = decodedResponse
            print("topLevel.fuelStations.count is \(topLevel?.fuelStations.count ?? 0)")

            for station in decodedResponse.fuelStations {
                print(station.stationName)
            }
        
            self.topLevel = nil

        } catch {
            print("Invalid Data")
        }

    }//load data

}//struct fuel Station list view

And the data:

@Model
class SDFuelStation: Codable {

    enum CodingKeys: CodingKey {
        case id, city, stationName, streetAddress
    }//enum

    public var id: Int

    var stationName: String = ""
    var streetAddress: String = ""
    var city: String = ""

    public init(stationName: String, streetAddress: String, city: String) {
        self.id = 0
        self.stationName = stationName
        self.streetAddress = streetAddress
        self.city = city
    }

    required public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        stationName = try container.decode(String.self, forKey: .stationName)
        streetAddress = try container.decode(String.self, forKey: .streetAddress)
        city = try container.decode(String.self, forKey: .city)
    }//required init

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
    
        try container.encode(id, forKey: .id)
        try container.encode(stationName, forKey: .stationName)
        try container.encode(streetAddress, forKey: .streetAddress)
        try container.encode(city, forKey: .city)
    }

}//class

struct TopLevelX: Codable {
    let fuelStations: [FuelStationX]
}

struct FuelStationX: Codable {

    let id: Int

    var stationName: String = ""
    var streetAddress: String = ""
    var city: String = ""

}//struct

The error is in the Model code in a getter:

enter image description here

Any guidance would be appreciated. Xcode 15.0 iOS 17

EDIT: I mistakenly missed the SDTopLevel class:

@Model
class SDTopLevel: Codable {

    enum CodingKeys: CodingKey {
        case fuelStations
    }

    var fuelStations: [SDFuelStation]

    init(fuelStations: [SDFuelStation]) {
        self.fuelStations = fuelStations
    }

    required public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        fuelStations = try container.decode([SDFuelStation].self, forKey: .fuelStations)
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(fuelStations, forKey: .fuelStations)
    }

}

The code for var fuelStations unfolds as the following and the error is on the line: return self.getValue(forKey: .fuelStations) and the error is "Thread 10: EXC_BREAKPOINT (code=1, subcode=0x1a8a5303c)"

{
    @storageRestrictions(accesses: _$backingData, initializes: _fuelStations)
        init(initialValue) {
                    _$backingData.setValue(forKey: \.fuelStations, to: initialValue)
                    _fuelStations = _SwiftDataNoType()
        }

    get {
                    _$observationRegistrar.access(self, keyPath: \.fuelStations)
                    return self.getValue(forKey: \.fuelStations)
        }

    set {
                    _$observationRegistrar.withMutation(of: self, keyPath: \.fuelStations) {
                            self.setValue(forKey: \.fuelStations, to: newValue)
                    }
        }
}

Solution

  • The solution is pretty similar to what we do in Core Data, we need to pass the model context to the decoder so we can use it in the init(from:) and insert the top level object.

    We are using the userInfo dictionary on the JSONDecoder for this so first we need to define a key

    let modelContextKey = CodingUserInfoKey(rawValue: "modelcontext")!
    

    Then before decoding we pass our Environment variable for ModelContext to the decoder

    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    decoder.userInfo[modelContextKey] = context
    

    Then we read this key and use it in the init(from:) for the model that is the root object for the json

    @Model
    class SDTopLevel: Codable {
        //...
    
        required public init(from decoder: Decoder) throws {
            guard let context = decoder.userInfo[CodingUserInfoKey(rawValue: "modelcontext")!] as? ModelContext else {
                fatalError() // replace with throw some error
            }
            let container = try decoder.container(keyedBy: CodingKeys.self)
            fuelStations = try container.decode([SDFuelStation].self, forKey: .fuelStations)
            context.insert(self)
        }
    

    We do not need to do the same for SDFuelStation because it is enough for SwiftData if one side of the relationship has been inserted into a model context.