swiftswiftuiswiftdata

One-to-Many circular reference deletion has unexpected side-effects


I have a data model that allows for nesting of TodoLists within another TodoList. From an app perspective, I'm trying to accomplish something like:

- Groceries
- Home
  - Gardening
  - Remodeling
    - Kids Room
- Work
  - Projects
  - Training

I've initially created a SwiftData model that allows me to do that:

enum Ownership: String, CaseIterable, Codable {
    case system = "SYSTEM"
    case user = "USER"
}

@Model
class TodoList {
    
    @Attribute(.unique)
    var todoListID: UUID

    var name: String
    var ownership: Ownership

    var childrenTodoList: [TodoList] = []
    
    @Relationship(deleteRule: .cascade, inverse: \TodoList.childrenTodoList)
    var parentTodoList: TodoList?
    
    init(name: String, todoListID: UUID = UUID(), children: [TodoList] = [], parent: TodoList? = nil, ownership: Ownership) {
        self.todoListID = todoListID
        self.name = name
        self.childrenTodoList = children
        self.parentTodoList = parent
        self.ownership = ownership
    }
}

I then created some sample data to mirror the hierarchy I'm trying to accomplish within the view:

struct SampleData {
    static let todoList: [TodoList] = {
        let inbox = TodoList(name: "Inbox", ownership: .system)
        let work = TodoList(name: "Work", ownership: .user)
        let home = TodoList(name: "Home", ownership: .user)
        let workProjects = TodoList(name: "Projects", ownership: .user)
        let workTraining = TodoList(name: "Training", ownership: .user)
        let homeGardening = TodoList(name: "Gardening", ownership: .user)
        let homeRemodeling = TodoList(name: "Remodeling", ownership: .user)
        let kidsRoom = TodoList(name: "Kids Room", ownership: .user)
        
        work.childrenTodoList.append(contentsOf: [ workProjects, workTraining])
        workProjects.parentTodoList = work
        workTraining.parentTodoList = work
        
        home.childrenTodoList.append(contentsOf: [ homeGardening, homeRemodeling ])
        homeGardening.parentTodoList = home
        homeRemodeling.parentTodoList = home
        
        homeRemodeling.childrenTodoList.append(kidsRoom)
        kidsRoom.parentTodoList = homeRemodeling
        
        return [ inbox, work, home, workProjects, workTraining, homeGardening, homeRemodeling, kidsRoom ]
    }()
}

In the View, I bring the data in via @Query and put it into a series of children views using a List and DisclosureGroup to make the nesting work. I can create new records and get them added to the modelContext and see the view and children views update automatically which is great.

I'm now trying to delete the data and I've run into a weird issue that I've not had any luck sorting out. I'm trying to determine if it's a limitation of SwiftData and I'll need to manually manage the data or if I'm just using the framework incorrectly.

Like I mentioned, in a parent view I bring the data in via @Query and use a computed properties to filter the data for use by children views. This sends root TodoList items into a List with DisclosureGroups and then it iterates over the children in each root list to create the hierarchy.

    @Query(sort: \TodoList.name)
    var todoLists: [TodoList]

    private var systemTodoLists: [TodoList] {
        todoLists.filter { $0.ownership == Ownership.system && $0.parentTodoList == nil }
    }
    
    private var userTodoLists: [TodoList] {
        todoLists.filter { $0.ownership == Ownership.user && $0.parentTodoList == nil }
    }
    
    private var favoriteTodoLists: [TodoList] {
        todoLists.filter { $0.isFavorite }
    }

One of the Views represents a Row in the list and has a Swipe action associated with it. Within the Swipe action, I have a button for deletion of the list. When pressed I use the modelContext to delete the list.

    .onTapGesture {
        modelContext.delete(todoList)
        try! modelContext.save()
    }

I have the following use-cases to demonstrate my issues and what the desired result is for each use case.

Screenshot of the data hierarchy before applying any use-case. enter image description here

Use Case #1: No Parent, No Children

This use case passes without an issue. I can delete the "Groceries" record and it removes it from the Views and database. enter image description here

Use Case #2: No Parent, Has Children

This use case fails. When I delete the "Work" record it deletes the "Work" record but then all of the children ("Projects" & "Training") are moved to the root of the list rather then cascading the delete. enter image description here

Use Case #3: Has Parent, No Children

This use case fails. When I delete the "Projects" record beneath "Work", it will delete "Projects" along with it's parent "Work". The "Training" record is promoted to the root of the list and not deleted. I would expect "Projects" to be deleted and "Work" be left alone with "Training" untouched. enter image description here

Use Case #4: Has Parent, Has Children

This use case fails. When I delete the "Remodeling" record it will delete itself along with it's parent "Home". The "Remodeling" child, "Kids Room" is left alone and promoted to the root of the list. The "Remodeling" sibling, "Gardening" is promoted to the root of the list. I would expect "Home" to be left alone and "Gardening" to be left alone as a child of "Home". I would expect "Remodeling" and "Kids Room" to be deleted. enter image description here

Use Case #5: No Parent, Has Children & GrandChildren

This use case fails. When I delete the "Home" record it will delete itself while leaving behind "Remodeling", "Remodeling/Kids Room" and "Gardening". I would expect for all of these children records to be deleted. enter image description here

What am I doing wrong with my data model here? I have tried several different combinations of the @Relationship macro both on the childrenTodoList property and the parentTodoList property to get the desired behavior but I've not been successful.

Is this a limitation of SwiftData or the underlying Core Data frameworks? If so, can I mix the management of the data (using @Query in views) with viewModels and data stores to manage the deletion and cleanup within the model parent/child relationship? Or would I have to completely remove the usage of @Query and just load/manage the data within my own data store via the Container myself?

I have a complete repro below. You can comment out the onAppear to prevent resetting the database if needed.

import SwiftUI
import SwiftData

// MARK: - Models
enum Ownership: String, CaseIterable, Codable {
    case system = "SYSTEM"
    case user = "USER"
}

@Model
class TodoList {
    
    @Attribute(.unique)
    var todoListID: UUID

    var name: String
    var ownership: Ownership

    var childrenTodoList: [TodoList] = []
    
    @Relationship(deleteRule: .cascade, inverse: \TodoList.childrenTodoList)
    var parentTodoList: TodoList?
    
    init(name: String, todoListID: UUID = UUID(), children: [TodoList] = [], parent: TodoList? = nil, ownership: Ownership) {
        self.todoListID = todoListID
        self.name = name
        self.childrenTodoList = children
        self.parentTodoList = parent
        self.ownership = ownership
    }
}

// MARK: - Data
struct SampleData {
    static let todoList: [TodoList] = {
        let inbox = TodoList(name: "Inbox", ownership: .system)
        let groceries = TodoList(name: "Groceries", ownership: .user)
        let work = TodoList(name: "Work", ownership: .user)
        let home = TodoList(name: "Home", ownership: .user)
        let workProjects = TodoList(name: "Projects", ownership: .user)
        let workTraining = TodoList(name: "Training", ownership: .user)
        let homeGardening = TodoList(name: "Gardening", ownership: .user)
        let homeRemodeling = TodoList(name: "Remodeling", ownership: .user)
        let kidsRoom = TodoList(name: "Kids Room", ownership: .user)
        
        work.childrenTodoList.append(contentsOf: [ workProjects, workTraining])
        workProjects.parentTodoList = work
        workTraining.parentTodoList = work
        
        home.childrenTodoList.append(contentsOf: [ homeGardening, homeRemodeling ])
        homeGardening.parentTodoList = home
        homeRemodeling.parentTodoList = home
        
        homeRemodeling.childrenTodoList.append(kidsRoom)
        kidsRoom.parentTodoList = homeRemodeling
        
        return [ inbox, groceries, work, home, workProjects, workTraining, homeGardening, homeRemodeling, kidsRoom ]
    }()
}

actor PreviewContainer {
    
    @MainActor
    static var container: ModelContainer = {
        return try! inMemoryContainer()
    }()

    static var inMemoryContainer: () throws -> ModelContainer = {
        let schema = Schema([ TodoList.self ])
        let container = try! ModelContainer(
            for: schema,
            configurations: [ModelConfiguration(isStoredInMemoryOnly: false)])
        
        return container
    }
}

// MARK: - Views

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    
    var body: some View {
        MainView()
            .onAppear {
                try! modelContext.delete(model: TodoList.self)
                SampleData.todoList.forEach { modelContext.insert($0) }
                try! modelContext.save()
            }
    }
}

struct MainView: View {
    @Query(sort: \TodoList.name)
    var todoLists: [TodoList]
    
    @Environment(\.modelContext) private var modelContext

    private var systemTodoLists: [TodoList] {
        todoLists.filter { $0.ownership == Ownership.system && $0.parentTodoList == nil }
    }
    
    private var userTodoLists: [TodoList] {
        todoLists.filter { $0.ownership == Ownership.user && $0.parentTodoList == nil }
    }
    
    var body: some View {
        List {
            ForEach(systemTodoLists) { list in
                Text(list.name)
            }
            
            Section("Lists") {
                ForEach(userTodoLists) { list in
                    getListContent(list)
                }
            }
        }
    }
    
    @ViewBuilder
    func getListContent(_ list: TodoList) -> some View {
        if list.childrenTodoList.isEmpty {
            getRowForList(list)
        } else {
            getRowForParentList(list)
        }
    }
    
    @ViewBuilder
    func getRowForParentList(_ list: TodoList) -> some View {
        DisclosureGroup(isExpanded: .constant(true)) {
            ForEach(list.childrenTodoList.sorted(by: { $0.name < $1.name })) { child in
                getListContent(child)
            }
        } label: {
            getRowForList(list)
        }
        
    }
    
    func getRowForList(_ todoList: TodoList) -> some View {
        HStack {
            Text(todoList.name)
            Spacer()
        }
        .contentShape(Rectangle())
        .onTapGesture {
            modelContext.delete(todoList)
            try! modelContext.save()
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(PreviewContainer.container)
}


Solution

  • deleteRule: .cascade means cascade the deletion to the specified target. In other words, "when I'm getting deleted, delete this object too." So for what you wrote:

    var childrenTodoList: [TodoList] = []
    
    @Relationship(deleteRule: .cascade, inverse: \TodoList.childrenTodoList)
    var parentTodoList: TodoList?
    

    This means when you delete a list, SwiftData should delete its parent as well (which cascades to that list's parent, etc. all the way up the tree). That's why all the siblings of ancestors cascaded to the root—their parent was deleted so the field was set to nil. That seems unintuitive and quite unlikely to be your intent.

    What you almost certainly mean is to cascade the deletion to the children:

    @Relationship(deleteRule: .cascade, inverse: \TodoList.parentTodoList)
    var childrenTodoList: [TodoList] = []
    
    var parentTodoList: TodoList?
    

    This means when you delete a list, SwiftData should delete all its child lists (and their children, etc.). It will also automatically remove the deleted node from its parent's list of children, no need to do that yourself.

    If you wanted to do something like move a deleted list's children up to its parent, you'd have to do that manually.