Skip to content

Instantly share code, notes, and snippets.

@NoahKamara
Last active October 29, 2024 21:19
Show Gist options
  • Save NoahKamara/d8660881b2ef8d6be18b8e26ed349bb7 to your computer and use it in GitHub Desktop.
Save NoahKamara/d8660881b2ef8d6be18b8e26ed349bb7 to your computer and use it in GitHub Desktop.
Combining New Swift Predicates
import Foundation
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
/// Allows you to use an existing Predicate as a ``StandardPredicateExpression``
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)
}
}
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
extension Predicate {
typealias Expression = any StandardPredicateExpression<Bool>
/// Returns the result of combining the predicates using the given closure.
///
/// - Parameters:
/// - predicates: an array of predicates to combine
/// - nextPartialResult: A closure that combines an accumulating expression and
/// an expression of the sequence into a new accumulating value, to be used
/// in the next call of the `nextPartialResult` closure or returned to
/// the caller.
/// - Returns: The final accumulated expression. If the sequence has no elements,
/// the result is `initialResult`.
static func combining<T>(
_ predicates: [Predicate<T>],
nextPartialResult: (Expression, Expression) -> Expression
) -> Predicate<T> {
return Predicate<T>({ variable in
let expressions = predicates.map({
VariableWrappingExpression<T>(predicate: $0, variable: variable)
})
guard let first = expressions.first else {
return PredicateExpressions.Value(true)
}
let closure: (any StandardPredicateExpression<Bool>, any StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> = {
nextPartialResult($0,$1)
}
return expressions.dropFirst().reduce(first, closure)
})
}
}
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
public extension Array {
/// Joins multiple predicates with an ``PredicateExpressions.Conjunction``
/// - Returns: A predicate evaluating to true if **all** sub-predicates evaluate to true
func conjunction<T>() -> Predicate<T> where Element == Predicate<T> {
func buildConjunction(lhs: some StandardPredicateExpression<Bool>, rhs: some StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> {
PredicateExpressions.Conjunction(lhs: lhs, rhs: rhs)
}
return Predicate<T>.combining(self, nextPartialResult: {
buildConjunction(lhs: $0, rhs: $1)
})
}
/// Joins multiple predicates with an ``PredicateExpressions.Disjunction``
/// - Returns: A predicate evaluating to true if **any** sub-predicate evaluates to true
func disjunction<T>() -> Predicate<T> where Element == Predicate<T> {
func buildConjunction(lhs: some StandardPredicateExpression<Bool>, rhs: some StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> {
PredicateExpressions.Disjunction(lhs: lhs, rhs: rhs)
}
return Predicate<T>.combining(self, nextPartialResult: {
buildConjunction(lhs: $0, rhs: $1)
})
}
}
@NoahKamara
Copy link
Author

Explanation:

TLDR: This Gist implements two methods conjunction() and disjunction() on Array<Predicate<T>> for combining predicates

This is the explanation, copied from this SO question. i recommend reading the thread it for further context
the reason for the error and subsequent crash is that the PredicateExpressions.Variable is used to resolve the Predicate input.

This is how Predicate Variable reolving works internally:

The Predicate you create looks something like this (when expanded):

let predicate = Foundation.Predicate<Person>({
	PredicateExpressions.build_contains(
		PredicateExpressions.build_KeyPath(
			root: PredicateExpressions.build_Arg($0),
			keyPath: \.name
		),
		PredicateExpressions.build_Arg("Luke")
	)
})

The closure takes parameters of PredicateExpressions.Variable<Input> which you need to pass as an argument to your expression $0

This variable will be unique for every predicate you created, meaning when you combine them using just the predicate.expression property, each expression has a distinct Variable leading to a unresolved Variable error.

I created a custom StandardPredicateExpression that takes a predicate and a variable and will do the following in it's evaluate method:

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

		// evaluate the expression with those bindings
		return try predicate.expression.evaluate(innerBindings)
	}
}

Extending the excellent work by [@orgtre][1] to create a solution that takes an array of predicates and a closure for combining them

extension Predicate {    
    typealias Expression = any StandardPredicateExpression<Bool>
    
    static func combining<T>(
        _ predicates: [Predicate<T>],
        nextPartialResult: (Expression, Expression) -> Expression
    ) -> Predicate<T> {
        return Predicate<T>({ variable in
            let expressions = predicates.map({
                VariableWrappingExpression<T>(predicate: $0, variable: variable)
            })
            guard let first = expressions.first else {
                return PredicateExpressions.Value(true)
            }
            
            let closure: (any StandardPredicateExpression<Bool>, any StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> = {
                nextPartialResult($0,$1)
            }
            
            return expressions.dropFirst().reduce(first, closure)
        })
    }
}

let compound = Predicate<Person>.combine([predicateA, predicateB]) {
    func buildConjunction(lhs: some StandardPredicateExpression<Bool>, rhs: some StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> {
        PredicateExpressions.Conjunction(lhs: lhs, rhs: rhs)
    }
    
    return Predicate<T>.combining(self, nextPartialResult: {
        buildConjunction(lhs: $0, rhs: $1)
    })
}

@fatbobman
Copy link

@NoahKamara Hello, thank you for your work.
I tried to merge the following two predicates into one. Both p1 and p2 can retrieve data normally. However, after merging, I encounter a runtime error: “Query encountered an error: SwiftData.SwiftDataError(_error: SwiftData.SwiftDataError._Error.unsupportedPredicate)”
What could be the reason?

let now = Date.now
let p1 = #Predicate<Item>{ $0.timestamp >= now }
let p2 = #Predicate<Item>{ $0.name == "abc"}
let a = [p1,p2]
let p3 = a.conjunction()

@Query(filter:p3) private var items: [Item]

@fatbobman
Copy link

@NoahKamara , I found the reason. There is no issue with your code; the composed predicate can correctly evaluate normal Swift types. However, after converting the predicate into a VariableWrappingExpression, SwiftData fails to correctly translate the transformed predicate into SQL statements.

@NoahKamara
Copy link
Author

NoahKamara commented Mar 1, 2024

Ah thanks for adding this info. Seems like there simply isn't a way to do this yet.
I might try later to implement a tranforming function similar to what apple outlines here which would transform the expression and build a new one that does not require custom expressions

@NoahKamara
Copy link
Author

@fatbobman
I've started work on a library that with a protocol for traversing a predicate and replacing a variable with another.
I've implemented the protocol for Equal and a few other expressions and added tests showing that this works with SwiftData

This solves the unsupportedPredicate issue because there are no custom predicates.
Implementation for all expressions should be fairly easy. It is essentially a recursive call on all child predicates that either ends in a predicate that does not support conversion, a leaf (returning a copy of self), or a variable, in which case it returns the replacement variable.

I plan on further building this proof of concept into a full library

@fatbobman
Copy link

@NoahKamara I'm really glad to hear that, and I wish you success soon! I've just published an article discussing the challenges currently faced when composing complex predicates for SwiftData. If your library is completed, I would be very happy to recommend it to everyone.
https://fatbobman.com/en/posts/how-to-dynamically-construct-complex-predicates-for-swiftdata/

@NoahKamara
Copy link
Author

@fatbobman just an fyi: the initial loading time for your website is ~25 secs I'm guessing this is due to my location (Germany)

@NoahKamara
Copy link
Author

@fatbobman I have added all the Expressions that are mention in the overview page of Predicate
https://github.com/NoahKamara/CompoundPredicate

@fatbobman
Copy link

@NoahKamara I had considered this implementation approach before, but it felt like there was too much work to be done, so I gave up. Bravo, you've done an impressive job. I will update the article later and recommend your achievements to more users.

@NoahKamara
Copy link
Author

@fatbobman thanks for the mention :) It was actually way less work than i thought. basically just call the initializer of each predicate with the properties of the self instance. since all of them must be expressions as well i can just recursively call my implementation of replacing(variable, replacement) and only need to add proper logic for the Variable expression

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment