How to Handle Optional Values in SwiftData Predicates

Published on

Get weekly handpicked updates on Swift and SwiftUI!

SwiftData has revamped the mechanism for creating data models, incorporating a type-safe mode for predicate creation based on model code. As a result, developers encounter numerous operations involving optional values when constructing predicates for SwiftData. This article will explore some techniques and considerations for handling optional values while building predicates.

From “Inside-Out” to “Outside-In” Transformation

Among the many innovations in SwiftData, the most striking is allowing developers to declare data models directly through code. In Core Data, developers must first create a data model in Xcode’s model editor (corresponding to NSManagedObjectModel) before writing or auto-generating NSManagedObject subclass code.

This process essentially transforms from the model (“inside”) to type code (“outside”). Developers could adjust the type code to some extent, such as changing Optional to Non-Optional or NSSet to Set, to optimize the development experience, provided these modifications do not affect the mapping between Core Data’s code and models.

The pure code declaration method of SwiftData completely changes this process. In SwiftData, the declaration of type code and data models is carried out simultaneously, or more accurately, SwiftData automatically generates the corresponding data models based on the type code declared by developers. The method of declaration has shifted from the traditional “inside-out” to “outside-in”.

Optional Values and Predicates

In the process of creating predicates for Core Data, the predicate expressions do not have a direct link to the type code. The properties used in these expressions correspond to those defined within the model editor (data model), and their “optional” characteristic does not align with the concept of optional types in Swift, but rather indicates whether the SQLite field can be NULL. This means that when a predicate expression involves a property that can be NULL and a non-NULL value, its optionality usually does not need to be considered.

Swift
public class Note: NSManagedObject {
    @NSManaged public var name: String?
}

let predicate = NSPredicate(format: "name BEGINSWITH %@", "fat")

However, the advent of SwiftData changes this scenario. Since the construction of SwiftData predicates is based on model code, the optional types therein truly embody the concept of optionals in Swift. This necessitates special attention to the handling of optional values when building predicates.

Consider the following SwiftData code example, where improper handling of optional values will lead to compilation errors:

Swift
@Model
final class Note {
  var name: String?
  init(name: String?) {
    self.name = name
  }
}

let predicate1 = #Predicate<Note> { note in
  note.name.starts(with: "fat")  // error 
}
// Value of optional type 'String?' must be unwrapped to refer to member 'starts' of wrapped base type 'String'

let predicate2 = #Predicate<Note> { note in
  note.name?.starts(with: "fat")  // error 
}
// Cannot convert value of type 'Bool?' to closure result type 'Bool'

Therefore, correctly handling optional values becomes a critical consideration when constructing predicates for SwiftData.

Correctly Handling Optional Values in SwiftData

Although predicate construction in SwiftData is similar to writing a closure that returns a boolean value, developers can only use the operators and methods listed in the official documentation, which are converted into corresponding PredicateExpressions through macros. For the optional type name property mentioned above, developers can handle it using the following methods:

Method 1: Using Optional Chaining and the Nil-Coalescing Operator

By combining optional chaining (?.) with the nil-coalescing operator (??), you can provide a default boolean value when the property is nil.

Swift
let predicate1 = #Predicate<Note> { 
  $0.name?.starts(with: "fat") ?? false
}

Method 2: Using Optional Binding

With optional binding (if let), you can execute specific logic when the property is not nil, or return false otherwise.

Swift
let predicate2 = #Predicate<Note> {
  if let name = $0.name {
    return name.starts(with: "fat")
  } else {
    return false
  }
}

Note that the predicate body can only contain a single expression. Therefore, attempting to return another value outside of if will not construct a valid predicate:

Swift
let predicate2 = #Predicate<Note> {
  if let name = $0.name {
    return name.starts(with: "fat")
  }
  return false
}

The restriction here means that if else and if structures are each considered a single expression, each having a direct correspondence to PredicateExpressions. In contrast, an additional return outside of an if structure corresponds to two different expressions.

Although only one expression can be included in the predicate closure, complex query logic can still be constructed through nesting.

Method 3: Using the flatMap Method

The flatMap method can handle optional values, applying a given closure when not nil, with the result still being able to provide a default value using the nil-coalescing operator.

Swift
let predicate3 = #Predicate<Note> {
  $0.name.flatMap { $0.starts(with: "fat") } ?? false
}

The above strategies provide safe and effective ways to correctly handle optional values in SwiftData predicate construction, thus avoiding compilation or runtime errors and ensuring the accuracy and stability of data queries.

Incorrect Approach: Using Forced Unwrapping

Even if a developer is certain a property is not nil, using ! to force unwrap in SwiftData predicates can still lead to runtime errors.

Swift
let predicate = #Predicate<Note> {
  $0.name!.starts(with: "fat") // error
}

// Runtime Error: SwiftData.SwiftDataError._Error.unsupportedPredicate

Unprocessable Optional Values

As of now (up to Xcode 15C500b), when the data model includes an optional to-many relationship, the methods mentioned above do not work. For example:

Swift
let predicate = #Predicate<Memo>{
      $0.assets?.isEmpty == true
}

// or 

let predicate = #Predicate<Memo>{ $0.assets == nil }

SwiftData encounters a runtime error when converting the predicate into SQL commands:

Bash
error: SQLCore dispatchRequest: exception handling request: <NSSQLCountRequestContext: 0x6000038dc620>, to-many key not allowed here with userInfo of (null)

Handling Optional Values in Special Cases

When constructing predicates in SwiftData, while specific methods are generally required to handle optional values, there are some special cases where the rules differ slightly.

Direct Equality Comparison

SwiftData allows for direct comparison in equality (==) operations involving optional values, without the need for additional handling of optionality. This means that even if a property is of an optional type, it can be directly compared as shown below:

Swift
let predicate = #Predicate<Note> {
  $0.name == "root"
}

This rule also applies to comparisons of optional relationship properties between objects. For example, in a one-to-one optional relationship between Item and Note, a direct comparison can be made (even if name is also an optional type):

Swift
let predicate = #Predicate<Item> {
  $0.note?.name == "root"
}

Special Cases with Optional Chaining

While there is no need for special handling in equality comparisons when an optional chain contains only one ?, situations involving multiple ?s in the chain, even though the code compiles and runs without errors, SwiftData cannot retrieve the correct results from the database through such a predicate.

Consider the following scenario, where there is a one-to-one optional relationship between Item and Note, and also between Note and Parent:

Swift
let predicate = #Predicate<Item> {
  $0.note?.parent?.persistentModelID == rootNoteID
}

To address this issue, it is necessary to ensure that the optional chain contains only one ?. This can be achieved by partially unwrapping the optional chain, for example:

Swift
let predicate = #Predicate<Item> {
  if let note = $0.note {
    return note.parent?.persistentModelID == rootNoteID
  } else {
    return false
  }
}

Or:

Swift
let predicate = #Predicate<Item> {
  if let note = $0.note, let parent = note.parent {
    return parent.persistentModelID == rootNoteID
  } else {
    return false
  }
}

The issue has been fixed in iOS 17.5+ and no longer exists.

Conclusion

In this article, we have explored how to correctly handle optional values in the process of constructing predicates in SwiftData. By introducing various methods, including the use of optional chaining and the nil-coalescing operator, optional binding, and the flatMap method, we have provided strategies for effectively handling optionality. Moreover, we highlighted the special cases of direct equality comparison of optional values and the special handling required when an optional chain contains multiple ?s. These tips and considerations are aimed at helping developers avoid common pitfalls, ensuring the construction of accurate and efficient data query predicates, thereby fully leveraging the powerful features of SwiftData.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed