I have been struggling with arbitrary behavior in SwiftData when using a two-layer relationship in a predicate. I wrote a very simple app to verify this behavior.
I have three models, grandparent, parent, child, with a 1:n relationship between grandparent/parent and parent/child.
@Model
final class Grandparent {
var id: UUID = UUID()
var name: String = ""
@Relationship(deleteRule: .nullify, inverse: \Parent.parent)
var children: [Parent]?
init(name: String) {
self.name = name
}
}
@Model
final class Parent {
var id: UUID = UUID()
var name: String = ""
@Relationship(deleteRule: .nullify, inverse: \Child.parent)
var children: [Child]?
var parent: Grandparent?
init(name: String, parent: Grandparent? = nil) {
self.name = name
self.parent = parent
}
}
@Model
final class Child {
var id: UUID = UUID()
var name: String = ""
var parent: Parent?
init(name: String, parent: Parent? = nil) {
self.name = name
self.parent = parent
}
}
I need to display a list of children depending on the selected grandparent. If no grandparent is selected, all children shall be shown, if a grandparent is selected, only the children related to this grandparent (via their parents) shall be shown.
I achieve this by having a picker in ContentView, selecting the grandparent, which then calls the FilteredChildrenView. In this view, I use a dynamic predicate based on the (optional) selectedGrandparent to filter my children query.
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Grandparent.name) private var grandparents: [Grandparent]
@State private var selectedGrandparent: Grandparent? = nil
var body: some View {
List {
Button(action: buildFamily) {
Text("Build family")
}
Button(action: deleteAllData) {
Text("Delete all data")
}
Picker("Grandparent", selection: $selectedGrandparent) {
Text("All grandparents").tag(nil as Grandparent?)
ForEach(grandparents) { grandparent in
Text(grandparent.name).tag(grandparent)
}
}
FilteredChildrenView(selectedGrandparent: selectedGrandparent)
}
}
private func buildFamily() {
...
}
private func deleteAllData() {
modelContext.container.deleteAllData()
}
}
struct FilteredChildrenView: View {
@Environment(\.modelContext) private var modelContext
@Query private var children: [Child]
var body: some View {
ForEach(children) { child in
Text(child.name)
}
}
init(selectedGrandparent: Grandparent?) {
let grandparentID = selectedGrandparent?.persistentModelID
let predicate = #Predicate<Child> { child in
if grandparentID == nil {
return true
} else {
return child.parent?.parent?.persistentModelID == grandparentID!
}
}
self._children = Query(
filter: predicate,
sort: \Child.name
)
}
}
The behavior is as expected for 10-15 seconds, after I have filled the database with fresh sample data. Then, all of a sudden, when selecting a grandparent, exactly one grandchild is displayed, and in most cases an unrelated one.
I do not understand this behavior.
--- Edit 19.12.25 ---
The solution suggested by Joakim Danielson in the comments solves the problem with an elegant workaround (I would say, better than the solution in discussion), not requiring predicates at all. I still wonder about this behavior.
1 Answer 1
I suspect the issues here are two-fold:
You're not explicitly saving the context in your
buildFamily()functionThe SwiftData Predicate may struggle with the two levels hop
child.parent?.parent?.persistentModelID, causing valid records to be silently filtered out - and showing only a single record even though multiple match.
Issue 1
The 10-seconds or so delay you noticed is likely due to the modelContext autosaving, probably because you're not explicitly calling try? modelContext.save() in your buildFamily() function (which wasn't provided in your sample code).
When initially inserted, objects get a temporary id, which then changes to a permanent persisted id when autosaves runs. Since your predicate filters by selectedGrandparent?.persistentModelID, it explains why you're seeing incorrect data - the selectedGrandparentstate references a temporary id which is no longer accurate once the context saves.
So the solution is simply to explicitly save the context after filling the database with the sample data:
//Fill the database with sample data
// ...
//Explicitly save context
try? modelContext.save()
Issue 2
An alternative, maybe simpler approach for filtering the grandchildren is to apply the filter in the parent and then passing just the filtered ones to FilteredChildrenView:
//Computed property to return the grandchildren of a selected grandparent or all grandchildren if no selection
private var grandChildren: [Child] {
if let selectedGrandparent {
// Flatten (grandparent -> parents -> children)
return (selectedGrandparent.children ?? [])
.flatMap { 0ドル.children ?? [] }
.sorted { (0ドル.name) < (1ドル.name) }
}
else {
// All grandchildren: flatten across all grandparents
return grandparents
.flatMap { 0ドル.children ?? [] }
.flatMap { 0ドル.children ?? [] }
.sorted { (0ドル.name) < (1ドル.name) }
}
}
Then your FilteredChildrenView becomes much simpler:
struct FilteredChildrenView: View {
// Parameters
let children: [Child]
//Body
var body: some View {
ForEach(children) { child in
Text(child.name)
}
}
}
And the call from parent:
FilteredChildrenView(children: grandChildren)
Here's the complete working code to try:
import SwiftUI
import SwiftData
@Model
final class FamilyGrandparent {
var id: UUID = UUID()
var name: String = ""
@Relationship(deleteRule: .nullify, inverse: \FamilyParent.parent)
var children: [FamilyParent]?
init(name: String) {
self.name = name
}
}
@Model
final class FamilyParent {
var id: UUID = UUID()
var name: String = ""
@Relationship(deleteRule: .nullify, inverse: \FamilyChild.parent)
var children: [FamilyChild]?
var parent: FamilyGrandparent?
init(name: String, parent: FamilyGrandparent? = nil) {
self.name = name
self.parent = parent
}
}
@Model
final class FamilyChild {
var id: UUID = UUID()
var name: String = ""
var parent: FamilyParent?
init(name: String, parent: FamilyParent? = nil) {
self.name = name
self.parent = parent
}
}
struct GrandParentContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \FamilyGrandparent.name) private var grandparents: [FamilyGrandparent]
@State private var selectedGrandparent: FamilyGrandparent? = nil
//Computed property to return the grandchildren of a selected grandparent or all grandchildren if no selection
private var grandChildren: [FamilyChild] {
if let selectedGrandparent {
// Flatten (grandparent -> parents -> children)
return (selectedGrandparent.children ?? [])
.flatMap { 0ドル.children ?? [] }
.sorted { (0ドル.name) < (1ドル.name) }
}
else {
// All grandchildren: flatten across all grandparents
return grandparents
.flatMap { 0ドル.children ?? [] }
.flatMap { 0ドル.children ?? [] }
.sorted { (0ドル.name) < (1ドル.name) }
}
}
//Body
var body: some View {
List {
Button(action: buildFamily) {
Text("Build family")
}
.disabled(!grandparents.isEmpty)
Button(role: .destructive) {
deleteAllData()
} label: {
Text("Reset family")
}
.disabled(grandparents.isEmpty)
Picker("Grandparent", selection: $selectedGrandparent) {
if !grandparents.isEmpty {
Text("All grandparents").tag(nil as FamilyGrandparent?)
ForEach(grandparents) { grandparent in
Text(grandparent.name).tag(grandparent)
}
}
else {
Text("No grandparents available").tag(nil as FamilyGrandparent?)
}
}
FilteredChildrenView(children: grandChildren)
}
}
private func buildFamily() {
// Create grandparents
let grandparentAlice = FamilyGrandparent(name: "Grandma Alice")
let grandparentBob = FamilyGrandparent(name: "Grandpa Bob")
// Create parents linked to grandparents
let parentCarol = FamilyParent(name: "Carol", parent: grandparentAlice)
let parentDave = FamilyParent(name: "Dave", parent: grandparentAlice)
let parentEve = FamilyParent(name: "Eve", parent: grandparentBob)
// Create children linked to parents
let childFrank = FamilyChild(name: "Frank", parent: parentCarol)
let childGrace = FamilyChild(name: "Grace", parent: parentCarol)
let childHeidi = FamilyChild(name: "Heidi", parent: parentDave)
let childIvan = FamilyChild(name: "Ivan", parent: parentEve)
let childJudy = FamilyChild(name: "Judy", parent: parentEve)
// Insert all into the model context. SwiftData will infer relationships from references.
modelContext.insert(grandparentAlice)
modelContext.insert(grandparentBob)
modelContext.insert(parentCarol)
modelContext.insert(parentDave)
modelContext.insert(parentEve)
modelContext.insert(childFrank)
modelContext.insert(childGrace)
modelContext.insert(childHeidi)
modelContext.insert(childIvan)
modelContext.insert(childJudy)
try? modelContext.save()
}
private func deleteAllData() {
try? modelContext.delete(model: FamilyChild.self)
try? modelContext.delete(model: FamilyParent.self)
try? modelContext.delete(model: FamilyGrandparent.self)
try? modelContext.save()
selectedGrandparent = nil
}
}
struct FilteredChildrenView: View {
// Parameters
let children: [FamilyChild]
//Body
var body: some View {
ForEach(children) { child in
Text(child.name)
}
}
}
#Preview {
GrandParentContentView()
.modelContainer(for: [FamilyGrandparent.self, FamilyParent.self, FamilyChild.self], inMemory: true)
}
@Model classalready conforms to thePersistentModel, that includesIdentifiable. Meaning the class already has aid: UUIDbuilt-in, there is no need to add yet anothervar id: UUID = UUID(), this may confuse SwiftData and yourself as to whichidyou are trying to use. Remove yours. See also PersistentModelbuildFamilyfunction which wasn't provided in your code. If you don't have it in there already, try addingmodelContext.save()and see if you still experience the same issue.