SwiftData ModelContext fails to delete all model instances from unit tests.

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?

Answered by DTS Engineer in 799169022

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.

Did you try to save the model context after doing the deletion? When using a model context created with ModelContext(modelContainer), you need to save the changes explicitly because the auto-save doesn't come to play, which is different from when you use mainContext.

If that doesn't help, I'd be interested in taking a closer look if you can provide a sample project to demo the issue.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Did you try to save the model context after doing the deletion? When using a model context created with ModelContext(modelContainer), you need to save the changes explicitly because the auto-save doesn't come to play, which is different from when you use mainContext.

Hmm… I can give that a try. My question then would be is why do these two functions:

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)
    }
  }
}

Seem to show different behavior without an explicit save being called? Calling the delete function (with no explicit save) returns with no error thrown and no models have been deleted. Calling the deleteWithIteration function (with no explicit save) returns with no error thrown and all models have been deleted. Should those two functions not return with the same state (either both functions delete all models or both functions delete no models)?

If that doesn't help, I'd be interested in taking a closer look if you can provide a sample project to demo the issue.

https://github.com/vanvoorden/2024-08-02

Here is a repo to reproduce the behaviors. I am not seeing any change when I explicitly save my model context.

Accepted Answer

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.

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.

Ahh… interesting! This is very valuable insight. Thanks! I am beginning to understand this behavior more.

A follow up question is I am still looking for one "single shot" function to delete all model instances (including staged and pending models prior to save). I can think of (at least) three options:

Explicitly save before attempting to delete all:

func testSaveAndDelete() throws {
  let modelContext = ModelContext(container)
  modelContext.insert(Item())
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 1)
  modelContext.insert(Item())
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 2)
  try modelContext.save()
  try modelContext.delete(model: Item.self)
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 0)
}

Attempt to delete all and then follow up with an iteration through all remaining:

func testSaveAndDeleteAndIterate() throws {
  let modelContext = ModelContext(container)
  modelContext.insert(Item())
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 1)
  try modelContext.save()
  modelContext.insert(Item())
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 2)
  try modelContext.delete(model: Item.self)
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 1)
  for model in try modelContext.fetch(FetchDescriptor<Item>()) {
    modelContext.delete(model)
  }
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 0)
}

Iterate through all inserted models prior to delete all:

func testSaveAndIterateAndDelete() throws {
  let modelContext = ModelContext(container)
  modelContext.insert(Item())
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 1)
  try modelContext.save()
  modelContext.insert(Item())
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 2)
  for model in modelContext.insertedModelsArray {
    //  TODO: filter only for `item` models! :D
    modelContext.delete(model)
  }
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 1)
  try modelContext.delete(model: Item.self)
  print(try modelContext.fetchCount(FetchDescriptor<Item>()) == 0)
}

All of these options seem to operate correctly and pass my tests. any opinion about which pattern might provide the right choice to optimize for memory and CPU? My intuition tells me the most efficient pattern might depend on the model schema and the state of this context at any point in time. Any more insight about what might be the right option here? Thanks!

SwiftData ModelContext fails to delete all model instances from unit tests.
 
 
Q