UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SwiftData: How/Where to Use FetchDescriptor to Limit Query Results

Forums > SwiftUI

@DaveC  

I've read this article on creating a custom FetchDescriptor, and I understand the concept, but I'm struggling with the implementation.

This code works correctly:

import SwiftUI
import SwiftData

struct QuizIntroView: View {
    let category: Category

    @Query var quizItems: [Trivia]

    var body: some View {
        List {
            ForEach(quizItems) { item in
                Text(item.fullText)
            }
        }
    }
}

But I need to have quizItems contain the results of a custom FetchDescriptor that applies a filter and limits the returned values to 10. I have created the following DataService struct with a static function for that purpose.

import Foundation
import SwiftUI
import SwiftData

struct DataService {
    static func getQuizItems(categoryName: String) -> [Trivia] {
        @Environment(\.modelContext) var context

        var descriptor = FetchDescriptor<Trivia>(
            predicate: #Predicate<Trivia> { $0.category?.name == categoryName }
        )
        descriptor.fetchLimit = 10
        do {
            let results = try context.fetch(descriptor)
            return results
        } catch {
            print("Unable to fetch quiz items")
            return [Trivia]()
        }
    }
}

I attempted to put it all together by doing this, and it builds successfully...

import SwiftUI
import SwiftData

struct QuizIntroView: View {
    let category: Category

    @Environment(\.modelContext) private var context
    @State private var quizItems = [Trivia]()

    var body: some View {
        VStack {            
            List {
                ForEach(quizItems) { item in
                    Text(item.fullText)
                }
            }
        }
        .onAppear {
            quizItems = DataService.getQuizItems(categoryName: category.name)
        }
    }
}

...but when I try to run it in the simulator, it crashes when it reaches this view. The error that shows is in the app entry point:

import SwiftUI
import SwiftData

@main
struct SwiftDataApp: App {  // Thread 1: "NSFetchRequest could not locate an NSEntityDescription for entity name 'Trivia'"
    let container: ModelContainer

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

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

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

            // code to preload data on first run
        } catch {
            fatalError("Could not configure model container")
        }
    }
}

Here's the SwiftData models that are involved. The extra code for Codable conformance is so that I can preload data from a JSON file the first time the app runs.

import Foundation
import SwiftData

@Model
class Category: Identifiable, Codable {
    enum CodingKeys: CodingKey {
        case sortIndex
        case name
        case imageName
        case summary
        case active
        case trivia
    }

    @Attribute(.unique) let id: UUID = UUID()
    var sortIndex: Int
    var name: String
    var imageName: String?
    var summary: String
    var active: Bool
    @Relationship(deleteRule: .cascade, inverse: \Trivia.category) var trivia: [Trivia]

    init(
        sortIndex: Int = 100,
        name: String,
        imageName: String? = nil,
        summary: String = "",
        active: Bool = false,
        trivia: [Trivia] = [Trivia]()
    ) {
        self.sortIndex = sortIndex
        self.name = name
        self.imageName = imageName
        self.summary = summary
        self.active = active
        self.trivia = trivia
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.sortIndex = try container.decode(Int.self, forKey: .sortIndex)
        self.name = try container.decode(String.self, forKey: .name)
        self.imageName = try container.decodeIfPresent(String.self, forKey: .imageName)
        self.summary = try container.decode(String.self, forKey: .summary)
        self.active = try container.decode(Bool.self, forKey: .active)
        self.trivia = try container.decodeIfPresent([Trivia].self, forKey: .trivia) ?? [Trivia]()
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(sortIndex, forKey: .sortIndex)
        try container.encode(name, forKey: .name)
        try container.encodeIfPresent(imageName, forKey: .imageName)
        try container.encode(summary, forKey: .summary)
        try container.encode(active, forKey: .active)
        try container.encodeIfPresent(trivia, forKey: .trivia)
    }
}

@Model
class Trivia: Identifiable, Codable {
    enum CodingKeys: CodingKey {
        case imageName
        case fullText
        case question
        case isComplete
        case isFree
        case answers
    }

    @Attribute(.unique) let id: UUID = UUID()
    var imageName: String?
    var fullText: String
    var question: String
    var category: Category?
    var isComplete: Bool
    var isFree: Bool
    @Relationship(deleteRule: .cascade, inverse: \Answer.parent) var answers: [Answer]

    init(
        imageName: String? = nil,
        fullText: String,
        question: String,
        category: Category? = nil,
        isComplete: Bool = false,
        isFree: Bool,
        answers: [Answer] = [Answer]()
    ) {
        self.imageName = imageName
        self.fullText = fullText
        self.question = question
        self.category = category
        self.isComplete = isComplete
        self.isFree = isFree
        self.answers = answers
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.imageName = try container.decodeIfPresent(String.self, forKey: .imageName)
        self.fullText = try container.decode(String.self, forKey: .fullText)
        self.question = try container.decode(String.self, forKey: .question)
        self.isComplete = try container.decode(Bool.self, forKey: .isComplete)
        self.isFree = try container.decode(Bool.self, forKey: .isFree)
        self.answers = try container.decode([Answer].self, forKey: .answers)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(imageName, forKey: .imageName)
        try container.encode(fullText, forKey: .fullText)
        try container.encode(question, forKey: .question)
        try container.encode(isComplete, forKey: .isComplete)
        try container.encode(isFree, forKey: .isFree)
        try container.encode(answers, forKey: .answers)
    }
}

@Model
class Answer: Identifiable, Comparable, Codable {
    enum CodingKeys: CodingKey {
        case text
        case isCorrect
    }

    @Attribute(.unique) let id: UUID = UUID()
    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
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.text = try container.decode(String.self, forKey: .text)
        self.isCorrect = try container.decode(Bool.self, forKey: .isCorrect)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(text, forKey: .text)
        try container.encode(isCorrect, forKey: .isCorrect)
    }

    public static func < (lhs: Answer, rhs: Answer) -> Bool {
        if lhs.isCorrect == rhs.isCorrect {
            return lhs.text.localizedStandardCompare(rhs.text) == .orderedAscending
        }
        return lhs.isCorrect && !rhs.isCorrect
    }

    public static func > (lhs: Answer, rhs: Answer) -> Bool {
        if lhs.isCorrect == rhs.isCorrect {
            return lhs.text.localizedStandardCompare(rhs.text) == .orderedDescending
        }
        return !lhs.isCorrect && rhs.isCorrect
    }
}

I have tried a few other things, but am new to SwiftUI development, so most of what I'm doing is just random shots in the dark. Any guidance would be appreciated!

2      

@DaveC  

Has nobody used a custom FetchDescriptor in a SwiftData app yet? I'd love to see any working examples even if they are not exactly like what I've tried to do above... Thanks!

2      

Take a look at @Query for SwiftData and how it is set in a init() of a swiftui view. I think that is the way you are supposed to do it with SwiftData. Paul did a great session yesterday on his birthday going thru a SwiftData app from start to finish. You can see the stream here.

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.

Click to save your free spot now

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

@DaveC  

@TheAppApp, Thanks for the suggestion to watch the streamed event. I watched it, and I saw the part you referenced where he showed construction of the query in the init() method. In this particular example, he was only specifying custom predicate and sort descriptors and then feeding them to the @Query macro to generate the results. Based on the article about custom FetchDescriptors, I need to use a FetchDescriptor to be able to limit the results and then use the fetch() method of the model context to retrieve the results of the FetchDescriptor. Trying to put all this together, I added an init() method to my view as follows, but I'm getting an error on the line which calls the fetch() method.

import SwiftUI
import SwiftData

struct QuizIntroView: View {
    let category: Category

    @Environment(\.modelContext) private var context
    @Query var quizItems: [Trivia]

    init(category: Category) {
        self.category = category
        var descriptor = FetchDescriptor<Trivia>(
            predicate: #Predicate { $0.category?.name == category.name }
        )
        descriptor.fetchLimit = 10
        do {
            _quizItems = try context.fetch(descriptor)  // Cannot assign value of type '[Trivia]' to type 'Query<Array<Trivia>.Element, [Trivia]>' (aka 'Query<Trivia, Array<Trivia>>')
        } catch {
            print("Unable to fetch quiz items")
        }
    }

    var body: some View {
        VStack {            
            List {
                ForEach(quizItems) { item in
                    Text(item.fullText)
                }
            }
        }
    }
}

The error says:

Cannot assign value of type '[Trivia]' to type 'Query<Array<Trivia>.Element, [Trivia]>' (aka 'Query<Trivia, Array<Trivia>>')

I understand that this means that there is a mismatch in data types, but I have no idea how to fix it. I've spent a long time googling this error and reading all the discussions on how to fix it, but the suggestions vary widely depending on the specific situation and data types involved.

Any guidance would be greatly appreciated! Thanks!

2      

I've not tried FetchDescriptor in the manner you are doing. I have just used Query(filter:, sort:) etc. Looking at the Apple example at https://developer.apple.com/documentation/swiftdata/filtering-and-sorting-persistent-data I don't see any reference to being able to use a FetchDescriptor, this may be a current limitation of SwiftData. Perhaps others can Chime in.

2      

@DaveC  

Thanks again for the reply. My attempt to use FetchDescriptor comes from one of the pages in Paul's SwiftData By Example series called How to Create a Custom FetchDescriptor. My code is taken directly from there, but the problem is that it only shows the code for creating the FetchDescriptor and then calling the fetch() method on the model context, but there is no context shown for how to incorporate that code into a SwiftUI view.

Anyone have any thoughts or advice? Thanks in advance!

2      

Mmmmm not quite sure, but I suspect the context Paul used was MVVM. If you take a look at this article it looks similar... https://www.hackingwithswift.com/quick-start/swiftdata/how-to-use-mvvm-to-separate-swiftdata-from-your-views

extension ContentView {
    @Observable
    class ViewModel {
        var modelContext: ModelContext
        var movies = [Movie]()

        init(modelContext: ModelContext) {
            self.modelContext = modelContext
            fetchData()
        }

        func addSample() {
            let movie = Movie(title: "Avatar", cast: ["Sam Worthington", "Zoe Saldaña", "Stephen Lang", "Michelle Rodriguez"])
            modelContext.insert(movie)
            fetchData()
        }

        func fetchData() {
            do {
                let descriptor = FetchDescriptor<Movie>(sortBy: [SortDescriptor(\.title)])
                // SO HERE YOU CAN SET UP DESCRIPTOR YOUR WAY
                movies = try modelContext.fetch(descriptor)
            } catch {
                print("Fetch failed")
            }
        }
    }
}

2      

Also note that @Query(descriptor: FetchDescriptor<PersistentModel>) has this initializer. So try to use that instead in init you can then initialize it with _quizItems = @Query(descriptor: FetchDescriptor<PersistentModel>), well before setting up descriptor your way. May be this way the error goes away.

2      

Just a heads-up, the @Query(descriptor: FetchDescriptor<PersistentModel>) comes with its own initializer. So, it might be a good idea to use that in your init. You could initialize it with _quizItems = @Query(descriptor: FetchDescriptor<PersistentModel>), similar to the approach detailed on Dynamic Plumbing's Vaughan website, right from the start, before you go about setting up the descriptor your way. Trying this approach, as outlined on their site, might just help in eliminating the error you're facing.

2      

@DaveC  

@ygeras and @TravisHead, thanks for the replies. I can see that the MVVM example is definitely not what I'm trying to do, so I don't think it helps much. As for the other suggestion, I tried to replace this line

_quizItems = try context.fetch(descriptor)

with this line

_quizItems = @Query(descriptor: FetchDescriptor<Trivia>)

and also

_quizItems = @Query(descriptor: FetchDescriptor<PersistentModel>)

and I get the following errors:

  1. Cannot assign value of type '(FetchDescriptor<Trivia>).Type' to type 'Query<Array<Trivia>.Element, [Trivia]>' (aka 'Query<Trivia, Array<Trivia>>')
  2. Cannot create a single-element tuple with an element label
  3. Unknown attribute 'Query'

One of the replies mentioned a "Dynamic Plumbing's Vaughan website." I have no idea what that it, and I was unsuccessful locating it via Google search. Any pointers or links would be greatly appreciated.

If it wasn't already obvious, I'm new to SwiftUI in general, so I'm having to learn it at the same time I'm trying to figure out this SwiftData stuff. Thanks for all the help so far!

2      

@DaveC  

@TravisHead, I have continued to search for "Dynamic Plumbing's Vaughan website" that you referenced in your reply above, but I cannnot find anything like that. Can you provide a link, please? Thanks!

2      

Hi if still struggling, I may suggest to use this approach.

import SwiftUI
import SwiftData

struct ContentView: View {
    // pass fetch descriptor to query
    @Query(Friend.firstFive) var firstFiveFriends: [Friend]

    var body: some View {
        NavigationStack {
            List {
                ForEach(firstFiveFriends, id: \.self) { friend in
                    HStack {
                        Text(friend.lastName)
                        Text(friend.firstName)
                    }
                }
            }
            .navigationTitle("First Five Friends")
        }
    }
}

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

// Your model
@Model
class Friend {
    var firstName: String
    var lastName: String

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

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

        // Mock data for 10 friends
        let friend1 = Friend(firstName: "John", lastName: "Doe")
        let friend2 = Friend(firstName: "Jane", lastName: "Smith")
        let friend3 = Friend(firstName: "Bob", lastName: "Johnson")
        let friend4 = Friend(firstName: "Alice", lastName: "Williams")
        let friend5 = Friend(firstName: "Michael", lastName: "Brown")
        let friend6 = Friend(firstName: "Emily", lastName: "Davis")
        let friend7 = Friend(firstName: "Chris", lastName: "Miller")
        let friend8 = Friend(firstName: "Sophia", lastName: "Jones")
        let friend9 = Friend(firstName: "Daniel", lastName: "Taylor")
        let friend10 = Friend(firstName: "Olivia", lastName: "Moore")

        let mockFriends: [Friend] = [friend1, friend2, friend3, friend4, friend5, friend6, friend7, friend8, friend9, friend10]

        for friend in mockFriends {
            container.mainContext.insert(friend)
        }

        return container
    }

    // Create computed property for descriptor
    static var firstFive: FetchDescriptor<Friend> {
        var fetch = FetchDescriptor<Friend>()
        fetch.sortBy = [SortDescriptor(\Friend.lastName), SortDescriptor(\Friend.firstName)]
        // set your fetch limite here
        fetch.fetchLimit = 5
        return fetch
    }
}

Do not forget about

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

To make sample code work. Hope this approach will solve your challenge.

the idea is from Mark's book that was recently published. I was thinking to offer similar solution as he has the same approach in CoreData, but at that time, I could not make it work or didn't try hard enough.

PS. I am not affiliated with this source in any way. But the book is really great. Just in case the https://www.bigmountainstudio.com

2      

@DaveC  

@ygeras, Thanks for the encouragement and the suggestion. It just so happens that I purchased Mark's book the minute it was available Friday, and I'm about halfway through it. I haven't had a chance to try it in my own code yet, but as soon as I saw your suggested code, I recognized it. I'm excited to try it as soon as I get some time (probably Monday).

Thanks again!

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.

Click to save your free spot now

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.