Observe a tree of Core Data relationships?

Background

I'm working with Xcode 12.5.1 (12E507) and macOS 11.5.1. I am trying to show a tree of NSManagedObject instances in an NSOutlineView. Here's the general schema:

The root of the tree I'm showing (object nil in the NSOutlineView) is always a Layer. Its list of rules can contain a mix of Rule objects and Section objects (thus the PolicyItem entity which Rules and Sections inherit from). Section objects can contain further Rule objects. Rules may contain a reference to another Layer, but generally don't.

The canonical data is on a remote server accessed via an API. The server does not allow cycles (so a Layer contains a Section contains a Rule, but that Rule cannot reference the Layer it is under), so no need to account for that.

I'm trying to handle rearranging rules. I have the drag-and-drop part working in the UI, and I am able to make the API call to rearrange the rule. The rule gets properly moved in the Core Data store, but on a background thread.

Layer.rules can be an ordered relationship (with ordering managed by Core Data), or I can manage the ordering myself with an integer to track the position of each PolicyItem. Same for Section.rules.


My Question

How should I recognize the changes in the data store and update the UI?

  • For unordered things, NSFetchedResultsController is pretty great. I've hooked it up to NSOutlineView.insertItems, .removeItems, and so on. I'm not sure how to get it to work with ordered data, though.

    Maybe use manual ordering, add a relationship from each rule to its parent layer (regardless of intervening section), run the FRC to fetch rules whose parent layer is the layer I want, then sort by [Rule.inRulesOfSection?.positionInt? ?? 0, Rule.positionInt]? That seems really *****, and I don't think it would handle changes in a layer in a rule.

  • With the upcoming async/await, I could redo the API interaction and update the positions in my drop handler when the call returns. That only helps for drag-and-drop, though. If somebody else makes a change on the server while the client is disconnected, it wouldn't be reflected on the client until the client's view is reloaded.

  • Is this something KVO should be able to do? I tried setting it up, but had trouble adding observers to any of the Sections.

  • Should I instead focus on NSManagedObjectContext change notifications? It looks like those notifications only contain information about the new state, not about the transition from old to new. That is, if I move a Rule from one Section to another, it looks like I would get the Rule and both Sections in the NSUpdatedObjectsKey, but it wouldn't tell me the rule was removed from one and added to the other. Or if it was rearranged, I would see the new position, but I don't see how I would find the old position.

Is there some other option I've missed?

Accepted Reply

Yes, there was an option I missed. NSTreeController seems to handle this really well, with a few small quirks.

  1. I had to add a 'rules' property to my rule. Simple extension:
extension Rule {
	@objc public var rules: NSSet? {
		return self.passToLayer?.rules
	}
}
  1. Setting up the binding was a little weird, as I've never done that in code before. Wound up with roughly this:
@IBOutlet var ruleViewContainer:NSView!
var ruleOutlineView:NSOutlineView = NSOutlineView()
private var layerToShow:Layer
private var treeController:NSTreeController = NSTreeController()
...
override func viewWillAppear() {
	...
	let outlineContainer = NSScrollView(frame:ruleViewContainer.bounds)
	outlineContainer.autoresizingMask = [.width, .height]
	outlineContainer.documentView = ruleOutlineView
	outlineContainer.hasVerticalScroller = true
	outlineContainer.hasHorizontalScroller = true
	ruleViewContainer.addSubview(outlineContainer)

	ruleOutlineView.usesAlternatingRowBackgroundColors = true
	ruleOutlineView.usesAutomaticRowHeights = true
	ruleOutlineView.delegate = self
	ruleOutlineView.dataSource = self
	ruleOutlineView.doubleAction = #selector(self.doubleClick)
	ruleOutlineView.target = self

	treeController.childrenKeyPath = "rules"
	treeController.bind(NSBindingName.content, to: layerToShow, withKeyPath: "rules", options: nil)
	ruleOutlineView.bind(NSBindingName.content, to: treeController, withKeyPath: "arrangedObjects", options: nil)
}
  1. Now all the items in the NSOutlineView are of type NSTreeNode. This broke my existing drag-and-drop and outlineView(_ outlineView:, viewFor tableColumn:, item:). Easy enough to fix. Just wrap all the references to item like this:
let representedItem = (item as! NSTreeNode).representedObject

Then use representedItem just like you used item before.

I handle the updates in my API interaction object, which then updates my Core Data store if the server confirms the attempted change was successful. The UI then updates.

I still have a few weird glitches. Moved objects are sometimes out of order. That should be much easier to figure out, though.

Replies

Yes, there was an option I missed. NSTreeController seems to handle this really well, with a few small quirks.

  1. I had to add a 'rules' property to my rule. Simple extension:
extension Rule {
	@objc public var rules: NSSet? {
		return self.passToLayer?.rules
	}
}
  1. Setting up the binding was a little weird, as I've never done that in code before. Wound up with roughly this:
@IBOutlet var ruleViewContainer:NSView!
var ruleOutlineView:NSOutlineView = NSOutlineView()
private var layerToShow:Layer
private var treeController:NSTreeController = NSTreeController()
...
override func viewWillAppear() {
	...
	let outlineContainer = NSScrollView(frame:ruleViewContainer.bounds)
	outlineContainer.autoresizingMask = [.width, .height]
	outlineContainer.documentView = ruleOutlineView
	outlineContainer.hasVerticalScroller = true
	outlineContainer.hasHorizontalScroller = true
	ruleViewContainer.addSubview(outlineContainer)

	ruleOutlineView.usesAlternatingRowBackgroundColors = true
	ruleOutlineView.usesAutomaticRowHeights = true
	ruleOutlineView.delegate = self
	ruleOutlineView.dataSource = self
	ruleOutlineView.doubleAction = #selector(self.doubleClick)
	ruleOutlineView.target = self

	treeController.childrenKeyPath = "rules"
	treeController.bind(NSBindingName.content, to: layerToShow, withKeyPath: "rules", options: nil)
	ruleOutlineView.bind(NSBindingName.content, to: treeController, withKeyPath: "arrangedObjects", options: nil)
}
  1. Now all the items in the NSOutlineView are of type NSTreeNode. This broke my existing drag-and-drop and outlineView(_ outlineView:, viewFor tableColumn:, item:). Easy enough to fix. Just wrap all the references to item like this:
let representedItem = (item as! NSTreeNode).representedObject

Then use representedItem just like you used item before.

I handle the updates in my API interaction object, which then updates my Core Data store if the server confirms the attempted change was successful. The UI then updates.

I still have a few weird glitches. Moved objects are sometimes out of order. That should be much easier to figure out, though.

Well, I thought ordering would be easier to solve, but I can't seem to figure it out.

I couldn't get NSTreeController to work with an ordered relationship in Core Data, so I went with unordered with a manual ordering criteria which I maintain. I added a column to my outline to show the ordering criteria.

Let's say I have a section which has three rules already. The ordering criteria is 1-indexed, so the three existing rules have positions 1, 2, and 3. Now I drag a rule from another section into this one, between existing positions 1 and 2. The new rule gets position 2, but the old rule still shows position 2 in the UI, and the bottom rule still shows position 3. The new rule generally ends up below old position 2, which is incorrect.

The managed objects are getting updated correctly. I added a double-click handler like so:

override func viewWillAppear() {
	...
	ruleOutlineView.doubleAction = #selector(self.doubleClick)
}

@objc func doubleClick(_ sender:NSOutlineView) {
	treeController.rearrangeObjects()
}

When I double-click in the outline view, the rows update to the correct ordering, and the correct rule numbers are shown.

In my object which downloads the data and updates my local Core Data store, I have tried this in the tail end of the ordering criteria update code:

let workingSection = ruleToAdd.inRulesOfSection
let workingLayer = workingSection?.inRulesOf ?? ruleToAdd.inRulesOf ?? nil
workingSection?.rules = workingSection?.rules
workingLayer?.rules = workingLayer?.rules

The intent was to mark the layer and section as dirty, to hopefully get Added a breakpoint ahead of the workingSection?.rules line. The code definitely gets called when the server responds to the drag attempt, and workingSection and workingLayer definitely contain the correct managed objects when it does. Tried updating other criteria like the section's name, but no change to behavior.

Any ideas?

I've been learning more about KVO in an attempt to figure out what is going on. I've added an extension like this:

extension Rule {
	public override func addObserver(
		_ observer: NSObject,
		forKeyPath keyPath: String,
		options: NSKeyValueObservingOptions = [],
		context: UnsafeMutableRawPointer?)
	{
		print("Adding observer \(String(describing: observer)) to object \(String(describing: self)).\(keyPath)")
	}
}

for each of my entities. When I initialize the tree controller and start showing the data, I get entries like this in my log:

Adding observer <NSTreeControllerTreeNode: 0x600002901b00>, child nodes {} to object <Section: 0x600002da3930> (entity: Section; id: 0xbd8a359649691e4d <x-coredata://7DDED95F-5892-47B7-B51E-46C60FFDEA6A/Section/p20>; data: {
    inRulesOf = "0xbd8a359e87a91e0d <x-coredata://7DDED95F-5892-47B7-B51E-46C60FFDEA6A/Layer/p9007>";
    metaRulesPosition = 4;
    name = "Some Section";
    rules = "<relationship fault: 0x600000d69520 'rules'>";
}).rules

It definitely adds the observer to the childrenKeyPath for every expanded element. metaRulesPosition is my manually-maintained sorting criteria, and NSTreeController definitely does not observe it.

It looks to me like I need to find a way for a change to the metaRulesPosition in the children to trigger a change notification for the 'rules' property of the parent.

My first thought is to set up observation from the entity to each of its children, but that seems to require making my entities' Codegen manual/none so I can add a property to track the observation for later removal.

Any other ideas?

Exploring this further, I tried switching my relationships to be ordered. Since NSTreeController apparently can't use an ordered relationship directly, I added a computed property called rulesArray and told my NSTreeController to use that as the child key path. I also added a little code as above to see when observers were added. Finally, I added some code for the various keyPathsForValuesAffecting methods to try and cause the NSTreeController to also observe the real property.

I noticed the keyPathsForValuesAffecting methods were never called. From the example implementation in the KVO guide, I noticed the default case calls to the same method on super, so I tried adding a super.addObserver(...) at the end of the override, and it seems to be working!

The extensions to my Core Data classes look like this:

extension Layer {
	@objc public override class func keyPathsForValuesAffectingValue(forKey key: String) -> Set<String> {
		print("Layer.keyPathsForValuesAffectingValue(forKey \(key)) called")
		switch key {
		case "rulesArray" :
			return Set(["rules"])
		default :
			return super.keyPathsForValuesAffectingValue(forKey: key)
		}
	}
	
	@objc public dynamic var rulesArray: [PolicyItem] {
		return self.rules?.array as? [PolicyItem] ?? []
	}
	
	public override func addObserver(
		_ observer: NSObject,
		forKeyPath keyPath: String,
		options: NSKeyValueObservingOptions = [],
		context: UnsafeMutableRawPointer?)
	{
		print("Adding observer \(String(describing: observer)) to object \(String(describing: self)).\(keyPath)")
		super.addObserver(observer, forKeyPath: keyPath, options: options, context: context)
	}
}

Each object is extended in a way that the rulesArray comes from the real relationship (or in the case of case of a Rule, comes from the Rule's passToLayer's real relationship, if it exists).

And now, when the ordered set is rearranged, the UI updates as expected! I don't actually need the addObserver(...) override, so I'm going to remove it. Still, thought this would be useful to anybody else who has a similar problem.