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
}