How to form a search predicate using swift data with a one to many model

I am working in Xcode 15 (beta) migrating to SwiftData and am having a hard time figuring out how to form a search predicate for my one to many model. The desired result is to return a query that returns only the articles and sections where the article "search" field contains the string a user is searching for.

Here is my current model:

@Model
class SectionsSD {    
  @Attribute(.unique) var id: String
  var section: String
  var rank: String  
  var toArticles: [ArticlesSD]?
    
  init(id:String, section: String, rank: String) {
      self.id = id
      self.section = section
      self.rank = rank
  }
}
@Model
class ArticlesSD {
  var id: String
  var title: String
  var summary: String
  var search: String
  var section: String
  var body: String
  @Relationship(inverse: \SectionsSD.toArticles) var toSection: SectionsSD?
 
  init(id: String, title: String, summary: String, search: String, section: String, body: String) {
    self.id = id
    self.title = title
    self.summary = summary
    self.search = search
    self.section = section
    self.body = body
  }
}

In CoreData I was able to do the following in my code to form and pass the search predicate ("filter" being the user input search text, "SectionsEntity" being my old CoreData model):

  _fetchRequest = FetchRequest<SectionsEntity>(sortDescriptors: [SortDescriptor(\.rank)], predicate: NSPredicate(format: "toArticles.search CONTAINS %@", filter))

I can't find any examples or info anywhere that explains how to form a predicate in SwiftData to achieve the same search results. I can't seem to find a way to represent toArticles.search properly assuming the capability is there. Here is what I've tried but Xcode complains about the $0.toArticles?.contains(filter) with errors about "Cannot convert value of type 'Bool?' to closure result type 'Bool'" and "Instance method 'contains' requires the types 'ArticlesSD' and 'String.Element' (aka 'Character') be equivalent"

let searchPredicate = #Predicate<SectionsSD> {
   $0.toArticles?.contains(filter)
}
 _sectionsSD = Query(filter: searchPredicate)

I've tried $0.toArticles?.search.contains(filter) but Xcode can't seem to find its way there like it did using CoreData

Any suggestions and examples on how to form a predicate in this use case would be appreciated.

Accepted Reply

Thank you for this.

I decided to check whether the logic of the predicate expression was wrong or the #Predicate itself was doing something, and it turns out it's the latter.

Instead of filtering the sections within the query, I moved it outside to the regular place filtering happens. This works as expected with the sections being filtered correctly by the filter text.

@Query private var sections: [SectionsSD]

let filter: String

var filteredSections: [SectionsSD] {
    sections.filter {
        $0.toArticles.flatMap {
            $0.contains { $0.search.contains(filter) }
        } == true

        // or as before
        // $0.toArticles?.contains { $0.search.contains(filter) } == true
    }
}

List(filteredSections) { ... }

So it's when placed inside the #Predicate are things not working.



It is definitely worth noting that we are only in the second beta of SwiftData so there is always the chance things don't work because of internal bugs. For example, in the release notes is this which could be the reason your predicate isn't working.

SwiftData

Known Issues

• SwiftData queries don't support some #Predicate flatmap and nil coalescing behaviors. (109723704)

It's best to wait for the next few betas to see if anything changes, and maybe new features might make things easier.

Replies

"Cannot convert value of type 'Bool?' to closure result type 'Bool'"

This is a simple Swift error. In your filter expression you are using optional chaining which is why it yields a result of type Bool?. The predicate closure, however, expects to be given a value of type Bool. Here is a fix for that particular error:

let searchPredicate = #Predicate<SectionsSD> {
    $0.toArticles?.contains(filter) == true
}


"Instance method 'contains' requires the types 'ArticlesSD' and 'String.Element' (aka 'Character') be equivalent"

$0.articles is of type [ArticlesSD]? and using contains on this array means you need to pass it a value of type ArticlesSD to check for. You are instead passing it a String (I'm assuming here that filter is a String) which is not what it expects. To fix this you can compare the filter to the search property in the contains closure.

let searchPredicate = #Predicate<SectionsSD> {
    $0.toArticles?.contains { $0.search.contains(filter) } == true
}



And now you may think this works…but it doesn't. Swift spits out this error: "Optional chaining is not supported here in this predicate. Use the flatMap(_:) function explicitly instead. (from macro 'Predicate')". To fix this you can do what it says and wrap the original condition inside of a flatMap, like this:

let searchPredicate = #Predicate<SectionsSD> {
    $0.toArticles.flatMap {
        $0.contains { $0.search.contains(filter) }
    } == true
}


Hope this helps (and works)!

  • Thanks for the suggestions. Very useful. One question: do you know if it is possible to perform a case-insensitive search with the Predicate macro? I tried using lowercased() but it is not allowed.

  • You can instead use the localizedLowercase computed property instead, because the lowercased() function isn't allowed.

  • Hi, thanks, but unfortunately I tried that before and get the error "Fatal error: Couldn't find \Book.title.localizedLowercase on Book" (title is a String property in the Book class)

    var predicate = #Predicate<Book> { $0.title.localizedLowercase.contains(search) }

Thank you partially helpful in getting past the errors so syntax is right but when I enter my search string (when it's 3 or more characters) and it kicks in using the predicate the result is no matches even though I know for certain that the search fields are present. If I don't run the predicate I get my full list with search entries (see screen shot) but if I type in a known string that's in the search string so should return a true (and maybe it does) I get nothing in the resulting sectionsDB. I've included some code here to show how I enter the predicate and pass it. Must be something down stream in "FilteredList" I need to do?

struct FilteredList: View {
    @EnvironmentObject var userSettings: UserSettings
    @Environment(\.modelContext) private var context
    @Query() var sectionsSD: [SectionsSD]
        
    private var isExpanded: Bool
    
    var body: some View {
        
        List(sectionsSD) { result in
            Section(result.section) {
                ForEach(result.toArticles!) { article in
                    NavigationLink(destination: ArticleView(title: article.title, summary: article.summary, content: article.body)) {
                        VStack(alignment: .leading) {
                            Text(article.title)
                                .font(.custom("Helvetica", size: 16))
                            if isExpanded {
                                Text(article.summary)
                                    .font(.custom("Helvetica", size: 12))
                                    .foregroundColor(Color(UIColor.systemGray))
                                Text(article.search)
                            }
                        }
                    }
                    /// Hide the row separator
                    .listRowSeparator(.hidden)
                }
            }
        }
    }
    
    init(filter: String, isExpanded: Bool) {
        if filter.count >= 3 {
            let searchPredicate = #Predicate<SectionsSD> {
                $0.toArticles.flatMap {
                    $0.contains { $0.search.contains(filter) }
                } == true
            }
            _sectionsSD = Query(filter: searchPredicate)
        }
        self.isExpanded = isExpanded
    }
    
}

Thank you for this.

I decided to check whether the logic of the predicate expression was wrong or the #Predicate itself was doing something, and it turns out it's the latter.

Instead of filtering the sections within the query, I moved it outside to the regular place filtering happens. This works as expected with the sections being filtered correctly by the filter text.

@Query private var sections: [SectionsSD]

let filter: String

var filteredSections: [SectionsSD] {
    sections.filter {
        $0.toArticles.flatMap {
            $0.contains { $0.search.contains(filter) }
        } == true

        // or as before
        // $0.toArticles?.contains { $0.search.contains(filter) } == true
    }
}

List(filteredSections) { ... }

So it's when placed inside the #Predicate are things not working.



It is definitely worth noting that we are only in the second beta of SwiftData so there is always the chance things don't work because of internal bugs. For example, in the release notes is this which could be the reason your predicate isn't working.

SwiftData

Known Issues

• SwiftData queries don't support some #Predicate flatmap and nil coalescing behaviors. (109723704)

It's best to wait for the next few betas to see if anything changes, and maybe new features might make things easier.

Your suggestion of moving it outside and gave me the expected result. So now I get the net sections per the filter which is good. While that gets my sections narrowed my model still has all the articles under each section. In the past I did this with a filter in the list (see commented out if statement):

                ForEach(result.toArticles!) { article in
//                    if article.search.contains(filter) {
                        NavigationLink(destination: ArticleView(title: article.title, summary: article.summary, content: article.body)) {
                            VStack(alignment: .leading) {
                                Text(article.title)
                                    .font(.custom("Helvetica", size: 16))
                                if isExpanded {
                                    Text(article.summary)
                                        .font(.custom("Helvetica", size: 12))
                                        .foregroundColor(Color(UIColor.systemGray))
                                }
                            }
                        }
//                    }
                    /// Hide the row separator
                        .listRowSeparator(.hidden)
                }

but it barfs on the NavigationLink line of code with: "No exact matches in reference to static method 'buildExpression'"

So I'm guess it's just still too early a beta here given the problem you originally located with the predicate and we just encounter problems further downstream until this is fixed. Or, under this model I have to find a way in the predicate as the second stage filter down to just the articles that in the toArticles that match to avoid using my simple "if" statement. Not sure how to do that in a predicate or if it's even possible.

My initial thought for this was to use something similar to @SectionedFetchRequest: get all the articles, filter them down, then group by section. At the moment SwiftData doesn't have that capability. I did file feedback for this (FB12292770) so hopefully this is possible later on and could be a solution for your problem. Sectioning would be more efficient because you are currently filtering twice, one for the sections' articles and then again for the articles in each section, when you could do it all at once including a predicate.


"No exact matches in reference to static method 'buildExpression'"

This normally means that there is an error somewhere else in the view code but you just need to find it. Try commenting out certain bits to see where the actual error is located. My guess, by looking at your code, is that you are applying the .listRowSeparator(.hidden) to the if statement and not an actual view.



I have noted that you are force unwrapping result.toArticles!. Maybe you could remove the optional and give it a default value instead if you are confident it won't be nil.

var toArticles: [ArticlesSD] = []

This might eliminate some of the optionality problems encountered with the predicate.

Thanks for all your help on this and filling the ticket as well. Clearly there are some issues with the basic predicate not being able to do it as we first tried and having to move it. Let's hope they address that issue (it's still early beta so hopeful). Yes, it's a pain to do two separate sorts. This is the difference between simple todo list examples vs more real world use cases. Why have a sorted section list with orphan sections and not be able to sort out the irrelevant items in each section in the predicate searches. I guess part of this is that Apple still views these as basically persistent arrays and are having to do gymnastics to interrelate them. Why not just bite the bullet and provide a sql like interface over it? In the end it's a big improvement over core data and makes it way easier for developers to do a lot of the backend data management for apps not just view based data entry.

The .listRowSeparator placement was me just being tired and not attentive when I was putting in the secondary filter. Thanks for being kind on pointing that out. I have some cleanup to do but this all worked in the end and I'll look for it to morph with each beta release. Eventually it will get there, right? I'll force unwrap just in case and I have some other tidbits of cleanup to do.

  • You're very welcome. It's always nice to help others out and you always learn things along the way. Apple doesn't always know what we developers are thinking which is why feedback is so important. Hopefully, SwiftData gets better because if it's anything like SwiftUI's introduction then a lot of people will be happy. Good luck with your predicates!

Add a Comment

Here's the fix that I found.

let searchPredicate = #Predicate<SectionsSD> {
   $0.toArticles?.contains(filter) ?? false
}
  • Have you actually tested this? Trying to use either #Predicate fails for me completely in the @Query. Think Apple still have work to do here.

  • I’m using UIKit and this form of Predicate works for me.

Add a Comment