How to Dynamically Construct Complex Predicates for SwiftData

Published on

Get weekly handpicked updates on Swift and SwiftUI!

NSCompoundPredicate allows developers to combine multiple NSPredicate objects into a single compound predicate. This mechanism is particularly suited for scenarios that require data filtering based on multiple criteria. However, in the new Foundation framework restructured with Swift, the direct functionality corresponding to NSCompoundPredicate is missing. This change poses a significant challenge for developers who wish to build applications using SwiftData. This article aims to explore how to dynamically construct complex predicates that meet the requirements of SwiftData, utilizing PredicateExpression, under the current technical conditions.

Update on March 11, 2024: Within less than a week of this article’s publication, Noah Kamara has developed a predicate merging solution compatible with SwiftData — CompoundPredicate. An introduction and explanation of this solution are provided at the end of the article.

Challenge: Implementing Flexible Data Filtering Capabilities

During the development of the new version of the Health Notes app, I decided to replace the traditional Core Data with SwiftData to leverage the modern features of the Swift language. One of the core 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 diversified filtering schemes for user data retrieval. Here are some predicates used for fetching Memo instances:

Swift
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 {
          return note.persistentModelID == noteID || 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 early versions of the application, users could flexibly combine filtering conditions, such as incorporating star marks with images, or filtering by specific notes and keywords. Previously, such dynamic combination requirements could be easily achieved using NSCompoundPredicate, which allows developers to dynamically combine multiple predicates and use the result as the retrieval condition for Core Data. However, after switching to SwiftData, I found the corresponding functionality to dynamically combine Swift Predicates was missing, which posed a significant constraint on the core functionality of the application. Addressing this issue is crucial for maintaining the functionality of the app and the satisfaction of its users.

Combining NSPredicate Methods

NSCompoundPredicate offers a powerful way for developers to dynamically combine multiple NSPredicate instances into a single compound predicate. Here is an example demonstrating how to use the AND logical operator to combine two separate predicates a and b into a new predicate:

Swift
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 for construction via strings, developers can utilize this feature to manually build new predicate expressions by combining the predicateFormat property. This method offers additional flexibility, enabling developers to directly manipulate and combine the string representations of existing predicates:

Swift
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 when using NSPredicate, they are not applicable to 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 the Predicate structure by declaring types that conform to the PredicateExpression protocol. Due to the potentially complex nature of this process, Foundation provides the #Predicate macro to simplify the operation.

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

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

Swift
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 expression corresponding to the && operator. It connects two expressions that return Boolean values, forming a complete expression. Theoretically, if we could extract and combine expressions from Swift Predicates individually, we could dynamically combine predicates based on the AND logic.

The expression types corresponding to || and ! are PredicateExpressions.Disjunction and PredicateExpressions.Negation, respectively.

Given that Swift Predicate provides an expression attribute, it’s natural to consider utilizing this attribute to achieve such dynamic combinations:

Swift
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 above code results in a compilation error:

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

A deeper exploration into the implementation of the Predicate structure and PredicateExpressions.Conjunction reveals the constraints involved:

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

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 issue lies in the expression property being of the type any StandardPredicateExpression<Bool>, which doesn’t contain sufficient information to identify the specific PredicateExpression implementation type. Since Conjunction requires the exact types of the left and right sub-expressions for initialization, we are unable to use the expression property directly to dynamically construct new combined expressions.

Dynamic Predicate Construction Strategy

Although we cannot directly utilize the expression attribute of Swift Predicate, there are still alternative ways to achieve the goal of dynamically constructing predicates. The key lies in understanding how to extract or independently create expressions from existing predicates and utilize expression builders such as build_Conjunction or build_Disjunction to generate new predicate expressions.

Utilizing the #Predicate Macro to Construct Expressions

Directly constructing predicates based on expression types can be quite cumbersome. A more practical method is to use the #Predicate macro, allowing developers to indirectly build and extract predicate expressions. This approach is inspired by the contribution of community member nOk on Stack Overflow.

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

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

image-20240304161732812

Since constructing an instance of PredicateExpression requires different parameters based on the type of expression, the following method cannot be used to generate the correct expression:

Swift
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 the expression to create a new PredicateExpression instance, we can redefine the same expression using a closure:

Swift
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 right-hand side value of the expression (such as "fat") might need to be dynamically assigned, we can design a closure that returns another expression closure. This allows the name to be determined at runtime:

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

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

Combining Expressions to Construct New Predicates

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

Swift
// #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 the #Predicate macro to construct the initial predicate.
  2. Extract the expression from the expanded macro code and create a closure that generates the expression.
  3. Combine two expressions into a new one using a Boolean logic expression (such as Conjunction), thereby constructing a new predicate instance.
  4. Repeat the above steps if multiple expressions need to be combined.

This method, although requiring some additional steps to manually create and combine expressions, provides a possibility for dynamically constructing complex Swift Predicates.

Dynamic Combination of Expressions

Having mastered the complete 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.

Drawing inspiration from an example provided by Jeremy Schonfeld on Swift Forums, we can construct a method to dynamically synthesize predicates for retrieving Memo data, as shown below:

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

Usage example:

Swift
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 predicate expression type), constructing a flexible and generic combination mechanism similar to NSCompoundPredicate appears 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 of the Conjunction (logical AND) type, which is relatively straightforward. If future requirements extend to include Disjunction (logical OR), we will need to introduce additional logical judgments and identifiers in the combination process to flexibly address different logical combination requirements while maintaining code readability and maintainability. This may require more meticulous design to adapt to the variable combination logic while ensuring not to sacrifice Swift’s type safety features.

The complete code can be viewed here.

An Implementation Inapplicable to SwiftData

The updated solution provided by Noah Kamara is referenced in the following section.

Noah Kamara showcased a snippet of code in his Gist that provides capabilities similar to NSCompoundPredicate, making the combination of Swift Predicates straightforward and convenient. This method appears to be an intuitive and powerful solution:

Swift
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 encounter obstacles in SwiftData?

Noah Kamara introduced a custom type named VariableWrappingExpression in the code, which implements the StandardPredicateExpression protocol to encapsulate the expression extracted from the Predicate’s expression attribute. This encapsulation method does not involve the specific type of the expression; it merely calls the evaluation method of the encapsulated expression during the predicate evaluation.

Swift
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 the SwiftData environment, this dynamically combined predicate can function correctly because it directly relies on the evaluation logic of Swift Predicate. However, SwiftData operates differently. When filtering data with SwiftData, it does not directly invoke the evaluation method of Swift Predicate. Instead, SwiftData parses the expression tree in the Predicate’s expression attribute and converts these expressions into SQL statements to perform data retrieval in the SQLite database. This means the evaluation process is accomplished by generating and executing SQL commands, operating entirely at the database level.

Therefore, when SwiftData attempts to convert this dynamically combined predicate into SQL commands, 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 might still need to rely on the strategy introduced in this article.

CompoundPredicate: A Predicate Merging Solution for SwiftData

In previous discussions, we unveiled a significant obstacle: developers were unable to directly use the expression attribute of Predicate to compose complex predicates due to the absence of specific predicate expression types. A few days after this article was published, Noah Kamara introduced an innovative solution—CompoundPredicate. This new strategy abandons the previous reliance on a custom StandardPredicateExpression implementation, opting instead for a type-casting strategy that effectively concretizes the information of expression. This improvement means developers can avoid the cumbersome process of manually extracting and combining expressions.

Noah Kamara designed the VariableReplacing protocol and implemented it for each type of PredicateExpression, as shown below:

Swift
public protocol VariableReplacing<Output>: PredicateExpression {
    associatedtype Output
    typealias Variable<T> = PredicateExpressions.Variable<T>
    func replacing<T>(_ variable: Variable<T>, with replacement: Variable<T>) -> Self
}

extension PredicateExpressions.Variable: VariableReplacing {
    public func replacing<T>(_ variable: Variable<T>, with replacement: Variable<T>) -> Self {
        if let replacement = replacement as? Self, variable.key == key {
            return replacement
        } else {
            return self
        }
    }
}

This method enables the automatic acquisition of the exact type of expressions inside the Predicate during the predicate combination process, facilitating an automated and efficient combination of predicates.

Swift
let notTooShort = #Predicate<Book> {
    $0.pages > 50
}

let notTooLong = #Predicate<Book> {
    $0.pages <= 350
}

let lengthFilter = [notTooShort, notTooShort].conjunction()

// Match books that are just the right length
let titleFilter = #Predicate<Book> {
    $0.title.contains("Swift")
}

// Match books that contain "Swift" in the title or
// are just the right length
let filter = [lengthFilter, titleFilter].disjunction()

Importantly, this approach does not introduce expressions unrecognized by SwiftData, so the combined predicates can be directly used with SwiftData. This solution can be considered the most ideal interim strategy provided to developers before an official, more comprehensive solution is released.

Optimizing Swift Predicate Expression Compilation Efficiency

Constructing complex Swift Predicate expressions can significantly impact compilation efficiency. The Swift compiler needs to parse and generate complex type information when processing these expressions. When the expressions are overly complex, the time required for the compiler to perform type inference can dramatically increase, slowing down the compilation process.

Consider the following predicate example:

Swift
let result = #Predicate<Memo> {
  if let itemData = $0.itemData, let item = itemData.item, let note = item.note {
    return note.persistentModelID == noteID || note.parent?.persistentModelID == noteID
  } else {
    return false
  }
}

In this example, even minor code changes can cause the compilation time for this file to exceed 10 seconds. This delay can also occur when generating expressions using closures. To alleviate this issue, we can utilize Xcode’s auxiliary features to clarify the type of the expression. Using Option + Click on the closure reveals the exact type of the expression, allowing us to provide a precise type annotation for the closure’s return value.

image-20240304183427052

Swift
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 the expression in the above complex predicate is shown as follows:

image-20240304202411294

Specifying the expression’s type can help the compiler process the code faster, significantly improving the overall compilation efficiency because it avoids the time the compiler spends on type inference.

This strategy is applicable not only in cases where predicates need to be combined but also in situations involving complex predicates without combination. By extracting expressions and specifying types explicitly, developers can significantly improve the compilation time for complex predicates, ensuring a more efficient development experience.

Conclusion

This article explored methods for dynamically constructing complex predicates within the SwiftData environment. Although the current solutions may not be as elegant and straightforward as we might hope, they do provide a viable way for applications relying on SwiftData to implement flexible data querying capabilities without being limited by the absence of certain features.

Despite having found methods that work within the current technological constraints, we still hope that future versions of Foundation and SwiftData will offer built-in support to make constructing dynamic, complex predicates simpler and more intuitive. Enhancing these capabilities would further augment the practicality of Swift Predicate and SwiftData, enabling developers to implement complex data processing logic more efficiently.

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