Community
Participate
Working Groups
Build ID: M20071023-1652 Steps To Reproduce: This has been reproduced using the org.eclipse.emf.transaction.exapmle plugin (the emf-transaction-examples-1.1.1.zip download). The example was modified as follows (code details are given below): (1) Use a RecordingCommand when changing the last name of a writer (2) Add a ResourceSetListener that triggers a RecordingCommand to modify the title of the writer's first book, whenever the last name of the writer has changed When editing the writers last name in the properties view, the title of the first book is updated accordingly. However, when undoing the operation, the RecordingCommand that updates the book's title receives the undo() message twice. The second execution of undo effectively redoes the command. Hence the book's title does not appear to be affected by the undo. ---Code details--- (1) The ResourceSetListener: public class MyResourceSetListener extends ResourceSetListenerImpl { public Command transactionAboutToCommit(ResourceSetChangeEvent event) throws RollbackException { List notifications = event.getNotifications(); CompoundCommand cc = new CompoundCommand(); Set handledWriters = new HashSet(); for (Iterator i = notifications.iterator(); i.hasNext();) { Notification n = (Notification)i.next(); if (n.getNotifier() instanceof EObject && n.getFeature() == EXTLibraryPackage.eINSTANCE.getPerson_LastName() && !handledWriters.contains(n)) { EObject notifier = (EObject)n.getNotifier(); if (notifier.eClass() == EXTLibraryPackage.eINSTANCE.getWriter()) { final Writer w = (Writer)notifier; if (!w.getBooks().isEmpty()) { final Book firstBook = (Book)w.getBooks().get(0); if (!firstBook.getTitle().startsWith(w.getLastName())) { // We can use any EMF command here cc.append(new RecordingCommand(event.getEditingDomain()) { public void doExecute() { firstBook.setTitle(w.getLastName() + ":" + firstBook.getTitle()); } } ); } } handledWriters.add(w); } } } // It is important to return null if we have nothing to // contribute to this transaction. return cc.isEmpty() ? null : cc; } } (2) Adding the ResourceSetListener in EXTLibraryEditingDomainFactory.createEditingDomain(): public TransactionalEditingDomain createEditingDomain() { // create an editing domain with a default resource set implementation // and delegating command execution to the default (workbench) // operation history TransactionalEditingDomain result = WorkspaceEditingDomainFactory.INSTANCE.createEditingDomain(); //TransactionalEditingDomain result = TransactionalEditingDomain.Factory.INSTANCE.createEditingDomain(); // add an exception handler to the editing domain's command stack ((TransactionalCommandStack) result.getCommandStack()).setExceptionHandler( new CommandStackExceptionHandler()); result.addResourceSetListener(new MyResourceSetListener()); return result; } (3) To force the use of a RecordingCommand to set Writer lastName, a private ItemPropertyDescriptor subclass is added in PersonItemProvider: private class LastNamePropertyDescriptor extends ItemPropertyDescriptor { public LastNamePropertyDescriptor (AdapterFactory adapterFactory, ResourceLocator resourceLocator, String displayName, String description, EStructuralFeature feature, boolean isSettable, boolean multiLine, boolean sortChoices, Object staticImage, String category, String[] filterFlags) { super(adapterFactory, resourceLocator, displayName, description, feature, isSettable, multiLine, sortChoices, staticImage, category, filterFlags); } public Object getPropertyValue(Object object) { return ((Person)object).getLastName(); } public void setPropertyValue(Object object, Object value) { final Person rc = (Person) object; final TransactionalEditingDomain editingDomain = (TransactionalEditingDomain) getEditingDomain(object); final String newName = (String)value; List<Command> cmdList = new ArrayList<Command>(); cmdList.add(new RecordingCommand(editingDomain) { public void doExecute() { rc.setLastName(newName); } }); editingDomain.getCommandStack().execute(new CompoundCommand(cmdList)); } public void resetPropertyValue(Object object) { super.resetPropertyValue(object); } } (4) To use LastNamePropertyDescriptor, the PersonItemProvider.addLastNamePropertyDescriptor() is modified as follows: /** * This adds a property descriptor for the Last Name feature. * <!-- begin-user-doc --> * <!-- end-user-doc --> * @generated NOT */ protected void addLastNamePropertyDescriptor(Object object) { itemPropertyDescriptors.add //(createItemPropertyDescriptor (new LastNamePropertyDescriptor (((ComposeableAdapterFactory)adapterFactory).getRootAdapterFactory(), getResourceLocator(), getString("_UI_Person_lastName_feature"), //$NON-NLS-1$ getString("_UI_PropertyDescriptor_description", "_UI_Person_lastName_feature", "_UI_Person_type"), //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ EXTLibraryPackage.Literals.PERSON__LAST_NAME, true, false, false, ItemPropertyDescriptor.GENERIC_VALUE_IMAGE, null, null)); } More information: The first run of RecordingCommand.undo() is done as a consequence of an explicit attempt to undo the triggerCommand in EMFCommandOperation.doUndo(). The second RecordingCommand.undo() is done in accordance with a CommandChangeDescription that is recorded in the transaction by the TransactionImpl.addTriggers() method. If I replace the RecordingCommand with a SetCommand in the ResourceSetListener, I have verified that undo is still run twice on the SetCommand. However, since the SetCommand keeps track of its own oldValue, the second run of undo does not change anything. One simple fix could be to make RecordingCommand more robust to inconsistent use of undo/redo: Instead of merely running applyAndReverse() in respose to both undo/redo, it should check to see what the most recent action was. If an attempt is made to repeat the most recent action, the RecordingCommand should do nothing.
One possible workaround (without tampering with the EMF transaction jars) is to replace the use of RecordingCommand with the use of the class given below. This class keeps track of the most recent operation to avoid multiple undo/redo: public abstract class MyRecordingCommand extends CommandWrapper implements ConditionalRedoCommand { private RecentOperation recentOperation; private enum RecentOperation { none, execute, undo, redo } protected XiRecordingCommand(TransactionalEditingDomain domain) { super(); command = new PrivateRecordingCommand(domain) { protected void doExecute() { XiRecordingCommand.this.doExecute(); } }; recentOperation = RecentOperation.none; } public boolean canRedo() { return ((ConditionalRedoCommand) command).canRedo(); } @Override public void execute() { super.execute(); recentOperation = RecentOperation.execute; } protected abstract void doExecute(); @Override public void undo() { if (recentOperation != RecentOperation.undo) { super.undo(); recentOperation = RecentOperation.undo; } } @Override public void redo() { if (recentOperation != RecentOperation.redo) { super.redo(); recentOperation = RecentOperation.redo; } } private abstract class PrivateRecordingCommand extends RecordingCommand { public PrivateRecordingCommand(TransactionalEditingDomain domain) { super(domain); } } }
Targeting a fix in the 1.2 (current) release. With a work-around available, I'm happy not to take the risk of fixing in the Europa Winter Maintenance.
Fixed in the 1.2.1 maintenance branch. Investigation of this problem (creating JUnit tests to reproduce it) uncovered a second, related, issue: in the case of RecordingCommands employed as triggers in the context of the execution of an AbstractEMFOperation, the triggers are never undone at all! The effect is the same as undoing them twice. The originally-reported problem occurs only when a RecordingCommand is executed on the command-stack by virtue of being nested in a CompoundCommand. When a RecordingCommand was executed on the stack, the stack would take care not to add the trigger commands to it because the RecordingCommand was known to have recorded triggers already. However, in the CompoundCommand case (such as in the bug reporter's code snippet), the RecordingCommand captured the triggers *and* the stack added the to the compound because the compound isn't a RecordingCommand. So, the problem is resolved by ensuring that a RecordingCommand detects when it is not the "root" command in a transaction, in which case it creates a new transaction for itself, to capture the triggers locally. This ensures that the command-stack will not append the same triggers to the root transaction when it completes, because they are captured by the nested RecordingCommand. This is the same strategy used for triggers triggered by other triggers that are RecordingCommands. :-) The other problem that I found in my investigation was caused by a failure of the applyAndReverse() method of a change-description that captured a RecordingCommand (via a CommandChangeDescription) to first check that the command can be undone or redone (due to mismatch in the change and command APIs). In consequence, the canUndo() method of the RecordingCommand doesn't have a chance to initialize the 'change' field that is required to effect an undo. To fix this, I updated the RecordingCommand's undo/redo methods to double-check that it is undoable or redoable (as appropriate), which ensures that the 'change' field is initialized.
Fix available in R1_2_maintenance: 1.2.1.M200807161540.
Fix available in R1_2_maintenance: 1.2.1 (R200808130946).
Fix available in HEAD: 1.3.0M1 (S200808131342).