SwiftData: Solving Filtering by an Entity in the Predicate

SwiftData: Solving Filtering by an Entity in the Predicate

When SwiftData was first announced I decided to leave it alone and migrate over later when hopefully there would be more support. Then I realized now, before my app enters the App Store and has users with data, may be the best time to switch over. That said, I've had difficulties getting the same functionalities working and have had to give up some of my loved bits for now. Filtering my browse lists by an entity relationship; however, wasn't one I could give up and I had trouble solving it for awhile. And like all issues it was a simple solution.

Pinterest geared image showing my post title, images from below, and my main URL.

My simpler app ideas have browse views that were easy to migrate over to SwiftData but my favorite app has more complicated browse views that limit the content based on a relationship in the data. Unlike the user filtering and sectioned lists that I decided to forgo for now, this filtering was pivotal to my app's functionality and as such I knew I needed to get it working.

The Problem

Source Example Code

A quick aside on the example code. When getting a handle on SwiftData I started out with Hacking with Swift's free (as of right now on December 9th, 2023) SwiftData by Example online book. In case things change the download version, currently showing up as $30 for me in the U.S., can be found here. He starts the book by going over a sample project walkthrough (starting here) that can also be found on his GitHub page. The project, iTour, creates a simple app that keeps track of potential destinations you could visit along with their related sights. So for this data each Destination entity can have zero to many attached Sight entities while each Sight has only one connected Destination.

Image shows the SwiftData by Example headline image with a "Join over 90,000 readers today".
Screenshot of the Hacking with Swift download version page taken on December 9th, 2023. There's also currently a free SwiftData by Example online book too.

The Problem Itself

My problem was that I wasn't able to filter my query by another entity within the predicate. Whatever I tried failed to build while also taking some time for such attempts to fail which further aggravated the situation. Since I had already created the SwiftData by Example walkthrough project and have a membership with access to his forum I decided to recreate my problem using his code as the base and post it to his forum here. I didn't receive any replies but on there you can see the process I went through, the temporary solution I hacked together so I had a working browse allowing me to take a break to work on other things, and my eventual solution which of course is also shown below.

To demonstrate this issue I took the sample project and changed the SightsView file to have two views so you can filter the list of Sights by a search term, change the sort, and, most importantly for me now, pass in an optional Destination entity to further filter the list of Sights. For the reasoning behind the two views for the browse you can check out this section of his walkthrough while the predicate logic itself is in the next section here. For this code change, as I had trouble with compiling and wanted a quick example, I only passed in nil for the Destination.

The code showing the problem, replacing the contents of the SightsView file, is:

import SwiftUI
import SwiftData

struct SightsView: View {
  @State private var path = [Sight]()
  @State private var sortOrder = [SortDescriptor(\Sight.name)]
  @State private var searchText = ""

  var body: some View {
    NavigationStack(path: $path) {
      SightsListingView(sort: sortOrder, searchString: searchText, destination: nil)
        .navigationTitle("Sights")
        .searchable(text: $searchText)
    }
  }
}

struct SightsListingView: View {
  @Environment(\.modelContext) var modelContext

  @Query var sights: [Sight]

  init(sort: [SortDescriptor<Sight>], searchString: String, destination: Destination?) {
    _sights = Query(filter: #Predicate {
      if destination == nil {
        return $0.name.localizedStandardContains(searchString)
        //                            below assuming it's equal now si
      } else if searchString.isEmpty {
        return $0.destination == destination
      } else {
        return $0.destination == destination && $0.name.localizedStandardContains(searchString)
      }
      // Other attempt
      //            if destination == nil && searchString.isEmpty {
      //                return true
      //            } else if destination == nil {
      //                return $0.name.localizedStandardContains(searchString)
      //            } else if searchString.isEmpty {
      //                return $0.destination == destination
      //            } else {
      //                return $0.destination == destination && $0.name.localizedStandardContains(searchString)
      //            }
    }, sort: sort)
  }

  var body: some View {
    List {
      ForEach(sights) { sight in
          VStack(alignment: .leading) {
              Text(sight.name)
                  .font(.headline)
          }
      }
      .onDelete(perform: deleteSights)
    }
  }

  func deleteSights(_ indexSet: IndexSet) {
    for index in indexSet {
      let sight = sights[index]
      modelContext.delete(sight)
    }
  }
}

Building Issues

My problem was that anything I attempted in the Predicate to filter the listed Sights by the parent Destination hung when attempting to build and was never able to complete the build. One of the errors was:

The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions
Xcode screenshot showing the error list with my code alongside with the error and vague explanation open.
Screenshot of my code showing the error with the expanded explanation open.

In addition to that I also received the odd error where the compiler didn't time out but the predicate itself was problematic. These errors were still similar to the expanded code shown above.

The Solution

Path to the Solution

I tried many attempts to solve the issue before settling for a hacky version of the code that compiled, showed the right data, yet didn't update so I could temporarily set aside the problem and attempt to migrate other bits of my app over. That said, this problem hung over my head as I knew this was pivotal and thus needed to be fixed if I was ever going to keep using SwiftData in the future.

After solving another major for me issue I decided to stop putting this off and again tried to look up solutions online where I found a StackOverflow question titled SwiftData @Query with #Predicate on Relationship Model. The question and solution didn't spark anything major but the backup answer, pictured here, did help me come to the proper solution.

Image shows the follow up answer on the Stack Overflow.
Screenshot of Joel's answer to the StackOverflow question that lead me to the right solution was taken on December 9th, 2023.

I went back to my code and removed the Destination itself from the predicate and instead used its precomputed id. This worked in the sample project, shown in the next section, but didn't work in my actual app. A quick update from id to the persistentModelID; however, fixed it there and thus the final solution was found.

Compiled and Filtering Worked

With the solution finally found the code was able to successfully compile, the app ran, and the resulting Sights list was empty! Yes!!!!

Image shows two iPhone simulator with the one on the left displaying a list of sights and the one on the right empty. Between the two is a purple and blue background, a white arrow, and white celebratory fireworks.
We went from a list of sights to none! Exactly what I wanted.

With the code compiling and working properly by showing no Sights, as the Destination passed in was nil, I quickly moved to the Sights section within the EditDestinationView file and added a Button to open a sheet to the browse so I could pass in an actual Destination.

// ...

// Bool to handle opening the sheet with the browse
@State private var showBrowse: Bool = false

var body: some View {

  // ...
  
  Section("Sights") {
  
    // Quick button to show the filtering in action
    Button(action: 
        showBrowse = true
    }, label: {
        Text("See All Sights (\(destination.sights.count))")
    })
    .sheet(isPresented: $showBrowse, content: {
        // And pass in the current destination 
        // so we only see the sights also shown below
        SightsView(destination: destination)
    })
    
    ForEach(sortedSights) { sight in
        // ...
    }
  }
  
  // ...
  
}

// ...

And here's what the sights looked like opened from different Destination views.

Image shows four iPhone simulators showing the different edit view and corresponding browse results for two different destinations.
With the Destination passed in the proper sights were shown and we could then search within just those sights.

What the Problem Was

The simple solution to this problem was that you can't include an entity other than the one you're currently filtering which, in this case, was Sight. Thus the very act of including Destination broke the predicate. Spring boarding off of the StackOverflow question listed above I instead took the persistentModelID from the Destination entity passed in and used that resulting ID within the predicate to filter the sights by.

The Final Code That Works

As such my code for the SightsView stayed the same while the init itself, in my SightsListingView, changed to the following:

init(sort: [SortDescriptor<Sight>], searchString: String, destination: Destination?) {
  // Make a point of grabbing 
  if let thisID = destination?.persistentModelID {
    _sights = Query(filter: #Predicate<Sight> {
      if searchString.isEmpty {
        return $0.destination?.persistentModelID == thisID
      } else {
        return $0.destination?.persistentModelID == thisID && $0.name.localizedStandardContains(searchString)
      }
    }, sort: sort)
  } else {
    // if id is nil than I don't want ANYTHING
    _sights = Query(filter: #Predicate<Sight> { currSight in
      // If you remove the 'currSight in' you get error: Cannot convert value of type '() -> Bool' to expected argument type '(Sight) -> Bool'
      return false
    }, sort: sort)
  }
}

I took a quick screenshot to show the gorgeousness of the resulting code in Xcode.

Colorful code on a white background. The shown code is also found right above.
Screenshot of the final working code in Xcode.

TLDR: The Code

The final browse form code showing all the changes to the SwiftData by Example sample project can simply overwrite the SightsView file with the following:

import SwiftUI
import SwiftData

struct SightsView: View {
    
  @State private var path = [Sight]()
  @State private var sortOrder = [SortDescriptor(\Sight.name)]
  @State private var searchText = ""
  
  @State var destination:Destination?
  
  var body: some View {
    NavigationStack(path: $path) {
      SightsListingView(sort: sortOrder, searchString: searchText, destination: destination)
        .navigationTitle("Sights")
        .searchable(text: $searchText)
    }
  }
}

struct SightsListingView: View {
  @Environment(\.modelContext) var modelContext
  
  @Query var sights: [Sight]
  
  init(sort: [SortDescriptor<Sight>], searchString: String, destination: Destination?) {
    if let thisID = destination?.persistentModelID {
      _sights = Query(filter: #Predicate<Sight> {
        if searchString.isEmpty {
          return $0.destination?.persistentModelID == thisID
        } else {
          return $0.destination?.persistentModelID == thisID && $0.name.localizedStandardContains(searchString)
        }
      }, sort: sort)
    } else {
      // if id is nil than I don't want ANYTHING
      _sights = Query(filter: #Predicate<Sight> { currSight in
        // If you remove the 'currSight in' you get error: Cannot convert value of type '() -> Bool' to expected argument type '(Sight) -> Bool'
        return false
      }, sort: sort)
    }
  }
  
  var body: some View {
    List {
      ForEach(sights) { sight in
        VStack(alignment: .leading) {
          Text(sight.name)
            .font(.headline)
        }
      }
      .onDelete(perform: deleteSights)
    }
  }
  
  func deleteSights(_ indexSet: IndexSet) {
    for index in indexSet {
      let sight = sights[index]
      modelContext.delete(sight)
    }
  }
}

Of course this also included the quick button I added to the EditDestinationView within the Sights section:

// Bool to handle opening the sheet with the browse
@State private var showBrowse: Bool = false

var body: some View {

  // ...
  
  Section("Sights") {
  
    // Quick button to show the filtering in action
    Button(action: 
        showBrowse = true
    }, label: {
        Text("See All Sights (\(destination.sights.count))")
    })
    .sheet(isPresented: $showBrowse, content: {
        // And pass in the current destination 
        // so we only see the sights also shown below
        SightsView(destination: destination)
    })
    
    ForEach(sortedSights) { sight in
        // ...
    }
  }
  
  // ...
  
}

And with that the code was fixed and my browse views could again be fetched and updated properly!

I hope this code fix helps you have a great day. If you have any questions and/or tips I'd love to hear them so feel free to share them in the comments below.


If you’re interested in getting any of my future blog updates I normally share them to my Facebook page and Instagram account. You’re also more than welcome to join my email list located right under the search bar or underneath this post.




Related Posts

Latest Posts