Skip to content

Latest commit

 

History

History
816 lines (565 loc) · 25 KB

README.md

File metadata and controls

816 lines (565 loc) · 25 KB

🎯 PredicateKit

GitHub Workflow Status (branch) GitHub release (latest SemVer)

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. Simply import PredicateKit, write your predicates and use the functions NSManagedObjectContext.fetch(where:) or NSManagedObjectContext.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 info
  • cacheName 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]
}

// 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

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 specified list.

// Matches all notes where the text is one of the elements in the specified list.
let predicate = (\Note.text).in("a", "b", "c", "d")

When the property is of type 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.profile).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 resuult 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

fetchLimit

offset

Specifies the number of initial matching objects to skip.

Usage
managedObjectContext.fetch(where: ...)
  .offset(100)
NSFetchRequest equivalent

fetchOffset

batchSize

Specifies the batch size of the objects in the fetch request.

Usage
managedObjectContext.fetch(where: ...)
  .batchSize(80)
NSFetchRequest equivalent

fetchBatchSize

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

includesPendingChanges

fromStores

Specifies the persistent stores to be searched when the fetch request is executed.

Usage
let store1: NSPersistentStore = ...
let store2: NSPersistenStore = ...

managedObjectContext.fetch(where: ...)
  .fromStores(store1, store2)
NSFetchRequest equivalent

affectedStores

fetchingOnly

Specifies the key-paths to fetch.

Usage
managedObjectContext.fetch(where: ...)
  .fetchingOnly(\.text, \.creationDate)
NSFetchRequest equivalent

propertiesToFetch

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

returnsDistinctResults

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

propertiesToGroupBy

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

shouldRefreshRefetchedObjects

having

pecifies 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

havingPredicate

includingSubentities

Specifies whether subentities are included in the result.

Usage
managedObjectContext.fetch(where: ...)
  .includingSubentities(true)
NSFetchRequest equivalent

includesSubentities

returningObjectsAsFaults

Specifies whether objects returned from the fetch request are faults.

Usage
managedObjectContext.fetch(where: ...)
  .returningObjectsAsFaults(true)
NSFetchRequest equivalent

returnsObjectsAsFaults

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 object. (Optional, defaults to .ascending)
  • comparator: a custom comparator to use to sort the objects. (Optional, defaults to nil)
Usage
managedObjectContext.fetch(where: ...)
  .sorted(by: \.text)
  .sorted(by: \.creationDate, .descending)

Debugging

In DEBUG mode, you can inspect the actual NSFetchRequests 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! ⚡️