SQLAlchemy Planned¶
Planned — not implemented yet
open_fsm.contrib.sqlalchemy does not exist. This page describes the API
being designed for it, so the shape can be reviewed before it ships.
Nothing on this page is executable, and no code block here is run by the
test suite.
For what works today, see Binding state to storage.
Same shape, no saving¶
Like the Django integration, this is a flow class wrapping a mapped
instance — the pattern
Viewflow uses and the one
open-fsm inherited. The mapped class keeps a plain column and knows nothing
about the workflow.
What is different is that SQLAlchemy's unit of work already does the persisting.
Assigning a mapped attribute marks the instance dirty, and the session writes it
on flush — so this integration should supply the getter and setter and
nothing else. Adding a save hook would issue a second UPDATE and defeat
the unit of work.
That makes it the smallest of the three:
__init__ storing the instance |
Provided by ModelFlow |
| Getter and setter | Derived from the attribute name |
| Saving | Nothing — the session owns it |
| Concurrency | A version column or with_for_update(), both already SQLAlchemy's |
Proposed API¶
class Report(Base):
__tablename__ = 'reports'
id: Mapped[int] = mapped_column(primary_key=True)
text: Mapped[str]
state_field: Mapped[ReportState] = mapped_column(
SQLEnum(ReportState),
default=ReportState.NEW,
)
from open_fsm.contrib.sqlalchemy import ModelFlow, ModelState
class ReportFlow(ModelFlow):
state = ModelState(ReportState, default=ReportState.NEW, attribute='state_field')
@state.transition(source=ReportState.NEW, target=ReportState.APPROVED)
def approve(self):
self.instance.approvals.append(Approval())
@state.transition(source=ReportState.NEW, target=ReportState.REJECTED)
def reject(self):
...
def approve_report(session, pk):
report = session.scalars(
select(Report).where(Report.id == pk).with_for_update()
).one()
ReportFlow(report).approve()
session.commit()
return report
One transaction. The state change and whatever the transition body did to the object graph are flushed together.
Under discussion¶
Opinions are welcome on the issue tracker.
- Whether
ModelStateis needed at all. Because the session handles saving, a getter and setter over a mapped attribute are two plain lines — see below.ModelStatesaves a little boilerplate and adds a dependency. This may end up a documentation page and no module, which would be the right outcome. AsyncSession. The same synchronous-transition, awaited-commit split as Tortoise. The transition itself needs no changes; only the caller awaits.- Optimistic concurrency.
__mapper_args__ = {'version_id_col': ...}turns a lost update into aStaleDataErrorat flush. Whether the integration should translate that into aTransitionNotAllowedis open — arguably it should not, since they fail at different times and mean different things. on_successsemantics. Mapping it onto anafter_flushevent rather than firing it at transition time would make the hook mean "persisted" instead of "state changed". That is a better guarantee and a bigger change.
What to do today¶
Two methods over the mapped attribute, and no save hook:
class ReportFlow(StateEngine):
state = State(ReportState, default=ReportState.NEW)
def __init__(self, report):
self.report = report
@state.getter()
def _get_state(self):
return self.report.state_field
@state.setter()
def _set_state(self, value):
self.report.state_field = value
@state.transition(source=ReportState.NEW, target=ReportState.APPROVED)
def approve(self):
...
Note the absence of @state.on_success(). Assigning self.report.state_field
marks the instance dirty, and session.commit() writes it.
Related pages¶
- Binding state to storage — the pattern that works now
- Credits and Attribution — the Viewflow lineage
- Django ORM and Tortoise ORM — the other planned integrations