# #Predicate pitfalls

## Contents

- [Why valid Swift can still fail](#why-valid-swift-can-still-fail)
- [Expressions that fail at runtime](#expressions-that-fail-at-runtime)
- [What the store understands](#what-the-store-understands)
- [Capturing values safely](#capturing-values-safely)
- [Finding the clause that crashes](#finding-the-clause-that-crashes)

## Why valid Swift can still fail

`#Predicate` does not run your closure. It converts the closure into an
expression tree that the store translates into a query. Only part of Swift has
a translation, and the compiler does not always know which part. Code outside
that part can build cleanly and then throw or crash the first time the fetch
runs.

## Expressions that fail at runtime

| Expression | What goes wrong | Use instead |
|---|---|---|
| `$0.title.uppercased() == key` | String transforms are not translated | `localizedStandardContains` or `caseInsensitiveCompare` |
| `$0.title.count > 5` | `count` on a string is not translated | Store the length as its own property, or filter after fetching |
| `$0.labels.count == 0` | `count` on a to-many relationship fails | `$0.labels.isEmpty` (iOS 17.4+) or a stored flag |
| `$0.nickname.isEmpty` where `nickname` is `String?` | Can crash on some OS versions | `$0.nickname == nil` or `$0.nickname == ""` |
| A computed property on the model | Only stored attributes exist in the store | Query the stored properties it is built from |
| `$0.dueAt < Date.now` | The date is not the one you meant, and the predicate goes stale | Bind `let now = Date()` first and capture `now` |
| `$0.state == .open` or `.rawValue` on an enum before iOS 18 | Enum comparison is not translated on older systems | Store the raw value in its own property, or require iOS 18+ |
| Loops, `let` inside the closure, mutation, heavy `switch` | Build error or runtime failure | Boolean logic, ternaries, optional chaining and binding |
| Any other method call | Crashes unless it is on the supported list | Restructure around supported operations |

`isEmpty` is fine on collections and to-many relationships (from iOS 17.4). The
warning above is only about `isEmpty` on an optional `String`.

## What the store understands

- Comparison: `==`, `!=`, `<`, `<=`, `>`, `>=`
- Logic: `&&`, `||`, `!`
- Strings: `localizedStandardContains(_:)`, `contains(_:)`, `starts(with:)`,
  `caseInsensitiveCompare(_:)`
- Collections: `contains(where:)`, `allSatisfy(_:)`, `filter(_:)`, `isEmpty`
- Other: optional chaining, `??`, the ternary operator, `+ - * /`, `as?` and `is`

## Capturing values safely

Compute every outside value into a `let` before the macro. The predicate copies
captured values when it is created, so what you bind is what the query uses.

```swift
import SwiftData
import Foundation

func openTicketsFilter(matching text: String, urgentOnly: Bool) -> Predicate<Ticket> {
    let now = Date()
    return #Predicate<Ticket> { ticket in
        ticket.dueAt >= now
            && (text.isEmpty || ticket.subject.localizedStandardContains(text))
            && (!urgentOnly || ticket.isUrgent)
    }
}
```

Here `text` is a non-optional `String`, so `text.isEmpty` is safe. The
`text.isEmpty ||` and `!urgentOnly ||` shapes let one predicate serve the
filtered and unfiltered cases.

## Finding the clause that crashes

A failing predicate usually surfaces as `SwiftData.PredicateError` or as an
`NSInvalidArgumentException` from the SQLite layer.

1. Cut the predicate down to a single clause and run the fetch.
2. Add the other clauses back one at a time until it fails.
3. For the failing clause, check that it uses only supported operations and
   only stored properties.
4. Run the fetch on the oldest OS you deploy to. Several operations were added
   in later releases and fail only on older systems.
