0

I've been fighting with SwiftData for the past few days without reaching an understanding. I have to say I'm just a beginner so I may have made mistakes somewhere else too, but still I don't understand.

So, what I'm trying to do is to have a list of Words (a class of mine), which are stored in SwiftData, filtered depending on the category the user chooses. It appears SwiftData has other ideas though.

For organizing the code I took inspiration from Apple's sample code.

Category model

Let's start with the Category model (which represents the category a word may belong to). ColorComponents is a very simple Codable struct I wrote to store a color, not important.

import Foundation
import SwiftData

@Model
class Category: Codable, Equatable {
    enum CodingKeys: CodingKey {
        case name, primaryColor, secondaryColor
    }
    
    @Attribute(.unique) let name: String
    let primaryColor: ColorComponents
    let secondaryColor: ColorComponents
    
    init(name: String, primaryColor: ColorComponents, secondaryColor: ColorComponents) {
        self.name = name
        self.primaryColor = primaryColor
        self.secondaryColor = secondaryColor
    }
    
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try container.decode(String.self, forKey: .name)
        self.primaryColor = try container.decode(ColorComponents.self, forKey: .primaryColor)
        self.secondaryColor = try container.decode(ColorComponents.self, forKey: .secondaryColor)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.name, forKey: .name)
        try container.encode(self.primaryColor, forKey: .primaryColor)
        try container.encode(self.secondaryColor, forKey: .secondaryColor)
    }
    
    static func ==(lhs: Category, rhs: Category) -> Bool {
        lhs.name == rhs.name
    }
    
    static let example = Category(name: "General", primaryColor: ColorComponents(color: .mint), secondaryColor: ColorComponents(color: .blue))
}

Word model

Then, the Word model. Now, this contains a static method to return a predicate. Apple's sample code suggests this and is perhaps the only way to have a predicate changing together with its input data.

import Foundation
import SwiftData

@Model
class Word {
    let term: String
    let learntOn: Date
    var notes: String
    @Relationship var category: Category?
    
    var categoryName: String {
        category?.name ?? "No category"
    }
    
    init(term: String, learntOn: Date, notes: String = "", category: Category? = nil) {
        self.term = term
        self.learntOn = learntOn
        self.notes = notes
        self.category = category
    }
    
    static func predicate(category: Category?) -> Predicate<Word> {
        return #Predicate<Word> { word in
            // this expression is what I would like to have, but it throws an error at runtime
            category == nil || word.category == category
        }
    }
    
    static let example = Word(term: "Swift", learntOn: .now, notes: "A swift testing word.")
}

These are the two models I have. In the main view I create the model container using .modelContainer(for: Word.self).

SwiftUI View

I then have the view where the query is being made. According to Apple, given that the category is passed to the initializer itself, this way of doing things ensures that the query is updated at every category change (that ideally I'd like for the user to be able to select at any time).

import SwiftData
import SwiftUI

struct WordsCardsListView: View {
    let category: Category?
    @Query private var words: [Word]
    
    init(category: Category? = .example) {
        self.category = category
        
        let predicate = Word.predicate(category: category!)    // force unwrapping just for testing, of course
        let sortDescriptors = [
            SortDescriptor(\Word.learntOn, order: .reverse)
        ]
        _words = Query(filter: predicate, sort: sortDescriptors)
    }
    
    var body: some View {
        List {
            // other views
            
            ForEach(words) { word in
                WordCardView(word: word)
                    .listRowSeparator(.hidden)
            }
        }
        .listStyle(.plain)
    }
}

The errors I get

I did try every combination possible, I believe, but I always get a SwiftData.SwiftDataError._Error.unsupportedPredicate error at runtime (or sometimes the predicate won't even compile). From what I can gather the predicate does not support comparing objects (perhaps, it fails every time I try to compare a Category or even a Word) and it also fails when trying to access word.category?.name, either with optional chaining or force unwrapping (given that the category's name is unique I would have been ok with that too). I do know that predicates are somewhat limited in what they can accept as expressions, but I don't understand why Apple implementation works and mine does not, since I believe there are not significant differences.

I do know that the easiest solution would be to just query for all words and then filter them afterwards (and it's probably what I will end up doing), but it puzzles me that such a simple idea (a filter that updates live) is not so easy to obtain with SwiftData.

Anyway, I thank anyone that read up to this point and that will take the time to answer.

4
  • Unrelated maybe but I think it's best to always create properties for both ends of the relationship so Category should have a property var words: [Word]?. Commented Jan 26, 2024 at 12:02
  • As for Apples predicate working and your's not, Apple is using a struct property in their predicate and not a relationship and that is the reason why your predicate isn't working. Maybe make Category a struct as well? If not then you are better of fetching all Word objects and using a computed property with a filter that does what you want the predicate to do. Commented Jan 26, 2024 at 12:10
  • @JoakimDanielson yes, I hadn't noticed it but that's probably the difference between my code and Apple's. I do need Category to be its own model though, as I'd like the user to CRUD new ones, so I guess I'll just use a computed property as a filter. Weird how SwiftData simplifies a lot of things but is very hard on some things I would consider quite basic. As per the categories having a reference to the words, it's something I didn't need in this very initial code but I'll consider it now, thanks. Commented Jan 26, 2024 at 15:09
  • One main reason why SwiftData is so hard to get right in some cases is simply because it isn’t ready yet and has some bugs and is also missing features Commented Jan 26, 2024 at 16:28

1 Answer 1

1

Ok so, leaving this here for anyone that might find it helpful, I did find a solution in the end.

There was one more combination I had not tried, one that puts together the two main approaches I mentioned in the question. I had in fact tried to compare strings and not objects and I had also tried to use local variables in the predicate (it turns out, SwiftData does not support the use of external variables in its predicates, only local variables are supported, so a local copy has to be made before creating the predicate).

So what I discovered to work is to combine the two solutions: not comparing categories in the predicate but strings, and only using local variables. That is to say:

static func predicate(category: Category?) -> Predicate<Word> {
    let categoryName = category?.name

    return #Predicate<Word> { word in
        categoryName == nil || word.category?.name == categoryName
    }
}

That said, I'm not sure I'll use this approach eventually, by using an inverse relationship in Category I can already have the list of words a category contains without creating a dynamic query, but it was nevertheless interesting to understand where this code was different from Apple's.

The only thing I still find quite puzzling is the fact that comparing two categories (local variables of course) does not work, even though Category conforms to Equatable, but that's probably because of the limitations of the #Predicate macro.

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

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.