Description
StateManager is currently used as an instance of a reference StateManager
that lives as a property on the model and sources its states from an external enum. This worked in the pre-type hint era, but it was already littering the client model with conditional states and their lambdas, and is not transparent to type analysis. The API could be rewritten to be cleaner:
class ProjectState(StateType[int]):
# StateManager is like an enum now, but without the magic of Enum.
# Enums require special-casing by type checkers, with many gotchas.
# The data type for State values is specified as a generic arg, and it
# could also point to an existing enum for validation, in the form:
# `class ProjectState(StateType[ProjectStateEnum])`
# This should validate 1:1 mapping from enum members to states.
is_draft = State(1, __("Draft"))
is_published = State(2, __("Published"))
is_withdrawn = State(3, __("Withdrawn"))
is_deleted = State(4, __("Deleted"))
# or: is_deleted = State(ProjectStateEnum.DELETED) # Metadata copied from enum
# Groups of states can be defined right here, unlike in Enum.
# Using `|` is less ambiguous than the old set-based syntax, and
# possible because states are already objects that can implement `__or__`
is_gone = is_withdrawn | is_deleted # But how is this assigned a title?
# Conditional states can be defined like properties and can
# assume a fixed relationship with the client model (positional arg)
@is_published.conditional
def is_future(self, project: Project) -> bool:
return project.start_at > utcnow()
# SQL expression can also be defined here, although it's likely to give us
# similar grief to SQLAlchemy, forcing us to make it `.inplace.expression`
@is_future.expression
def is_future(self, cls: Type[Project]) -> sa.ColumnExpression:
return cls.start_at > sa.func.utcnow()
class Project(…, Model):
# The state manager should sits directly on the column, not alongside, assuming
# SQLAlchemy gives us that kind of access. We'll need more hijinks to give
# mapped_column a hint of what the SQL data type is.
state: Mapped[ProjectState] = sa.orm.mapped_column(
ProjectState(),
default=ProjectState.is_draft
# or: default=ProjectStateEnum.DRAFT
)
Transitions are particularly painful. We've learnt they're not simple. They have multiple stages spread across models, forms and views, and is one major reason for moving the state spec out of the model. Transitions have:
- Pre-validation:
- Is this transition available given the object's current state? (Model concern)
- Is this transition available given the user's credentials? (View concern)
- Is this transition available given related state managers and causal chains (see below; potentially both Model and View concern)
- Requirements:
- What must the user supply to be able to make this transition? (View concern, requesting form schema)
- Validation:
- Has the user supplied the required information? (Form concern)
- Execution
- What should we change alongside the state? (eg: publish date)
- Dependencies across state managers
- Multiple state managers in the same model
- State managers spread across models (eg: publish transition is available only if parent is also published)
- Chained transitions (eg: deleting parent will also delete all children, which are different models with different state managers, but the causal chain requires confirmation the user can execute the entire chain)
StateManager's current method of defining a transition via a single method on the model is inadequate. That method currently does pre-validation with execution, limited to the model level, with no support for form schema, validation or causal chains. We will instead need to define a transition as a distinct noun that serves as a registry for the various supporting bits across models, forms and views.
Should this transition registry reside in the state manager, sharing namespace with states? Or in the model as at present?