Can’t undo more than one operation - objective-c

When I call undo on the context following deletion of a single object, all works as expected. But if user deletes an object, then deletes another object, undo will work only to restore the second object, no matter how many times user requests undo, as though undoLevels were set to 1. This happens whether undoLevels is at the default of 0 (unlimited) or is explicitly set to 6 as a test.
Furthermore, if a single action deletes multiple objects, calling undo afterward has no effect; none of the objects is restored. I tried explicitly bracketing the deletion loop with begin/endUndoGrouping, to no avail. The undoManager’s groupsByEvent is YES (by default), but it makes no difference whether I call a straight undo or undoNestedGroup.
Is the context somehow being saved after each operation? No, because if I quit and relaunch the app after running these tests, all objects are still present in the database.
What am I missing?
OK, you want code. Here’s what I imagine is most relevant:
Context getter:
- (NSManagedObjectContext *) managedObjectContextMain {
if (managedObjectContextMain) return managedObjectContextMain;
NSPersistentStoreCoordinator *coordinatorMain = [self persistentStoreCoordinatorMain];
if (!coordinatorMain) {
// present error...
return nil;
}
managedObjectContextMain = [[NSManagedObjectContext alloc] init];
[managedObjectContextMain setPersistentStoreCoordinator: coordinatorMain];
// Add undo support. (Default methods don't include this.)
NSUndoManager *undoManager = [[NSUndoManager alloc] init];
// [undoManager setUndoLevels:6]; // makes no difference
[managedObjectContextMain setUndoManager:undoManager];
[undoManager release];
// ...
return managedObjectContextMain;
}
Multiple-object deletion method (called by a button on a modal panel):
/*
NOTE FOR SO:
SpecialObject has a to-one relationship to Series.
Series has a to-many relationship to SpecialObject.
The deletion rule for both is Nullify.
Series’ specialObject members need to be kept in a given order. So Series has a transformable attribute, an array of objectIDs, used to prepare a transient attribute, an array of specialObjects, in the same order as their objectIDs.
*/
- (void) deleteMultiple {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
NSUndoManager *undoMgr = [contextMain undoManager];
[undoMgr beginUndoGrouping];
// Before performing the actual deletion, drop the seln in the locator table.
[appDelegate.objLocatorController.tvObjsFound deselectAll:self];
// Get the indices of the selected objects and enumerate through them.
NSIndexSet *selectedIndices = [appDelegate.objLocatorController.tvObjsFound selectedRowIndexes];
NSUInteger index = [selectedIndices firstIndex];
while (index != NSNotFound) {
// Get the obj to be deleted and its series.
SpecialObject *sobj = [appDelegate.objLocatorController.emarrObjsLoaded objectAtIndex:index];
Series *series = nil;
series = sobj.series;
// Just in case...
if (!series) {
printf("\nCESeries' deleteMultiple was called when Locator seln included objs that are not a part of a series. The deletion loop has therefore aborted.");
break;
}
// Get the obj's series index and delete it from the series.
// (Series has its own method that takes care of both relnshp and cache.)
NSUInteger uiIndexInSeries = [series getSeriesIndexOfObj:sobj];
[series deleteObj:sobj fromSeriesIndex:uiIndexInSeries];
// Mark the special object for Core Data deletion; it will still be a non-null object in emarrObjsLoaded (objLocatorController’s cache).
[contextMain deleteObject:sobj];
// Get the next index in the set.
index = [selectedIndices indexGreaterThanIndex:index];
}
[undoMgr endUndoGrouping];
// Purge the deleted objs from loaded, which will also reload table data.
[appDelegate.objLocatorController purgeDeletedObjsFromLoaded];
// Locator table data source has changed, so reload. But end with no selection. (SeriesBox label will have been cleared when Locator seln was dropped.)
[appDelegate.objLocatorController.tvObjsFound reloadData];
// Close the confirm panel and stop its modal session.
[[NSApplication sharedApplication] stopModal];
[self.panelForInput close];
}
Here’s the Series method that removes the object from its relationship and ordered cache:
/**
Removes a special object from the index sent in.
(The obj is removed from objMembers relationship and from the transient ordered obj cache, but it is NOT removed from the transformable array of objectIDrepns.)
*/
- (void) deleteObj:(SpecialObject *)sobj fromSeriesIndex:(NSUInteger)uiIndexForDeletion {
// Don't proceed if the obj is null or the series index is invalid.
if (!sobj)
return;
if (uiIndexForDeletion >= [self.emarrObjs count])
return;
// Use the safe Core Data method for removing the obj from the relationship set.
// (To keep it private, it has not been declared in h file. PerformSelector syntax here prevents compiler warning.)
[self performSelector:#selector(removeObjMembersObject:) withObject:sobj];
// Remove the obj from the transient ordered cache at the index given.
[self.emarrObjs removeObjectAtIndex:uiIndexForDeletion];
// But do NOT remove the obj’s objectID from the transformable dataObjIDsOrdered array. That doesn't happen until contextSave. In the meantime, undo/cancel can use dataObjIDsOrdered to restore this obj.
}
Here’s the method, and its follow-up, called by comm-z undo:
- (void) undoLastChange {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
// Perform the undo. (Core Data has integrated this functionality so that you can call undo directly on the context, as long as it has been assigned an undo manager.)
// [contextMain undo];
printf("\ncalling undo, with %lu levels.", [contextMain.undoManager levelsOfUndo]);
[contextMain.undoManager undoNestedGroup];
// Do cleanup.
[self cleanupFllwgUndoRedo];
}
- (void) cleanupFllwgUndoRedo {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
DataSourceCoordinator *dataSrc = appDelegate.dataSourceCoordinator;
// ...
// Rebuild caches of special managed objects.
// (Some managed objects have their own caches, i.e. Series' emarrObjs. These need to be refreshed if their membership has changed. There's no need to use special trackers; the context keeps track of these.)
for (NSManagedObject *obj in [contextMain updatedObjects]) {
if ([obj isKindOfClass:[Series class]] && ![obj isDeleted])
[((Series *)obj) rebuildSeriesCaches];
}
// ...
// Regenerate locator's caches.
[appDelegate.objLocatorController regenerateObjCachesFromMuddies]; // also reloads table
}
Here’s the series method that regenerates its caches following undo/awake:
- (void) rebuildSeriesCaches {
// Don't proceed if there are no stored IDs.
if (!self.dataObjIDsOrdered || [self.dataObjIDsOrdered count] < 1) {
// printf to alert me, because this shouldn’t happen (and so far it doesn’t)
return;
}
NSMutableArray *imarrRefreshedObjIdsOrdered = [NSMutableArray arrayWithCapacity:[self.objMembers count]];
NSMutableArray *emarrRefreshedObjs = [NSMutableArray arrayWithCapacity:[self.objMembers count]];
// Loop through objectIDs (their URIRepns) that were stored in transformable dataObjIDsOrdered.
for (NSURL *objectIDurl in self.dataObjIDsOrdered) {
// For each objectID repn, loop through the objMembers relationship, looking for a match.
for (SpecialObject *sobj in self.objMembers) {
// When a match is found, add the objectID repn and its obj to their respective replacement arrays.
if ([[sobj.objectID URIRepresentation] isEqualTo:objectIDurl]) {
[imarrRefreshedObjIdsOrdered addObject:objectIDurl];
[emarrRefreshedObjs addObject:sobj];
break;
}
// If no match is found, the obj must have been deleted; the objectID repn doesn't get added to the replacement array, so it is effectively dropped.
}
}
// Assign their replacement arrays to the transformable and transient attrs.
self.dataObjIDsOrdered = imarrRefreshedObjIdsOrdered;
self.emarrObjs = emarrRefreshedObjs;
}
(I’ve omitted the Locator’s regenerateObjCachesFromMuddies because, although I am using its table to view the results of the deletion and undo, I can reload the table with a new fetch, completely regenerating the table’s caches, and this test still shows that the undo isn’t working.)
As usual, just the task of putting together a SO question helps solve the problem, and I realize now that undo works fine as long as I’m working with simple objects that don’t involve the reciprocal SpecialObject-Series relationship. I’m doing something wrong there...

I think you're getting into a fight with custom undo stuff and Core Data's automagic support.
In normal undo/redo code, you have undoable funnel points. Usually an undoable add and its inverse undoable remove. Calling one registers the other as the inverse action and vice-versa. User undo/redo then just goes back and forth between them. You separate your "user created a new Foo" code from your "now add this foo to the collection undoably" code (that way "remove Foo" and "add Foo" work independently of supplying a newly-created Foo).
With Core Data, add and remove means "insert into the context and remove from the context". Also, you still need custom funnel methods because (in your case), you're doing some additional stuff (updating a cache). This is easy enough to do with a Foo, but what happens when you want to manipulate the relationship between a Foo/Bar assembly that gets created in one action?
If creating a Foo created a few Bars with it, it'd be one thing (-awakeFromInsert and the like) since you'd only have to deal with updating your caching (which you could do, by the way, through key/value observing the context for changes). Since creating a Foo seems to establish relationships with existing Bars (which are already in the context), you run into a difficult wall when trying to cooperate with CD's built-in undo support.
There is no easy solution in this case if you're using the built-in Core Data undo/redo support. In this case, you can do as this post suggests and turn it off. You can then handle undo/redo entirely yourself ... but you'll have a lot of code to write to observe your objects for changes to interesting attributes, registering the inverse action for each.
While it isn't a solution to your problem, I hope it at least points out the complexity of what you're trying to do and gives you a possible path forward. Without knowing a LOT more about your model (at the conceptual level at least) and how your UI presents it to the user, it's hard to give specific architectural advice.
I hope I'm wrong about this case - maybe someone else can give you a better answer. :-)

It turns out that you can have Foo creation that involves changing relationships with pre-existing Bars, and custom caches, and NSUndoManager can still handle it all — but with a kink: You have to save the context after each such change; otherwise the undo manager will cease to function.
Since undo can actually reach back to states before the save, this is not such a bad thing. It does complicate matters if you want the user to be able to revert to the state when they last chose to save, but that can be handled by making a copy of the database whenever the user chooses to save.
So in the deleteMultiple method, following the while deletion loop, I added a call to save the context.
There’s another error in my scheme, which is that I erroneously thought NSUndoManager would ignore transformable attributes. Well, obviously, since transformable attrs are persisted, they are tracked by the persistentStoreCoordinator and are therefore included in undo operations. So when I failed to update the xformable attr array upon deletion, thinking I would need its info for restoration in the event of undo, I was ruining the action/inverse-action symmetry.
So in the deleteObject:fromSeriesIndex method, the Series method that handles the caches, I added this code, updating the transformable ObjectID array:
NSMutableArray *emarrRemoveID = [self.dataObjIDsOrdered mutableCopy];
[emarrRemoveID removeObjectAtIndex:uiIndexForDeletion];
self.dataObjIDsOrdered = emarrRemoveID;
[emarrRemoveID release];
(My assumption that the NSUndoManager would ignore the transient cache was correct. The call to rebuildSeriesCaches in cleanupFllwgUndoRedo takes care of that.)
Undo now works, both for simple objects and for objects in SpecialObject-Series relationships. The only remaining problem is that it takes more than one command-Z to happen. I’ll have to experiment more with the groupings…
EDIT: It isn’t necessary to save the context post-deletion if the managed object’s custom caches are handled correctly:
1) The caches should NOT be rebuilt following undo. The undo manager will take care of this on its own, even for the transient cache, as long as the transient property is included in the managed object model.
2) When changing the NSMutableArray cache (emarrObjs), using removeObjectAtIndex alone will confuse the undo manager. The entire cache must be replaced, the same way it is with the NSArray cache dataObjIDsOrdered.

Related

Add and remove from array carried by RACSignal

I have one RACSignal that carries NSArray of objects of class Bookmark
RACSignal *bookmarksSignal = ... // Carries #[[[Bookmark alloc] init], ...]
And two RACCommands to add and delete bookmark
RACCommand *addBookmarkCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input){
return [RACSignal return:input];
}];
...
[addBookmarkCommand execute: bookmark1];
RACCommand *deleteBookmarkCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input){
return [RACSignal return:input];
}];
...
[deleteBookmarkCommand execute: bookmark2]
Commands are executed on user interaction. How can I combine bookmarksSignal and executions signals of both commands to create signal that carries NSArray of bookmarks that contains original bookmarks with those added by addBookmarkCommand and without those removed by deleteBookmarkCommand?
(bookmarks are compared by url property)
I'm afraid that I'm missing something obvious, but I can't figure out how to do this in pure way.
For lack of time, I'll go with a more laconic answer compared to #IanHenry's :)
For anything here that doesn't make sense, please comment and I'll be happy explain in detail.
// Map additions into blocks that add the bookmark to the given mutable array.
RACSignal *addBookmarkSignal = [[addBookmarkCommand concat] map:^(Bookmark *bookmark) {
return ^(NSMutableArray *bookmarks) {
return [bookmarks addObject:bookmark];
};
}];
// Map deletions into blocks that remove the bookmark from the given mutable array.
RACSignal * addBookmarkSignal = [[deleteBookmarkCommand concat] map:^(Bookmark *bookmark) {
return ^(NSMutableArray *bookmarks) {
[bookmarks removeObject:bookmark];
};
}];
// Combine the add and delete functions into a single signal.
RACSignal *updatesSignal = [RACSignal merge:#[addBookmarkSignal, addBookmarkSignal]];
RACSignal *updatedBookmarksSignal = [[bookmarksSignal
// Each time bookmarksSignal sends an array, this -map: builds a
// signal that updates the latest list of bookmarks, and sends it.
map:^(NSArray *bookmarks) {
NSMutableArray *mutableBookmarks = [bookmarks mutableCopy];
// Using the update blocks from the add/delete commands,
// produce the modified list of bookmarks.
return [[updatesSignal
map:^(void (^update)(NSMutableArray *)) {
update(mutableBookmarks);
return [mutableBookmarks copy];
}]
startWith:bookmarks];
}]
// When bookmarksSignal sends anew, switch to the newest signal of updates.
switchToLatest];
Let's forget about RACCommand for a second, and pretend that we just have a signal of bookmarks to add and a signal of bookmarks to remove and we want to create a signal of sets of bookmarks based on that. It's a good starting point, and we can figure out how to implement those after the fact.
RACSignal *addedSignal = ...;
RACSignal *removedSignal = ...;
Then we want a signal that's the combination of those two signals into a single set of all the added things without anything removed afterwards (we can map it into an NSArray after the fact if we want to).
RACSignal *bookmarkSetSignal = ...;
Now we have to fill it in. We could optimize here by making a mutable set that we modify and just send references to that same set every time a change happens. But that's sort of contrary to the nature of signals. Let's put that optimization on hold for a minute and do it the pure, functional way.
We're going to use the scanWithStart:reduce: method, because it fits this problem perfectly. It's like a fold that returns every intermediate value, which is exactly what we want.
But first we have to make addedSignal and removedSignal useful. Here's my idea: merge them into a single signal, but attach another value to them that says whether it's an add or a remove.
// turn a signal of bookmarks into a signal of tuples of the form (bookmark, isAdded)
RACSignal *changes = [RACSignal merge:#[[addedSignal map:^(id x) { return RACTuplePack(x, #YES); }],
[removedSignal map:^(id x) { return RACTuplePack(x, #NO); }]]];
Now that it's just one signal, it'll be a little easier to wrangle it. Then we can fold those changes into a single value (the composition of all the changes), except that we're reporting every step of the way. So it's a scan, not a fold. But scan makes a bad verb. Anyway:
RACSignal *bookmarkSetSignal = [changes scanWithStart:[NSSet set] reduce:^(NSSet *running, RACTuple *next) {
RACTupleUnpack(Bookmark *bookmark, NSNumber *isAddingNumber) = next;
if (isAddingNumber.boolValue) {
[running setByAddingObject:bookmark];
} else {
// you can do this with a much nicer helper, but this is the shortest way for this answer...
return [running filteredSetUsingPredicate:[NSPredicate predicateWithFormat:#"url != %#", bookmark.url]];
}
}];
Great! We started with an empty set, and every time a change occurred we created a new set by adding or removing that element. running is always whatever we computed last time (starting from the empty set), and next is the description of the change that should happen (a bookmark + whether or not it was being added or removed). We now have a signal of sets of bookmarks, just like we wanted!
Except now we need to fill out addedSignal and removedSignal.
The exact way we do this is a little...well, it depends on the user interaction. We could make each one a subject, and then user interaction would manually send new values. That might be the right way to do it. It's cleaner than manually triggering an RACCommand. Anyway, that's a separate question. Assuming the exact RACCommand format that you have now, I think we can implement it like this:
RACSignal *addedSignal = [self.addBookmarkCommand.executionSignals switchToLatest];
RACSignal *removedSignal = [self.deleteBookmarkCommand.executionSignals switchToLatest];
executionSignals is a signal of every signal returned in the signal block. Which are merely [RACSignal return:bookmark]. By switchingToLatest, we're basically "unwrapping" that value. But if we used a subject we wouldn't need to wrap/unwrap in the first place. But anyway, separate discussion.
This code will almost certainly require a little modification to do what you want (haven't tested it), but hopefully this'll point you in the right direction idea-wise.

keyPathsForValuesAffectingValueForKey: does it trigger changes recursively?

I'm reading the Key-Value Observing guide from Apple, and it hasn't explained one point in detail.
My question is: say I have a name property that's dependent on firstName and lastName, and I also have another property group that's dependent on name and gender.
Following the guide, I've written this
+ (NSSet *)keyPathsForValuesAffectingName {
return [NSSet setWithObjects:#"lastName",#"firstName",nil];
}
When writing the corresponding method for group, can I write it like this
+ (NSSet *)keyPathsForValuesAffectingGroup {
return [NSSet setWithObjects:#"name",#"gender",nil];
}
Or I have to add all the related properties
+ (NSSet *)keyPathsForValuesAffectingGroup {
return [NSSet setWithObjects:#"firstName",#"lastName",#"gender",nil];
}
Note that both group and name are computed, readonly properties.
If your questions means ...
Can keyPathsForValuesAffecting<key> be chained to indirectly affect keys through several dependant properties?
... then the answer is yes.
Edit: Apple's documentation on keyPathsForValuesAffectingValueForKey: confirms support for indirect dependent notifications.
Your last comment seems to suggest that you expect KVO notifications (indirect or not) being sent when directly changing an ivar. That's not the case. KVO relies on using the proper setter or on manually announce the change by sending will/didChangeValueForKey:
I'm pretty sure, that this will work, because it would be hard to let it not work.
Let us imagine, that you have three external (= different objects) observers for the properties.
What happens:
A. The opening door for KVO is a message didChangeValueForKey: to the observed object. If you have turned on automatic KVO, this message is sent automatically, when the setter for the key is called. (This is done in a dynamic generated subclass, which overwrites the setter.)
B. Then KVO code is triggered that looks semantically like this: (And it is simplified for only supporting keys, but not key paths.)
NSSet *observers = kvoBucket[key];
for( id observer in observers )
{
[observer observeValue:[self valueForKey:key] forKeyPath:key …]
}
C. With affecting keys there has to be a second loop, informing about a change of the depending properties. (I assume, that this list is generated at the initialization of the class' object or adding the observation.)
// Inform observers
NSSet *observers = kvoBucket[key];
for( id observer in observers )
{
[observer observeValue:[self valueForKey:key] forKeyPath:key …]
}
// Scan for affected properties
NSSet *keyOfaffectedProperties = affectedPropertiesBucket[key];
for( NSString *key in keyOfaffectedProperties )
{
// trigger kvo for affected property
}
No let us sit down on the developer's chair. He can duplicate the first loop into the second loop. In this case it would not work. But this is duplicate code. (And the real code is more complicated for sure.) Why should he do that? Everyone of us would say: I already have this code! Let's use it again:
- (void)informOberserversForChangeOfKey:(NSString*)key
// Inform observers
NSSet *observers = kvoBucket[key];
for( id observer in observers )
{
[observer observeValue:[self valueForKey:key] forKeyPath:key …]
}
// Scan for affected properties
NSSet *keyOfaffectedProperties = affectedPropertiesBucket[key];
for( NSString *keyOfAffectedProperty in keyOfaffectedProperties )
{
[self informObserversForChangeOfKey:keyOfAffectedProperty];
}
If there is no optimization, which makes recursion impossible or inconvenient, it should work. I would bet it works – But only a small amount of money. ;-)
Most people here have got the right idea but to confirm this I've set up a test project with exactly the same code mentioned in my question.
And the result: it works for both ways.

Prevent Core Data persisting object

Is it possible to prevent Core Data to persist an object?
Let's say if I have a NSManagedObject subclass with a BOOL property isTemporary.
So when a save is called on the context I would do a check on the object:
if (self.isTemporary) {
// Do not save
} else {
// Save this object
}
EDIT: More background information for the issue
Hmm to clarify my issue, I create an object, if it exists already I'm the db I fetch it, if it doesn't exist I insert it and set the the temporary flag of the object to YES. I set the flag because it's not clear at this stage if the user will perform a save or cancel action. If he saves I set the flag temporary to NO. If he cancels then I delete the object if temporary flag is YES.
So far so good, but in the meantime in the background there can occur core data save operations in the background that will persist these objects even though I don't want them persisted (because they should be temporary). So if I'm unlucky and app is killed I could have unwanted objects that have the temporary flag set to YES. One option would be to perform a clean operation on startup of the app to remove objects with temporary flag YES. But everything would be a whole lot easier if it would not persist those objects.
Correct, that is how it can be done.
if (self.isTemporary && self.managedObjectContext) {
[self.managedObjectContext delete:self];
}
Note that the managedObjectContext of a NSManagedObject becomes nil once it is deleted.
If you want to prevent Core Data from persisting your ManagedObject, in other words if you don't want your object to be written in the file, you can achieve this by initializing your ManagedObject in following way:
#implementation MyManagedObject
- (id) init
{
NSEntityDescription* entity = [NSEntityDescription entityForName:#"MyEntity" inManagedObjectContext:managedObjectContext];
self = [super initWithEntity:entity insertIntoManagedObjectContext:Nil];
}
#end
Now if you want to save your object, save it in following way:
[managedObjectContext insertObject:myManagedObject];
NSError* error;
if ([managedObjectContext save:&error]) {
NSLog(#"Successfully saveed ManagedObject!");
} else {
NSLog(#"Failed to save ManagedObject!");
}
Hope this helps!

NSTreeController/NSOutlineView loses its selection

I'm developing a desktop Cocoa application. In the app I have a view-based NSOutlineView binded to an NSTreeController:
The NSTreeController is in entity mode and driven by Core Data. Everything works as expected until the underlaying model graph changes. Whenever a new object inserted into the registered NSManagedObjectContext the NSTreeController refresh its content and the binded NSOutlineView shows the result properly. The content of the controller sorted by "title" with an NSSortDescriptor and I set this sorting during the application startup. The only drawback is that the selectionIndexPath doesn't change even if the preserve selection box is checked in the NSTreeController's preferences. I want to keep the selection on the object that was selected before the new node appeared in the tree.
I've subclassed NSTreeController to debug what's happening with the selection during the change of object graph. I can see that the NSTreeController changes it's content via KVO but the setContent: method doesn't invoked. Than the setSelectionIndexPaths: called via the NSTreeControllerTreeNode KVO but the parameter contains the previous indexPath.
So, to be clear:
Top Level 1
Folder 1-1
Folder 1-2
Top Level 2
Folder 2-1
*Folder 2-3 <== Selected
Folder 2-4
In the initial stage the "Folder 2-3" selected. Then "Folder 2-2" inserted into the NSManagedObjectContext with [NSEntityDescription insertNewObjectForEntityForName:#"Folder" inManagedObjectContext:managedObjectContext];:
Top Level 1
Folder 1-1
Folder 1-2
Top Level 2
Folder 2-1
*Folder 2-2 <== Selected
Folder 2-3
Folder 2-4
I want to keep the selection on "Folder 2-3", hence I've set the "Preseve selection" but it seems that NSTreeController completely ignore this property or I misunderstood something.
How I can force NSTreeController to keep its selection?
UPDATE1:
Unfortunately none of the mutation methods (insertObject:atArrangedObjectIndexPath:, insertObjects:atArrangedObjectIndexPaths: etc.) has ever called in my NSTreeController subclass. I've override most of the factory methods to debug what's going under the hood and that's what I can see when a new managed object inserted into the context:
-[FoldersTreeController observeValueForKeyPath:ofObject:change:context:] // Content observer, registered with: [self addObserver:self forKeyPath:#"content" options:NSKeyValueObservingOptionNew context:nil]
-[FoldersTreeController setSelectionIndexPaths:]
-[FoldersTreeController selectedNodes]
-[FoldersTreeController selectedNodes]
The FoldersTreeController is in entity mode and binded to the managedObjectContext of Application delegate. I have a root entity called "Folders" and it has a property called "children". It's a to-many relationship to an other entity called Subfolders. The Subfolders entity is a subclass of Folders, so it has the same properties as its parent. As you can see on the first attached screenshot the NSTreeController's entity has been set to the Folders entity and it's working as expected. Whenever I insert a new Subfolder into the managedObjectContext it appears in the tree under the proper Folder (as a subnode, sorted by NSSortDescriptor binded to the NSTreeController), but none of the NSTreeController mutation methods are called and if the newly inserted subfolder appears earlier in the list it pulls down everything but the selection remains in the same position.
I can see that the setContent: method is called during the application launch, but that's all. It seems that NSTreeController observe the root nodes (Folders) and reflect model changes somehow via KVO. (So, when I create a new Subfolder and add it to its parent with [folder addChildrenObject:subfolder] it's appearing in the tree, but none of the tree mutation methods are invoked.)
Unfortunately I cannot use the NSTreeController mutation methods directly (add:, addChild:, insert:, insertChild:) because the real applicataion updates the models in a background thread. The background thread uses its own managedObjectContext and merge the changes in batches with mergeChangesFromContextDidSaveNotification. It makes me crazy, because everything is working fine expect the NSOutlineView's selection. When I bunch of Subfolders merged into the main managedObjectContext from the background thread the tree updates itself, but I lost the selection from the object that was selected before the merge.
Update2:
I've prepared a small sample to demonstrate the issue: http://cl.ly/3k371n0c250P
Expand "Folder 1" then select Select "Subfolder 9999"
Press "New subfolder". It will create 50 subfolder in the background operation with batches.
As you can see, the selection will be lost from "Subfolder 9999" even if its saved before the content change in MyTreeController.m
By my reading of the docs and headers, NSTreeController uses NSIndexPaths to store selection. This means that its idea of selection is a chain of indexes into a tree of nested arrays. So as far as it knows, it is preserving the selection in the situation you describe. The problem here is the you're thinking of selection in terms of "object identity" and the tree controller defines selection as "a bunch of indexes into nested array". The behavior you describe is (AFAICT) the expected out-of-the-box behavior for NSTreeController.
If you want selection preservation by object identity, my suggestion would be to subclass NSTreeController and override all mutating methods such that you capture the current selection using -selectedNodes before the mutation, then re-set the selection using -setSelectionIndexPaths: with an array created by asking each formerly selected node for its new -indexPath after the mutation.
In short, if you want behavior other than the stock behavior, you're going to have to write it yourself. I was curious how hard this would be so I took a stab at something that appears to work for the cases I bothered to test. Here 'tis:
#interface SOObjectIdentitySelectionTreeController : NSTreeController
#end
#implementation SOObjectIdentitySelectionTreeController
{
NSArray* mTempSelection;
}
- (void)dealloc
{
[mTempSelection release];
[super dealloc];
}
- (void)p_saveSelection
{
[mTempSelection release];
mTempSelection = [self.selectedNodes copy];
}
- (void)p_restoreSelection
{
NSMutableArray* array = [NSMutableArray array];
for (NSTreeNode* node in mTempSelection)
{
if (node.indexPath.length)
{
[array addObject: node.indexPath];
}
}
[self setSelectionIndexPaths: array];
}
- (void)insertObject:(id)object atArrangedObjectIndexPath:(NSIndexPath *)indexPath
{
[self p_saveSelection];
[super insertObject: object atArrangedObjectIndexPath: indexPath];
[self p_restoreSelection];
}
- (void)insertObjects:(NSArray *)objects atArrangedObjectIndexPaths:(NSArray *)indexPaths
{
[self p_saveSelection];
[super insertObjects:objects atArrangedObjectIndexPaths:indexPaths];
[self p_restoreSelection];
}
- (void)removeObjectAtArrangedObjectIndexPath:(NSIndexPath *)indexPath
{
[self p_saveSelection];
[super removeObjectAtArrangedObjectIndexPath:indexPath];
[self p_restoreSelection];
}
- (void)removeObjectsAtArrangedObjectIndexPaths:(NSArray *)indexPaths
{
[self p_saveSelection];
[super removeObjectsAtArrangedObjectIndexPaths:indexPaths];
[self p_restoreSelection];
}
#end
EDIT: It a little brutal (performance-wise) but I was able to get something working for calls to -setContent: as well. Hope this helps:
- (NSTreeNode*)nodeOfObject: (id)object
{
NSMutableArray* stack = [NSMutableArray arrayWithObject: _rootNode];
while (stack.count)
{
NSTreeNode* node = stack.lastObject;
[stack removeLastObject];
if (node.representedObject == object)
return node;
[stack addObjectsFromArray: node.childNodes];
}
return nil;
}
- (void)setContent:(id)content
{
NSArray* selectedObjects = [[self.selectedObjects copy] autorelease];
[super setContent: content];
NSMutableArray* array = [NSMutableArray array];
for (id object in selectedObjects)
{
NSTreeNode* node = [self nodeOfObject: object];
if (node.indexPath.length)
{
[array addObject: node.indexPath];
}
}
[self setSelectionIndexPaths: array];
}
Of course, this relies on the objects actually being identical. I'm not sure what the guarantees are with respect to CoreData across your (unknown to me) background operation.

How do I persist data managed by NSArrayController without Core Data or NSKeyedArchiver?

I hope you'll excuse the seemingly broad nature of this question, but it gets quite specific.
I'm building a document-based Cocoa application that works like most others except that I am using SQLCipher for my data store (a variant of SQLite), because you don't get to set your own persistent data store in Core Data, and also I really need to use this one.
In my document sub-class, I've got an NSMutableArray property named categories. In the document nib I've got an NSArrayController bound to categories, and I've got an NSCollectionView bound to the array controller.
Each of my model objects in the array (each is a Category) is bound to a record in the underlying data store, so when some property of a Category changes, I want to call [category save], when a Category is added to the set, I want to call, again, [category save], and finally, when a category is removed, [category destroy].
I've wired up a partial solution, but it falls apart on the removal requirement, and everything about it seems to me as though I'm barking up the wrong tree. Anyway, here's what's going on:
Once the document and nib are all loaded up, I start observing the categories property, and assign it some data:
[self addObserver:self
forKeyPath:#"categories"
options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
context:MyCategoriesContext];
self.categories = [Category getCategories];
I've implemented the observation method in such a way as that I am informed of changes so that the document can respond and update the data store.
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
{
NSNumber *changeKind = (NSNumber *)[change objectForKey:#"NSKeyValueChangeKind"];
if (context == MyCategoriesContext)
{
switch ([changeKind intValue])
{
case NSKeyValueChangeInsertion:
{
Category *c = (Category *)[change objectForKey:NSKeyValueChangeNewKey];
NSLog(#"saving new category: %#", c);
[c save];
break;
}
case NSKeyValueChangeRemoval:
{
Category *c = (Category *)[change objectForKey:NSKeyValueChangeOldKey];
NSLog(#"deleting removed category: %#", c);
[c destroy];
break;
}
case NSKeyValueChangeReplacement:
{
// not a scenario we're interested in right now...
NSLog(#"category replaced with: %#", (Category *)[change objectForKey:NSKeyValueChangeNewKey]);
break;
}
default: // gets hit when categories is set directly to a new array
{
NSLog(#"categories changed, observing each");
NSMutableArray *categories = (NSMutableArray *)[object valueForKey:keyPath];
NSIndexSet *allIndexes = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [categories count])];
[self observeCategoriesAtIndexes:allIndexes];
break;
}
}
}
else if (context == MyCategoryContext)
{
NSLog(#"saving category for change to %#", keyPath);
[(Category *)object save];
}
else
{
// pass it on to NSObject/super since we're not interested
NSLog(#"ignoring change to %#:#%#", object, keyPath);
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
As you can see from that listing (and as you might already be aware), it's not enough to observe the categories property, I need to observe each individual category so that the document is notified when it's attributes have been changed (like the name) so that I can save that change immediately:
- (void)observeCategoriesAtIndexes:(NSIndexSet *)indexes {
[categories addObserver:self
toObjectsAtIndexes:indexes
forKeyPath:#"dirty"
options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
context:MyCategoryContext];
}
This looks to me like a big kludge, and I suspect I'm working against Cocoa here, but for the most part it works.
Except for removal. When you add a button to your interface, and assign it to the array controller's remove: action, it will properly remove the category from the categories property on my document.
In doing so, the category is deallocated while it is still under observation:
2010-09-03 13:51:14.289 MyApp[7207:a0f] An instance 0x52db80 of class Category was deallocated while key value observers were still registered with it. Observation info was leaked, and may even become mistakenly attached to some other object. Set a breakpoint on NSKVODeallocateBreak to stop here in the debugger. Here's the current observation info:
<NSKeyValueObservationInfo 0x52e100> (
<NSKeyValueObservance 0x2f1a480: Observer: 0x2f0fa00, Key path: dirty, Options: <New: YES, Old: YES, Prior: NO> Context: 0x1a67b4, Property: 0x2f1a3d0>
...
)
In addition, because the object has been deallocated before I've been notified, I don't have the opportunity to call [category destroy] from my observer.
How is one supposed to properly integrate with NSArrayController to persist changes to the data model pre-Core Data? How would one work-around the remove problem here (or is this the wrong approach entirely?)
Thanks in advance for any advice!
It would seem, based on some initial hacking, that subclassing NSArrayController is the way to go here. Over-riding the various insertObject(s) and removeObject(s) methods in that API gives me the perfect place to add in this logic for messing with the data model.
And from there I can also begin to observe the individual items in the content array for changes, etc, stop observation before destroying/deallocating them, etc, and let the parent class handle the rest.
Thanks for this solution is due to Bill Garrison who suggested it on the cocoa-unbound list.
I would observe changes to categories list, and when the list changes, store the array of categories away in a secondary NSArray, 'known categories', using mutableCopy. Next time the list changes, compare that 'known' list to the new list; you can tell which categories are missing, which are new, etc. For each removed category, stop observing it and release it.
Then take a new mutable copy for the 'known' list of categories, ready for the next call.
Since you have an additional array holding the categories, they aren't released before you're ready.