0

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.

asked Dec 18, 2025 at 21:35
8
  • Possibly unrelated, but note, @Model class already conforms to the PersistentModel, that includes Identifiable. Meaning the class already has a id: UUID built-in, there is no need to add yet another var id: UUID = UUID(), this may confuse SwiftData and yourself as to which id you are trying to use. Remove yours. See also PersistentModel Commented Dec 18, 2025 at 22:55
  • Thanks, workingdog. I actually didn't have the UUID in any of my models, until I got a runtime error ("Duplicate keys if type were found in a Dictionary"), and after investigating, I found several sources suggesting to add UUID to the model. It seemed to have helped, no runtime error since. Commented Dec 19, 2025 at 6:56
  • If you create the sample data and then quit and restart the app, does it work correctly then when selecting a grandparent? Commented Dec 19, 2025 at 8:17
  • 1
    I actually meant the other way, start with the children of the grandparent and then merge together all the children of each child. Commented Dec 19, 2025 at 16:46
  • 1
    The ten seconds or so delay is likely the modelContext autosaving, which can cause views to update and possibly lose state. I suppose you fill the database in the buildFamily function which wasn't provided in your code. If you don't have it in there already, try adding modelContext.save() and see if you still experience the same issue. Commented Dec 20, 2025 at 16:53

1 Answer 1

1

I suspect the issues here are two-fold:

  1. You're not explicitly saving the context in your buildFamily() function

  2. The 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)
}
answered Dec 21, 2025 at 18:32
Sign up to request clarification or add additional context in comments.

1 Comment

Thank you, Andrei, I now understand the behavior!

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.