A named record state: the tag that names it and the properties that refine it.
A state module is a struct of witnesses, record plus whatever its
properties extract, and match/2 builds it or says why the record is not in
that state. A Hoare.Transition names states as its from and to.
defmodule Issued do
use Hoare.State, status: :issued, witnesses: [:lines], preloads: [:lines]
@impl Hoare.State
def properties, do: [&billable_lines/1]
defp billable_lines(%Issued{record: %{lines: []}}), do: {:error, :no_lines}
defp billable_lines(%Issued{record: %{lines: lines}} = state), do: {:ok, %{state | lines: lines}}
end
Hoare.State.match(Issued, invoice)
#=> {:ok, %Issued{record: invoice, lines: [...]}} | {:error, :not_issued | :no_lines}The tag is read from :status unless field: names another column, and a
state that declares no status: is untagged: it is its properties, which
alone decide whether the record is in it.
defmodule Reserved do
use Hoare.State, witnesses: [:reservation], preloads: [:reservation]
@impl Hoare.State
def properties, do: [&reserved/1]
defp reserved(%Reserved{record: %{reservation: nil}}), do: {:error, :not_reserved}
defp reserved(%Reserved{record: %{reservation: r}} = state), do: {:ok, %{state | reservation: r}}
endA property is an arrow over the state struct: it refines the tag, and may
fill a witness so nothing downstream has to look again. use is a
convenience over the behaviour; a module may implement the callbacks and
define the struct itself. defstate/3 declares several states in one module.
Summary
Callbacks
The record field the tag is read from; :status unless declared.
The reason reported when the record's tag does not tag it.
What the record must have loaded for the properties to read it.
Arrows over the state struct that refine the tag and fill its witnesses.
The value that tags a record as being in this state; undefined when the state is untagged.
Functions
Declares the state from its options; properties/0 stays overridable.
The one state of states the record is in.
Declares a state as a module nested in the caller, so one module holds a record's states and each stays a struct of its own.
Builds the state from the record, or says why the record is not in it.
Matches a sum of states: the first whose tag tags the record decides; otherwise the untagged states are tried in declared order.
The state's declared preloads; none when it declares none.
The state's tag, {field, value}; nil when the state is its properties alone.
Types
Callbacks
@callback field() :: atom()
The record field the tag is read from; :status unless declared.
@callback missing() :: reason()
The reason reported when the record's tag does not tag it.
@callback preloads() :: [term()]
What the record must have loaded for the properties to read it.
@callback properties() :: [property()]
Arrows over the state struct that refine the tag and fill its witnesses.
@callback status() :: atom()
The value that tags a record as being in this state; undefined when the state is untagged.
Functions
Declares the state from its options; properties/0 stays overridable.
use Hoare.State, status: :issued, witnesses: [:lines], preloads: [:lines]
use Hoare.State, status: :SHIPPED, field: :line_status
use Hoare.State, witnesses: [:reservation]:status is the tag, read from :field (:status unless given); without
it the state is untagged and its properties alone decide. :missing
defaults to :not_<status>, downcased; :witnesses are the struct's keys
after record; :preloads defaults to none. The struct's type is t/0.
@spec classify([module(), ...], subject()) :: {:ok, struct()} | {:error, :unclassified | {:ambiguous, [module(), ...]}}
The one state of states the record is in.
Exactly one match is the executable form of "the states are exhaustive and
exclusive": none is :unclassified, more than one is {:ambiguous, states}.
Unlike match_any/2 every state is tried, tag or no tag, so run it over the
rows as a coverage audit before any writer moves onto a transition.
Declares a state as a module nested in the caller, so one module holds a record's states and each stays a struct of its own.
defmodule Invoice.State do
import Hoare.State, only: [defstate: 2, defstate: 3]
defstate Draft, status: :draft
defstate Paid, status: :paid
defstate Issued, status: :issued, witnesses: [:lines], preloads: [:lines] do
@impl Hoare.State
def properties, do: [&billable_lines/1]
...
end
endThe states are Invoice.State.Draft and so on, and the module holding them
gains all/0, the states in declared order, and classify/1 over them. Add
import_deps: [:hoare] to .formatter.exs to keep defstate free of
parentheses.
Builds the state from the record, or says why the record is not in it.
A tagged state checks its tag, then its properties; an untagged state is its properties, so they alone decide.
Matches a sum of states: the first whose tag tags the record decides; otherwise the untagged states are tried in declared order.
When none matches, the reason is the first failure in declared order: a
tagged state's missing/0, an untagged state's first failing property.
The state's declared preloads; none when it declares none.
The state's tag, {field, value}; nil when the state is its properties alone.