Hoare.State behaviour (hoare v0.3.0)

Copy Markdown View Source

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}}
end

A 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

property()

@type property() :: (struct() -> {:ok, struct()} | {:error, reason()})

reason()

@type reason() :: atom()

subject()

@type subject() :: %{optional(atom()) => term()}

tag()

@type tag() :: {field :: atom(), value :: atom()} | nil

Callbacks

field()

(optional)
@callback field() :: atom()

The record field the tag is read from; :status unless declared.

missing()

(optional)
@callback missing() :: reason()

The reason reported when the record's tag does not tag it.

preloads()

(optional)
@callback preloads() :: [term()]

What the record must have loaded for the properties to read it.

properties()

@callback properties() :: [property()]

Arrows over the state struct that refine the tag and fill its witnesses.

status()

(optional)
@callback status() :: atom()

The value that tags a record as being in this state; undefined when the state is untagged.

Functions

__using__(opts)

(macro)

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.

classify(states, record)

@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.

defstate(name, opts, block \\ [do: nil])

(macro)

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
end

The 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.

match(state, record)

@spec match(module(), subject()) :: {:ok, struct()} | {:error, reason()}

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.

match_any(states, record)

@spec match_any([module(), ...], subject()) :: {:ok, struct()} | {:error, reason()}

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.

preloads(state)

@spec preloads(module()) :: [term()]

The state's declared preloads; none when it declares none.

tag(state)

@spec tag(module()) :: tag()

The state's tag, {field, value}; nil when the state is its properties alone.