Easy Predicate for SwiftData


I recently worked on a SwiftData app where I needed to filter data in a Query while loading views. The key difference across my views was that they all used the same property as a filter:

var status: Status

The Problem with Enums in SwiftData Filters

SwiftData currently has limitations when using enums in the filter property. I won't go into full detail, as this topic has already been extensively covered:

However, one crucial thing to note is that SwiftData filtering requires primitive types, such as String or Int.

Let's take a look at an example:

@Model
final class Pet {
  var status: Status

  init(status: Status) {
    self.status = status
  }
}

Now, if we try to use status as an enum in the filter, we get an error:

Member access without an explicit base is not supported in this predicate.

struct ActivePetList: View {
  @Query private var pets: [Pet]

  init() {
    self._pets = Query(filter: #Predicate { $0.status == .active })
  }
    
  var body: some View {
    // .. your view
  }
}

The Workaround: Using a Primitive Type

To bypass this issue, we can store the raw primitive type (String) and create a computed property (getter and setter) to handle the conversion:

@Model
final class Pet {
  var statusRaw: String
  
  var status: Status {
    get { Status(rawValue: statusRaw) ?? .active }
    set { statusRaw = newValue.rawValue }
  }

  init(status: Status) {
    self.statusRaw = status.rawValue
  }
}

This allows us to use the statusRaw property in the Query:

struct ActivePetList: View {
  @Query private var pets: [Pet]

  init() {
    self._pets = Query(filter: #Predicate { $0.statusRaw == "active" })
  }
    
  var body: some View {
    // .. your view
  }
}

The Issue with Strings in Filters

While this approach works, using raw strings introduces risk - especially when multiple screens require filtering. Consider a scenario with statuses like .active, .inactive, and .pendingDeletion. A simple typo in a string could lead to hours of debugging.

Wouldn't it be safer to do this instead?

init() {
  self._pets = Query(filter: #Predicate { $0.statusRaw == Status.active.rawValue })
}

Surprisingly, this results in an error:

Key path cannot refer to enum case 'active'

A Cleaner Approach: Using a Predicate Extension

To avoid these pitfalls, a neat trick is to extend Predicate and define static variables for common filters:

extension Predicate {
  static var activePets: Predicate<Pet> {
    #Predicate<Pet> { $0.status == .active }
  }

  static var inActivePets: Predicate<Pet> {
    #Predicate<Pet> { $0.status == .inActive }
  }
}

But we can improve this further by making it a static function:

extension Predicate {
  static func pets(with status: Status) -> Predicate<Pet> {
    #Predicate<Pet> { $0.statusRaw == status.rawValue }
  }
}

Now, using it in a query is much cleaner and safer:

init() {
  self._pets = Query(filter: .pets(with: .active))
//  self._pets = Query(filter: .pets(with: .inActive))
//  self._pets = Query(filter: .pets(with: .pendingDeletion))
}

Conclusion

By leveraging an extension on Predicate, we avoid the pitfalls of raw strings while keeping our filters type-safe and easy to use. This method makes filtering SwiftData queries more robust and less error-prone.


Enjoyed this content? Fuel my creativity!

If you found this article helpful, you can keep the ideas flowing by supporting me. Buy me a coffee or check out my apps to help me create more content like this!

Coffee Check out my apps