Swift Data issue with a recursive model class

Dear Apple Developer Forum

As the title suggests, I have an issue with Swift Data when I want to modify a property of a recursive model class instance. Please consider the following sample project:

import SwiftUI
import SwiftData

@main
struct ISSUEApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Item.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

@Model
final class Item {
    var name: String?
    var parent: Item?
    
    init(name: String?, parent: Item?) {
        self.name = name
        self.parent = parent
    }
    
}


struct ContentView: View {
    
    @Environment(\.modelContext) private var context
    @Query private var items: [Item]
    @State private var itemToMove: Item?
    @State private var count: Int = 0
    @State private var presentMoveView: Bool = false
    
    var body: some View {
        NavigationStack() {
            List(items, id: \.id) {item in
                Button(action: {
                    itemToMove = item
                }, label: {
                    Text("Id: \(item.name ?? "ERROR") and my parent iD is \(item.parent?.name ?? "root")")
                        .bold(itemToMove == item)
                        .italic(itemToMove == item)
                })
            }
            .sheet(isPresented: $presentMoveView, content: {
                MoveView(toMove: self.itemToMove!)
            })
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        let i = Item(name: "\(count)", parent: nil)
                        context.insert(i)
                        try? context.save()
                        count += 1
                    }, label: {
                        Text("Add an item")
                    })
                }
                ToolbarItem(placement: .navigationBarLeading) {
                    Button(action: {
                        presentMoveView.toggle()
                    }, label: {
                        Text("Move selected item")
                    })
                }
                
            }
            
        }
        
    }
}

struct MoveView: View {
    
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    @Query private var items: [Item]
    @Bindable var toMove: Item
    @State private var selectedFutureParent: Item?
    
    var body: some View {
        NavigationStack(){
            List(items, id: \.id) {item in
                Button(action: {
                    selectedFutureParent = item
                }, label: {
                    Text("Id: \(item.name ?? "ERROR") and my parent iD is \(item.parent?.name ?? "root")")
                        .bold(selectedFutureParent == item)
                        .italic(selectedFutureParent == item)
                })
            }
            .toolbar(){
                ToolbarItem{
                    Button("Move", action: {
                        toMove.parent = selectedFutureParent
                        dismiss()
                    })
                }
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)
}

Please launch the preview of this app, add items (as many as you'd like), select one and click on the "Move selected item" button. Select the new parent of the item. As you may have noticed, both selected items (moved item and new parent) are modified, whereas only one equality is used. This issue seems to be independent from the @Bindable property wrapper. I tried many things, such as using index instead of direct elements; using local let constant for the parent but the constant is still modified (very weird...)

Thank you in advance for your help ! Best regards

Answered by DTS Engineer in 808068022

Based on your description, SwiftData actually behaves correctly. To elaborate, let's look at your model:

@Model
final class Item {
    var name: String?
    var parent: Item?
    ....
}

You rely on SwiftData to infer that parent is a to-one relationship, which is fine, but as a part of the inference process, SwiftData also determines that the inverse relationship of parent is parent. So assuming you have two items, item1 and item2, when you set item1.parent to item2, SwiftData will automatically set item2.parent to item1, because it sees item2.parent as the inverse relationship of item1.parent.

If you don't intent to have an inverse relationship, you can explicitly specify a nil inverse relationship using Relationship:

@Model
final class Item {
    var name: String?
    @Relationship(deleteRule: .nullify, inverse: nil)
    var parent: Item?
    ....
}

If your intent is to have a bi-directional to-one relationship, add an inverse relationship, as shown below:

@Model
final class Item {
    var name: String?
    var child: Item?

    @Relationship(deleteRule: .nullify, inverse: \Item.child)
    var parent: Item?
    ....
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Accepted Answer

Based on your description, SwiftData actually behaves correctly. To elaborate, let's look at your model:

@Model
final class Item {
    var name: String?
    var parent: Item?
    ....
}

You rely on SwiftData to infer that parent is a to-one relationship, which is fine, but as a part of the inference process, SwiftData also determines that the inverse relationship of parent is parent. So assuming you have two items, item1 and item2, when you set item1.parent to item2, SwiftData will automatically set item2.parent to item1, because it sees item2.parent as the inverse relationship of item1.parent.

If you don't intent to have an inverse relationship, you can explicitly specify a nil inverse relationship using Relationship:

@Model
final class Item {
    var name: String?
    @Relationship(deleteRule: .nullify, inverse: nil)
    var parent: Item?
    ....
}

If your intent is to have a bi-directional to-one relationship, add an inverse relationship, as shown below:

@Model
final class Item {
    var name: String?
    var child: Item?

    @Relationship(deleteRule: .nullify, inverse: \Item.child)
    var parent: Item?
    ....
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Swift Data issue with a recursive model class
 
 
Q