Search code examples
swiftswiftdata

Why does a ModelActor context retrieve empty models when they have already been saved?


Let's create a simple app using two SwiftData models.

@Model
final class Item {
    @Attribute(.unique) var timestamp: Date
    var tag: Tag?
    
    init(timestamp: Date, tag: Tag? = nil) {
        self.timestamp = timestamp
        self.tag = tag
    }
}

@Model
final class Tag {
    @Attribute(.unique) var name: String
    var items: [Item]
    
    init(name: String, items: [Item]) {
        self.name = name
        self.items = items
    }
}

A ModelContainer is injected into all windows.

@main
struct ModelActorDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Item.self, Tag.self])
    }
}

A ModelActor is created to inject some sample data.

@ModelActor
final actor DataManager {
    var idByTagName = [String : PersistentIdentifier]()
    var idByItemDate = [Date : PersistentIdentifier]()

    func run() async throws {
        for name in ["foo", "bar", "baz"] {
            let tag = Tag(name: name, items: [])
            modelContext.insert(tag)
            idByTagName.updateValue(tag.id, forKey: name)
        }
        try modelContext.save()
        
        for date in [Date.now, Date.now, Date.now] {
            let item = Item(timestamp: date, tag: nil)
            modelContext.insert(item)
            idByItemDate.updateValue(item.id, forKey: date)
        }
        try modelContext.save()
        
        for (name, id) in idByTagName {
            let tag = self[id, as: Tag.self]!
            let item = self[idByItemDate.randomElement()!.value, as: Item.self]!
            tag.items.append(item)
        }
        try modelContext.save()
    }
}

So that DataManager can run into a task.

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        NavigationStack {
            // …
        }
        .task {
            let dataManager = DataManager(modelContainer: modelContext.container)
            do {
                try await dataManager.run()
            } catch {
                debugPrint(error)
                fatalError(error.localizedDescription)
            }
        }
    }
}

Running this causes a CoreData/SwiftData error. Why are tags and items empty?

CoreData: annotation: repairing validation failure Error Domain=NSCocoaErrorDomain Code=1560 "Multiple validation errors occurred." UserInfo={NSDetailedErrors=( "Error Domain=NSCocoaErrorDomain Code=1570 "%{PROPERTY}@ is a required value." UserInfo={NSValidationErrorObject=<NSManagedObject: 0x600000c3fc00> (entity: Item; id: 0x600002fe93e0 x-coredata:///Item/t07C086EC-9380-4BB6-97C5-9263B27DF24E7; data: {\n tag = "0x600002ffa060 x-coredata:///Tag/t07C086EC-9380-4BB6-97C5-9263B27DF24E2";\n timestamp = nil;\n}), NSLocalizedDescription=%{PROPERTY}@ is a required value., NSValidationErrorKey=timestamp, NSValidationErrorValue=null}", "Error Domain=NSCocoaErrorDomain Code=1550 "%{PROPERTY}@ is not valid." UserInfo={Dangling reference to an invalid object.=null, NSValidationErrorValue=<NSManagedObject: 0x600000c3f340> (entity: Tag; id: 0x600002ffa060 x-coredata:///Tag/t07C086EC-9380-4BB6-97C5-9263B27DF24E2; data: {\n items = (\n "0x600002fe93e0 x-coredata:///Item/t07C086EC-9380-4BB6-97C5-9263B27DF24E7"\n );\n name = nil;\n}), NSAffectedObjectsErrorKey=(\n "<NSManagedObject: 0x600000c3f340> (entity: Tag; id: 0x600002ffa060 x-coredata:///Tag/t07C086EC-9380-4BB6-97C5-9263B27DF24E2; data: {\n items = (\n \"0x600002fe93e0 x-coredata:///Item/t07C086EC-9380-4BB6-97C5-9263B27DF24E7\"\n );\n name = nil;\n})"\n), NSValidationErrorObject=<NSManagedObject: 0x600000c3fc00> (entity: Item; id: 0x600002fe93e0 x-coredata:///Item/t07C086EC-9380-4BB6-97C5-9263B27DF24E7; data: {\n tag = "0x600002ffa060 x-coredata:///Tag/t07C086EC-9380-4BB6-97C5-9263B27DF24E2";\n timestamp = nil;\n}), NSLocalizedDescription=%{PROPERTY}@ is not valid., NSValidationErrorKey=tag, NSValidationErrorShouldAttemptRecoveryKey=true}" )}


Solution

  • There are two things at play here, the first one is that your cache (dictionary) isn't valid and the other looks like a bug in ModelActor and/or ModelContext.

    When creating a model instance and insert it into a model context the id property is generated and set but this value is only temporary and will change to a permanent id once save() is called on the ModelContext.

    This is clearly seen if we print the property in the debugger, first for the unsaved object

    p idByTagName["foo"]
    (SwiftData.PersistentIdentifier?) some {
      id = (url = "x-coredata:///Tag/t6A8A0E75-DE4B-45D5-83E1-4AEA970ED56C8")
      implementation = 0x0000600002f65140 {
        storeIdentifier = nil
        isTemporary = true
        URIRepresentation = "x-coredata:///Tag/t6A8A0E75-DE4B-45D5-83E1-4AEA970ED56C8"
        primaryKey = "t6A8A0E75-DE4B-45D5-83E1-4AEA970ED56C8"
        entityName = "Tag"
        managedObjectID = 0x0000600000baef60 {
          ... (edited out)
        }
      }
    }
    

    and then the saved object

    id = (url = "x-coredata://7EF034C1-B220-42A9-86ED-555E79BA6936/Tag/p18")
      implementation = 0x0000600002f17d20 {
        storeIdentifier = some {
          _guts = {
            _object = (_countAndFlagsBits = 13835058055282163748, _object = 0x4000600001e63940)
          }
        }
        isTemporary = false
        URIRepresentation = "x-coredata://7EF034C1-B220-42A9-86ED-555E79BA6936/Tag/p18"
        primaryKey = "p18"
        entityName = "Tag"
        managedObjectID = 0x805629559f5bd665 {}
      }
    }
    

    You can clearly see the difference (note that the id property has an id property!). So storing away the PersistentIdentifier value for an unsaved model object is a bad idea.

    Now the second part of this is that let tag = self[id, as: Tag.self]! actually returns an object (that seems empty) for some reason instead of nil. Here you should have gotten nil back and a crash because of force unwrapping a nil value. So this must be a bug in SwiftData as I mentioned earlier.


    A quick fix for this is to move try modelContext.save() inside the loop before you add the id to the dictionary so it is the final version you use.

    A better solution is to not use any dictionaries at all and instead fetch all Item and Tag objects before the third loop where you connect them.