core-dataswiftuinsfetchrequest

Unreliable update with @FetchRequest predicate filtering on UUID typed attributes?


I'm new-ish to Swift, and trying to better understand Core Data with some (I thought!) simple projects.

I'm trying to use an @FetchRequest property wrapper to list child-items which belong to a given parent. This works for most cases, but not when the to-parent relationship is the only thing which would cause a child item to be displayed after a new child item is added.

The problem I'm facing seems to boil down to any filtering done with UUID values using predicates in FetchRequest decorators not being updated the same way other attributes are. The minimal example I've (with an edit) constructed shows just the behaviour when using a UUID for the predicate, and avoids any relationship complications.

My question is, is there a 'nice' way to make this behaviour (UUID in a predicate driving a FetchRequest which updates views properly) work, or something I'm missing, like registering one of the parts as an observed item, or something I'm doing triggering an update at the wrong time?

I have produced an example which illustrates the puzzle I'm having. Note: I'm using iOS 14.4 and Xcode 12.4 I appreciate these are not current, but my machine does not support an OS past Catalina, so I'm stuck here until I can afford a new one.

My Core Data model is as follows:

core data model

The Item entity has two attributes (uuid, of type UUID) and an Int16 attribute (number).

The app has two buttons: + to add a new item with a random number value and a fixed UUID of 254DC... and 5 which adds a item, with a number value of 5 (and the same, fixed, UUID).

The predicate[s] I'm using to drive this list filter Item entities which either:

The problem is that when adding (using the + button) a new entity which should show up in this view just because its uuid property matches, it does not appear. It does appear if you quit the app, and return to it (this proves that the predicate does indeed 'match' the UUID values).

After tapping 5 (now showing two rows with number 5, as expected):

two fives

After tapping + twice (unchanged view, should have two new rows showing):

two fives

If we exit the app and then reopen it, the added Items (300 and 304 in this case) are only now visible:

four rows

Different phrasing of the question: Why do these updates work unreliably on UUID, but perfectly with the number (Int16-typed) attribute?

Working as expected:

Notes:

View code

import SwiftUI
import CoreData

struct ItemList: View {
    @Environment(\.managedObjectContext) private var viewContext
   
    static let filterUUID = "254DC821-F654-4D45-BC94-CEEE12A428CB"
    
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.number, ascending: true)],
        predicate: NSPredicate(format: "number == %@ OR uuid == %@", NSNumber(5), filterUUID))
    var items : FetchedResults<Item>

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in ItemRowView(item: item) }
                .onDelete(perform: deleteItems)
            }
            .navigationTitle(Text(items.isEmpty ? "No items" : "Item count: \(items.count)"))
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    Button(action: addRandom) {
                        Label("Add Item", systemImage: "plus")
                    }
                    
                    Button(action: addFive) {
                        Label("Add Five", systemImage: "5.circle.fill")
                    }
                }
            }
        }
    }

    private func addItem(number: Int) {
        viewContext.perform {
            let newItem = Item(context: viewContext)
            newItem.uuid = UUID(uuidString: ItemList.filterUUID)
            newItem.number = Int16(number)
            try! viewContext.save()
        }
    }
    
    private func addRandom() { addItem(number: Int.random(in: 10...500)) }
    private func addFive() { addItem(number: 5) }

    private func deleteItems(offsets: IndexSet) {
            viewContext.perform {
                offsets.map { items[$0] }.forEach(viewContext.delete)
                try! viewContext.save()
            }
    }
}


struct ItemRowView : View {
    @ObservedObject var item : Item
    
    var body : some View {
        HStack {
            Text("\(item.number)")
                .font(.largeTitle)
            Text(item.uuid?.uuidString.prefix(8) ?? "None")
        }
    }
}

What have I tried already?

I've spent the best part of two days trawling documentation, StackOverflow, and various blogs, and while there have been several things which seemed on first look to be exactly the answer I was looking for, the simpler I got my example, the more confusing it became.

This started out as a similar issue related to parent-child fetching, but I've narrowed the actual problem I'm facing down to something smaller, which can be demonstrated with the single entity with an UUID, and no relationships.


Supporting code (App and PersistenceController)

import SwiftUI
import CoreData

@main
struct minimalApp: App {
    let persistenceController = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ParentListView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

struct PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "minimal")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
}

Solution

  • I believe the weird behaviour is due to your predicate, which is being evaluated in two different ways. When you first run the app, or after closing and restarting, the predicate is parsed and passed to SQLite (as a WHERE clause in a SELECT statement). Thereafter, when you add new items, the predicate is evaluated directly in memory (ie in the NSManagedObjectContext) - no need to involve SQLite.

    In your predicate, you are comparing a UUID type attribute with a String value - which fails. Even if the string representation of the UUID attribute is the same as the string you compare it with, the context sees them as different and regards the new object as failing the predicate. Hence the view is not updated.

    However, SQLite is much more tolerant of type mismatches. (I'm guessing CoreData's implementation of UUID attributes is to store the string representation - but that's just a guess). So when you quit and restart, the fetch is processed by SQLite which regards the new object as meeting the predicate and accordingly includes it in the results.

    To get the correct behaviour, I think you need your predicate to compare the UUID attribute with the UUID which has the correct string representation:

    NSPredicate(format: "number == %@ OR uuid == %@", NSNumber(5), UUID(uuidString: ItemList.filterUUID))