Search code examples
objective-ccocoacore-datansundomanager

Can’t undo more than one operation


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...


Solution

  • 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.