Core Data Queries Using Expressions

Core Data supports a rich expression syntax for performing queries for agreggate values (sum, min, max, average, etc.). Let’s see an example of how it works.

Last updated: Sep 3, 2024

Querying Core Data

Core Data can have a steep learning curve for anybody new to either the Mac or iOS platforms. One of the key points for me was understanding that Core Data is not a relational database but a persistent object store with many features to manage the life-cycle of an object.

I think some of the confusion comes from the fact the Core Data can use SQLite as the underlying object store but that’s an implementation detail that can lead you astray. So if Core Data is not a relational database how do you do those things that would be easy if you could just use an SQL query?

A Core Data fetch request with a combination of predicates and sort descriptors covers many common queries. But when you want to find the minimum, maximum, average, sum, or some other aggregate value of an attribute using expressions can be easier and more efficient.

Core Data Model

My example model has a single entity representing an item in a todo list:

Item defined in Core Data Model editor

My NSManagedObject sublass looks like this:

@objc(Item)
final class Item: NSManagedObject {
  @NSManaged var createdAt: Date?
  @NSManaged var complete: Bool
  @NSManaged var note: String?
  @NSManaged var title: String?
}

Fetching the minimum and maximum values of an attribute

Suppose I want to find the range (oldest to newest) of creation dates of the items. A first approach might be to sort the items by ascending creation date. The first and last items, if any, give me the date range:

func bounds(_ context: NSManagedObjectContext) throws ->
 ClosedRange<Date>? {
  let request = NSFetchRequest<Item>(entityName: "Item")
  request.sortDescriptors = [
    NSSortDescriptor(keyPath: \Item.createdAt,
                   ascending: true)
  ]

  let result = try context.fetch(request)
  if let first = result.first?.createdAt,
     let last = result.last?.createdAt {
    return first ... last
  }
  return nil
}

This works but at the cost of reading all items into memory. We can do better.

SQL Query Debugging

Before we look at alternatives, let’s look under the covers to see how Core Data performs the fetch request. Enabling the launch argument -com.apple.CoreData.SQLDebug 1 in the scheme causes Core Data to log each SQL query to the console.

See Debugging Core Data for details of this and other useful Core Data debug settings.

With the debugging enabled we can see the query that Core Data is using and the fetch execution time:

CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZCOMPLETE,
 t0.ZCREATEDAT, t0.ZNOTE, t0.ZTITLE
 FROM ZITEM t0 ORDER BY t0.ZCREATEDAT
CoreData: annotation: sql connection fetch time: 0.0493s
CoreData: annotation: total fetch execution time: 0.0510s for 5000 rows.

Core Data performs a select to retrieve all of the attributes from the item table, ordered by creation date. The total fetch execution time was 0.0510s using a database containing 5,000 items running on an iPhone 13 Pro.

Restricting The Properties to Fetch

Before looking at the use of expressions there is one minor optimisation that we could consider applying to the previous fetch request. Since we only want the creation date we can modify the properties to fetch:

request.propertiesToFetch = ["createdAt"]

Looking at the SQL debug you can see that the select statement now only retrieves the single attribute

CoreData: sql: SELECT 1, t0.Z_PK, t0.ZCREATEDAT
 FROM ZITEM t0 ORDER BY t0.ZCREATEDAT
CoreData: annotation: sql connection fetch time: 0.0163s
CoreData: annotation: total fetch execution time: 0.0193s for 5000 rows.

That’s an improvement but we’re still fetching 5000 items.

Using an Expression

A better way to create this type of query is to use an expression with the function(s) we want to run. Unfortunately there is a little bit more code required.

Starting with an expression for the earliest (minimum) value:

func bounds(_ context: NSManagedObjectContext) throws -> ClosedRange<Date>? {
  let keyPathExpression = NSExpression(forKeyPath: \Item.createdAt)
        
  let earliestDescription = NSExpressionDescription()
  earliestDescription.name = "earliestDate"
  earliestDescription.expression = NSExpression(forFunction: "min:",
    arguments: [keyPathExpression])
  earliestDescription.expressionResultType = .dateAttributeType

The NSExpressionDescription defines a value that a fetch request returns. In this case we are using the min: function and providing it with the key path to our item’s createdAt property as an argument. The name is used to retrieve the value from the fetch request result.

There are a wide range of functions that we could apply including average:, sum:, min:, max:, median:, sqrt:, etc., for the full list check the documentation for the NSExpression class.

The expression for the latest (maximum) date is similar:

  let latestDescription = NSExpressionDescription()
  latestDescription.name = "latestDate"
  latestDescription.expression = NSExpression(forFunction: "max:",
    arguments: [keyPathExpression])
  latestDescription.expressionResultType = .dateAttributeType

Since our fetch request is not returning items we need to configure it to return a dictionary result type. Our two expressions then become the properties to fetch:

  let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Item")
  request.resultType = .dictionaryResultType
  request.propertiesToFetch = [earliestDescription, latestDescription]

The array result we get back from the fetch request should hold a single dictionary containing our earliest and latest date values keyed using the expression description names:

  if let result = try context.fetch(request).first as? [String: Date],
     let first = result["earliestDate"],
     let last = result["latestDate"] {
    return first ... last
  }

  return nil
}

That’s a lot more code than the original solution but the SQL debug log shows the difference:

CoreData: sql: SELECT min( t0.ZCREATEDAT), max( t0.ZCREATEDAT)
 FROM ZITEM t0 
CoreData: annotation: sql connection fetch time: 0.0104s
CoreData: annotation: total fetch execution time: 0.0106s for 1 rows.

Core Data is using SQLite to perform the min and max functions directly on the createdAt property in the database avoiding the need to retrieve all 5,000 values.