๐ฏ PredicateKit
PredicateKit is an alternative to NSPredicate
allowing you to
write expressive and type-safe predicates for CoreData using key-paths,
comparisons and logical operators, literal values, and functions.
Contents
Motivation
CoreData is a formidable piece of technology, however not all of its API has caught up with the modern Swift world. Specifically, fetching and filtering objects from
CoreData relies heavily on NSPredicate
and NSExpression
. Unfortunately, a whole range of bugs and runtime errors can easily be introduced using those APIs.
For instance, we can compare a property of type String
to a value of type Int
or even use a non-existant property in a predicate; these mistakes will go un-noticed
at compile time but can cause important errors at runtime that may not be obvious to diagnose. This is where PredicateKit comes in by making it virtually impossible to
introduce these types of errors.
Concretely, PredicateKit provides
- a type-safe and expressive API for writing predicates. When using PredicateKit, all properties involved in your predicates are expressed using key-paths. This ensures that the usage of inexistant properties or typos are caught at compile time. Additionally, all operations such as comparisons, functions calls, etc. are strongly-typed, making it impossible to write invalid predicates.
- an improved developer experience. Enjoy auto-completion and syntax highlighting when writing your predicates. In addition, PredicateKit
is just a lightweight replacement for
NSPredicate
, no major change to your codebase is required, no special protocol to conform to, no configuration, etc. Simplyimport PredicateKit
, write your predicates and use the functionsNSManagedObjectContext.fetch(where:)
orNSManagedObjectContext.count(where:)
to execute them.
Installation
Carthage
Add the following line to your Cartfile
.
github "ftchirou/PredicateKit" ~> 1.0.0
CocoaPods
Add the following line to your Podfile
.
pod 'PredicateKit', ~> '1.0.0'
Swift Package Manager
Update the dependencies
array in your Package.swift
.
dependencies: [
.package(url: "https://github.com/ftchirou/PredicateKit", .upToNextMajor(from: "1.0.0"))
]
Quick start
Fetching objects
To fetch objects using PredicateKit, use the function fetch(where:)
on an instance of NSManagedObjectContext
passing as argument a predicate. fetch(where:)
returns an object of type FetchRequest
on which you call result()
to execute the request and retrieve the matching objects.
Example
let notes: [Note] = try managedObjectContext
.fetch(where: \Note.text == "Hello, World!" && \Note.creationDate < Date())
.result()
You write your predicates using the key-paths of the entity to filter and a combination of comparison and logical operators, literal values, and functions calls.
See Writing predicates for more about writing predicates.
Fetching objects as dictionaries
By default, fetch(where:)
returns an array of subclasses of NSManagedObject
. You can specify that the objects be returned as an array of dictionaries ([[String: Any]]
)
simply by changing the type of the variable storing the result of the fetch.
Example
let notes: [[String: Any]] = try managedObjectContext
.fetch(where: \Note.text == "Hello, World!" && \Note.creationDate < Date())
.result()
Configuring the fetch
fetch(where:)
returns an object of type FetchRequest
. You can apply a series of modifiers on this object to further configure how the objects should be matched and returned.
For example, sorted(by: \Note.creationDate, .descending)
is a modifier specifying that the objects should be sorted by the creation date in the descending order. A modifier returns a mutated FetchRequest
; a series
of modifiers can be chained together to create the final FetchRequest
.
Example
let notes: [Note] = try managedObjectContext
.fetch(where: (\Note.text).contains("Hello, World!") && \Note.creationDate < Date())
.limit(50) // Return 50 objects matching the predicate.
.offset(100) // Skip the first 100 objects matching the predicate.
.sorted(by: \Note.creationDate) // Sort the matching objects by their creation date.
.result()
See Request modifiers for more about modifiers.
Fetching objects with the @FetchRequest property wrapper
PredicateKit extends the SwiftUI @FetchRequest
property wrapper to support type-safe predicates. To use, simply initialize a @FetchRequest
with a predicate.
Example
import PredicateKit
import SwiftUI
struct ContentView: View {
@SwiftUI.FetchRequest(predicate: \Note.text == "Hello, World!")
var notes: FetchedResults<Note>
var body: some View {
List(notes, id: \.self) {
Text($0.text)
}
}
}
You can also initialize a @FetchRequest
with a full-fledged request with modifiers and sort descriptors.
Example
import PredicateKit
import SwiftUI
struct ContentView: View {
@SwiftUI.FetchRequest(
fetchRequest: FetchRequest(predicate: (\Note.text).contains("Hello, World!"))
.limit(50)
.offset(100)
.sorted(by: \Note.creationDate)
)
var notes: FetchedResults<Note>
var body: some View {
List(notes, id: \.self) {
Text($0.text)
}
}
}
Both initializers accept an optional parameter animation
that will be used to animate changes in the fetched results.
Example
import PredicateKit
import SwiftUI
struct ContentView: View {
@SwiftUI.FetchRequest(
predicate: (\Note.text).contains("Hello, World!"),
animation: .easeInOut
)
var notes: FetchedResults<Note>
var body: some View {
List(notes, id: \.self) {
Text($0.text)
}
}
}
Fetching objects with an NSFetchedResultsController
In UIKit, you can use fetchedResultsController()
to create an NSFetchedResultsController
from a configured fetch request. fetchedResultsController
has two optional parameters:
sectionNameKeyPath
is a key-path on the returned objects used to compute section infocacheName
is the name of a file to store pre-computed section info.
Example
let controller: NSFetchedResultsController<Note> = managedObjectContext
.fetch(where: \Note.text == "Hello, World!" && \Note.creationDate < Date())
.sorted(by: \Note.creationDate, .descending)
.fetchedResultsController(sectionNameKeyPath: \Note.creationDate)
Counting objects
To count the number of objects matching a predicate, use the function count(where:)
on an instance of NSManagedObjectContext
.
Example
let count = try managedObjectContext.count(where: (\Note.text).beginsWith("Hello"))
Documentation
Writing predicates
Predicates are expressed using a combination of comparison operators and logical operators, literal values, and functions.
Comparisons
Basic comparisons
A comparison can be expressed using one of the basic comparison operators <
, <=
, ==
, >=
, and >
where the left hand side of
the operator is a key-path and the right hand side
of the operator is a value whose type matches the value type of the key-path on the left hand side.
Example
class Note: NSManagedObject {
@NSManaged var text: String
@NSManaged var creationDate: Date
@NSManaged var numberOfViews: Int
@NSManaged var tags: [String]
@NSManaged var attachment: Attachment
}
// Matches all notes where the text is equal to "Hello, World!".
let predicate = \Note.text == "Hello, World!"
// Matches all notes created before the current date.
let predicate = \Note.creationDate < Date()
// Matches all notes where the number of views is at least 120.
let predicate = \Note.numberOfViews >= 120
// Matches all notes having the specified attachment. `Attachment` must conform to `Identifiable`.
let predicate = \Note.attachment == attachment
String comparisons
If the property to compare is of type String
, comparisons can be additionally expressed with special functions such as beginsWith
,
contains
, or endsWith
.
// Matches all notes where the text begins with the string "Hello".
let predicate = (\Note.text).beginsWith("Hello")
// Matches all notes where the text contains the string "Hello".
let predicate = (\Note.text).contains("Hello")
// Matches all notes where the text matches the specified regular expression.
let predicate = (\Note.text).matches(NSRegularExpression(...))
Any of the following functions can be used in a string comparison predicate.
beginsWith
contains
endsWith
like
matches
These functions accept a second optional parameter specifying how the string comparison should be performed.
// Case-insensitive comparison.
let predicate = (\Note.text).beginsWith("Hello, World!", .caseInsensitive)
// Diacritic-insensitive comparison.
let predicate = (\Note.text).beginsWith("Hello, World!", .diacriticInsensitive)
// Normalized comparison.
let predicate = (\Note.text).beginsWith("Hello, World!", .normalized)
Membership checks
between
You can use the between
function or the ~=
operator to determine whether a property's value is within a specified range.
// Matches all notes where the number of views is between 100 and 200.
let predicate = (\Note.numberOfViews).between(100...200)
// Or
let predicate = \Note.numberOfViews ~= 100...200
in
You can use the in
function to determine whether a property's value is one of the values in a variadic list of arguments, an array, or a set.
// Matches all notes where the text is one of the elements in the specified variadic arguments list.
let predicate = (\Note.numberOfViews).in(100, 200, 300, 400)
// Matches all notes where the text is one of the elements in the specified array.
let predicate = (\Note.text).in([100, 200, 300, 400])
// Matches all notes where the text is one of the elements in the specified set.
let predicate = (\Note.text).in(Set([100, 200, 300, 400]))
When the property type is a String
, in
accepts a second parameter that determines how the string should be compared to the elements in the list.
// Case-insensitive comparison.
let predicate = (\Note.text).in(["a", "b", "c", "d"], .caseInsensitive)
Compound predicates
Compound predicates are predicates that logically combine one, two or more predicates.
AND predicates
AND predicates are expressed with the &&
operator where the operands are predicates. An AND predicate
matches objects where both its operands match.
// Matches all notes where the text begins with 'hello' and the number of views is at least 120.
let predicate = (\Note.text).beginsWith("hello") && \Note.numberOfViews >= 120
OR Predicates
OR predicates are expressed with the ||
operator where the operands are predicates. An OR predicate matches
objects where at least one of its operands matches.
// Matches all notes with the text containing 'hello' or created before the current date.
let predicate = (\Note.text).contains("hello") || \Note.creationDate < Date()
NOT Predicates
NOT predicates are expressed with the unary !
operator with a predicate operand. A NOT predicate matches all objects
where its operand does not match.
// Matches all notes where the text is not equal to 'Hello, World!'
let predicate = !(\Note.text == "Hello, World!")
Array operations
You can perform operations on properties of type Array
(or expressions that evaluate to values of type Array
) and use the result in a predicate.
Select an element in an array
first
// Matches all notes where the first tag is 'To Do'..
let predicate = (\Note.tags).first == "To Do"
last
// Matches all notes where the last tag is 'To Do'..
let predicate = (\Note.tags).last == "To Do"
at(index:)
// Matches all notes where the third tag contains 'To Do'.
let predicate = (\Note.tags).at(index: 2).contains("To Do")
Count the number of elements in an array
count
// Matches all notes where the number of elements in the `tags` array is less than 5.
let predicate = (\Note.tags).count < 5
// or
let predicate = (\Note.tags).size < 5
Combine the elements in an array
If the elements of an array are numbers, you can combine or reduce them into a single number and use the result in a predicate.
class Account: NSManagedObject {
@NSManaged var purchases: [Double]
}
sum
// Matches all accounts where the sum of the purchases is less than 2000.
let predicate = (\Account.purchases).sum < 2000
average
// Matches all accounts where the average purchase is 120.0
let predicate = (\Account.purchases).average == 120.0
min
// Matches all accounts where the minimum purchase is 98.5.
let predicate = (\Account.purchases).min == 98.5
max
// Matches all accounts where the maximum purchase is at least 110.5.
let predicate = (\Account.purchases).max >= 110.5
Aggregate comparisons
You can also express predicates matching all, any, or none of the elements of an array.
all
// Matches all accounts where every purchase is at least 95.0
let predicate = (\Account.purchases).all >= 95.0
any
// Matches all accounts having at least one purchase of 20.0
let predicate = (\Account.purchases).any == 20.0
none
// Matches all accounts where no purchase is less than 50.
let predicate = (\Account.purchases).none <= 50
Predicates with one-to-one relationships
If your object has a one-to-one relationship with another one, you can target any property of the relationship simply by using the appropriate key-path.
Example
class User: NSManagedObject {
@NSManaged var name: String
@NSManaged var billingInfo: BillingInfo
}
class BillingInfo: NSManagedObject {
@NSManaged var accountType: String
@NSManaged var purchases: [Double]
}
// Matches all users with the billing account type 'Pro'
let predicate = \User.billingInfo.accountType == "Pro"
// Matches all users with an average purchase of 120
let predicate = (\User.billingInfo.purchases).average == 120.0
Predicates with one-to-many relationships
You can run aggregate operations on a set of relationships using the all(_:)
, any(_:)
, or none(_:)
functions.
Example
class Account: NSManagedObject {
@NSManaged var name: String
@NSManaged var profiles: Set<Profile>
}
class Profile: NSManagedObject {
@NSManaged var name: String
@NSManaged var creationDate: String
}
// Matches all accounts where all the profiles have the creation date equal to the specified one.
let predicate = (\Account.profiles).all(\.creationDate) == date
// Matches all accounts where any of the associated profiles has a name containing 'John'.
let predicate = (\Account.profiles).any(\.name).contains("John"))
// Matches all accounts where no profile has the name 'John Doe'
let predicate = (\Account.profiles).none(\.name) == "John Doe"
Sub-predicates
When your object has one-to-many relationships, you can create a sub-predicate that filters the "many" relationships and use the
result of the sub-predicate in a more complex predicate. Sub-predicates are created using the global all(_:where:)
function. The first
parameter is the key-path of the collection to filter and the second parameter is a predicate that filters the collection.
all(_:where:)
evaluates to an array; that means you can perform any valid array operation on its result such as size
, first
, etc.
Example
// Matches all the accounts where the name contains 'Account' and where the number of profiles whose
// name contains 'Doe' is exactly 2.
let predicate = (\Account.name).contains("Account")
&& all(\.profiles, where: (\Profile.name).contains("Doe")).size == 2)
Request modifiers
You can configure how matching objects are returned by applying a chain of modifiers to the object returned by NSManagedObjectContext.fetch(where:)
.
Example
let notes: [Note] = try managedObjectContext
.fetch(where: (\Note.text).contains("Hello, World!") && \Note.creationDate < Date())
.limit(50) // Return 50 objects matching the predicate.
.offset(100) // Skip the first 100 objects matching the predicate.
.sorted(by: \Note.text) // Sort the matching objects by their creation date.
.result()
limit
Specifies the number of objects returned by the fetch request.
Usage
managedObjectContext.fetch(where: ...)
.limit(50)
NSFetchRequest
equivalent
offset
Specifies the number of initial matching objects to skip.
Usage
managedObjectContext.fetch(where: ...)
.offset(100)
NSFetchRequest
equivalent
batchSize
Specifies the batch size of the objects in the fetch request.
Usage
managedObjectContext.fetch(where: ...)
.batchSize(80)
NSFetchRequest
equivalent
prefetchingRelationships
Specifies the key-paths of the relationships to prefetch along with objects of the fetch request.
Usage
managedObjectContext.fetch(where: ...)
.prefetchingRelationships(\.billingInfo, \.profiles)
NSFetchRequest
equivalent
relationshipKeyPathsForPrefetching
includingPendingChanges
Specifies whether changes unsaved in the managed object context are included in the result of the fetch request.
Usage
managedObjectContext.fetch(where: ...)
.includingPendingChanges(true)
NSFetchRequest
equivalent
fromStores
Specifies the persistent stores to be searched when the fetch request is executed.
Usage
let store1: NSPersistentStore = ...
let store2: NSPersistentStore = ...
managedObjectContext.fetch(where: ...)
.fromStores(store1, store2)
NSFetchRequest
equivalent
fetchingOnly
Specifies the key-paths to fetch.
Usage
managedObjectContext.fetch(where: ...)
.fetchingOnly(\.text, \.creationDate)
NSFetchRequest
equivalent
returningDistinctResults
Specifies whether the fetch request returns only distinct values for the key-paths specified by fetchingOnly(_:)
.
Usage
managedObjectContext.fetch(where: ...)
.fetchingOnly(\.text, \.creationDate)
.returningDistinctResults(true)
NSFetchRequest
equivalent
groupBy
Specifies the key-paths of the properties to group the result by, when the result of the request is of type [[String: Any]]
.
Usage
let result: [[String: Any]] = managedObjectContext.fetch(where: ...)
.groupBy(\.creationDate)
NSFetchRequest
equivalent
refreshingRefetchedObjects
Specifies whether the property values of fetched objects will be updated with the current values in the persistent store.
Usage
managedObjectContext.fetch(where: ...)
.shouldRefreshRefetchedObjects(false)
NSFetchRequest
equivalent
having
Specifies the predicate to use to filter objects returned by a request with a groupBy(_:)
modifier applied.
Usage
let result: [[String: Any]] = managedObjectContext.fetch(where: ...)
.groupBy(\.creationDate)
.having((\Note.text).contains("Hello, World!"))
NSFetchRequest
equivalent
includingSubentities
Specifies whether subentities are included in the result.
Usage
managedObjectContext.fetch(where: ...)
.includingSubentities(true)
NSFetchRequest
equivalent
returningObjectsAsFaults
Specifies whether objects returned from the fetch request are faults.
Usage
managedObjectContext.fetch(where: ...)
.returningObjectsAsFaults(true)
NSFetchRequest
equivalent
sorted
Specifies how the objects returned by the request should be sorted. This modifier takes one required parameter and 2 optional ones:
by
: the key-path by which to sort the objects. (Required)order
: the order in which to sort the objects. (Optional, defaults to.ascending
)comparator
: a custom comparator to use to sort the objects. (Optional, defaults tonil
)
Usage
managedObjectContext.fetch(where: ...)
.sorted(by: \.text)
.sorted(by: \.creationDate, .descending)
Debugging
In DEBUG
mode, you can inspect the actual NSFetchRequest
s that are being executed by using the modifier inspect(on:)
on a FetchRequest
.
Example
struct Inspector: NSFetchRequestInspector {
func inspect<Result>(_ request: NSFetchRequest<Result>) {
// Log or print the request here.
}
}
let notes: [Note] = try managedObjectContext
.fetch(where: \Note.text == "Hello, World!")
.sorted(by: \Note.creationDate, .descending)
.inspect(on: Inspector())
.result()
Happy coding!