NSFetchedResultsController with relationship not updating
Asked Answered
S

4

35

Let's say I have two entities, Employee and Department. A department has a to-many relationship with an employee, many employees can be in each department but each employee only belongs to one department. I want to display all of the employees in a table view sorted by data that is a property of the department they belong to using an NSFetchedResultsController. The problem is that I want my table to update when a department object receives changes just like it does if the regular properties of employee change, but the NSFetchedResultsController doesn't seem to track related objects. I've gotten passed this issue partially by doing the following:

for (Employee* employee in department.employees) {
    [employee willChangeValueForKey:@"dept"];
}

/* Make Changes to department object */

for (Employee* employee in department.employees) {
    [employee didChangeValueForKey:@"dept"];
}

This is obviously not ideal but it does cause the employee based FRC delegate method didChangeObject to get called. The real problem I have left now is in the sorting a FRC that is tracking employee objects:

NSEntityDescription *employee = [NSEntityDescription entityForName:@"Employee" inManagedObjectContext:self.managedObjectContext];
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"department.someProperty" ascending:NO];

This works great and sorts the employees correctly the first time it's called, the problem is that when I make changes to some property of a department that should change the sorting of my employee table, nothing happens. Is there any nice way to have my employee FRC track changes in a relationship? Particularly I just need some way to have it update the sorting when the sort is based on a related property. I've looked through some similar questions but wasn't able to find a satisfactory solution.

Suspicion answered 23/9, 2011 at 19:27 Comment(0)
L
27

The NSFetchedResultsController is only designed to watch one entity at a time. Your setup, while it makes sense, it a bit beyond what the NSFetchedResultsController is currently capable of watching on its own.

My recommendation would be to set up your own watcher. You can base it off the ZSContextWatcher I have set up on GitHub, or you can make it even more straightforward.

Basically, you want to watch for NSManagedObjectContextDidSaveNotification postings and then reload your table when one fire that contains your department entity.

I would also recommend filing a rdar with Apple and asking for the NSFetchedResultsController to be improved.

Lavatory answered 23/9, 2011 at 19:43 Comment(14)
Not sure you would want setup an FRC to watch more than one entity at a time. The permutations would get big and ugly in a hurry. You might also have issues with circularity.Langston
I have run across situations where iPad views need to watch more than one entity to maintain state. That is where the idea came from originally.Lavatory
I would be in favor of a secondary or subclass. The more complexity you add to the base class, he more you have to manage and debug.Langston
This is an old thread, but I am having the same issue. Have there been any improvements to NSFetchedResultsController or any new methods for tracking changes predicate relations to the FRC entity?Crummy
There are no improvements because what the OP was doing is beyond the scope of the design of the NSFRC.Lavatory
So what's the best way to tackle this? Not use the fetchresultcontroller for this case?Pilchard
If you need to watch multiple objects for your view to react properly then you should probably build your own watcher. This is accomplished pretty easily by listening to the NSManagedObjectContextDidSaveNotification, filtering by object and then reacting appropriately.Lavatory
Marcus, isn't there anything that can be done in order to continue to use NSFetchedResultsController while still being able to monitor relationship changes? NSFetchedResultsController is so feature-rich I would never want to try to recreate its functionality. I just want it be able to trigger its updates. Is there a way to use KVO in conjunction with NSFetchedResults controller? I've only ever seen on or the other used, but can they be used together?Crummy
The NSFRC triggers off changes being saved to the MO. You could have a parent MO listen for changes across a relationship and then change a local value and request a save. But then you are risking saving something mid edit that the user is working on. Doable but I personally would not solve it that way. I would either set up multiple NSFRC instances or build my own watcher.Lavatory
@MarcusS.Zarra I am a little confused about how ZSContextWatcher is supposed to work. Is it possible to emulate NSFetchedResultsController with its sections and rows with ZSContextWatcher? Do you have any example you can point to where this ZSContextWatcher is being used?Crummy
Posting here is not the appropriate place for that discussion, I suggest either opening a question directly or emailing me directly.Lavatory
@Crummy - very much so. Say you have entities "CD_House". A property changes (eg, color, height) and your NSF.R.C. is triggered. Say CD_House contains a relationship to "CD_Person". A house can have a few CD_Person. See the other answer... You create a NSManaged ObjectContext ObjectsDidChange Notification to CD_Person. N(not house.) And when there is any change in a CD_Person ... you touch the CD_House it belongs to! et voilà, CD_House is altered, so your fetched results controller is triggered perfectly. It seems very, VERY difficult to do well, though. And ...Papillary
@Crummy you can see it immediately leads to my question here: stackoverflow.com/questions/60277521Papillary
@Papillary Thanks. I eventually did figure that out. I just looked at your related question, and it perfectly illustrates the issue - almost all 'solutions' for this issue, where we continue to use the NSFetchedResultsController, end up being hackish. I'll make more comments on that page.Crummy
M
7

Swift

Because the NSFetchedResultsController is designed for one entity at a time, you have to listen to the NSManagedObjectContextObjectsDidChangeNotification in order to be notified about all entity relationship changes.

Here is an example:

//UITableViewController
//...
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChangeHandler(notification:)), name: .NSManagedObjectContextObjectsDidChange, object: mainManagedContext)
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    NotificationCenter.default.removeObserver(self, name: .NSManagedObjectContextObjectsDidChange, object: mainManagedContext)
}

@objc fileprivate func managedObjectsDidChangeHandler(notification: NSNotification) {
    tableView.reloadData()
}
//...
Mobcap answered 16/3, 2019 at 23:6 Comment(2)
Peter, thanks, but can you do that for a "particular" one of your Entity (say, "Employee" in the example) - or do you just get every single change ?!Papillary
@Papillary No, in my example you observe all objects changes in the managed object context. You need to filter your Entity-Changes.Mobcap
M
2

This is a known limitation of NSFetchedResultsController: it only monitors the changes of you entity's properties, not of its relationships' properties. But your use case is totally valid, and here is how to get over it.

Working Principle

After navigating a lot of possible solutions, now I just create two NSFetchedResultsController: the initial one (in your case, Employee), and another one to monitor the entities in the said relationship (Department). Then, when a Department instance is updated in the way it should update your Employee FRC, I just fake a change of the instances of affiliated Employee using the NSFetchedResultsControllerDelegate protocol. Note that the monitored Department property must be part of the NSSortDescriptors of its NSFetchedResultsController for this to work.

Example code

In your example if would work this way:

In your view controller:

var employeesFetchedResultsController:NSFetchedResultsController!
var departmentsFetchedResultsController:NSFetchedResultsController!

Also make sure you declare conformance to NSFetchedResultsControllerDelegate in the class declaration.

In viewDidLoad():

override func viewDidLoad() {         
    super.viewDidLoad()
    // [...]
    employeesFetchedResultsController = newEmployeesFetchedResultsController()
    departmentsFetchedResultsController = newDepartmentsFetchedResultsController()
    // [...]
}

In the departmentsFetchedResultsController creation:

func newDepartmentsFetchedResultsController() {
    // [specify predicate, fetchRequest, etc. as usual ]
    let monitoredPropertySortDescriptor:NSSortDescriptor = NSSortDescriptor(key: "monitored_property", ascending: true)
    request.sortDescriptors = [monitoredPropertySortDescriptor]
    // continue with performFetch, etc
}

In the NSFetchedResultsControllerDelegate methods:

That's where the magic operates:

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {

    if controller == departmentsFetchedResultsController {
        switch(type){
        case .insert, .delete, .update:
             managedObjectContext.performAndWait {
                let department = anObject as! Department
                for employee in (department.employees ?? []) {
                    // we fake modifying each Employee so the FRC will refresh itself.
                    let employee = employee as! Employee // pure type casting
                    employee.department = department
                }
             }
            break

        default:
        break
        }
    }
}

This fake update of the department of each impacted employee will trigger the proper update of employeesFetchedResultsController as expected.

Meniscus answered 11/7, 2020 at 15:8 Comment(0)
A
1

SwiftUI

I haven't seen posts that directly addressed this issue in SwiftUI. After trying solutions outlined in many posts, and trying to avoid writing custom controllers, the single factor that made it work in SwiftUI—which was part of the previous post from harrouet (thank you!)—is:

Make use of a FetchRequest on Employee.

If you care about, say, the employee count per department, the fake relationship updates did not make a difference in SwiftUI. Neither did any willChangeValue or didChangeValue statements. Actually, willChangeValue caused crashes on my end. Here's a setup that worked:

import CoreData
struct SomeView: View {
    @FetchRequest var departments: FetchedResults<Department>
    // The following is only used to capture department relationship changes
    @FetchRequest var employees: FetchedResults<Employee>
    var body: some View {
        List {
            ForEach(departments) { department in
                DepartmentView(department: department,
                               // Required: pass some dependency on employees to trigger view updates
                               totalEmployeeCount: employees.count)
            }
        }
        //.id(employees.count) does not trigger view updates
    }
} 
struct DepartmentView: View {
    var department: Department
    // Not used, but necessary for the department view to be refreshed upon employee updates
    var totalEmployeeCount: Int
    var body: some View {
        // The department's employee count will be refreshed when, say,
        // a new employee is created and added to the department
        Text("\(department) has \(department.employees.count) employee(s)")
    }
}

I don't know if this fixes all the potential issues with CoreData relationships not propagating to views, and it may present efficiency issues if the number of employees is very large, but it worked for me.

An alternative that also worked for establishing the right employee count without grabbing all employees—which may address the efficiency issue of the above code snippet—is to create a view dependency on a NSFetchRequestResultType.countResultType type of FetchRequest:

// Somewhere in a DataManager:
import CoreData
final class DataManager {
    static let shared = DataManager()
    let persistenceController: PersistenceController
    let context: NSManagedObjectContext!
    init(persistenceController: PersistenceController = .shared) {
        self.persistenceController = persistenceController
        self.context = persistenceController.container.viewContext
    }
    func employeeCount() -> Int {
        var count: Int = 0
        context.performAndWait {
            let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Employee")
            fetchRequest.predicate = nil
            fetchRequest.resultType = NSFetchRequestResultType.countResultType
            do {
                count = try context.count(for: fetchRequest)
            } catch {
                fatalError("error \(error)")
            }
        }
        return count
    }
}

And the main View becomes:

import CoreData
struct SomeView: View {
    @FetchRequest var departments: FetchedResults<Department>
    // No @FetchRequest for all employees
    var dataManager = DataManager.shared
    var body: some View {
        List {
            ForEach(departments) { department in
                DepartmentView(department: department,
                               // Required: pass some dependency on employees to trigger view updates
                               totalEmployeeCount: dataManager.employeeCount())
            }
        }
        //.id(dataManager.employeeCount()) does not trigger view updates
    }
}
// DepartmentView stays the same.

Again, this may not resolve all possible relationship dependencies, but it gives hope that view updates can be prompted by considering various types of FetchRequest dependencies within the SwiftUI views.

A note that DataManager needs NOT be an ObservableObject being observed in the View for this to work.

Aziza answered 19/7, 2021 at 18:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.