Hi! I'm running into some confusing behavior when attempting to delete all instance of one model type from a ModelContext
. My problem is specifically using the delete(model:where:includeSubclasses:)
^1 function (and passing in a model type). I seem to be running into situations where this function fails silently without throwing an error (no models are deleted).
I am seeing this same behavior from Xcode_15.4.0
and Xcode_16_beta_4
.
I start with a model:
@Model final public class Item {
var timestamp: Date
public init(timestamp: Date = .now) {
self.timestamp = timestamp
}
}
Here is an example of a Store
class that wraps a ModelContext
:
final public class Store {
public let modelContext: ModelContext
public init(modelContainer: SwiftData.ModelContainer) {
self.modelContext = ModelContext(modelContainer)
}
}
extension Store {
private convenience init(
schema: Schema,
configuration: ModelConfiguration
) throws {
let container = try ModelContainer(
for: schema,
configurations: configuration
)
self.init(modelContainer: container)
}
}
extension Store {
public convenience init(url: URL) throws {
let schema = Schema(Self.models)
let configuration = ModelConfiguration(url: url)
try self.init(
schema: schema,
configuration: configuration
)
}
}
extension Store {
public convenience init(isStoredInMemoryOnly: Bool = false) throws {
let schema = Schema(Self.models)
let configuration = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)
try self.init(
schema: schema,
configuration: configuration
)
}
}
extension Store {
public func fetch<T>(_ type: T.Type) throws -> Array<T> where T : PersistentModel {
try self.modelContext.fetch(
FetchDescriptor<T>()
)
}
}
extension Store {
public func fetchCount<T>(_ type: T.Type) throws -> Int where T : PersistentModel {
try self.modelContext.fetchCount(
FetchDescriptor<T>()
)
}
}
extension Store {
public func insert<T>(_ model: T) where T : PersistentModel {
self.modelContext.insert(model)
}
}
extension Store {
public func delete<T>(model: T.Type) throws where T : PersistentModel {
try self.modelContext.delete(model: model)
}
}
extension Store {
public func deleteWithIteration<T>(model: T.Type) throws where T : PersistentModel {
for model in try self.fetch(model) {
self.modelContext.delete(model)
}
}
}
extension Store {
private static var models: Array<any PersistentModel.Type> {
[Item.self]
}
}
That should be pretty simple… I can use this Store
to read and write Item
instances to a ModelContext
.
Here is an example of an executable that shows off the unexpected behavior:
func main() async throws {
do {
let store = try Store(isStoredInMemoryOnly: true)
store.insert(Item())
print(try store.fetchCount(Item.self) == 1)
try store.delete(model: Item.self)
print(try store.fetchCount(Item.self) == 0)
}
do {
let store = try Store(isStoredInMemoryOnly: true)
store.insert(Item())
print(try store.fetchCount(Item.self) == 1)
try store.deleteWithIteration(model: Item.self)
print(try store.fetchCount(Item.self) == 0)
}
do {
let store = try StoreActor(isStoredInMemoryOnly: true)
await store.insert(Item())
print(try await store.fetchCount(Item.self) == 1)
try await store.delete(model: Item.self)
print(try await store.fetchCount(Item.self) == 0)
}
do {
let store = try StoreActor(isStoredInMemoryOnly: true)
await store.insert(Item())
print(try await store.fetchCount(Item.self) == 1)
try await store.deleteWithIteration(model: Item.self)
print(try await store.fetchCount(Item.self) == 0)
}
}
try await main()
My first step is to set up an executable with an info.plist
to support SwiftData.^2
My expectation is all these print
statements should be true
. What actually happens is that the calls to delete(model:where:includeSubclasses:)
seem to not be deleting any models (and also seem to not be throwing errors).
I also have the option to test this behavior with XCTest
. I see the same unexpected behavior:
import XCTest
final class StoreXCTests : XCTestCase {
func testDelete() throws {
let store = try Store(isStoredInMemoryOnly: true)
store.insert(Item())
XCTAssert(try store.fetchCount(Item.self) == 1)
try store.delete(model: Item.self)
XCTAssert(try store.fetchCount(Item.self) == 0)
}
func testDeleteWithIteration() throws {
let store = try Store(isStoredInMemoryOnly: true)
store.insert(Item())
XCTAssert(try store.fetchCount(Item.self) == 1)
try store.deleteWithIteration(model: Item.self)
XCTAssert(try store.fetchCount(Item.self) == 0)
}
}
Those tests fail… implying that the delete(model:where:includeSubclasses:)
is not actually deleting any models.
FWIW… I see the same behavior (from command-line and XCTest
) when my Store
conforms to ModelActor
.^3 ^4
This does not seem to be the behavior I am seeing from using the delete(model:where:includeSubclasses:)
in a SwiftUI app.^5 Calling the delete(model:where:includeSubclasses:)
function from SwiftUI does delete all the model instances.
The SwiftUI app uses a ModelContext
directly (without a Store
type). I can trying writing unit tests directly against ModelContext
and I see the same behavior as before (no model instances are being deleted).^6
Any ideas about that? Is this a known issue with SwiftData that is being tracked? Is the delete(model:where:includeSubclasses:)
known to be "flaky" when called from outside SwiftUI? Is there anything about the way these ModelContext
instance are being created that we think is leading to this unexpected behavior?
Thanks for providing the tests, which indeed unveil a difference between delete<T>(_ model: T)
and delete(model:where:includeSubclasses:)
.
The API reference of delete<T>(_ model: T)
says:
If the model is new and in an unsaved state, the context simply discards it.
This makes it clear that the method discards the items that are inserted into the context but not saved to the store.
The reference of delete(model:where:includeSubclasses:)
doesn't mention that behavior, and I believe that is because delete(model:where:includeSubclasses:)
goes down directly to the store to delete the objects (for better performance), like what NSBatchDeleteRequest does, and doesn't discard the unsaved objects in the context.
In your tests, if you save the inserted item, delete(model: Item.self)
will do the work, as shown below:
let modelContext = ModelContext(container)
modelContext.insert(Item())
try modelContext.save() // Save the item after inserting it.
print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 1) // print true.
try modelContext.delete(model: Item.self)
print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 0) // print true.
The difference is indeed pretty subtle, or even confusing, and is worth a better documentation. If you don't mind, I'd suggest that you file a feedback report (http://developer.apple.com/bug-reporting/) – If you do so, please share your report ID here for folks to track.
Best,
——
Ziqiao Chen
Worldwide Developer Relations.