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.
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:
@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
.
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.
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:
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.
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.
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:
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:
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:
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):
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
:
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:
let predicate = #Predicate<Item> {
if let note = $0.note {
return note.parent?.persistentModelID == rootNoteID
} else {
return false
}
}
Or:
let predicate = #Predicate<Item> {
if let note = $0.note, let parent = note.parent {
return parent.persistentModelID == rootNoteID
} else {
return false
}
}
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.