Search code examples
ioscore-datansfetchedresultscontrollernsbatchupdaterequest

NSFetchedResultsController doesn't know data was changed with a NSBatchUpdateRequest


TL;DR

Apparently, NSFetchedResultsController doesn't know data was changed on a given NSManagedObjectContext when that change happens by executing a NSBatchUpdateRequest. Is it possible to force NSFetchedResultsController to reload its data so controller(_:didChange:at:for:newIndexPath:) on NSFetchedResultsControllerDelegate gets called?

The problem

I'm using a fetched results controller to be informed about changes in the underlying data so I can update my table view when it changes. I'm adopting the NSFetchedResultsControllerDelegate protocol and implementing the controller(_:didChange:at:for:newIndexPath:) method. Everything seems to be working, because the did change method it's called when I insert, delete or update individual items.

The problem happens when I'm batch-updating several items with NSBatchUpdateRequest. When I do that, the delegate method controller(_:didChange:at:for:newIndexPath:) is not called (I'm executing these updates in the same context used by the fetched results controller).

Code

#1: Fetched results controller initialization:

func createFetchedResultsController() -> NSFetchedResultsController {
    let fetchRequest: NSFetchRequest<EAlbum> = NSFetchRequest<EAlbum>(entityName: "EAlbum")
    fetchRequest.predicate = NSCompoundPredicate(type: .and, subpredicates: createPredicates())
    addSortDescriptors(on: fetchRequest)

    let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                              managedObjectContext: AppDelegate.myPersistentContainer.viewContext,
                                                              sectionNameKeyPath: nil,
                                                              cacheName: nil)

    fetchedResultsController.delegate = self
    return fetchedResultsController
}

#2: NSFetchedResultsControllerDelegate:

extension FeedTableViewController: NSFetchedResultsControllerDelegate {

    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.beginUpdates()
    }

    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.endUpdates()
    }

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                    didChange anObject: Any,
                    at indexPath: IndexPath?,
                    for type: NSFetchedResultsChangeType,
                    newIndexPath: IndexPath?) {
        switch type {
        case .insert:
            if let indexPath = newIndexPath {
                tableView.insertRows(at: [indexPath], with: .fade)
            }
        case .delete:
            if let indexPath = indexPath {
                tableView.deleteRows(at: [indexPath], with: .fade)
            }
        default:
            // TODO
        }
    }

}

#3: Function where I use NSBatchUpdateRequest to update a bunch of items from AppDelegate.myPersistentContainer.viewContext:

func markAllAsRead(using context: NSManagedObjectContext) -> Bool {
    let request = NSBatchUpdateRequest(entityName: "EAlbum")
    request.predicate = NSPredicate(format: "%K == YES", #keyPath(EAlbum.markUnread))
    request.propertiesToUpdate = [#keyPath(EAlbum.markUnread): false]
    request.resultType = .statusOnlyResultType

    do {
        let result = try context.execute(request) as? NSBatchUpdateResult
        return result?.result as? Bool ?? false
    } catch {
        return false
    }
}

#4: Function where I update just one item from AppDelegate.myPersistentContainer.viewContext:

func updateUnreadFlag(albumId: String, markUnread: Bool, using context: NSManagedObjectContext) {
    guard let album = try? EAlbum.findAlbum(matching: albumId, in: context) else { return }
    album.markUnread = markUnread
    do {
        try context.save()
    } catch {
        // Handle error
    }
}

When #4 is called, the delegate controller(_:didChange:at:for:newIndexPath:) method is called. But when executing #3, the delegate method does not get called. It seems like NSFetchedResultsController doesn't know that data was changed on a given NSManagedObjectContext when that change happens by executing a NSBatchUpdateRequest.

Workaround I've tried

Since my fetched results controller doesn't know that data was changed, I thought about reloading it by hand like so:

func reloadFetchedResultsController() {
    fetchedResultsController = createFetchedResultsController()

    do {
        try fetchedResultsController.performFetch()
    } catch {
        // Handle error
    }

    tableView.reloadData()
}

So everytime after #3 is executed, I would call reloadFetchedResultsController() to reload the fetched results controller and the table view. But for some reason it seems that it loads the old data, because nothing changes on my table view.

Questions

A: Is it possible to let a fetched results controller know that data was changed when executing a batch update?

B: Is it possible to force my fetched results controller to reload its data? Because, as a last resort, I could force a reload on my fetched results controller after executing #3, and finally force a table view update after that, so it would reload it's cells using the newly batch-updated data from the fetched results controller.


Solution

  • From the documentation:

    After executing the batch update request you have to merge the changes into the context.

    Try

    func markAllAsRead(using context: NSManagedObjectContext) -> Bool {
        let request = NSBatchUpdateRequest(entityName: "EAlbum")
        request.predicate = NSPredicate(format: "%K == YES", #keyPath(EAlbum.markUnread))
        request.propertiesToUpdate = [#keyPath(EAlbum.markUnread): false]
        request.resultType = .updatedObjectIDsResultType
    
        do {
            let result = try context.execute(request) as? NSBatchUpdateResult
            guard let objectIDArray = result?.result as? [NSManagedObjectID] else { return false }
            let changes = [NSUpdatedObjectsKey : objectIDArray]
            NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context])
            return true
        } catch {
            return false
        }
    }