BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

SOLVED: SwiftData Predicate changing at user input?

Forums > SwiftUI

Hi everyone, 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.

2      

Hi there! Two points that catch my eye in the code.

  1. Not related to your issue but in your model do you have One-To-Many relationship? You have this part @Relationship var category: Category?, but you don't have inverse as e.g. @Relationship(inverse: \Category.words) var category: Category? in Word class and then add var words: [Word] = [] in Category class, so that a word can have a category and category can have many words. Well, i suppose the logic is like that.
  2. So now why your predicate is not working. The predicate cannot use "dynamic" data passed from outside. You will have to create a local constant to pass that data and provide it to the predicate. Something like this:
init(category: Category? = .example) {
        self.category = category

        let passedCategory = category

        let predicate = Word.predicate(category:  passedCategory!)
        let sortDescriptors = [
            SortDescriptor(\Word.learntOn, order: .reverse)
        ]
        _words = Query(filter: predicate, sort: sortDescriptors)
    }

or even like this.

   static func predicate(category: Category?) -> Predicate<Word> {
   let passedCategory = category
        return #Predicate<Word> { word in
            // this expression is what I would like to have, but it throws an error at runtime
            passedCategory == nil || word.category == passedCategory
        }
    }

2      

@ygeras yes, the categories having a reference to the words is something I didn't need in this very initial code but I'll consider it now, thanks.

As per the main issue, both solutions you propose are something I had read about online and tried already, unfortunately that doesn't seem to work either (I tried again exactly as you suggested with no luck).
I did notice that the difference between my code and Apple's is that they do not use another model inside the one used in the predicate, they only use a struct property. That's the only difference I can find.

Anyway, I think I'll just query all words at the same time and filter them later as I was already thinking, this solution just does not seem to work.

2      

In predicate, try to compare NOT category to category, BUT String to String. This approach works for sure, see sample below:

struct ContentView: View {
    @Query var words: [Word]

    var body: some View {
        VStack {
            List(words) { word in
                Text(word.term)
            }

            CategoriesFilterView(category: Category(name: "Fruits"))
        }
    }
}

struct CategoriesFilterView: View {
    @Query var words: [Word]
    let category: Category

    init(category: Category) {
        self.category = category

        let categoryName = category.name

        let predicate = #Predicate<Word> { word in
            word.category?.name == categoryName
        }
        _words = Query(filter: predicate)
    }

    var body: some View {
        List(words) { word in
            Text(word.term)
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(Word.preview)
}

@Model
class Word {
    let term: String
    @Relationship(inverse: \Category.words) var category: Category?

    init(term: String) {
        self.term = term
    }
}

@Model
class Category {
    @Attribute(.unique) let name: String
    var words: [Word] = []

    init(name: String) {
        self.name = name
    }
}

extension Word {
    @MainActor
    static var preview: ModelContainer {
        let container = try! ModelContainer(for: Word.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true))

        // Sample data creation

        // Create categories
        let category1 = Category(name: "Animals")
        let category2 = Category(name: "Fruits")
        container.mainContext.insert(category1)
        container.mainContext.insert(category2)

        // Create words
        let word1 = Word(term: "Lion")
        let word2 = Word(term: "Apple")
        let word3 = Word(term: "Elephant")
        let word4 = Word(term: "Banana")

        // Establish relationships
        word1.category = category1
        word2.category = category2
        word3.category = category1
        word4.category = category2

        return container
    }
}

2      

Ah yes, now this works. I had in fact tried both of the approaches before but perhaps never at the same time: I had tried to compare strings and not objects and I had tried using local variables, but probably I never tried doing both.

I did find it weird that no solution seemed to work. Thanks for the help, I'll now consider which approach is better, also considering your suggestion of the inverse relationship!

EDIT: I did end up using this, so thank you very much!

2      

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

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.