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

SOLVED: SwiftData Preloading Data with Relationships

Forums > SwiftUI

@DaveC  

Hey everyone! New iOS developer here... My first public app is going to be a trivia app with items divided into several categories. I've decided I'd like to use SwiftData to manage my 4-5 categories and 1500+ items.

In order to give me some practice in preparation for the development of the main app, I decided to try to create a simple app to create and manage the data store that will be used in the main app. I've spent days poring over tutorials, watching video, recreating all the apps decribed there. I'm currently trying to understand how to pre-populate some sample data into the data store. I've been successful in doing so with a single model or with multiple unrelated models. Where I'm struggling is to try to figure out if I can preload data into related models. I've seen a few examples discussing that very thing, but the relationships show in all those examples are between simple String properites. I'm trying to

@Model
class Category: Identifiable {

    @Attribute(.unique) let id: UUID = UUID()
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Trivia.category) var trivia: [Trivia]

    init(
        name: String,
        trivia: [Trivia] = [Trivia]()
    ) {
        self.name = name
        self.trivia = trivia
    }
}
@Model
class Trivia: Identifiable {
    @Attribute(.unique) let id: UUID = UUID()
    var question: String
    @Relationship(deleteRule: .cascade, inverse: \Answer.parent) var answers: [Answer]
    var category: Category?

    init(
        question: String,
        answers: [Answer],
        category: Category? = nil,
    ) {
        self.question = question
        self.answers = answers
        self.category = category
    }
}
@Model
class Answer {
    var parent: Trivia
    var text: String
    var isCorrect: Bool

    init(
        parent: Trivia,
        text: String,
        isCorrect: Bool
    ) {
        self.parent = parent
        self.text = text
        self.isCorrect = isCorrect
    }
}

I have created the sample data below that I'd like to preload. For now, I'm just focusing on the Category and Trivia models with Trivia.answers being an empty array. I'd eventually like to be able to add some data into that property as well, but I figured I needed to take it one step at a time. For the time being, my sample categories have not trivia property and my sample trivia items have not category property. I can preload the categories and the trivia items separately from each other, but I'm trying to understand how to establish the relationships during the preload process.

extension Category {
    static var starterCategories: [Category] {
        [
            Category(
                name: "Category 1"
            ),
            Category(
                name: "Category 2"
            ),
            Category(
                name: "Uncategorized"
            )
        ]
    }
}

extension Trivia {
    static var starterTrivia: [Trivia] {
        [
            Trivia(
                question: "A quien madruga",
                answers: [],
                // I'd like to be able to assign a category during the initial loading, but don't know how to do that.
            ),
            Trivia(
                question: "Question 2",
                answers: []
                // I'd like to be able to assign a category during the initial loading, but don't know how to do that.
            )
        ]
    }
}

Here's the code for my app entry point. This was primarily taken from Stewart Lynch's SwiftData video playlist although I have also reviewed the related HWS topics and others I have found also. The code belows gets my sample data loaded, but there's no connection between the categories and trivia items.

import SwiftUI
import SwiftData

@main
struct TriviaManagerApp: App {
    let container: ModelContainer

    var body: some Scene {
        WindowGroup {
            HomeView()
        }
        .modelContainer(container)
    }

    init() {
        let schema = Schema([Category.self])
        print("Created schema variable")
        let config = ModelConfiguration("TriviaManager", schema: schema)
        print("Created model configuration")
        do {
            container = try ModelContainer(for: schema, configurations: config)
            print("Created container")

            var categoryFetchDescriptor = FetchDescriptor<Category>()
            categoryFetchDescriptor.fetchLimit = 1
            if try container.mainContext.fetch(categoryFetchDescriptor).count == 0 {
                Category.starterCategories.forEach {
                    container.mainContext.insert($0)
                }
            }

            var triviaFetchDescriptor = FetchDescriptor<Trivia>()
            triviaFetchDescriptor.fetchLimit = 1
            if try container.mainContext.fetch(triviaFetchDescriptor).count == 0 {
                Trivia.starterTrivia.forEach {
                    container.mainContext.insert($0)
                }
            }

        } catch {
            fatalError("Could not configure model container")
        }
        print(URL.applicationSupportDirectory.path(percentEncoded: false))
    }
}

Like I said, I'm new to all of this, so I may be missing something obvious, but over a weeks of multiple hours a day of tutorials and videos haven't gotten me there yet... Any guidance would be appreciated!

3      

hi Dave,

i would suggest not having independent, static arrays of Category and Trivia model objects, but rather define the information you want to load using structs that show exactly the hierarchy of Category --> Trivia --> Answer. these can be statically defined if you wish, or can defined externally in JSON and read in from that JSON to create model objects.

a possible start:

// define CategoryData, TriviaData, and AnswerData structs
struct AnswerData: Codable {
    let text: String
    let isCorrect: Bool
}

struct TriviaData: Codable {
    let question: String
    let answers: [AnswerData]
}

struct CategoryData: Codable {
    let name: String
    let trivia: [TriviaData]
}

// define categories like this ...
let category1 = CategoryData(
    name: "Category 1",
    trivia:
        [TriviaData(
            question: "A quien madruga",
            answers: [
                AnswerData(text: "Wrong answer 1", isCorrect: false),
                AnswerData(text: "Wrong answer 2", isCorrect: false),
                AnswerData(text: "Correct answer", isCorrect: true)
            ]
        ),
         TriviaData(
            question: "Question 2",
            answers: [
                AnswerData(text: "Wrong answer 1", isCorrect: false),
                AnswerData(text: "Correct answer", isCorrect: true),
                AnswerData(text: "Wrong answer 2", isCorrect: false)
            ]
         )
        ]
)

let categoryDataList:  = [category1, ... ]

if your app discovers at setup that no categories have been loaded, it should not be too hard to loop over such a categoryDataList of structs, and for each, create a corresponding Category model object with a name and insert into the model context; then for each of the associated TriviaData values, create a corresponding Triva model object with a question, insert into the model context, and append it to the Category's trivia array; and go one more step to read out the answerData creating Answer model objects, setting the text and isCorrect, inserting into the model context, and appending to the Trivia's questions array.

some quick comments.

(1) i made all the structs Codable. if you ever wanted to archive/restore the data (or possibly edit data externally directly in JSON and reload), this will make your work a lot easier.

i have used most of the techniques i described above in an app i've kept going for a few years called ShoppingList. it's been updated to use SwiftData, and perhaps some of this will be useful to you.

(2) you mention 1500+ trivia items. are these aleady defined, say, in a spreadsheet or some custom data format? if so, there could be more direct ways to load the data than to turn everything into such formal structs.

hope you'll find some of this useful,

DMG

3      

@DaveC  

I would suggest not having independent, static arrays of Category and Trivia model objects, but rather define the information you want to load using structs that show exactly the hierarchy of Category --> Trivia --> Answer.

Thanks, @delawaremathguy for the ideas! After going through your post, I decided to see if I could merge your idea not separating the models with the approach I had already implemented by building the sample data as a single array of categories with the trivia items embedded in the categories and the answers embedded in the trivia items. I have acheived partial success, but I'm not quite there yet.

So, what I did first was to restructure the sample data like this:

    static var starterCategories: [Category] {
        [
            Category(
                name: "Category 1",
                trivia: [
                            Trivia(
                                question: "Question 1",
                                answers: [
                                            Answer(
                                                text: "Answer 1.1",
                                                isCorrect: true
                                            ),
                                            Answer(
                                                text: "Answer 1.2",
                                                isCorrect: false
                                            ),
                                            Answer(
                                                text: "Answer 1.3",
                                                isCorrect: false
                                            )
                                        ]
                            ),
                            Trivia(
                                question: "Question 2",
                                answers: [
                                            Answer(
                                                text: "Answer 2.1",
                                                isCorrect: true
                                            ),
                                            Answer(
                                                text: "Answer 2.2",
                                                isCorrect: false
                                            ),
                                            Answer(
                                                text: "Answer 2.3",
                                                isCorrect: false
                                            )
                                        ]
                            )
                        ]
            ),
            Category(
                name: "Category 2"
            ),
            Category(
                name: "Category 3"
            ),
            Category(
                name: "Uncategorized"
            )
        ]
    }

I also slightly modified the models to look like this:

@Model
class Category: Identifiable {
    @Attribute(.unique) let id: UUID = UUID()
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Trivia.category) var trivia: [Trivia]

    init(
        name: String,
        trivia: [Trivia] = [Trivia]()
    ) {
        self.name = name
        self.trivia = trivia
    }
}

@Model
class Trivia: Identifiable {
    @Attribute(.unique) let id: UUID = UUID()
    var question: String
    @Relationship(deleteRule: .cascade, inverse: \Answer.parent) var answers: [Answer]
    var category: Category?

    init(
        question: String,
        answers: [Answer] = [Answer](),
        category: Category? = nil,
    ) {
        self.question = question
        self.answers = answers
        self.category = category
    }
}

@Model
class Answer {
    var parent: Trivia?
    var text: String
    var isCorrect: Bool

    init(
        parent: Trivia? = nil,
        text: String,
        isCorrect: Bool
    ) {
        self.parent = parent
        self.text = text
        self.isCorrect = isCorrect
    }
}

I then updated my init() method in the app entry point to this:

    init() {
        let schema = Schema([Category.self])
        let config = ModelConfiguration("TriviaManager", schema: schema)

        do {
            container = try ModelContainer(for: schema, configurations: config)

            let categories = Category.starterCategories
            var categoryFetchDescriptor = FetchDescriptor<Category>()
            categoryFetchDescriptor.fetchLimit = 1
            if try container.mainContext.fetch(categoryFetchDescriptor).count == 0 {
                categories.forEach { category in
                    container.mainContext.insert(category)
                    let trivia = category.trivia
                    if !trivia.isEmpty {
                        trivia.forEach { item in
                            item.category = category
//                            let answers = item.answers
//                            if !answers.isEmpty {
//                                answers.forEach { answer in
//                                    answer.parent = item
//                                }
//                            }
                        }
                    }
                }
            }
        } catch {
            fatalError("Could not configure model container")
        }
        print(URL.applicationSupportDirectory.path(percentEncoded: false))
    }

Notice that there are some lines commented out in my init() method. With those lines commented out, the data is successfully preloaded with the two Trivia items successfully linked to Category 1 via the relationship defined in the models. When I saw that it had worked, I added the additional lines (that are currently commented out) because that seemed like a logical next step to add the relationship between each of the trivia items and their respective Answer array items.

Unfortunately, that last part didn't work, and I had to comment out the offending lines because when I leave them in, my code builds successfully but the app crashes on during launch with the following explanation (along with additional raw data) in the console window:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Illegal attempt to establish a relationship 'parent' between objects in different contexts (source = <NSManagedObject: 0x6000021134d0> (entity: Answer; id: 0x600000229180 <x-coredata:///Answer/t224E3C7A-10E9-45B4-9013-2C7B8D7634264>; data: {
    isCorrect = 0;
    parent = nil;
    text = "Answer 1";
}) , destination = <NSManagedObject: 0x6000021135c0> (entity: Trivia; id: 0x600000229240 <x-coredata:///Trivia/t224E3C7A-10E9-45B4-9013-2C7B8D7634265>; data: {
    answers =     (
        "0x600000229180 <x-coredata:///Answer/t224E3C7A-10E9-45B4-9013-2C7B8D7634264>",
        "0x600000228a40 <x-coredata:///Answer/t224E3C7A-10E9-45B4-9013-2C7B8D7634262>",
        "0x600000229120 <x-coredata:///Answer/t224E3C7A-10E9-45B4-9013-2C7B8D7634263>"
    );
    category = "0x600000225720 <x-coredata:///Category/t224E3C7A-10E9-45B4-9013-2C7B8D76342610>";
    id = "89A011A0-6ACD-4140-A8F7-BDD167E4A944";
    question = "Question 1";
}))'

I may be wrong, but it feels like I'm so close! Any thoughts on what I need to adjust to overcome this last hurdle?

3      

hi Dave,

even though you did not go the "define via struct, JSON-ready" route, the direct definition of your starterCategories seems fine. you got the idea, however.

as for the inner-most setting of the parent of each answer, i looked at this in Xcode with the debugger, and i was puzzled, because all the model objects were showing the same modelContext value, so why would you get an error about objects in different contexts?

it took me a while to play with a few things, and the following code now works fine because i have explicitly inserted everything into the same context (even though they appeared to already be in the same context):

for category in Category.starterCategories {
    container.mainContext.insert(category)
    for item in category.trivia {
        item.category = category
        container.mainContext.insert(item) // explicit use of insert
        for answer in item.answers {
            container.mainContext.insert(answer)  // explicit use of insert
            answer.parent = item
        }
    }
}

(i removed your .forEach usage; it seemed a little easier to track the code with the debugger using direct code rather than using closures for the loops.)

my guess is that while SwiftData seemed happy inserting just the Category (and by implication its submodels two levels deep) into the modelContext, it's actually that Core Data's underneath was on a slightly different page and didn't get the message down to the second level Answers. i'm thinking that although the execution error was saying "context," it really meant Core Data's managedObjectContext and not the object's modelContext.

hope that helps,

DMG

3      

@DaveC  

@delawaremathguy, once again, thank you! That seems to have done the trick!

I never would have tried that without you suggesting it because everything I've read so far says to not try to insert multiple models but to allow the relationships to do their thing by inserting one model and letting SwiftData pull in the other models that have relationships to it. And since I'm new at this stuff, I don't know enough yet about how it works under the hood to know when it makes sense to try something different.

Thanks again!

3      

hi Dave,

glad i could help; and guessing that this was likely a Core Data thing underneath the hood was a little bit of an experiment. and it seems that inserting a model object into a modelContext does not cause problems even when the model object already lives in the context. (although in your case, doing so seems to get Core Data aligned with SwiftData.)

i know that i've run into SwiftData not fully understanding relationships (certainly in some earlier Xcode betas), and it was helpful then to explicitly decorate the back-links with @Relationship just for emphasis without relying on SwiftData to infer them.

by that, i mean decorating var category: Category? for Trivia as

@Relationship(deleteRule: .nullify)
var category: Category?

and the same for var parent: Trivia? in Answer with

@Relationship(deleteRule: .nullify)
var parent: Trivia?

your code might be demonstrating a bug in SwiftData. you might consider filing a feedback.

regards,

DMG

2      

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.