TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

Filtering @Query using Predicate

Paul Hudson    @twostraws   

You've already seen how @Query can be used to sort SwiftData objects in a particular order, but it can also be used to filter that data using a predicate – a series of tests that get applied to your data, to decide what to return.

The syntax for this is a little odd at first, mostly because this is actually another macro behind the scenes - Swift converts our predicate code into a series of rules it can apply to the underlying database that stores all of SwiftData's objects.

Let's start with something simple, using the same User model we used previously:

@Model
class User {
    var name: String
    var city: String
    var joinDate: Date

    init(name: String, city: String, joinDate: Date) {
        self.name = name
        self.city = city
        self.joinDate = joinDate
    }
}

Now we can add a couple of properties to ContentView that can show all the users we have:

@Environment(\.modelContext) var modelContext
@Query(sort: \User.name) var users: [User]

And finally, we can show all those users in a list, and we'll also add a button to add some sample data easily:

NavigationStack {
    List(users) { user in
        Text(user.name)
    }
    .navigationTitle("Users")
    .toolbar {
        Button("Add Samples", systemImage: "plus") {
            let first = User(name: "Ed Sheeran", city: "London", joinDate: .now.addingTimeInterval(86400 * -10))
            let second = User(name: "Rosa Diaz", city: "New York", joinDate: .now.addingTimeInterval(86400 * -5))
            let third = User(name: "Roy Kent", city: "London", joinDate: .now.addingTimeInterval(86400 * 5))
            let fourth = User(name: "Johnny English", city: "London", joinDate: .now.addingTimeInterval(86400 * 10))

            modelContext.insert(first)
            modelContext.insert(second)
            modelContext.insert(third)
            modelContext.insert(fourth)
        }
    }
}

Tip: Those join dates represent some number of days in the past of future, which gives us some interesting data to work with.

When working with sample data like this, it's helpful to be able to delete existing data before adding the sample data. To do that, add the following code before the let first = line:

try? modelContext.delete(model: User.self)

That tells SwiftData to tell all existing model objects of the the type User, which means the database is clear before we add the sample users.

To finish our little sample app, we just need to make sure the App struct uses the modelContainer() modifier to set up SwiftData correctly:

WindowGroup {
    ContentView()
}
.modelContainer(for: User.self)

Now go ahead and run the app, then press the + button to insert four users.

You can see they appear in alphabetical order, because that's what we asked for in our @Query property.

Now let's try filtering that data, so that we only show users whose name contains a capital R. To do this we pass a filter parameter into @Query, like this:

@Query(filter: #Predicate<User> { user in
    user.name.contains("R")
}, sort: \User.name) var users: [User]

Let's break that down:

  1. The filter starts with #Predicate<User>, which means we're writing a predicate (a fancy word for a test we're going to apply).
  2. That predicate gives us a single user instance to check. In practice that will be called once for each user loaded by SwiftData, and we need to return true if that user should be included in the results.
  3. Our test checks whether the user's name contains the capital letter R. If it does, the user will be included in the results, otherwise they won't.

So, when you run the code now you'll see that both Rosa and Roy appear in our list, but Ed and Johnny are left off because their names don't contain a capital R. The contains() method is case-sensitive: it considers capital R and lowercase R to be difference, which is why it didn't find the "r" in "Ed Sheeran".

That works great for a simple test of predicates, but it's very rare users actually care about capital letters – they usually just want to write a few letters, and look for that match anywhere in the results, ignoring case.

For this purpose, iOS gives us a separate method localizedStandardContains(). This also takes a string to search for, except it automatically ignores letter case, so it's a much better option when you're trying to filter by user text.

Here's how it looks:

@Query(filter: #Predicate<User> { user in
    user.name.localizedStandardContains("R")
}, sort: \User.name) var users: [User]

In our little test data that means we'll see three out of the four users, because those three have a letter "r" somewhere in their name.

Now let's go a step further: let's upgrade our filter so that it matches people who have an "R" in their name and who live in London:

@Query(filter: #Predicate<User> { user in
    user.name.localizedStandardContains("R") &&
    user.city == "London"
}, sort: \User.name) var users: [User]

That uses Swift's "logical and" operator, which means both sides of the condition must be true in order for the whole condition to be true – the user's name must contain an "R" and they must live in London.

If we only had the first check for the letter R, then Ed, Rosa, and Roy would match. If we only had the second check for living in London, then Ed, Roy, and Johnny would match. Putting both together means that only Ed and Roy match, because they are the only two with an R somewhere in their name who also live in London.

You can add more and more checks like this, but using && gets a bit confusing. Fortunately, these predicates support a limited subset of Swift expressions that make reading a little easier.

For example, we could rewrite our current predicate to this:

@Query(filter: #Predicate<User> { user in
    if user.name.localizedStandardContains("R") {
        if user.city == "London" {
            return true
        } else {
            return false
        }
    } else {
        return false
    }
}, sort: \User.name) var users: [User]

Now, you might be thinking that's a little verbose – that could remove both else blocks and just end with return true, because if the user actually matched the predicate the return true would already have been hit.

Here's how that would look:

@Query(filter: #Predicate<User> { user in
    if user.name.localizedStandardContains("R") {
        if user.city == "London" {
            return true
        }
    }

    return false
}, sort: \User.name) var users: [User]

Sadly that code isn't actually valid, because even though it looks like we're executing pure Swift code it's important you remember that doesn't actually happen – the #Predicate macro actually rewrites our code to be a series of tests it can apply on the database, which doesn't use Swift internally.

To see what's happening internally, press undo a few times to get the original version with two else blocks. Now right-click on #Predicate and select Expand Macro, and you'll see a huge amount of code appears. Remember, this is the actual code that gets built and run – it's what our #Predicate gets converted into.

So, that's just a little of how #Predicate works, and why some predicates you might try just don't quite work how you expect – this stuff looks easy, but it's really complex behind the scenes!

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.

Learn more here

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

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 5.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.