The Ultimate Guide on How to use Predicate in Swift
NSPredicate has always been a powerful tool provided by Apple, allowing developers to filter and evaluate data collections with complex logical conditions in a natural and efficient way. As Swift continues to mature and evolve, the 2023 Swift community undertook a major update to re-implement the Foundation framework using pure Swift language. This significant update introduced new Predicate functionality coded in Swift, marking a new stage in data processing and evaluation.
What is a predicate
In modern software development, efficient and accurate filtering and evaluation of data are crucial. A predicate (Predicate) is a powerful tool that allows developers to achieve this by defining logical conditions that return Boolean values (true or false). This plays a core role not only in filtering collections or finding specific elements within them but also as the foundation for data processing and business logic implementation.
Although Apple’s NSPredicate
provides this capability, it relies on Objective-C syntax, poses risks of runtime errors, and faces platform limitations that restrict its application scope and flexibility.
class MyObject: NSObject {
@objc var name: String
init(name: String) {
self.name = name
}
}
let object = MyObject(name: "fat")
// create NSPredicate
let predicate = NSPredicate(format: "name = %@", "fat")
XCTAssertTrue(predicate.evaluate(with: object)) // true
let objs = [object]
// filter object by predicate
let filteredObjs = (objs as NSArray).filtered(using: predicate) as! [MyObject]
XCTAssertEqual(filteredObjs.count, 1)
Introduction and Improvement of Swift Predicate
To overcome these limitations and expand the application scope of predicates, the Swift community re-implemented the Foundation framework, introducing Predicate functionality coded in Swift. This new feature not only breaks away from Objective-C dependency but also simplifies predicate construction using Swift’s macro capabilities, as shown below:
class MyObject {
var name:String
init(name: String) {
self.name = name
}
}
let object = MyObject(name: "fat")
let predicate = #Predicate<MyObject>{ $0.name == "fat" }
try XCTAssertTrue(predicate.evaluate(object)) // true
let objs = [object]
let filteredObjs = try objs.filter(predicate)
XCTAssertEqual(filteredObjs.count, 1)
In this example, we construct a logical condition using the #Predicate
macro. This construction method is similar to writing closure code, allowing developers to build more complex logic in a natural way, such as predicates with multiple conditions:
let predicate = #Predicate<MyObject>{ object in
object.name == "fat" && object.name.count < 3
}
try XCTAssertTrue(predicate.evaluate(object)) // false
Furthermore, the MyObject
class no longer needs to inherit from NSObject
or use @objc
attributes for its properties to support KVC. Swift Predicate is also applicable to types that still inherit from NSObject
.
Comparison between NSPredicate and Swift Predicate
Compared to NSPredicate, Swift Predicate offers numerous improvements:
- Open-source nature and platform compatibility: Supports cross-platform usage, such as on Linux and Windows.
Safety
- Type Safety: Leveraging Swift’s type checking to reduce runtime errors.
- Development Efficiency: Benefiting from Xcode support, improving code writing speed and accuracy.
- Syntax Freedom: Providing greater expressive freedom, not limited by Objective-C syntax rules.
- Generality: Applicable to all Swift types, no longer limited to classes inheriting from NSObject.
- Modern Swift Feature Support: Supporting Sendable and Codable features, making it more suitable for current Swift programming paradigms.
Through these improvements, Swift Predicate not only optimizes developers’ workflows but also opens up new paths for the expansion and growth of the Swift ecosystem.
Main Components of Swift Predicate
Before diving into the usage methods and considerations of Swift Predicate, we need to understand its structure. Specifically, we should know what elements make up a predicate and how predicate macros work.
The PredicateExpression
Protocol
The PredicateExpression
protocol (or a specific type that conforms to it) defines the conditional logic of an expression. For example, it can represent a "less than" condition with specific logical judgments to determine whether an input value is less than a given value. This protocol is the most critical part of building the Swift Predicate architecture. The declaration of the PredicateExpression
protocol is as follows:
public protocol PredicateExpression<Output> {
associatedtype Output
func evaluate(_ bindings: PredicateBindings) throws -> Output
}
The Foundation framework provides a series of predefined expression types that conform to the PredicateExpression
protocol, allowing developers to directly use types or type methods under PredicateExpressions
to construct predicate expressions. This paves the way for building flexible and powerful conditional evaluation logic. For example, if we want to construct an expression representing the number 4, the corresponding code is as follows:
let express = PredicateExpressions.Value(4)
The implementation of PredicateExpressions.Value
code is as follows:
extension PredicateExpressions {
public struct Value<Output> : PredicateExpression {
public let value: Output
public init(_ value: Output) {
self.value = value
}
public func evaluate(_ bindings: PredicateBindings) -> Output {
return self.value
}
}
}
The Value
struct directly encapsulates a value and, when its evaluate
method is called, simply returns the encapsulated value. This makes Value
an effective way to represent constant values in predicate expressions.
Note that the
PredicateExpression
protocol'sevaluate
method can return any type of value, not limited to Boolean types.
Furthermore, if we need to define a table expression for the condition “3 < 4”, the corresponding code example is as follows:
let express = PredicateExpressions.LessThan(3, 4)
… (rest of the text remains the same)
let express = PredicateExpressions.build_Comparison(
lhs: PredicateExpressions.Value(3),
rhs: PredicateExpressions.Value(4),
op: .lessThan
)
This code snippet generates a type instance that conforms to the PredicateExpression
protocol:
PredicateExpressions.Comparison<PredicateExpressions.Value<Int>, PredicateExpressions.Value<Int>>
When calling the evaluate
method on this instance, it returns a Boolean value representing the result of the comparison.
Developers can build complex logical judgments by nesting expressions. The resulting type expressions also become correspondingly complex.
June 2024 update
At WWDC 2024, the Foundation’s predicate system introduced several new features, including new expression methods and the most significant improvement: the addition of the #Expression
macro. This greatly simplifies the process of building predicate expressions, even when building standalone expressions. In previous versions, developers could only experience this convenience when using the #Predicate
macro.
The #Expression
macro allows developers to define predicates using multiple independent expressions, making it easier to build complex predicates and increasing the reusability of expressions.
Unlike predicates, which can only return a Boolean value, expressions can return any type. Therefore, when declaring an expression, developers must explicitly specify the input and output types.
let unplannedItemsExpression = #Expression<[BucketListItem], Int> { items in
items.filter {
!$0.isInPlan
}.count
}
let today = Date.now
let tripsWithUnplannedItems = #Predicate<Trip>{ trip
// The current date falls within the trip
(trip.startDate ..< trip.endDate).contains(today) &&
// The trip has at least one BucketListItem
// where 'isInPlan' is false
unplannedItemsExpression.evaluate(trip.bucketList) > 0
}
Predicate Structure
The Predicate
struct, even when defined using a macro, remains the core of predicate logic. This struct is responsible for binding logical conditions (implemented by PredicateExpression
) to specific values. This mechanism enables Predicate
to instantiate specific condition logic and accept input values for evaluation.
Its definition is as follows:
public struct Predicate<each Input> : Sendable {
public let expression : any StandardPredicateExpression<Bool>
public let variable: (repeat PredicateExpressions.Variable<each Input>)
public init(_ builder: (repeat PredicateExpressions.Variable<each Input>) -> any StandardPredicateExpression<Bool>) {
self.variable = (repeat PredicateExpressions.Variable<each Input>())
}
Main Features
- Boolean Return Value Limitation: The
Predicate
class is designed to handle expressions that return boolean values. This means that the final result of a complex expression tree must be a boolean value in order to facilitate logical judgments. - Construction Process: When creating a
Predicate
, you must provide a closure that takes parameters of typePredicateExpressions.Variable
and returns an expression that conforms to theStandardPredicateExpression<Bool>
protocol. - The
StandardPredicateExpression
Protocol: This is an extension of thePredicateExpression
protocol, requiring expressions to also conform toCodable
andSendable
. Currently, only Foundation-provided expressions are allowed to comply with this protocol.
public protocol StandardPredicateExpression<Output> : PredicateExpression, Codable, Sendable {}
- Advanced Features of Closure and Variable Property: Leveraging Swift’s Parameter Packs feature,
Predicate
supports creating predicates that can handle multiple generic parameters simultaneously, a functionality not available inNSPredicate
.
For example, using the Predicate
struct and PredicateExpression
protocol, you can create a predicate to compare two integers n
and m
(n < m
) as follows:
// Define a closure: compares two integer values whether they satisfy "less than" relationship.
// This closure takes two PredicateExpressions.Variable<Int> type parameters,
// and constructs a PredicateExpression representing the "less than" comparison logic.
let express = { (value1: PredicateExpressions.Variable<Int>, value2: PredicateExpressions.Variable<Int>) in
PredicateExpressions.build_Comparison(
lhs: value1,
rhs: value2,
op: .lessThan
)
}
// Use the express closure to construct a Predicate instance,
// where express defines the evaluation logic, i.e., whether the first parameter is less than the second.
let predicate = Predicate {
express($0, $1)
}
let n = 3
let m = 4
// Evaluate the predicate: check if n is less than m, expecting a return of true.
try XCTAssertTrue(predicate.evaluate(n, m))
The Predicate
Macro
Compared to building predicates using strings like with NSPredicate
, although directly using PredicateExpression
and Predicate
structs can provide type safety checks and auto-completion advantages, this approach is less efficient and more difficult to write and read, undoubtedly increasing the cognitive burden on developers when creating predicates.
To alleviate these complexities, Foundation introduces the Predicate
macro (#Predicate
), aiming to help developers construct Swift Predicates in a more concise and efficient manner.
Using the same example of building a predicate to judge whether n < m
, the macro significantly simplifies the construction process:
let predicate = #Predicate<Int,Int>{ $0 < $1}
In Xcode, by viewing the expanded macro code, we can clearly see how the macro simplifies the previously complex logic that required a large amount of code.
The Predicate macro’s implementation code is approximately 1200 lines long and only supports pre-built predicate expressions in Foundation, as well as specific methods that can be used within predicates. When converting, errors will occur if an unsupported expression type or method is encountered, or if no corresponding expression exists.
By introducing the Predicate macro, Swift provides a concise yet powerful way to build complex predicate logic, allowing developers to directly construct complex logical judgments in almost native Swift code form, significantly improving code readability and maintainability. More importantly, using the Predicate macro greatly reduces the cognitive burden on developers when building complex queries, making the development workflow smoother and more efficient.
Techniques and Considerations for Building Swift Predicates
After understanding the composition of Swift Predicates, we can better grasp the restrictions and techniques involved in building predicates.
Restrictions on Global Functions
When using the Predicate macro to build predicates, note that the conversion logic converts closure code into Foundation’s pre-built PredicateExpress
expressions. The current implementation of PredicateExpress
does not support direct access to global functions or type methods or properties returning data. Therefore, when using such data to construct predicates, it is necessary to use the let
keyword to first obtain the desired data. For example:
func now() -> Date {
.now
}
let predicate = #Predicate<Date>{ $0 < now() } // Global functions are not supported in this predicate
The correct way is to first get the function or property value, then construct the predicate:
let now = now()
let predicate = #Predicate<Date>{ $0 < now }
Similarly, direct access to type properties is also restricted:
let predicate = #Predicate<Date>{ $0 < Date.now }
// Key path cannot refer to static member 'now'
let now = Date.now
let predicate = #Predicate<Date>{ $0 < now }
This is because the current predicate expression only supports instance property KeyPaths and does not support type properties.
Restrictions on Instance Methods
Similarly, direct calls to instance methods (such as .lowercased()
) are also unsupported in predicates.
struct A {
var name:String
}
let predicate = #Predicate<A>{ $0.name.lowercased() == "fat" } // The lowercased() function is not supported in this predicate
In such cases, it is necessary to use the built-in methods supported by Swift Predicate, for example:
let predicate = #Predicate<A>{ $0.name.localizedLowercase == "fat" }
Currently, the available set of built-in methods is relatively limited and includes but is not limited to: contains
, allSatisfy
, flatMap
, filter
, subscript
, starts
, min
, max
, localizedStandardContains
, localizedCompare
, and caseInsensitiveCompare
. Developers should regularly review Apple's official documentation or directly refer to the source code of the Predicate macro for a comprehensive understanding of the latest supported methods.
Since the built-in methods are not exhaustive, some common predicate construction patterns in NSPredicate may not be supported in Swift Predicate. This means that although Swift Predicate provides powerful tools for building type-safe and expressive predicates, developers may still need to find alternative solutions or wait for future extensions to cover a wider range of use cases.
Supporting the creation of predicates with multiple generic parameters
Thanks to the Parameter Packs feature, Swift Predicate offers more flexibility by allowing developers to define predicates that can accept multiple generic parameters. This capability greatly extends the applicability of predicates and enables developers to easily handle various complex conditional judgment requirements.
As shown in the n < m
example constructed earlier, this method not only applies to single-type parameter comparisons but can also be extended to multiple different types of parameters, further enhancing Swift Predicate's expressiveness and flexibility compared to traditional Swift higher-order functions. This feature makes Swift Predicate a powerful tool for building complex logic judgments while maintaining code clarity and type safety.
struct A {
var name: String
}
struct B {
var age: Int
}
let predicate = #Predicate<A, B> { a, b in
!a.name.isEmpty && b.age > 10
}
Creating complex judgment logic through nesting mechanisms
The design of Swift Predicate allows developers to build structured predicates by nesting predicate expressions. This capability makes it easier and more intuitive to implement conditional judgments that would typically rely on subqueries in NSPredicate. Today, these complex logic expressions can be written in a way that is more consistent with the programming habits of the Swift language, improving code readability and maintainability.
struct Address {
var city: String
}
struct People {
var address: [Address]
}
let predicate = #Predicate<People> { people in
people.address.contains { address in
address.city == "Dalian"
}
}
Note that the above methods do not work when the data model contains multiple relationships and is optional.
Supporting the construction of predicates with optional values
Swift Predicate supports the use of optional value types, which is a significant advantage when handling optional properties in data models. This support allows developers to directly handle optional values within predicate logic, making it easier to write predicate expressions that are more direct and clear.
For example, the following example demonstrates how to process an optional string property in Swift Predicate based on whether it starts with a specific prefix:
let predicate = #Predicate<Note> {
if let name = $0.name {
return name.starts(with: "fat")
} else {
return false
}
}
For developers who want to delve deeper into how to efficiently handle optional values in Swift Predicate, we recommend reading How to Handle Optional Values in SwiftData Predicates.
Swift Predicate is thread-safe
Swift Predicate’s design takes into account the needs of concurrent programming, ensuring its thread safety. By conforming to the Sendable
protocol, Swift Predicate supports safe transfer between different execution contexts. This feature significantly enhances the utility of Swift Predicate, making it adaptable to the widespread demands for concurrency and asynchronous programming in modern Swift applications.
Support for Serialization and Deserialization
By implementing the Codable
protocol, Swift Predicate can be converted into JSON or other formats, enabling data serialization and deserialization. This feature is particularly important for scenarios where predicate conditions need to be saved to a database or configuration file, or shared between clients and servers.
The following example demonstrates how to serialize a Predicate
instance as JSON data, which can then be stored or transmitted:
struct A {
var name:String
}
let predicate = #Predicate<A>{ $0.name == "fatbobman" }
var configuration = Predicate<A>.EncodingConfiguration.standardConfiguration
configuration.allowKeyPath(\A.name, identifier: "name")
let data = try JSONEncoder().encode(predicate, configuration: configuration)
Considerations for Complex Predicates
When building complex predicates, be aware of their impact on compilation time. Similar to the situation when building interfaces in SwiftUI, constructing complex Swift Predicate expressions requires the Swift compiler to process and transform them into a large and complex type. If the complexity of the expression exceeds a certain threshold, the compiler’s type inference will significantly increase.
When encountering compilation-time issues, developers can consider placing complex predicate declarations in separate Swift files. This not only helps organize and manage code but also reduces the likelihood of triggering recompilation due to frequent modifications elsewhere.
No Support for Custom Predicate Expressions
Currently, although developers can create custom expression types conforming to the PredicateExpress
protocol, official support is limited to using pre-built StandardPredicateExpression
implementations from Foundation. Therefore, while custom expression types can be created, they cannot be directly used in building predicates with the Predicate macro.
Even if developers annotate their custom expressions as conforming to the StandardPredicateExpression
protocol, the Predicate macro only supports using built-in StandardPredicateExpression
instances. This limitation prevents developers from utilizing custom expressions in Predicate macros and constructing predicates with them.
No Support for Combining Multiple Predicates
Unlike NSCompoundPredicate, which allows combining multiple simple logical NSPredicates into more complex ones, Swift Predicate currently does not provide a similar capability. This restricts developers’ flexibility when building complex predicates to some extent.
In future articles, I will introduce how to dynamically build complex predicates using the PredicateExpress
protocol to meet specific requirements, providing an alternative solution to overcome this limitation.
Applying Swift Predicate in SwiftData
SwiftData and Core Data use Predicates as data retrieval conditions in many development scenarios. Understanding how SwiftData handles Swift Predicate is crucial for maximizing its utility.
Interaction Mechanism between SwiftData and Swift Predicate
When setting a FetchDescriptor
with a Predicate in SwiftData, it does not directly adopt the evaluation mechanism of Swift Predicate. Instead, it parses the express
property of the predicate to define an expression tree and converts these expressions into SQL statements to retrieve data from the SQLite database. This means that the evaluation operation is actually performed on the database side through SQL instructions.
Restrictions on Predicate Parameters in SwiftData
SwiftData imposes certain restrictions on predicate parameters, which may affect how you use predicates in your application. SwiftData requires each FetchDescriptor to correspond to a specific data entity. Therefore, when building predicates, the corresponding entity type becomes the only parameter of the predicate, which is crucial for effective use of SwiftData in building predicates.
Expression Limitations of SwiftData Predicate Construction
Although Swift Predicate provides a powerful framework for data filtering, its expressiveness in the SwiftData environment has some limitations compared to using NSPredicate with Core Data. When facing specific filtering requirements, developers may need to adopt indirect methods, such as executing multiple filters or adding specific properties to entities beforehand to adapt to the current predicate capabilities. For example, since the built-in starts
method is case-sensitive, if you want to implement case-insensitive matching, it is recommended to create a pre-processed version of the filtered property (e.g., by converting all characters to lowercase) to support more flexible data retrieval.
Runtime Errors in Predicates
Even if Swift Predicate does not have compilation errors, using SwiftData for data retrieval may encounter situations where it cannot successfully convert the predicate into an SQL statement, resulting in runtime errors. Consider the following example:
let predicate = #Predicate<Note> { $0.id == noteID }
// Runtime error: Couldn't find \Note.id on Note with fields
Although the Note
type conforms to the PersistentModel protocol and its id
property is of type PersistentIdentifier, SwiftData cannot recognize the id
property when converting the predicate into an SQL instruction. In this case, developers should use the persistentModelID
property for comparison (in addition to properties corresponding to underlying data models, persistentModelID
is one of the few supported properties):
let predicate = #Predicate<Note> { $0.persistentModelID == noteID }
Additionally, trying to apply built-in methods on PersistentModel attributes may also encounter issues:
let predicate = #Predicate<Note> {
$0.name.localizedLowercase.starts(with: "abc".localizedLowercase)
}
// Runtime error: Couldn't find \Note.name.localizedLowercase on Note with fields
When SwiftData converts these expressions, many built-in methods are not applicable to PersistentModel attributes and are treated as KeyPath
by SwiftData. Therefore, developers may need to create additional properties (e.g., the lowercase version of an attribute) to adapt to this scenario.
Failure to Retrieve Expected Results
In some cases, Swift Predicate can compile and run without errors in the SwiftData environment but fail to retrieve the expected results because SwiftData converted incorrect SQL instructions. The following example illustrates this:
let predicate = #Predicate<Item> {
$0.note?.parent?.persistentModelID == rootNoteID
}
This predicate will not report any compilation or runtime errors, but it will ultimately fail to retrieve data correctly. To resolve this issue, we need to build the same logical predicate using a different approach that handles optional values correctly; see How to Handle Optional Values in SwiftData Predicates for details:
let predicate = #Predicate<Item> {
if let note = $0.note {
return note.parent?.persistentModelID == rootNoteID
} else {
return false
}
}
Therefore, conducting thorough and timely unit testing is particularly important when building SwiftData predicates. Through testing, developers can verify whether the predicate’s behavior matches their expectations and ensure the accuracy and stability of data retrieval.
Conclusion
Swift Predicate brings a powerful and flexible tool to Swift developers, making data filtering and logical judgments more intuitive and efficient. Through this article’s exploration, I hope that developers not only master the strong capabilities and usage methods of Swift Predicate but also find creative solutions when facing challenges and limitations.