0

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)
                    }
        }
}
5
  • 1
    your picture is unreadable. Show the error in full as text and on which line of your code. Commented Nov 15, 2023 at 7:14
  • In your code you say //await loadDataSD()//does not work, is this where you have the error? Is the error about decoding the SDTopLevel, if so can you show the code for your SDTopLevel. Commented Nov 15, 2023 at 7:32
  • Code and clarification added above. Commented Nov 15, 2023 at 17:40
  • Yes - the await loadDataSD is the code that attempts to decode the JSON into the @Model class and this is the piece that does not work. Commented Nov 15, 2023 at 17:44
  • I did remove the insert commands - since I did not get that far. Maybe your thought about decoding directly is a clue. I have been decoding and placing objects in Core Data for years, but I believe in all instances I decode into a struct and then create Core Data objects. Nevertheless, it seems to me that my issue is that the JSON is simply not decoded into the SDTopLevel - I must have a Decodable error somewhere. Commented Nov 15, 2023 at 21:27

2 Answers 2

4

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.

Sign up to request clarification or add additional context in comments.

4 Comments

Thanks! First attempt - this works for me. I'll do some testing.
@Joakim Danielson I'm not sure where the first two blob of codes go? "let modelContextKey...." and "...decoder.userInfo[modelContextKey] = context". And where is "context" defined?
The first one should be global so it can be accessed from both places. context is defined in the view, see the first code section in the question, you need to replace it with whatever model context you have where you do the decoding.
Thank you! I am using it for sample/preview data, and it works well.
1

Try this approach, where you declare a relationship between the SDTopLevel and the SDFuelStation.

Decoding json data directly into model type works very well, as shown in this example code.

import SwiftUI
import SwiftData

@main
struct TestApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([SDTopLevel.self])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

struct ContentView: View {
    @Environment(\.modelContext) var context
    
    var body: some View {
        FuelStationListView()
    }
}

struct FuelStationListView: View {
    @Environment(\.modelContext) var context
    @Query(sort: \SDFuelStation.stationName) var fuelStations: [SDFuelStation]
 //   @Query() var sdTopLevel: [SDTopLevel]

    var body: some View {
        NavigationStack {
            Button("Fetch") {
                Task {
                    await loadDataSD()
                }
            }
//            List {
//                ForEach(sdTopLevel) { sd in
//                    ForEach(sd.fuelStations ?? []) { fuelStation in
//                        Text(fuelStation.stationName)
//                    }
//                }
//            }
            
            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)
            context.insert(decodedResponse)  // <--- here
        } catch {
            print("----> error: \(error)")
        }
    }
    
}

@Model
class SDFuelStation: Identifiable, Codable {  // <--- here
    var id: Int
    var stationName: String = ""
    var streetAddress: String = ""
    var city: String = ""
    
    enum CodingKeys: CodingKey {
        case id, city, stationName, streetAddress
    }
    
    @Relationship var sdTopLevel: SDTopLevel?  // <--- here
    
    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)
    }
    
    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)
    }
 
}

@Model
class SDTopLevel: Identifiable, Codable {  // <--- here
    let id = UUID()
    
    @Relationship(inverse: \SDFuelStation.sdTopLevel) 
    var fuelStations: [SDFuelStation]?  // <--- here
    
    enum CodingKeys: CodingKey {
        case 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)
    }
    
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.