SwiftData has reworked the engine for creating data models, including a safe way to create predicates based on model code. As a result, developers encounter many operations involving optional values when constructing predicates for SwiftData. This article will explore some techniques and considerations for handling optional values during predicate construction.
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 automatically generating NSManagedObject subclass code.
This process essentially transforms from a model (“inside”) to type code (“outside”). Developers could adapt the type code to some extent, such as change Optional
to Non-Optional
or NSSet
to Set
to optimize the development experience, provided these changes do not affect the mapping between the code and the Core Data model.
SwiftData’s clean code declaration method completely changes this process. In SwiftData, the declaration of type code and data model is done simultaneously, or more precisely, SwiftData automatically generates the corresponding data models based on the type code declared by the developers. The declaration method has shifted from the traditional “inside out” to “outside in”.
Optional values and predicates
In the process of creating predicates for the underlying data, the predicate expressions have no direct connection 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 conform to the concept of optional types in Swift, but indicates whether an SQLite field can NULL
. This means that when a predicate expression includes a NULLable property and a non-NULL value, its optionality should not normally be considered.
public class Note: NSManagedObject
@NSManaged public var name: String?
let predicate = NSPredicate(format: "name BEGINSWITH %@", "fat")
However, the advent of SwiftData is changing this scenario. Because the SwiftData predicate construct is based on model code, the optional types in it truly embody the concept of optionality in Swift. This requires special attention to the handling of optional values when constructing the predicate.
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, the correct handling of optional values becomes a critical consideration when creating predicates for SwiftData.
Correct handling of optional values in SwiftData
Although the construction of predicates in SwiftData is similar to writing a closure that returns a boolean value, developers can only use the operators and methods specified in the official documentation, which are converted to the appropriate PredicateExpressions
through macros. For optional type name
above property, developers can process it using the following methods:
Method 1: Using optional chaining and the zero coalescence operator
By combining optional chaining (?.
) with the null join operator (??
), you can provide a default Boolean value when the property nil
.
let predicate1 = #Predicate<Note>
$0.name?.starts(with: "fat") ?? false
Method 2: Using optional binding
With optional binding (if let
), you can execute certain logic when the property is not null 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 contain only one expression. Therefore, attempt to return another value outside 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
each structure is considered a single expression, and each has a direct correspondence with PredicateExpressions
. In contrast, the additional return outside the an if
structure corresponds to two different expressions.
Although only one expression can be included in the closure predicate, complex query logic can still be constructed by nesting.
Method 3: Use flatMap
method
The flatMap
method can handle optional values, applying the given closure when not nil
provided that the result can still yield the 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 efficient ways to correctly handle optional values in the SwiftData predicate construct, thus avoiding compilation or runtime errors and ensuring the accuracy and stability of data queries.
Incorrect approach: using forced unwinding
Even if the developer is sure that the property is not null, using !
forcing unwrapping into SwiftData predicates can still lead to runtime errors.
let predicate = #Predicate<Note>
$0.name!.starts(with: "fat") // error
// Runtime Error: SwiftData.SwiftDataError._Error.unsupportedPredicate
Handling optional values in special cases
When constructing predicates in SwiftData, although specific methods for handling optional values are generally required, there are some special cases where the rules are slightly different.
Direct comparison of equality
SwiftData allows direct comparison of equality (==
) operations that include optional values without requiring additional optionality handling. This means that even if the property is of an optional type, it can be compared directly, 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 electoral 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
Although there is no need for special handling in equality comparisons when the option chain contains only one ?
situations involving multiple ?
in the chain, even though the code compiles and runs without errors, SwiftData cannot retrieve the correct results from the database via such a predicate.
Consider the following scenario, where there is a one-to-one electoral relationship between Item
and Note
and also in between Note
and Parent
:
let predicate = #Predicate<Item>
$0.note?.parent?.persistentModelID == rootNoteID
To solve this problem, it is necessary to ensure that the option chain contains only one ?
. This can be achieved by partially unwinding the selection 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 explored how to properly handle optional values in the predicate construction process in SwiftData. By introducing various methods, including the use of optional chaining and the nil-coalescing operator, optional linking and flatMap
method, we have provided strategies for effective option handling. Moreover, we have highlighted the special cases of directly comparing the equality of optional values and the special handling required when the optional chain contains more ?
s. These tips and considerations are intended to help developers avoid common pitfalls, ensuring the construction of accurate and efficient data query predicates, thereby taking full advantage of SwiftData’s powerful features.