肘子的 Swift 记事本

Swift Predicate: Usage, Composition, and Considerations

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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.

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
let express = PredicateExpressions.Value(4)

The implementation code for PredicateExpressions.Value is shown below:

Swift
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 of PredicateExpression 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:

Swift
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:

Swift
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:

Swift
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 receives PredicateExpressions.Variable type parameters and returns an expression that follows the StandardPredicateExpression<Bool> protocol.
  • The StandardPredicateExpression Protocol: This is an extension of the PredicateExpression protocol, requiring the expression to also follow the Codable and Sendable protocols. Currently, only the expressions preset by the Foundation are allowed to comply with this protocol.
Swift
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 in NSPredicate.

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):

Swift
// 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:

Swift
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.

image-20240225182917655

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:

Swift
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:

Swift
let now = now()
let predicate = #Predicate<Date>{ $0 < now }

Similarly, there are restrictions on directly accessing type properties:

Swift
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.

Swift
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:

Swift
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.

Swift
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.

Swift
struct Address {
  var city:String
}
struct People {
  var address:[Address]
}

let predicate = #Predicate<People>{ people in
  people.address.contains { address in
    address.city == "Dalian"
  }
}

When the data model includes an optional to-many relationship, the above methods do not work.

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 directly handle optional values 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:

Swift
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:

Swift
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:

Swift
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 specially supported properties besides the underlying data model’s corresponding properties):

Swift
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:

Swift
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:

Swift
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:

Swift
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.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways