SwiftData Expressions

SwiftData gained support for predicate expression in iOS 18. How does it compare to the expressions we can use with Core Data?

Core Data Queries with NSExpression

When Apple launched SwiftData with iOS 17 there were some gaps compared to Core Data. A big one for me was the ability to create an aggregate query (FB12292503). For example, to fetch the minimum, maximum, sum, or average of a property.

I first wrote about using a Core Data expression to query for min and max values back in 2012. Here’s an updated example with a Core Data log entity that contains a set of markers:

public final class CDLog: NSManagedObject {
  @NSManaged public var name: String?
  @NSManaged public var timestamp: Date?
  @NSManaged public var markers: NSSet?
  ...
}

The markers also contain a timestamp:

public final class CDMarker: NSManagedObject {
  @NSManaged public var timestamp: Date?
  @NSManaged public var log: CDLog?
  ...
}

I want to know the range (min…max) of timestamps for the markers in a log entry. One way would be to fetch the markers, sorted by timestamp, and return the timestamps of the first, and last markers. A better way is to have SQLite calculate and directly return the values.

Core Data has long supported creating fetch requests and predicates with the Foundation type NSExpression. It takes a lot of code but here’s a request that fetches the minimum (earliest) and maximum (latest) dates of markers in a log entry:

public class func boundsRequest(
  _ timeline: NSManagedObjectID
) -> NSFetchRequest<NSFetchRequestResult> {

  let request = NSFetchRequest<NSFetchRequestResult>(
    entityName: CDMarker.entityName)
  request.predicate = CDMarker.inTimeline(timeline)
  request.resultType = .dictionaryResultType
  
  let keyPathExpression = NSExpression(forKeyPath:
    \CDMarker.timestamp)

  let earliestDescription = NSExpressionDescription()
  earliestDescription.name = "earliestDate"
  earliestDescription.expression =
    NSExpression(forFunction: "min:",
                   arguments: [keyPathExpression])
  earliestDescription.expressionResultType = .dateAttributeType

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

  request.propertiesToFetch = [
    earliestDescription, latestDescription
  ]
  return request
}

The two NSExpression properties apply a function (min or max) to a keypath returning the two values in a dictionary. Core Data translates the expressions to an SQLite statement performing the work at the database level.

SwiftData Predicate Expressions

In iOS 18, SwiftData can make use of Foundation’s new #Expression macro to make it easier to build more complex predicates. From the WWDC session:

Expressions allow for reference values that do not produce true or false but instead allow for arbitrary types.

You can then evaluate the expression as part of more complex predicate. Let’s see an example.

I have an Item model class that can contain one or more Record items:

@Model final class Item {
  var name: String   
  var timestamp: Date    
  var record: [Record]
}

The Record model includes a score:

@Model final class Record {
  var score: Int
   ...
}

Suppose I want to find all items with three or more records of which at least two are a passing score of 90 or more. We can write an expression that counts the number of passing records:

let passingScore = #Expression<[Record], Int> { records in
  records.filter { $0.score >= 90 }.count
}

Note: The body of the expression is restricted to a single expression.

We can then evaluate this expression in a compound item predicate:

let highScoring = #Predicate<Item> { item in
  item.records.count > 2 &&
  passingScore.evaluate(item.records) > 1
}

The resulting SQL query:

CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZNAME, t0.ZTIMESTAMP
 FROM ZITEM t0 WHERE (
  (SELECT COUNT(t1.Z_PK) FROM ZRECORD t1 WHERE (
   t0.Z_PK = t1.Z1RECORDS) ) > ? AND 
  (SELECT COUNT(t2.Z_PK) FROM ZRECORD t2 WHERE (
   t0.Z_PK = t2.Z1RECORDS AND ( t2.ZSCORE >= ?)) ) > ?
 ) 

This look promising but I could already write this predicate without the expression:

let highScoring = #Predicate<Item> { item in
  item.records.count > 2 &&
  item.records.filter { $0.score >= 90 }.count > 1
}

If you expand the expression macro or check the Expression documentation you’ll see it’s creating PredicateExpressions which appear to support a wide range of methods. Unfortunately I can’t seem to get most of them to work either in predicates or expressions.

For example, similar to my earlier Core Data example, here’s an expression to return the maximum score in a collection of records:

let maxScore = #Expression<[Record], Int> { records in
  records.map { $0.score }.max() ?? 0
}

That fails to compile:

The map() function is not supported in this expression

So maybe this will improve in future updates but right now I’m not sure I’m gaining anything from using expressions? Let me know if you’ve had more success.

Learn More