Construct complex predicates for SwiftData

NSCompoundPredicate allows developers to combine more NSPredicate objects into one complex predicate. This mechanism is particularly suitable for scenarios that require data filtering based on multiple criteria. However, in the new Foundation framework restructured with Swift, the direct functionality fits NSCompoundPredicate is missing. This change presents a significant challenge for developers who want to build applications using SwiftData. This article aims to explore how to dynamically construct complex predicates that meet the requirements of SwiftData, using PredicateExpressionaccording to the valid technical conditions.

Challenge: Implementing flexible data filtering capabilities

During the development of the new version of the Health Notes application, I decided to replace the traditional Core Data with SwiftData in order to take advantage of the modern features of the Swift language. One of the key aspects of this data-centric application is to provide users with flexible and powerful data filtering capabilities. In this process, I faced a key challenge: how to construct diverse filtering schemes to retrieve user data. Here are some predicates used to retrieve Memo instances:

extension Memo {
  public static func predicateFor(_ filter: MemoPredicate) -> Predicate<Memo>? 
    var result: Predicate<Memo>?
    switch filter 
    case .filterAllMemo:
      // nil
      break
    case .filterAllGlobalMemo:
      result = #Predicate<Memo>  $0.itemData == nil 
    case let .filterAllMemoOfRootNote(noteID):
      result = #Predicate<Memo> 
        if let itemData = $0.itemData, let item = itemData.item, let note = item.note  note.parent?.persistentModelID == noteID
         else 
          return false
        
      
    case .filterMemoWithImage:
      result = #Predicate<Memo>  $0.hasImages 
    case .filterMemoWithStar:
      result = #Predicate<Memo>  $0.star 
    case let .filterMemoContainsKeyword(keyword):
      result = #Predicate<Memo> 
        if let content = $0.content 
          return content.localizedStandardContains(keyword)
         else 
          return false
        
      
    
    return result
  
}

In the first versions of the application, users could flexibly combine filtering conditions, such as including stars with images or filtering by specific notes and keywords. Previously, such dynamic combination requirements could easily be achieved using NSCompoundPredicate, which allows developers to dynamically combine multiple predicates and use the results as conditions to retrieve underlying data. However, after switching to SwiftData, I found that the proper functionality for dynamically combining Swift predicates was missing, which was a significant limitation to the core functionality of the application. Solving this problem is essential to maintain the functionality of the application and the satisfaction of its users.

Combining NSPredicate methods

NSCompoundPredicate offers a powerful way for developers to dynamically combine multiple NSPredicate instances into one complex predicate. Here is an example that shows how to use AND logical operator for combining two separate predicates a and b into a new predicate:

let a = NSPredicate(format: "name = %@", "fat")
let b = NSPredicate(format: "age < %d", 100)
let result = NSCompoundPredicate(type: .and, subpredicates: [a, b])

Moreover, since NSPredicate allows construction via arrays, developers can use this feature to manually construct new predicate expressions by combining them predicateFormat property. This method offers additional flexibility, allowing developers to directly manipulate and combine string representations of existing predicates:

let a = NSPredicate(format: "name = %@", "fat")
let b = NSPredicate(format: "age < %d", 100)
let andFormatString = a.predicateFormat + " AND " + b.predicateFormat // name == "fat" AND age < 100
let result = NSPredicate(format: andFormatString)

Unfortunately, while these methods are very effective and flexible in use NSPredicate, are not applicable to the Swift predicate. This means that when transitioning to using SwiftData, we need to explore new ways to achieve similar dynamic predicate combination functionality.

The challenge of combining Swift predicates

In the previous article, “Swift Predicate: Usage, Composition, and Considerations,” we explored the structure and composition of Swift predicates in detail. In essence, developers construct Predicate structure by declaring the corresponding types PredicateExpression protocol. Due to the potentially complex nature of this process, the Foundation provides #Predicate macro to simplify work.

When we build Swift predicates, #Predicate the macro automatically converts these operators into corresponding predicate expressions:

let predicate = #Predicate<People>  $0.name == "fat" && $0.age < 100 

After the macro is expanded, we can see the detailed composition of the predicate expression:

Foundation.Predicate<People>(
    PredicateExpressions.build_Conjunction(
        lhs: PredicateExpressions.build_Equal(
            lhs: PredicateExpressions.build_KeyPath(
                root: PredicateExpressions.build_Arg($0),
                keyPath: \.name
            ),
            rhs: PredicateExpressions.build_Arg("fat")
        ),
        rhs: PredicateExpressions.build_Comparison(
            lhs: PredicateExpressions.build_KeyPath(
                root: PredicateExpressions.build_Arg($0),
                keyPath: \.age
            ),
            rhs: PredicateExpressions.build_Arg(100),
            op: .lessThan
        )
    )
)

Here, PredicateExpressions.build_Conjunction creates a PredicateExpressions.Conjunction matching expression && operator. It concatenates two expressions that return Boolean values, forming a complete expression. In theory, if we could extract and combine expressions from Swift predicates individually, we could dynamically combine predicates based on AND logic.

Matching expression types || and ! are PredicateExpressions.Disjunction and PredicateExpressions.Negationrespectively.

Given that the Swift predicate provides expression attribute, it is natural to consider using this attribute to achieve such dynamic combinations:

let a = #Predicate<People1>  $0.name == "fat"
let b = #Predicate<People1>  $0.age < 10 
let combineExpression = PredicateExpressions.build_Conjunction(lhs: a.expression, rhs: b.expression)

However, attempting the code above results in a compilation error:

Type 'any StandardPredicateExpression<Bool>' cannot conform to 'PredicateExpression'

Deeper implementation research Predicate structure and PredicateExpressions.Conjunction reveals the limitations involved:

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


extension PredicateExpressions 
    public struct Conjunction<
        LHS : PredicateExpression,
        RHS : PredicateExpression
    > : PredicateExpression
    where
        LHS.Output == Bool,
        RHS.Output == Bool
    
        public typealias Output = Bool
        
        public let lhs: LHS
        public let rhs: RHS
        
        public init(lhs: LHS, rhs: RHS) 
            self.lhs = lhs
            self.rhs = rhs
        
        
        public func evaluate(_ bindings: PredicateBindings) throws -> Bool 
            try lhs.evaluate(bindings) && rhs.evaluate(bindings)
        
    
    
    public static func build_Conjunction<LHS, RHS>(lhs: LHS, rhs: RHS) -> Conjunction<LHS, RHS> 
        Conjunction(lhs: lhs, rhs: rhs)
    

The problem lies in expression a property that is of type any StandardPredicateExpression<Bool>which does not contain enough information to identify a particular one PredicateExpression type of implementation. From Conjunction requires exact types of left and right subexpressions to initialize, we cannot use expression property directly for the dynamic construction of new combined expressions.

Dynamic predicate construction strategy

Although we cannot use directly expression attributes of Swift predicates, there are still alternative ways to achieve the goal of dynamic predicate construction. The key lies in understanding how to extract or independently create expressions from existing predicates and use expression building tools such as build_Conjunction or build_Disjunction for generating new predicate expressions.

Being used #Predicate A macro for constructing expressions

Directly constructing predicates based on expression types can be quite cumbersome. A more practical method is to use it #Predicate macros, allowing programmers to indirectly build and extract predicate expressions. This approach was inspired by a contribution from community member nOk on Stack Overflow.

For example, consider the predicate constructed using #Predicate macro:

let filterByName = #Predicate<People>  $0.name == "fat" 

By examining the code expanded from the macro, we can extract the part of the code that forms the predicate expression.

Because constructing a PredicateExpression instance requires different parameters based on the type of expression, the following method cannot be used to generate a valid expression:

let expression = PredicateExpressions.build_Equal(
  lhs: PredicateExpressions.build_KeyPath(
      root: PredicateExpressions.build_Arg($0), // error: Anonymous closure argument not contained in a closure
      keyPath: \.name
  ),
  rhs: PredicateExpressions.build_Arg("fat")
)

Although we cannot directly replicate an expression to create a new one PredicateExpression for example, we can redefine the same expression using a closure:

let expression =  (people: PredicateExpressions.Variable<People>) in
  PredicateExpressions.build_Equal(
      lhs: PredicateExpressions.build_KeyPath(
          root: PredicateExpressions.build_Arg(people),
          keyPath: \.name
      ),
      rhs: PredicateExpressions.build_Arg("fat")
  )

Creating parameterized expression closures

Since the value of the right-hand side of an expression (such as "fat") may need to be dynamically assigned, we can design a closure that returns another expression closure. This allows the name to be specified at runtime:

let filterByNameExpression =  (name: String) in
   (people: PredicateExpressions.Variable<People>) in
    PredicateExpressions.build_Equal(
      lhs: PredicateExpressions.build_KeyPath(
        root: PredicateExpressions.build_Arg(people),
        keyPath: \.name
      ),
      rhs: PredicateExpressions.build_Arg(name)
    )
  

Using this closure that returns an expression, we can dynamically construct a predicate:

let name = "fat"
let predicate = Predicate<People>(filterByNameExpression(name))

Combining expressions to construct new predicates

Once we have defined closures that return expressions, we can use PredicateExpressions.build_Conjunction or other logical constructors to create new predicates containing complex logic:

// #Predicate<People>  $0.age < 10 
let filterByAgeExpression =  (age: Int) in
   (people: PredicateExpressions.Variable<People>) in
    PredicateExpressions.build_Comparison(
        lhs: PredicateExpressions.build_KeyPath(
            root: PredicateExpressions.build_Arg(people),
            keyPath: \.age
        ),
        rhs: PredicateExpressions.build_Arg(age),
        op: .lessThan
    )
  


// Combine new Predicate
let predicate = Predicate<People> 
  PredicateExpressions.Conjunction(
    lhs: filterByNameExpression(name)($0),
    rhs: filterByAgeExpression(age)($0)
  )

The complete process is as follows:

  1. Use #Predicate macro to construct the initial predicate.
  2. Extract the expression from the extended macro code and create a closure that generates the expression.
  3. Combine two expressions into a new one using a Boolean expression (such as Conjunction), which constructs a new instance of the predicate.
  4. Repeat the above steps if more expressions need to be combined.

This method, while requiring some additional steps to manually create and combine expressions, provides the ability to dynamically construct complex Swift predicates.

Dynamic combination of expressions

Having mastered the entire process from predicate to expression and back to predicate, I now need to create a method that can dynamically combine expressions and generate predicates according to the requirements of my current project.

Taking inspiration from an example provided by Jeremy Schonfeld on the Swift forums, we can construct a method to dynamically synthesize predicates to retrieve Memo data, as shown below:

extension Memo {
  static func combinePredicate(_ filters: [MemoPredicate]) -> Predicate<Memo> 
    func buildConjunction(lhs: some StandardPredicateExpression<Bool>, rhs: some StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> 
      PredicateExpressions.Conjunction(lhs: lhs, rhs: rhs)
    

    return Predicate<Memo>( memo in
      var conditions: [any StandardPredicateExpression<Bool>] = []
      for filter in filters 
        switch filter 
        case .filterAllMemo:
          conditions.append(Self.Expressions.allMemos(memo))
        case .filterAllGlobalMemo:
          conditions.append(Self.Expressions.allGlobalMemos(memo))
        case let .filterAllMemoOfRootNote(noteID):
          conditions.append(Self.Expressions.memosOfRootNote(noteID)(memo))
        case .filterMemoWithImage:
          conditions.append(Self.Expressions.memoWithImage(memo))
        case .filterMemoWithStar:
          conditions.append(Self.Expressions.memosWithStar(memo))
        case let .filterMemoContainsKeyword(keyword):
          conditions.append(Self.Expressions.memosContainersKeyword(keyword)(memo))
        
      
      guard let first = conditions.first else 
        return PredicateExpressions.Value(true)
      

      let closure: (any StandardPredicateExpression<Bool>, any StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> = 
        buildConjunction(lhs: $0, rhs: $1)
      

      return conditions.dropFirst().reduce(first, closure)
    )
  
}

Example of use:

let predicate = Memo.combinePredicate([.filterMemoWithImage,.filterMemoContainsKeyword(keyword: "fat")])

In the current implementation, due to Swift’s strong type system (each filtering logic corresponds to a specific type of predicate expression), constructing a flexible and generic combination mechanism similar to NSCompoundPredicate looks relatively complex. The challenge we face is how to maintain type safety while implementing a sufficiently flexible strategy for combining expressions.

For my application scenario, the primary requirement is to handle combinations Conjunction (logical I) type, which is relatively simple. If future requirements expand to include Disjunction (logical OR), we will need to introduce additional logical judgments and identifiers in the combining process to flexibly respond to different logical combination requirements while maintaining code readability and maintainability. This may require a more meticulous design to accommodate variable combination logic while ensuring that Swift’s type safety features are not sacrificed.

You can see the complete code here.

Implementation not applicable to SwiftData

Noah Kamara presented a code snippet in his Gist that provides similar capabilities NSCompoundPredicate, making the combination of Swift predicates simple and convenient. This method seems to be an intuitive and powerful solution:

let people = People(name: "fat", age: 50)
let filterByName = #Predicate<People>  $0.name == "fat" 
let filterByAge = #Predicate<People>  $0.age < 10 
let combinedPredicate = [filterByName, filterByAge].conjunction()
try XCTAssertFalse(combinedPredicate.evaluate(people)) // return false

Despite its appeal, we cannot adopt this method in SwiftData. Why does this seemingly perfect solution run into obstacles in SwiftData?

Noah Kamara introduced a custom type called VariableWrappingExpression in the code it implements StandardPredicateExpression a protocol for encapsulating an expression extracted from a predicate expression attribute. This encapsulation method does not involve a specific type of expression; it just calls the evaluation method of the encapsulated expression during predicate evaluation.

struct VariableWrappingExpression<T>: StandardPredicateExpression 
    let predicate: Predicate<T>
    let variable: PredicateExpressions.Variable<T>
    
    func evaluate(_ bindings: PredicateBindings) throws -> Bool 
        // resolve the variable
        let value = try variable.evaluate(bindings)
        
        // create bindings for the expression of the predicate
        let innerBindings = bindings.binding(predicate.variable, to: value)
        
        return try predicate.expression.evaluate(innerBindings)
    

Outside of a SwiftData environment, this dynamic combined predicate can work correctly because it directly relies on Swift predicate evaluation logic. However, SwiftData works differently. When filtering data using SwiftData, the evaluate method of the Swift predicate is not called directly. Instead, SwiftData parses the expression tree in the predicate expression attribute and converts those expressions into SQL statements to perform data retrieval in the SQLite database. This means that the evaluation process is achieved by generating and executing SQL statements, operating entirely at the database level.

Therefore, when SwiftData tries to convert this dynamic combined predicate into SQL statements, the inability to recognize the custom VariableWrappingExpression type results in a runtime error of unSupport Predicate.

If your scenario does not involve using predicates in SwiftData, Noah Kamara’s solution might be a good choice. However, if your requirement is to build dynamically combined predicates within the SwiftData environment, you may still need to rely on the strategy presented in this article.

Optimizing Swift predicate expression compilation efficiency

Constructing complex Swift predicate expressions can significantly affect compilation performance. The Swift compiler needs to parse and generate complex type information when processing these expressions. When expressions are too complex, the time it takes the compiler to perform type inference can increase dramatically, slowing down the compilation process.

Consider the following predicate example:

let result = #Predicate<Memo> 
  if let itemData = $0.itemData, let item = itemData.item, let note = item.note  else 
    return false
  

In this example, even minor code changes can cause the compile time for this file to exceed 10 seconds. This delay can also occur when generating expressions using closures. To alleviate this problem, we can use Xcode’s helper features to disambiguate the expression type. Using the +click option on the closure reveals the exact type of the expression, allowing us to provide a precise type label for the closure’s return value.

letters

let memosWithStar =  (memo: PredicateExpressions.Variable<Memo>) -> PredicateExpressions.KeyPath<PredicateExpressions.Variable<Memo>, Bool> in
  PredicateExpressions.build_KeyPath(
    root: PredicateExpressions.build_Arg(memo),
    keyPath: \.star
  )

The specific type of expression in the complex predicate above is shown as follows:

Determining the type of an expression can help the compiler process code faster, significantly improving overall compilation efficiency because it avoids the time the compiler spends on type inference.

This strategy is applicable not only in cases where it is necessary to combine predicates, but also in situations involving complex predicates without combination. By extracting expressions and specifying types explicitly, developers can significantly improve compile time for complex predicates, providing a more efficient development experience.

Conclusion

This article explored methods for dynamically constructing complex predicates within the SwiftData environment. While the current solutions may not be as elegant and simple as hoped, they provide a viable way for applications that rely on SwiftData to implement flexible querying capabilities without being limited by the lack of certain features.

Although we have found methods that work within current technological limitations, we still hope that future versions of Foundation and SwiftData will offer built-in support to make the construction of dynamic, complex predicates simpler and more intuitive. Improving these capabilities would further increase the practicality of Swift Predicate and SwiftData, allowing developers to more efficiently implement complex data processing logic.

Source link

Leave a Reply

Your email address will not be published. Required fields are marked *