SwiftData Predicates For Parent Relationships

How do you write SwiftData predicates to query for parent relationships?

SwiftData Relationships

Suppose I have a SwiftData model with a parent Project class that contains a collection of Topic children:

@Model class Project {
  var name: String
  ...

  @Relationship(deleteRule: .cascade, inverse: \Topic.project)
  var topics: [Topic]? = []
}

@Model class Topic {
  var name: String
  ...
  var project: Project? = nil
}

The relationship properties are optional for compatibility with CloudKit sync.

Query All Topics In A Project

In a TopicList view I want to query for all topics owned by a parent project. I’m passing the identifier of the parent project as a parameter to the view and constructing the predicate and query in the view init:

struct TopicList: View {
  let project: Project.ID
  @Binding var selectedTopic: Topic.ID?

  @Query(sort: \Topic.name)
  private var topics: [Topic]

  init(project: Project.ID) {
    self.project = project
    let predicate = #Predicate<Topic> { topic in
      topic.project?.persistentModelID == project
    }
    self._topics = Query(filter: predicate, sort: \Topic.name)

  var body: some View {
    List(selection: $selectedTopic) {
      ForEach(topics) { topic in
        Text(topic.name)
      }
  }
}

Focussing on just the predicate to match the identifier of the topic project. The project attribute of the Topic is an optional so we need to use optional chaining.

let predicate = #Predicate<Topic> { topic in
  topic.project?.persistentModelID == project
}

That gives me all the topics in a single parent project. What if I want to query for topics in several projects?

Query For Children In Set Of Parents

Assuming my view is now passed a set of project identifiers to match:

let projects: Set<Project.ID>

I want to write the following predicate:

let predicate = #Predicate<Topic> { topic in
  projects.contains(topic.project?.persistentModelID)
}

That doesn’t compile as the contains method doesn’t expect an optional identifier:

Cannot convert value of type ‘PersistentIdentifier?’ to expected argument type ‘Project.ID’ (aka ‘PersistentIdentifier’)

This article on predicates with optional values gave me some ideas. First using an if-let:

let predicate = #Predicate<Topic> { topic in
  if let parent = topic.project {
    return projects.contains(parent.persistentModelID)
  } else {
    return false
  }
}

That works but is clumsy to write. I think I slightly prefer the flatMap version:

let predicate = #Predicate<Topic> {
  $0.project.flatMap {
    projects.contains($0.persistentModelID)
  } ?? false
}

Neither is intuitive and I know I’m going to have to look this up again the next time I need it.

KeyPaths Not Supported

One other caveat. The #Predicate macro doesn’t handle accessing properties via a keypath. Suppose my set of project identifiers is a property on an observable navigation state class:

@Observable NavigationState {
  let projects: Set<Project.ID> 
}

Attempting to access the projects via the navigation state causes a runtime crash:

@Bindable var state: NavigationState

let predicate = #Predicate<Topic> {
  $0.project.flatMap {
    state.projects.contains($0.persistentModelID)
  } ?? false
}

Fatal error: Predicate does not support keypaths with multiple components

The workaround is to introduce a temporary variable, outside of the macro, when constructing the predicate:

let projects = state.projects
let predicate = #Predicate<Topic> {
  $0.project.flatMap {
    projects.contains($0.persistentModelID)
  } ?? false
}