nOk
nOk

Reputation: 3439

Combining Predicate in SwiftData

I'm trying to combine multiple Predicates of the Type with and / or. Previously with CoreData and NSPredicate I'd just do this:

let predicate = NSPredicate(value: true)
let predicate2 = NSPredicate(value: false)
let combinedPred = NSCompoundPredicate(type: .or, subpredicates: [predicate, predicate2])

Is there a comparable way to do this using SwiftData and #Predicate? And if not, how could I implement a way to create partial conditions beforehand and combine them in a predicate later?

The only way I've found of doing this as an expression is like this, but this would make my predicate hundredths of lines long

let includeOnlyFavorites = true
#Predicate { includeOnlyFavorites ? $0.isFavorite : true }

Context:

I'm developing an App that allows users to save and query items using shortcut actions. The items are stored using SwiftData and queried using EntityPropertyQuery

Apple implements the Query properties like this:

static var properties = QueryProperties {
    Property(\BookEntity.$title) {
        EqualToComparator { NSPredicate(format: "title = %@", $0) }
        ContainsComparator { NSPredicate(format: "title CONTAINS %@", $0) }
    }
}

and later combines the predicates with NSCompoundPredicate.


Tried and failed:

Closure with Bool return:

let isFavorite = { (item: Item) in item.isFavorite }
let predicate = #Predicate<Item> { isFavorite($0) }

I also thought i might be able to use StandardPredicateExpression in another predicate because in the documentation it reads:

"A component expression that makes up part of a predicate, and that's supported by the standard predicate type." but there are no further explanations on this type

Upvotes: 14

Views: 4649

Answers (4)

AcBap
AcBap

Reputation: 99

Below is a solution for my use case with SwiftData. Initially, all the checks were done in one expression.

   static func predicate(searchText: String, entryType: SafeBox.EntryTypeId = .all) -> Predicate<SafeBoxEntryModel> {
        
        // Using "SafeBox.CategoryId.acctAndId.rawValue" in the expression causes this error,
        // "Cannot infer key path type from context; consider explicitly specifying a root type".
        let acctCat = SafeBox.CategoryId.acctAndId.rawValue
            
        let pred1 = #Predicate<SafeBoxEntryModel> { sbEntry in
            sbEntry.category == acctCat
        }
        
        let pred2 = #Predicate<SafeBoxEntryModel> { sbEntry in
            sanitizedSearchText.isEmpty
        }
        
        // Look at actual attrs.
        let pred3 = #Predicate<SafeBoxEntryModel> { sbEntry in
            sbEntry.uniqueKey.localizedStandardContains(sanitizedSearchText)
            || sbEntry.desc.localizedStandardContains(sanitizedSearchText)
            || sbEntry.username.localizedStandardContains(sanitizedSearchText)
            || sbEntry.email.localizedStandardContains(sanitizedSearchText)
        }
        
        let pred4 = #Predicate<SafeBoxEntryModel> { sbEntry in
            sbEntry.searchableBlob.localizedStandardContains(sanitizedSearchText)
            || sbEntry.note.localizedStandardContains(sanitizedSearchText)
        }

        return #Predicate<SafeBoxEntryModel> { sbEntry in
            pred1.evaluate(sbEntry)
            && (pred2.evaluate(sbEntry) || pred3.evaluate(sbEntry) || pred4.evaluate(sbEntry))
        }
    }

Upvotes: 0

nOk
nOk

Reputation: 3439

I've build a library that implements this for predicate expression supported by SwiftData. (If you're targeting iOS 17.4 (or equivalent) there are also instructions on how to do it the new builtin way)

https://github.com/NoahKamara/CompoundPredicate/

Old Answer / Implementation details of CompoundPredicate:

Building on the answer by @orgtre

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

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

Check this Gist for an implementation

Upvotes: 3

orgtre
orgtre

Reputation: 235

Generalizing the answer by nOk a bit using a suggestion on the Swift Forums I've had success with this procedure for combining SwiftData predicates:

First write out each predicate using the #Predicate macro and make sure they work individually (failures at runtime happen frequently even without compilation errors). Here is an example of a Predicate:

let predicate1 = #Predicate<Item> { item in
    ...
}

Next, right-click on each use of #Predicate and select "Expand Macro". This might take quite some time and if the expansion doesn't appear (this happened often for me), I've found that it might help to simplify the predicate by splitting it up, removing a few "||" or "&&" conditions, rewriting it in other ways, or transferring it to an essentially equivalent simpler dummy project.

An expanded #Predicate macro looks something like this:

let predicate1 = Foundation.Predicate<Item>({ item in
    PredicateExpressions.xxx(
      ...
    )
})

Copy everything in the expansion except the first and last line and assign it to a constant:

let expression1 = PredicateExpressions.xxx( ... )

Repeat for each predicate. After seeing a few of these expansions you might also try to directly modify or write the PredicateExpression manually (this is essential for more complex cases the #Predicate macro can't handle).

Once you have a bunch of predicate expressions stored in constants, say named expression1 to expression3, you can conditionally combine them into one Predicate, for example by forming their conjunction like this:

func makePredicate(useExpression1: Bool, useExpression2: Bool, useExpression3: Bool) -> Predicate<Item> {

    func buildConjunction(lhs: some StandardPredicateExpression<Bool>, rhs: some StandardPredicateExpression<Bool>) -> any StandardPredicateExpression<Bool> {
        PredicateExpressions.Conjunction(lhs: lhs, rhs: rhs)
    }

    return Predicate<Item>({ item in
        var conditions: [any StandardPredicateExpression<Bool>] = []

        let expression1 = PredicateExpressions.xxx( ... )

        let expression2 = PredicateExpressions.xxx( ... )

        let expression3 = PredicateExpressions.xxx( ... )

        if useExpression1 {
            conditions.append(expression1)
        }
        if useExpression2 {
            conditions.append(expression2)
        }
        if useExpression3 {
            conditions.append(expression3)
        }

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

let combinedPredicate = makePredicate(useExpression1: true, useExpression2: true, useExpression3: true)

A few notes:

  • This assumes that each predicate only has one argument of type Item named item.
  • Replace PredicateExpressions.Conjunction with PredicateExpressions.Disjunction in the function buildConjunction (and preferably rename it) to combine the predicates using logical OR instead.
  • I have found no way to directly use each predicates related expression, if I instead use let expression1 = predicate1.expression it just crashes at runtime.
  • By circumventing the #Predicate macro like this and directly using PredicateExpressions one can construct much more advanced predicates than the #Predicate macro can handle. If one still runs into a "unable to type-check this expression in reasonable time"-error one could just split out any PredicateExpression into a further constant.

Upvotes: 8

nOk
nOk

Reputation: 3439

Wow. This was hard, but i've found a solution to my problem:

If we use PredicateExpression instead of Predicate we can later build a predicate like this:

let expression = PredicateExpressions.Value(true)

let predicate = Predicate<String>({ input in
    expression
})

The next step was injecting the input. I chose to just create a closure that takes a variable and returns an expression (because the initialiser that takes expressions does not provide a value, but a variable)

let variableExp = { (variable: PredicateExpressions.Variable<String>) in
    let value = PredicateExpressions.Value("Hello There")
    
    return PredicateExpressions.Equal(
        lhs: variable,
        rhs: value
    )
}

let variablePredicate = Predicate<String>({ input in
    variableExp(input)
})

Example with Model

Swift Data Model:

@Model
class Book {
    @Attribute
    var title: String
    
    @Attribute
    var lastReadDate: Date
    
    init(title: String, lastReadDate: Date) {
        self.title = title
        self.lastReadDate = lastReadDate
    }
}

Create Closures for the expressions we want to use.

typealias BookVariable = PredicateExpressions.Variable<Book>
typealias BookKeyPath<T> = PredicateExpressions.KeyPath<BookVariable,T>

let dateEquals = { (input: BookKeyPath<Date>, _ value: Date) in
    return PredicateExpressions.Equal(
        lhs: input,
        rhs: PredicateExpressions.Value(value)
    )
}

The actual filtering:

// Do we want to filter by date at all?
let filterByDate = true

// The date we want to test against
let testDate = Date.now

let variablePredicate = Predicate<Book>({ input in
    // Wrap values
    let shouldFilterByDate = PredicateExpressions.Value(filterByDate)
    let alwaysTrue = PredicateExpressions.Value(true)
    
    // Create date Expression with testDate
    let dateExp = dateEquals(BookKeyPath(root: input, keyPath: \Book.lastReadDate), testDate)

    // Predicate that ,
    // if shouldFilterByDate evaluates to true returns dateExp result
    // otherwise returns expression that evaluates to true
    return PredicateExpressions.build_Conditional(
        shouldFilterByDate,
        dateExp,
        alwaysTrue
    )
})

let descriptor = FetchDescriptor(predicate: variablePredicate)
modelContext.fetch(descriptor)

Upvotes: 16

Related Questions