What code changes (code attached) required to ensure core data @FetchRequest variable behaves dynamically?

How should my code be modified to ensure that when an exception happens at the Core Data layer when adding a new item, that SwiftUI does NOT continue to show a new item?


Background: When I run the below code I get an exception when adding a new item doing the "context.save()", HOWEVER whilst the new item request really failed (did not save to Core Data), the UI does show a new item. It is as if the "lists" variable in the @FetchRequest line is not behaving dynamically.


Question - How do I fix code so that the application works properly?



Code:


    import SwiftUI
   
    struct ContentView: View {
        @Environment(\.managedObjectContext) var context
        @FetchRequest(fetchRequest: List.allListFetchRequest()) var lists: FetchedResults
       
        private func addListItem() {
            let newList = List(context: context)
            newList.id = 1
            newList.title = "Testing 123"
            do {
                try context.save()
            } catch let e as NSError {
                print("Could not save new List. \(e.debugDescription)")
                return
            }
           
        }
   
        var body: some View {
            NavigationView {
                VStack {
                    ForEach(lists) { list in
                        Text("List = \(list.title)")
                    }
                }
                .navigationBarTitle( Text("My Todo Lists") )
                .navigationBarItems(
                    trailing: Button(action: {self.addListItem()} ) {
                        Image(systemName: "plus.square")
                    }
                )
            }
        }
    }


Example Output:


    Could not save new List. Error Domain=NSCocoaErrorDomain Code=133021 "(null)" UserInfo={NSExceptionOmitCallstacks=true, conflictList=(
        "NSConstraintConflict (0x600001fcccc0) for constraint (\n    id\n): database: (null), conflictedObjects: (\n    \"0x600000a7e360 \",\n    \"0xfb1bb528bb57810c \"\n)"
    )}
  • This is a constraint error. I'll submit an answer so it's clear what it is and possibly how to fix it.

Add a Comment

Accepted Reply

This is happening, I believe, because the content of the in-memory object graph and the on-disk storage are different things, and SwiftUI is watching one, while the error is talking about the other.


SwiftUI is looking at the contents of your ManagedObjectContext. Since you've created a new item, it sees that (you had 2 items, now you have 3). When you try to save, that operation fails, and it gives you information on why—in this case, the `id` property value isn't unique—so that you can modify the version in your context and try again. The object is still there, though, so SwiftUI still sees it and displays it.


So, to prevent SwiftUI showing an object that couldn't be saved, the solution is actually fairly straightforward: in your `catch` block, delete the offending object from the ManagedObjectContext:


private func addListItem() {
    context.performBlock { context in
        let newList = List(context: context)
        newList.id = 1
        newList.title = "Testing 123"
        do {
            try context.save()
        }
        catch {
            print("Failed to save new item. Error = \(error)")
            context.delete(newList)
            // don't need to save here, because we're in `performBlock` and have reverted the only unsaved change.
        }
    }
}


Also, it's a good idea to use `performBlock` when working with your managed object contexts, because threading issues with CoreData are really insiduous and quite hard to debug.

Replies

This is happening, I believe, because the content of the in-memory object graph and the on-disk storage are different things, and SwiftUI is watching one, while the error is talking about the other.


SwiftUI is looking at the contents of your ManagedObjectContext. Since you've created a new item, it sees that (you had 2 items, now you have 3). When you try to save, that operation fails, and it gives you information on why—in this case, the `id` property value isn't unique—so that you can modify the version in your context and try again. The object is still there, though, so SwiftUI still sees it and displays it.


So, to prevent SwiftUI showing an object that couldn't be saved, the solution is actually fairly straightforward: in your `catch` block, delete the offending object from the ManagedObjectContext:


private func addListItem() {
    context.performBlock { context in
        let newList = List(context: context)
        newList.id = 1
        newList.title = "Testing 123"
        do {
            try context.save()
        }
        catch {
            print("Failed to save new item. Error = \(error)")
            context.delete(newList)
            // don't need to save here, because we're in `performBlock` and have reverted the only unsaved change.
        }
    }
}


Also, it's a good idea to use `performBlock` when working with your managed object contexts, because threading issues with CoreData are really insiduous and quite hard to debug.

thanks Jim - I'll try this - by the way do you create a private context to use performBlock? Or are you using this with the main context (a NSMainQueueConcurrencyType)? Just looking up performBlock (Xcode is telling me it's renamed to perform) and how to use and often they seem to use it with a private NSPrivateQueueConcurrencyType context you create(?)...

(by the way it worked per your code suggestion - no use of private contact)

The perform() methods will always work; for unspecified concurrency types I believe they just run the block synchronously. However, using it everywhere leads to nicely portable code: it'll work on any concurrency type.

Error

This is a constraint error.

It means you added a constraint for the id attribute on your List entity.

So when you try to save, Core Data sees there is a duplicate id and so will throw an NSConstraintConflict error.

Your error message could be: "That id already exists." or "Duplicate id found."

Another way to handle this is to set the mergePolicy property on your context property:

context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump // Keeps the new entity

or

context.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump // Keeps the existing entity

You can set that mergePolicy using onAppear for just this view or set it when you first create your persistent container.

Hope this helps future Core Data coders!