TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: How SwiftData following external JSON changes

Forums > SwiftUI

@MaxAL  

Dear Community,

I need your help. I am new to SwiftUI and I'm trying to implement a function that automatically retrieves data after a few checks when the app reloads. The data comes from an external JSON file stored inside the app using SwiftData. I'm attempting to either download new data (if it's not already present in the User model) or refresh existing data if certain values for myuser are different.

In general, the app's database should monitor changes in the external JSON. I started with the first approach, adding an if check for new records, but the code is not working as expected (the function adds all records again and again).

I appreciate any guidance or suggestions you can provide. Thank you!

Example code.

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query var users: [User]

    var body: some View {
        ForEach(users) {user in
            ...
        }
    }

    func loadData() async {
 // guard users.isEmpty else { return }
        do {
            let url = URL(string: "https://www.test.com/test.json")!

            let (data, _) = try await URLSession.shared.data(from: url)
            let decoder = JSONDecoder()

            decoder.dateDecodingStrategy = .iso8601

            let downloadUsers = try decoder.decode([User].self, from: data)
            let insertContext = ModelContext(modelContext.container)

            for myuser in downloadUsers {

                //If not working. downloads every time.
                if users.contains(downloadUsers) == false {
                    insertContext.insert(myuser)
                    try insertContext.save()
                } else {      // for own check that code is working
                    print ("Yes, it is inside")
                }
//                insertContext.insert(myuser)
            }
//            try insertContext.save()
        } catch {
            print("Download data")
        }
    }
}

1      

One of the possible ways to arrange your code is like so. You can just copy paste to check the workflow. The URL fetch is working one.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query var users: [FriendModel]

    var body: some View {
        NavigationStack {
            List {
                ForEach(users) { user in
                    HStack {
                        Text(user.firstName)
                        Text(user.lastName)
                    }
                }
            }
            .navigationTitle("Users")
            .toolbar {
                Button("Fetch data", systemImage: "globe") {
                    Task {
                        await importUsers()
                    }
                }
            }
        }
    }

    func importUsers() async {
        guard let url = URL(string: "https://dummyjson.com/users") else {
            fatalError("There is a problem with the API URL")
        }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let friends = try JSONDecoder().decode(Friends.self, from: data)

            for user in friends.users {
                // In order you can use user last name to compare in predicate
                // you will need to save it in contstant
                let userToCompare = user.lastName

                // Here we will look if user is already saved in db
                let filter = #Predicate<FriendModel> { friend in
                    friend.lastName == userToCompare
                }

                // Create a fetch
                let fetch = FetchDescriptor(predicate: filter)

                // Fetch brings back [FriendModel] so even if nothing is there it brings back
                // empty array, so we also check if it is not empty it means user exists we will continue looping
                // through array
                if let userExists = try? modelContext.fetch(fetch), !userExists.isEmpty {
                    print("User with last name: \(userToCompare) already saved!")
                    continue
                }

                // if no user is found, let's create that user
                let friendModel = FriendModel(
                    firstName: user.firstName,
                    lastName: user.lastName
                )
                modelContext.insert(friendModel)
                print("New user with last name: \(friendModel.lastName) created!")
            }

        } catch {
            print(error.localizedDescription)
        }
    }
}

#Preview {
    NavigationStack {
        ContentView()
    }
    .modelContainer(for: FriendModel.self, inMemory: true)
}

@Model
class FriendModel {
    var firstName: String
    var lastName: String

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

struct Friend: Decodable {
    var firstName: String
    var lastName: String
}

struct Friends: Decodable {
    var users: [Friend]
}

1      

@MaxAL  

Dear @ygeras, thank you for the solution,but testing code (copy past) it hase some error.

1      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

You forgot to attach container in your @main entry

var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: FriendModel.self)
        }
    }

1      

I believe a much simpler solution is to add to FriendModel a CompositeAttribute based on firstName and lastName, and apply to it the .unique constraint: https://developer.apple.com/documentation/swiftdata/schema/compositeattribute

1      

@MaxAL  

Dear @ygeras , I played with your code for understanding how it works, but not working for me.

I tested as new project by copying and pasting the code into the ContentView. Based on your dummy JSON, I created two JSON files with the same name, users.json: 1) the original (29 items) and 2) with 28 records. Initially, I uploaded the JSON file with 28 items to my server, and the App successfully processed it. Later, I replaced it on the server with the file containing 29 records and pressed the 'Fetch Data' button in the app, expecting it to update and show the additional user. However, the app did not reflect the update and still showed only 28 users. Additionally, I tried using Context delete, but the app continued to display 28 users as it did after the initial upload. SwiftData records not changed as expected.

.toolbar {
                Button("Fetch data", systemImage: "globe") {
                    removeContainer()
                    Task {
                        await importUsers()
                    }
                }
            }
func removeContainer() {
        do {
            try modelContext.delete(model: FriendModel.self)

        } catch {
            print("Failed to clear all data.")
        }
    }

1      

Listen, I have no idea what exactly you are doing without seeing the real code. In words in can be one story in reality the other. The code that I offered does not include all the logic but the one possible solution how it can be handled.

Also all depends on what you downloaded with JSON containing 28 names and then replaced it with 29 names. If for example the file with 28 names contains Smith James, and then in another JSON file you add the same person, you won't have them 29 in your DB. As you are matching it by names, i.e. you download items and then check if the one with the same name already exists. If it does then that item won't be saved. You can also use not name but id, or any another unique property for that purpose.

Once again i just modified my previous code a bit for more readability and also printing out the db store, if you have any sql db reader you can verify yourself that data persists in db and when you remove person it also removed from db, I just myself verified that serveral times. Also if I download again the item that was deleted again saved to db as a NEW ITEM and in turn appears on the screen. So it is something in the code you're playing around... just try to split it in managable chunks and try to isolate the issue when something goes wrong.

Below modified code, but i did not change any logic in a big way that is different from the previous code. Only optimization with fetch count, which is really fast.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query(sort: \FriendModel.lastName) var users: [FriendModel]

    var body: some View {
        NavigationStack {
            List {
                Section("Total users: \(users.count)") {
                    ForEach(users) { user in
                        HStack {
                            Text(user.lastName)
                            Text(user.firstName)
                        }
                    }
                    .onDelete(perform: deleteRecord)
                }
            }
            .navigationTitle("Users")
            .toolbar {
                Button("Fetch data", systemImage: "globe") {
                    Task {
                        await fetchData()
                    }
                }
            }
            .onAppear {
                print(modelContext.sqliteCommand)
            }
        }
    }

    private func fetchData() async {
        guard let url = URL(string: "https://dummyjson.com/users") else {
            fatalError("There is a problem with the API URL")
        }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let friends = try JSONDecoder().decode(Friends.self, from: data)

            for user in friends.users {
                if doesRecordExist(for: user) {
                    continue
                } else {
                    createNewRecord(for: user)
                }
            }
        } catch {
            print(error.localizedDescription)
        }
    }

    private func doesRecordExist(for user: Friend) -> Bool {
        // In order to use user last name to compare in predicate
        // you will need to save it in contstant
        let userToCompare = user.lastName

        // Here we will look if user is already saved in db
        let filter = #Predicate<FriendModel> { friend in
            friend.lastName == userToCompare
        }

        // Create a fetch
        let descriptor = FetchDescriptor(predicate: filter)
        let fetchCount = try? modelContext.fetchCount(descriptor)

        if fetchCount != 0 {
            print("User with last name: \(userToCompare) already saved!")
            return true
        } else {
            return false
        }
    }

    private func createNewRecord(for user: Friend) {
        let friendModel = FriendModel(
            firstName: user.firstName,
            lastName: user.lastName
        )
        modelContext.insert(friendModel)
        print("New user with last name: \(friendModel.lastName) created!")
    }

    private func deleteRecord(indexSet: IndexSet) {
        for index in indexSet {
            modelContext.delete(users[index])
        }
    }
}

#Preview {
    NavigationStack {
        ContentView()
    }
    .modelContainer(for: FriendModel.self, inMemory: true)
}

@Model
class FriendModel {
    var firstName: String
    var lastName: String

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

struct Friend: Decodable {
    var firstName: String
    var lastName: String
}

struct Friends: Decodable {
    var users: [Friend]
}

extension ModelContext {
    var sqliteCommand: String {
        if let url = container.configurations.first?.url.path(percentEncoded: false) {
            "\(url)"
        } else {
            "No SQLite database found."
        }
    }
}

Watch out for number of users at the top of the seciton.

1      

@MaxAL  

Thank you for clarification. I use your code only. After replacing JSON file with higher number of names on server, SwiftUI don't find new names and don't add them.

1      

Check if JSON you receive is updated one. I am not an expert on server side, but maybe some issues with that... no clue. But as you can see from the code above you can delete data and after new fetch it is updated and saved. So logically if there are more items it should add them accordingly, as I do not see any issues that might stop them from updating...

1      

@MaxAL  

Thanks for replying. The server side is not involved. Here's a video of my process where you can see that SwiftData is not updating with new records after upload. Let me know if you have any insights or suggestions on how to fix this.

1      

The issue in how you update and what you do with your json. Using my pure code you can just change API to this string

"https://dummyjson.com/users?limit=40"

you can increase limit up to 100, seems like there are 93 records in total, by default you get only 29, and you can verify that code is working properly this way and new items are downloaded as expected.

1      

@MaxAL  

As seen on video, initialy JSON file had 28 records, uploaded file with 29. I didn't exeed any limits.

1      

I think you should check how to update JSON on the back end and how to communicate it to front end. I doubt that folks replacing files this way. When backend is setup correctly as in dummyjson it works properly. Once again, without seeing you set up in details, it is difficult to say what is wrong. There are thousands of reasons that something may goes wrong.

1      

As an option try to use GitHub gists to keep 2 json files and use those API to download. I have just run the test this way. Everything works absolutely perfectly. So the way you communitcate json to your device is not configured in proper way.

1      

@MaxAL  

Tested files from another server, everything is working. Will use GitHub. Thank you for help!

1      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.