[PEAK] Commands, Actions, Undo/Redo, and transactions
Phillip J. Eby
pje at telecommunity.com
Wed Jul 28 16:50:52 EDT 2004
I've pretty much decided that "command" sucks as a term and as a metaphor
for how workspaces will track undo/redo and transactions. For one thing,
most of what I've been calling commands aren't truly commands in the
"Command Pattern" sense, since they don't know how to "execute themselves".
So, I'm going to define a different terminology:
"""A behavior unit that can be undone or redone"""
undoable = Attribute("""True if this action can potentially be
key = Attribute(
"""A unique key identifying the subject of this action, or None"""
"""Undo the behavior"""
"""Reapply the behavior"""
"""A list of actions that will be undone/redone as a group"""
"""Does this action group have an action keyed as 'key'?"""
"""Yield the actions that comprise the group"""
active = Attribute(
"""True until 'finish()' or 'undo()' is called on this group"""
"""Add IAction action to the group
Raise an error if group is no longer active. If 'action' is
an 'IActionGroup', recursively invoke 'add()' on all of its
contents, except those whose keys are already present in this
"""Stop allowing actions to be added (also called by 'undo()')"""
"""Perform undo/redo of recorded actions"""
"""Undo the last action added to the undo stack
(while moving it to the redo stack)"""
"""Pop an action from the redo stack and redo it
(while moving it to the undo stack)"""
"""Record IActionGroup actionGroup as part of the history
(add to undo stack, clear redo stack, and if action
isn't undoable, clear the undo stack too.)"""
# XXX haveUndoables(), haveRedoables(), clear()?
The above is still a bit rough, as I'm not yet 100% certain of how the
state machine for histories should work. Anyway, each workspace will have
a "current action", which will be an IActionGroup, and an "undo manager",
which will be an IUndoManager. Performing operations on objects in the
workspace will cause IAction objects to be added to the IActionGroup,
potentially updating the IActionGroup's mementos in the process.
User-level undo and redo will of course use the undo manager. When each
user-level action is completed, it should call workspace.commitAction() to
'finish()' the current action, add it to the undo manager, and create a new
"current action". If an action in progress has to be aborted,
workspace.rollbackAction() should 'undo()' the current action and replace
it with a new "current action". (Notice that user-level undo/redo apply
only to actions that have actually been completed; rollback of an
in-progress action is not the same thing as 'undoLast()'.)
For typical database-backed applications, each top-level action will
correspond to a transaction. A commit will invoke commitAction(), an abort
will rollbackAction(), and the undo manager will be a "null object" that
doesn't keep any actual undo history.
Some applications may want nested transactions, in which case they'll need
to temporarily replace the current action and undo manager with
substitutes, and put them back when the nested transactions are
completed. Because there are so few real use cases for nested transactions
(translation: I don't personally have any) the framework probably won't
have any convenience APIs for this, though contributions would be
considered from folks who *do* use nested transactions. There may be some
interesting issues to work through for use cases like aborting an action
that has a nested action in progress, but as far as I can tell the
interfaces I've defined are *capable* of implementing all such
scenarios. It's just that depending on the kind of "nested transaction
API" you want to build, it may not be *easy*. :)
Anyway, that's the high-level overview; let's dip into the details for a
moment. There are two kinds of actions that can be recorded: state-based
and change-based. A state-based action just records the "before" state of
the action (or "after" state for redo), and it defines a *key* to indicate
what piece of state (such as an object's dictionary or an index key) to
identify the action.
The idea here is that an operation like changing an object's attribute can
check to see if the current action contains the key, and if not, add an
action to restore the state:
key = id(ob) # XXX just an example, real keys will be more complex!
if key not in ws.currentAction:
The idea here is that code like this will be part of the generic functions
surrounding change operations. 'Memento(ob)' would create an IAction that
saves the object's "before" state. When undone or redone, the memento
simply swaps the object's current state with the saved state. (Of course,
it would also need to keep track of whether it's in "before" or "after"
mode and accept or refuse the undo/redo accordingly.)
Because a state-based action simply puts things back the way they were when
undone, there's no need to have more than one state-based action per target
state per action group. That's what the 'key' is for. You don't create a
new action if the group already has an action for that key, and when the
group is added (committed) to a larger group, it can omit any actions that
already have a matching action in the larger group. (Because action groups
are undone or redone as a unit, there's no need to keep an action that
represents a snapshot somewhere in the middle of the action.)
Change-based actions are simpler than state-based actions, but they can
consume more time and space if objects change frequently within a top-level
action group. Change-based actions only know how to do something forwards
or backwards, and they are always added to the current action group
regardless of whether similar actions already appear in the group. They
have a key of 'None', so they also don't get aggregated when an action
group is merged with a parent action group.
Error handling is going to be interesting for IAction and IActionGroup
implementors, because an action group *must* be undone or redone
completely. If a contained action's undo() or redo() fails, the entire
workspace must be considered *unstable* and *unusable*. (At a minimum, all
action-related functions should be disabled, especially
commitAction/rollbackAction.) If the workspace is associated with a
transaction, the transaction must also be marked as corrupt. These rules
are harsh, but failure really shouldn't be an option here. In practice,
most undo()/redo() methods will be: 1) fairly trivial, and 2) supplied by
PEAK, which means they'll be tested by many PEAK users and unlikely to fail
unless the workspace really *is* corrupted in some way. (Action classes in
general are part of the mapping layer, not client code or the abstract
model, so few developers will have to deal with their trickiness directly.)
Whew! I think that about covers it. I think this actually constitutes a
"best of breed" API, in that it covers scenarios ranging from object
prevalence, to GUIs with undo/redo, to "typical" database applications, to
esoteric nested transaction needs.
Actually, I suppose that ZODB offers a comparable array of options,
especially ZODB4 with nested transactions, but this is a lot simpler in the
design and should even be simpler in the implementation than ZODB's version
of these concepts. ZODB also offers the notion of "versions" (branches of
history), although they would like to phase that capability out. PEAK
isn't going to offer a built-in equivalent, but I can see how in principle
one could write a "versioning undo manager" and maybe some other facilities
to allow something like it to exist in certain circumstances.
Hmm... actually, it'd probably be more useful just to implement a
"time-travelling" workspace that knows "as of" what version or date you are
looking at the underlying DB. Now there's an exciting idea for some
applications -- but not one that's going to get into the core any time soon!
Ah well. As usual, comments and questions are appreciated.
More information about the PEAK