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 DisclosureGroup
s 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.
This use case passes without an issue. I can delete the "Groceries" record and it removes it from the Views and database.
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.
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.
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.
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.
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)
}
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.