Lately, I've been increasingly frustrated with the limitations of Predicates in Swift, especially when it comes to combining them, unlike NSPredicate. The documentation on this topic has been rather vague, leading to numerous failed attempts and a descent into madness, which I've documented in my Stack Overflow post and in Fatbobman's article here.
However, I come bearing good news! I'm thrilled to announce the release of "CompoundPredicate"
This small library addresses the issue of combining predicates without the need for a custom PredicateExpression (which is not supported in SwiftData) and without the hassle of manually constructing expressions.
You can find the source code and documentation on GitHub.
Using it to combine predicates is easy:
import CompoundPredicate
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()
Hey @NoahKamara, thanks for sharing! This is super neat and I'm happy to see some new thoughts around potential APIs for Predicate!
In addition to your library, I wanted to share that Foundation now has support for this as well as of macOS 14.4 / iOS 17.4. Your example above can be written with the new Foundation support via the following:
let notTooShort = #Predicate<Book> { $0.pages > 50 }
let notTooLong = #Predicate<Book> { $0.pages <= 350 }
let titleFilter = #Predicate<Book> { $0.title.contains("Swift") }
let filter = #Predicate<Book> {
(notTooShort.evaluate($0) && notTooLong.evaluate($0)) || titleFilter.evaluate($0)
}
This capability might not be quite as easy to discover as a function like conjunction/disjunction on Predicate itself like you have in your library, but should support compound predicates as well as more complex constructions beyond just conjunctions/disjunctions. Hope this helps a bit in your Predicate endeavors! You can see more details about the Foundation change in this PR on the swift-foundation repo.
@jmschonfeld What does the performance picture look like for compound Predicate? Using your example, for instance, when we hit && notTooLong.evaluate($), is Foundation going to test everyBook in the collection being filtered and then use Set algebra to find the intersection with the first predicate's matches, or will it evaluate only the Book objects that passed the first notTooShort.evaluate($0)?
Or, put another way, is using compound Predicates any less performant than this:
For in-memory evaluation of the top-level Predicate, it's the latter - i.e. conjunctions and disjunctions follow the same "short circuiting" rules that normal Swift code does regardless of whether one of the branches is a "nested" predicate or a direct expression. Compound predicates may be slightly less performant in the cases where you do evaluate the second branch of the conjunction tree due to the indirection through an extra Predicate which wraps an existential StandardPredicateExpression, but it will not evaluate the RHS of the conjunction in the tree if the LHS evaluates to false.
If you convert the predicate to an alternate form, it's up to that conversion / alternate form to decide how it's evaluated (but I suspect most conversion would "flatten" the predicates while converting - this is what Foundation's NSPredicate conversion does)
Thanks! There's just one shortcoming that I see in Foundation's approach: how do we use this ability when we have a varying number of Predicates? For example:
let predicates: [Predicate<Book>] = [notTooShort, notTooLong, titleFilter]
let filter = #Predicate<Book> { book in
// This is illegal in #Predicate, so how do we do the equivalent?
for p in predicates {
p.evaluate(book)
}
}
The predicates that get applied to collections are often "dynamic" in my app. There are many options for how a user can filter rows in a table, for example, and the exact predicates I apply depend on those values. (Plus an extra predicate if the user is searching, for instance). So I don't have a nice, fixed set of Predicates to combine this way.
I need the flexibility that NSPredicate offered: "Here's a bunch of predicates. Smush them together."
This might compound the performance bottleneck of the existential just a bit, but it's technically possible via:
let predicates: [Predicate<Book>] = [notTooShort, notTooLong, titleFilter]
var filter = predicates[0]
for remaining in predicates.dropFirst() {
filter = #Predicate {
filter && remaining.evaluate($0)
}
}
(or using || to form a disjunction). There's no general Predicate.init(Collection<Predicate>) today mainly because there are many ways to combine predicates (conjunctions, disjunctions, etc.) and trying to spell out those possible options via API other than using the actual &&/|| operators felt a bit verbose/unnatural in Swift resulting in the more natural syntax using the real operators, but I can see how if you always want to use a specific operator to combine a variable list of predicates that the construction of that could be a bit unintuitive.
I've also considered whether there could be an underlying PredicateExpressions.Disjunction operator that accepted a variadic number of expressions rather than just two in which case you could manually construct that without the macro, but I haven't played around with that yet.
In my case, performance isn't that much of a consideration because I'm filtering only about 1,200 table rows on a Mac. I'm sure it's not as fast as it could be if I abandoned predicates, but it's irrelevant in practical terms because the re-sorts are functionally instant to a human.
Also, it looks like there's a small typo in your example. This resolved the error, but you're correct that the syntax to construct this is very un-obvious:
var filter = predicates[0]
for remaining in predicates.dropFirst() {
filter = #Predicate {
filter.evaluate($0) && remaining.evaluate($0)
}
}
I think @NoahKamara's approach would make a more ergonomic API for Foundation and could leverage the proposed variadic expressions:
let phrase 1 = [predicate1, predicate2, predicate3].conjunction()
let finalPredicate = [phrase1, someOtherPredicate].disjunction()
Or perhaps arrays are more natural than variadics:
let predicates = [notTooLong, notTooShort]
let p: Predicate<Book> = Predicate(conjunction: predicates)
let finalP: Predicate<Book> = Predicate(disjunction: [p, titleFilter])
These should really exist. I understand that there's more ways to combine Predicates, but I think those two initializers right there would cover 85% of the cases where developers are looking to combine predicates. And they're a lot more intuitive and obvious for people coming from NSCompoundPredicate.