Swift Predicate: Usage, Composition, and Considerations
NSPredicate has always been a powerful tool provided by Apple, allowing developers to filter and evaluate data collections in a natural and efficient way by defining complex logical conditions. Over time, with the continuous maturation and development of the Swift language, in 2023, the Swift community undertook the task of reconstructing the Foundation framework using pure Swift language. In this significant update, a new Predicate feature based on Swift coding was introduced, marking a new stage in data processing and evaluation. This article aims to explore the usage, structure, and key considerations of Swift Predicate in practical development.
What Is a Predicate?
In modern software development, efficiently and accurately filtering and evaluating data is crucial. Predicates serve as a powerful tool, allowing developers to achieve this goal by defining logical conditions that return a Boolean value (true or false). This plays a pivotal role not only in filtering collections or finding specific elements within a collection but also serves as the foundation for data processing and business logic implementation.
Although Apple's NSPredicate
offers this capability, it relies on Objective-C syntax, carries the risk of runtime errors, and faces platform limitations, which restrict its applicability and flexibility in various environments.
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 Improvements of Swift Predicate
To overcome these limitations and expand the application range of predicates, the Swift community restructured the Foundation framework, introducing a Predicate feature based on the Swift language. This new feature not only eliminates the dependency on Objective-C but also simplifies the predicate construction process through Swift's macro functionality, 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 constructed a logical condition using the #Predicate
macro. This construction method is very similar to writing closure code, allowing developers to naturally build more complex logic, such as predicates that include multiple conditions:
let predicate = #Predicate<MyObject>{ object in
object.name == "fat" && object.name.count < 3
}
try XCTAssertTrue(predicate.evaluate(object)) // false
Moreover, the current MyObject
does not need to inherit from NSObject
or use the @objc
annotation on its properties to support KVC. Of course, 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 and platform compatibility: Supports cross-platform use, such as on Linux and Windows.
- Type safety: Utilizes Swift's type checking to reduce runtime errors.
- Development efficiency: Benefits from Xcode support, enhancing the speed and accuracy of code writing.
- Syntax flexibility: Provides greater freedom of expression, not limited by the syntax rules of Objective-C.
- Versatility: Applicable to all Swift types, not just those inheriting from NSObject.
- Support for modern Swift features: Supports modern Swift features like Sendable and Codable, making it more suitable for the current Swift programming paradigm.
With these improvements, Swift Predicate not only optimizes the developer's workflow but also opens new avenues for the expansion and growth of the Swift ecosystem.
Main Components of Swift Predicate
Before diving into the usage and considerations of Swift Predicate, it's essential to understand its structure. Specifically, we should comprehend what elements constitute a Predicate and how the Predicate macro functions.
PredicateExpression Protocol
The PredicateExpression
protocol (or the concrete types adhering to this protocol) defines the conditional logic of expressions. For instance, it can represent a "less than" condition, containing specific logical judgments to determine whether an input value is less than a given value. This protocol is a crucial part of constructing 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 provides a series of predefined expression types that adhere 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 code for PredicateExpressions.Value
is shown below:
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
structure encapsulates a value directly and simply returns the encapsulated value when its evaluate
method is called. This makes Value
an effective means of representing constant values in predicate expressions.
It is worth noting that the
evaluate
method ofPredicateExpression
can return any type of value, not limited to Boolean types.
Furthermore, if we need to define an expression that represents the condition 3 < 4
, the corresponding code example is as follows:
let express = PredicateExpressions.build_Comparison(
lhs: PredicateExpressions.Value(3),
rhs: PredicateExpressions.Value(4),
op: .lessThan
)
This code snippet will generate an instance that conforms to the PredicateExpression
protocol:
PredicateExpressions.Comparison<PredicateExpressions.Value<Int>, PredicateExpressions.Value<Int>>
When the evaluate
method of this instance is called, it will return a Boolean value, indicating the result of the judgment.
Through the method of nesting expressions, developers can construct extremely complex logical judgments. At the same time, the type expressions generated become correspondingly complex.
The Predicate Structure
Even when defined via macros, the core of Swift Predicate is still the Predicate
structure. This structure is responsible for binding logical conditions (implemented by PredicateExpression
) with specific values. This mechanism allows Predicate
to instantiate specific logical conditions 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>())
self.expression = builder(repeat each variable)
}
public func evaluate(_ input: repeat each Input) throws -> Bool {
try expression.evaluate(
.init(repeat (each variable, each input))
)
}
}
Key features include:
- Boolean value return limitation:
Predicate
specifically deals with expressions that return Boolean values. This means that the final result of a complex expression tree must be a Boolean value to facilitate logical judgment. - Construction process: When constructing a
Predicate
, a closure must be provided. This closure receivesPredicateExpressions.Variable
type parameters and returns an expression that follows theStandardPredicateExpression<Bool>
protocol. - The
StandardPredicateExpression
protocol: This is an extension of thePredicateExpression
protocol, requiring the expression to also follow theCodable
andSendable
protocols. Currently, only the expressions preset by the Foundation are allowed to comply with this protocol.
public protocol StandardPredicateExpression<Output> : PredicateExpression, Codable, Sendable {}
Advanced features of construction closures and variable properties: Utilizing Swift's Parameter Packs feature,
Predicate
supports creating predicates that can handle multiple generic parameters simultaneously, a functionality not available inNSPredicate
.
For example, utilizing the Predicate
structure and the PredicateExpression
protocol, we can construct a predicate example that compares two integers n
and m
(to check if n < m
):
// Define a closure: Compare whether two integer values satisfy the "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
)
}
// Construct a Predicate instance using the express closure,
// where express defines the evaluation logic, i.e., whether the first parameter is less than the second parameter
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))
Predicate Macros
Compared to constructing NSPredicate
with strings, directly using PredicateExpression
and Predicate
structures to build predicates offers advantages such as type-safe checks and code autocompletion. However, this method is less efficient and the complexity in writing and reading the code is relatively higher, undeniably increasing the mental load on developers when creating predicates.
To reduce this complexity, Foundation introduced the Predicate macro (#Predicate
), aiming to assist developers in building Swift Predicates in a more concise and efficient manner.
Taking the construction of a predicate to determine if n < m
as an example again, using macros can significantly simplify the process:
let predicate = #Predicate<Int,Int>{ $0 < $1}
let n = 3
let m = 4
try XCTAssertTrue(predicate.evaluate(n,m)) // true
In Xcode, by examining the code generated after the macro expansion, we can clearly see how the macro simplifies the logic that previously required a significant amount of code.
The implementation code for the Predicate macro, which is about 1200 lines long, supports only the predefined predicate expressions in Foundation and specific methods that can be used within predicates. During the conversion process, an error will be thrown if an unsupported expression type, method, or a corresponding expression cannot be found.
By introducing the Predicate macro, Swift offers a concise and powerful way to construct complex predicate logic, allowing developers to directly build complex logical judgments in almost native Swift code, significantly enhancing the readability and maintainability of the code. More importantly, the use of the Predicate macro greatly reduces the mental burden on developers when constructing complex queries, making the development workflow smoother and more efficient.
Tips and Considerations for Building Swift Predicates
Having understood the structure of Swift Predicate, we can more accurately grasp the limitations and techniques involved in building Predicates.
Limitations With Global Functions
When building predicates using the Predicate macro, it's important to note that the macro's conversion logic translates closure code into the predefined PredicateExpress
expressions of Foundation. The current implementation of PredicateExpress
does not support direct access to global functions, methods, or data returned by type properties. Therefore, when using such data to construct predicates, you should first capture the needed data using the let
keyword. For example:
func now() -> Date {
.now
}
let predicate = #Predicate<Date>{ $0 < now() } // Global functions are not supported in this predicate
The correct approach is to first capture the function or property value and then construct the predicate:
let now = now()
let predicate = #Predicate<Date>{ $0 < now }
Similarly, there are restrictions on directly accessing type properties:
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 expressions only support KeyPaths, for instance, properties, and do not support type properties.
Limitations on Instance Methods
Similar to the previous point, directly calling instance methods (such as .lowercased()
) within predicates is also not supported.
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, one should use the built-in methods supported by Swift Predicate, for example:
let predicate = #Predicate<A>{ $0.name.localizedLowercase == "fat" }
The collection of built-in methods currently available is relatively limited, including but not limited to: contains
, allSatisfy
, flatMap
, filter
, subscript
, starts
, min
, max
, localizedStandardContains
, localizedCompare
, caseInsensitiveCompare
, etc. Developers should regularly consult Apple's official documentation or directly refer to the source code of the Predicate macro to gain a comprehensive understanding of the latest supported methods.
Given that the current set of built-in methods is not comprehensive, some common predicate construction methods in NSPredicate might not yet be supported in Swift Predicate. This means that although Swift Predicate provides powerful tools for constructing type-safe and expressive predicates, developers may still need to look for alternative solutions or wait for future extensions to cover a broader range of use cases.
Support for Predicates With Multiple Generic Parameters
Thanks to the Parameter Packs feature, Swift Predicate offers developers greater flexibility by allowing the definition of predicates that can accept multiple generic parameters. This capability significantly expands the applicability of predicates, enabling developers to easily handle a wide range of complex condition assessments.
As demonstrated in the earlier example of n < m
, this approach is not limited to comparisons of parameters of a single type but can be extended to parameters of multiple different types, further enhancing the expressive power and flexibility of Swift Predicate compared to traditional Swift higher-order functions. This feature makes Swift Predicate a powerful tool for constructing complex logical 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 Mechanism
The design of Swift Predicate allows developers to construct complex predicate logic through nesting predicate expressions. This capability makes it more intuitive and concise to implement condition judgments that typically rely on subqueries in NSPredicate
. Nowadays, these complex logical expressions can be more in line with Swift programming conventions, enhancing the readability and maintainability of the code.
struct Address {
var city:String
}
struct People {
var address:[Address]
}
let predicate = #Predicate<People>{ people in
people.address.contains { address in
address.city == "Dalian"
}
}
Support for Building Predicates With Optional Values
Swift Predicate supports the use of optional value types, which is a significant advantage when dealing with optional properties commonly found in data models. This support allows developers to handle optional values directly within the predicate logic, making the writing of predicate expressions more straightforward and clear.
For example, the following example demonstrates how to handle an optional string property in Swift Predicate, filtering 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 interested in gaining a deeper understanding of how to efficiently handle optional values in Swift Predicate, it is recommended to read How to Handle Optional Values in SwiftData Predicates.
Swift Predicate Is Thread-Safe
The design of Swift Predicate has taken into account the needs of concurrent programming, ensuring its thread safety. By adhering to the Sendable
protocol, Swift Predicate supports safe passage between different execution contexts. This feature significantly enhances the practicality of Swift Predicate, making it adaptable to the extensive demands for concurrency and asynchronous programming in modern Swift applications.
Swift Predicate Supports Serialization and Deserialization
By implementing the Codable
protocol, Swift Predicate can be converted into JSON or other formats, thereby achieving data serialization and deserialization. This feature is particularly important for scenarios that require saving predicate conditions to a database or configuration file or need to share predicate logic between client and server.
The following example demonstrates how to serialize a Predicate
instance into 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)
Be Mindful of the Impact on Compilation Time When Constructing Complex Predicates
Similar to the situation encountered when building interfaces in SwiftUI, when constructing complex Swift Predicate expressions, the Swift compiler needs to process and convert them into a vast and complex type. During this process, once the complexity of the expression exceeds a certain threshold, the time the compiler spends on type inference will significantly increase.
If compilation time is affected, developers might consider placing complex predicate declarations in separate Swift files. This approach not only helps with organizing and managing code but can also somewhat reduce the need for recompilation triggered by frequent modifications to other parts of the code.
Custom Predicate Expressions for Building Predicates Are Not Yet Supported
Currently, although developers can create custom expression types that conform to the PredicateExpress
protocol, the official guidelines do not allow these custom expressions to conform to the StandardPredicateExpression
protocol. Therefore, even though custom expression types can be created, these custom expressions cannot be directly used when constructing predicates.
Even if developers mark their custom expressions as conforming to the StandardPredicateExpression
protocol, the Predicate macro currently only supports the use of StandardPredicateExpression
implementations predefined in Foundation. This limitation prevents developers from using custom expressions within the Predicate macro, thereby hindering the ability to construct predicates with custom expressions.
Combining Multiple Predicates Into More Complex Ones Is Not Yet Supported
When constructing NSPredicate
, developers can flexibly combine multiple simple-logic NSPredicates
into more complex predicates using NSCompoundPredicate
. However, Swift Predicate currently does not offer a similar capability, which to some extent limits the developers' flexibility in constructing complex predicates.
In subsequent articles, I will introduce how to dynamically construct complex predicates using PredicateExpress
at the current stage to meet specific needs. This approach may provide an alternative solution in some cases to address the current limitation of not supporting the combination of multiple predicates.
Applying Swift Predicate in SwiftData
Using Predicates as data retrieval conditions in SwiftData and Core Data is a common scenario for many developers. Understanding how SwiftData processes Swift Predicates is crucial to maximizing their utility.
Interaction Mechanism Between SwiftData and Swift Predicate
When setting a Predicate for a FetchDescriptor
in SwiftData, SwiftData does not directly use the evaluation mechanism of Swift Predicate. Instead, it parses the expression tree defined by the Predicate's express
attribute and converts these expressions into SQL statements to retrieve data from the SQLite database. This means that, in the SwiftData environment, the evaluation operation is actually carried out through SQL commands from the SQLite database, occurring on the database side.
Limitations on Predicate Parameters in SwiftData
SwiftData requires each FetchDescriptor to correspond to a specific data entity. Therefore, when constructing a predicate, the corresponding entity type becomes the predicate's sole parameter, which is crucial for effectively utilizing SwiftData to build predicates.
Expression Capability Limitations of Predicates in SwiftData
Although Swift Predicate provides a powerful framework for data filtering, its expressive capability within the SwiftData environment is somewhat limited compared to using NSPredicate with Core Data. Faced with specific filtering needs, developers may need to resort to indirect methods, such as performing multiple filters or pre-adding specific properties to entities to adapt to the current predicate capabilities. For example, since the built-in starts
method is case-sensitive, to achieve case-insensitive matching, it is recommended to create a preprocessed version of the filtering property (such as converting it all to lowercase) to support more flexible data retrieval.
Runtime Error With Predicate
Even if a Swift Predicate compiles without errors, when using SwiftData to retrieve data, you might encounter situations where it cannot be successfully converted into an SQL statement, resulting in a runtime error. 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 type is also a PersistentIdentifier, SwiftData fails to recognize the id
property when converting the predicate into an SQL command. In this situation, developers should use the persistentModelID
property for comparison (during predicate conversion, persistentModelID
is one of the few especially supported properties besides the underlying data model's corresponding properties):
let predicate = #Predicate<Note> { $0.persistentModelID == noteID }
Furthermore, attempting to apply a set of built-in methods on properties of the PersistentModel might also pose a problem:
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 similarly unsuitable for properties of the PersistentModel, and SwiftData erroneously treats them as a KeyPath
. Thus, at this stage, developers might need to create additional properties (for example, a lowercase version of a property) to accommodate such scenarios.
Situations Where Expected Results Are Not Obtained
In certain cases, a Swift Predicate can compile and run smoothly within the SwiftData environment without producing any errors, yet it may fail to retrieve the expected results due to SwiftData incorrectly translating the SQL commands. The following example illustrates this point:
let predicate = #Predicate<Item> {
$0.note?.parent?.persistentModelID == rootNoteID
}
This predicate does not present any issues during compilation and runtime but ultimately fails to correctly fetch data. To address this problem, we need to construct the predicate with the same logic in a different way, ensuring it can correctly handle optional values. For more details, please see the article How to Handle Optional Values in SwiftData Predicates:
let predicate = #Predicate<Item> {
if let note = $0.note {
return note.parent?.persistentModelID == rootNoteID
} else {
return false
}
}
For this reason, conducting comprehensive and timely unit tests becomes particularly important when building SwiftData predicates. Through testing, developers can verify whether the behavior of the predicates matches the expectations, ensuring the accuracy of data retrieval and the stability of the application.
Summary
Swift Predicate offers Swift developers a powerful and flexible tool, making data filtering and logical judgment more intuitive and efficient. Through the discussion in this article, I hope developers can not only fully grasp the powerful functionalities and usage methods of Swift Predicate but also find creative solutions when facing challenges and limitations.