Swift Filtering With Predicates

You can do a lot with strings using the Swift standard library but sometimes you need to fall back on NSPredicate. In this post I build a filter for a collection of Swift value types using a configurable predicate.

Filtering Lists of Values

I’m experimenting with search interfaces using SwiftUI. This leads me to wanting to filter an array of items based on a growing set of criteria. To give a practical example, suppose my model is a country with a name, (optional) capital and a flag indicating if I’ve visited:

struct Country {
  var name: String
  var capital: String?
  var visited = false
}

Some example countries:

let countries = [
  Country(name: "Australia", capital: "Canberra", visited: true),
  Country(name: "Antartica"),
  Country(name: "Belgium", capital: "Brussels", visited: true),
  Country(name: "Canada", capital: "Ottawa", visited: true),
  Country(name: "Egypt", capital: "Cairo")
]

If I wanted to get a collection of countries I have visited I can use a filter:

let visited = countries.filter { $0.visited == true }
// {name "Australia", capital "Canberra", visited true}
// {name "Belgium", capital "Brussels", visited true}
// {name "Canada", capital "Ottawa", visited true}

It starts to get tricky though as the criteria gets more complicated. I also want to search for partial matches on the country name or capital and those searches should be case and diacritical insensitive.

Filtering 4 countries with a scope limited to visited countries with a capital beginning with ca

I want an API that allows me to filter countries based on a configurable scope:

countries.filter { $0.isIncluded(in: scope) }

First Steps

Let’s start by collecting my search criteria into a single struct that represents the scope:

enum SearchByField {
  case name
  case capital
}

// default scope always matches
struct SearchScope {
  var visitedOnly: Bool = false
  var beginsWith: String = ""
  var searchBy: SearchByField = .name
}

To search only for visited countries with a capital that begins with “s” my scope would be:

SearchScope(visitedOnly: true, beginsWith: "s", searchBy: .capital)

This is a situation where I see the value of writing unit tests as I go. I’m not going to show them all but my first test for filtering on visited countries might look like this:

let visited = Country(name: "UK", visited: true)
let scope = SearchScope(visitedOnly: true)
XCTAssertTrue(visited.isIncluded(in: scope))

A small extension on Country gives us what we need:

extension Country {
  func isIncluded(in scope: SearchScope) -> Bool {
    return !scope.visitedOnly || visited
  }
}

This doesn’t take us any further than the simple filter closure we used earlier but it does allow me to experiment with the API. For the next step I needed a recap on predicates.

NSPredicates with Swift

I want to test either the country name or capital for a matching prefix. I can almost do what I need using the Swift standard library but I want the match to be both case insensitive and ignore any accented (diacritical) characters. Searching for names beginning with “a” should match “Åland”.

I’ve written about predicates in the past but mostly when working with Objective-C types or customizing a Core Data fetch request. We can use them with Swift value types but the member lookup doesn’t work. For example, I can’t do this to test for the country name:

let france = Country(name: "France")
let predicate = NSPredicate(format: "name BEGINSWITH[cd] %@", "france")
predicate.evaluate(with: france)

That will only work if I make my model a class and make its members visible to the Objective-C runtime:

@objcMembers
class Country: NSObject {
    var name: String
    var capital: String?
    var visited = false

    init(...) { ... }
}

I want to keep my model as a Swift struct so we need to stick with SELF in the format string and evaluate the predicate on the property directly:

let france = Country(name: "France")
let predicate = NSPredicate(format: "SELF BEGINSWITH[cd] %@", "france")
predicate.evaluate(with: france.name)

Note: The [cd] modifier gives us a case and diacritical insensitive match.

I’m using BEGINSWITH to test for a prefix. Here’s a quick recap of other string comparisons we could use (see the Apple predicate programming guide for more examples including regex support):

NSPredicate(format: "SELF BEGINSWITH %@", "Fr")   
NSPredicate(format: "SELF CONTAINS %@", "anc")
NSPredicate(format: "SELF ENDSWITH %@", "ce")   
NSPredicate(format: "SELF LIKE %@", "F???ce")   

Matching The Name

Let’s add a predicate that will match for the name:

extension Country {
  func isIncluded(in scope: SearchScope) -> Bool {
    let predicate = NSPredicate(format: "SELF BEGINSWITH[cd] %@",
                    scope.beginsWith)

    return (!scope.visitedOnly || visited) &&
      (scope.beginsWith.isEmpty || predicate.evaluate(with: self.name))
  }
}

If the scope has a non blank search string we evaluate the predicate on the name of the country:

let aland = Country(name: "Åland", capital: "Mariehamn", visited: false)
let scope = SearchScope(beginsWith: "a")
XCTAssertTrue(aland.isIncluded(in: scope))  // true

This works but has some issues. I also want to search by capital and I don’t want to create the predicate each time.

Predicates with Substitution Variables

We can make the predicate a static member of the search scope and substitute the value we are searching for at runtime:

struct SearchScope {
    var visitedOnly: Bool = false
    var beginsWith: String = ""
    var searchBy: SearchByField = .name

    static let predicate = NSPredicate(format:
        "SELF BEGINSWITH[cd] $query")
}

We substitute the $query in the predicate format string with its value in the dictionary we pass when evaluating the predicate:

extension Country {
  func isIncluded(in scope: SearchScope) -> Bool {
    (!scope.visitedOnly || visited) &&
    (scope.beginsWith.isEmpty ||
     SearchScope.predicate.evaluate(with: self.name,
        substitutionVariables: ["query": scope.beginsWith])
    )
  }
}

Adding Key Paths

The final step is to support searching by capital. To keep a minimum of compile time safety I added a function to the SearchByField enum to give me the keypath for each choice:

enum SearchByField {
  case name
  case capital

  func keyPath() -> PartialKeyPath<Country> {
    switch self {
    case .name:
      return \.name
    case .capital:
      return \.capital
    }
  }
}

I can then lookup the field to use with the predicate based on my search scope at runtime:

extension Country {
  func isIncluded(in scope: SearchScope) -> Bool {
    (!scope.visitedOnly || visited) &&
     (scope.beginsWith.isEmpty ||
      SearchScope.predicate.evaluate(with: 
        self[keyPath: scope.searchBy.keyPath()],
        substitutionVariables: ["query": scope.beginsWith])
     )
  }
}

It’s a bit clumsy but my final test passes:

let oz = Country(name: "Australia", capital: "Canberra", visited: true)
let capitalScope = SearchScope(visitedOnly: true, beginsWith: "ca", searchBy: .capital)
XCTAssertTrue(oz.isIncluded(in: capitalScope))

It also works well with a SwiftUI List:

var body: some View {
  List(countries.filter { $0.isIncluded(in: self.scope) }) { 
    country in
      CountryRow(country: country)
  }
}

Let me know what I missed…

Read More