Git Product home page Git Product logo

fsmx's Introduction

Fsmx

A Finite-state machine implementation in Elixir, with opt-in Ecto friendliness.

Highlights:

  • Plays nicely with both bare Elixir structs and Ecto changesets
  • Ability to wrap transitions inside an Ecto.Multi for atomic updates
  • Guides you in the right direction when it comes to side effects

Installation

Add fsmx to your list of dependencies in mix.exs:

def deps do
  [
    {:fsmx, "~> 0.5.0"}
  ]
end

Usage

Simple state machine

defmodule App.StateMachine do
  defstruct [:state, :data]

  use Fsmx.Struct, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four",
    "four" => :*, # can transition to any state
    :* => ["five"] # can transition from any state to "five"
  }
end

Use it via the Fsmx.transition/2 function:

struct = %App.StateMachine{state: "one", data: nil}

Fsmx.transition(struct, "two")
# {:ok, %App.StateMachine{state: "two"}}

Fsmx.transition(struct, "four")
# {:error, "invalid transition from one to four"}

Callbacks before transitions

You can implement a before_transition/3 callback to mutate the struct when before a transition happens. You only need to pattern-match on the scenarios you want to catch. No need to add a catch-all/do-nothing function at the end (the library already does that for you).

defmodule App.StateMachine do
  # ...

  def before_transition(struct, "two", _destination_state) do
    {:ok, %{struct | data: %{foo: :bar}}}
  end
end

Usage:

struct = %App.StateMachine{state: "two", data: nil}

Fsmx.transition(struct, "three")
# {:ok, %App.StateMachine{state: "three", data: %{foo: :bar}}

Validating transitions

The same before_transition/3 callback can be used to add custom validation logic, by returning an {:error, _} tuple when needed:

defmodule App.StateMachine do
  # ...


  def before_transition(%{data: nil}, _initial_state, "four") do
    {:error, "cannot reach state four without data"}
  end
end

Usage:

struct = %App.StateMachine{state: "two", data: nil}

Fsmx.transition(struct, "four")
# {:error, "cannot reach state four without data"}

Decoupling logic from data

Since logic can grow a lot, and fall out of scope in your structs/schemas, it's often useful to separate all that business logic into a separate module:

defmodule App.StateMachine do
  defstruct [:state]

  use Fsmx.Struct, fsm: App.BusinessLogic
end

defmodule App.BusinessLogic do
  use Fsmx.Fsm, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four"
  }

  # callbacks go here now
  def before_transition(struct, "two", _destination_state) do
    {:ok, %{struct | data: %{foo: :bar}}}
  end

  def before_transition(%{data: nil}, _initial_state, "four") do
    {:error, "cannot reach state four without data"}
  end
end

Multiple state machines in the same struct

Not all structs have a single state machine, sometimes you might need more, using different fields for that effect. Here's how you can do it:

defmodule App.StateMachine do
  defstruct [:state, :other_state, :data]

  use Fsmx.Struct, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four",
    "four" => :*, # can transition to any state
    :* => ["five"] # can transition from any state to "five"
  }

  use Fsmx.Struct,
    state_field: :other_state,
    transitions: %{
        "initial" => ["middle", "middle2"],
        "middle" => "middle2",
        :* => "final"
    }
end

Use it via the Fsmx.transition/3 function:

struct = %App.StateMachine{state: "one", other_state: "initial", data: nil}

Fsmx.transition(struct, "two")
# {:ok, %App.StateMachine{state: "two", other_state: "initial"}}

Fsmx.transition(struct, "final", field: :other_state)
# {:ok, %App.StateMachine{state: "one", other_state: "final"}}

Ecto support

Support for Ecto is built in, as long as ecto is in your mix.exs dependencies. With it, you get the ability to define state machines using Ecto schemas, and the Fsmx.Ecto module:

defmodule App.StateMachineSchema do
  use Ecto.Schema

  schema "state_machine" do
    field :state, :string, default: "one"
    field :data, :map
  end

  use Fsmx.Struct, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four"
  }
end

You can then mutate your state machine in one of two ways:

1. Transition changesets

Returns a changeset that mutates the :state field (or {:error, _} if the transition is invalid).

{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Fsmx.transition_changeset(schema, "two")
# #Ecto.Changeset<changes: %{state: "two"}>

You can customize the changeset function, and again pattern match on specific transitions, and additional params:

defmodule App.StateMachineSchema do
  # ...

  # only include sent data on transitions from "one" to "two"
  def transition_changeset(changeset, "one", "two", params) do
    # changeset already includes a :state field change
    changeset
    |> cast(params, [:data])
    |> validate_required([:data])
  end

Usage:

{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Fsmx.transition_changeset(schema, "two", %{"data"=> %{foo: :bar}})
# #Ecto.Changeset<changes: %{state: "two", data: %{foo: :bar}>

2. Transition with Ecto.Multi

Note: Please read a note on side effects first. Your future self will thank you.

If a state transition is part of a larger operation, and you want to guarantee atomicity of the whole operation, you can plug a state transition into an Ecto.Multi. The same changeset seen above will be used here:

{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Repo.transaction()

When using Ecto.Multi, you also get an additional after_transition_multi/3 callback, where you can append additional operations the resulting transaction, such as dealing with side effects (but again, please know that side effects are tricky)

defmodule App.StateMachineSchema do
  def after_transition_multi(schema, _from, "four") do
    Mailer.notify_admin(schema)
    |> Bamboo.deliver_later()

    {:ok, nil}
  end
end

Note that after_transition_multi/3 callbacks still run inside the database transaction, so be careful with expensive operations. In this example Bamboo.deliver_later/1 (from the awesome Bamboo package) doesn't spend time sending the actual email, it just spawns a task to do it asynchronously.

A note on side effects

Side effects are tricky. Database transactions are meant to guarantee atomicity, but side effects often touch beyond the database. Sending emails when a task is complete is a straight-forward example.

When you run side effects within an Ecto.Multi you need to be aware that, should the transaction later be rolled back, there's no way to un-send that email.

If the side effect is the last operation within your Ecto.Multi, you're probably 99% fine, which works for a lot of cases. But if you have more complex transactions, or if you do need 99.9999% consistency guarantees (because, let's face it, 100% is a pipe dream), then this simple library might not be for you.

Consider looking at Sage, for instance.

# this is *probably* fine
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Repo.transaction()

# this is dangerous, because your transition callback
# will run before the whole database transaction has run
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Ecto.Multi.update(:update, a_very_unreliable_changeset())
|> Repo.transaction()

Contributing

Feel free to contribute. Either by opening an issue, a Pull Request, or contacting the team directly

If you found a bug, please open an issue. You can also open a PR for bugs or new features. PRs will be reviewed and subject to our style guide and linters.

About

Fsmx is maintained by Subvisual.

Subvisual logo

fsmx's People

Contributors

cschmatzler avatar dennmart avatar dhc02 avatar emig avatar maxim-filimonov avatar naps62 avatar ruudk avatar sebz avatar zamith avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

fsmx's Issues

Purpose of @callback transition_changeset is not clear

Hey ๐Ÿ‘‹

Thanks for a nice & clean library.
One question (pardon if a silly one) is about this callback definitions:

defmodule Fsmx.Fsm do
  ...
  if Code.ensure_loaded?(Ecto) do
    @callback transition_changeset(struct, Fsmx.state_t, Fsmx.state_t) :: Ecto.Changeset.t()
    @callback after_transition_multi(struct, Fsmx.state_t, Fsmx.state_t) ::
                {:ok, struct} | {:error, any}
  end
  ...

I thought the idea was to use @impl when overriding transition_changeset, like here:

defmodule App.StateMachineSchema do
  # ...

  @impl Fsmx.Fsm
  def transition_changeset(changeset, "one", "two", params) do
    # changeset already includes a :state field change
    changeset
    |> cast(params, [:data])
    |> validate_required([:data])
  end

but transition_changeset above accepts 4 params, while callback defines only 3 params.
Also there's no @behaviour in defmacro __using__.

This is not a bug report - just curious what was the original intent around @callback ๐Ÿ™

Next release

Hello

When will you be releasing the current master?
we would need the bugfix regarding the IO.inspect

thanks and kind regards

Naive DB state handling and no way to reload the DB state in transaction

The current implementation is naive - its assuming that DB state is equal to loaded (runtime) state. A correct implementation would need to assure that with some form of locking. For example, before transitioning, we could lock the row (via a write lock or a simple advisory lock) and re-read the state column, making sure our copy of it is up to date.

With non-transactional transition its ignored completely, but maybe that is fine for people who use it (????).
Now, a user of the library might think it's possible to achieve correctness with transition_multi. However, it has no way to accept the loaded schema inside transaction, and doesn't fetch the record itself. It accepts the schema argument which must be known (loaded) outside of transaction context.

An alternative approach would be to use a transition query with WHERE state=^previous_state, but then it wouldn't work with a changeset.

Transition to any state?

Is there a way to encode a transition from a state to any state, or do you have to list all the states you could transition from individually?

And is there any documentation of what the transitions key means exactly? I only see examples in the readme, such as:

  use Fsmx.Struct, transitions: %{
    "one" => ["two", "three"],
    "two" => ["three", "four"],
    "three" => "four"
  }

Multiple Transition Sets per Schema

Thanks for the package. Is there a possibility to have multiple transition sets per schema, for example

field(:state, :string, default: "start")
field(:second_set_state, :string, default: "start")
field(:third_set_state, :string, default: "start")
...

If I understand correctly, state is hard-coded here.

States as an :atom

Hey team, thank you for the work you are doing here.

Quick question, any specific reason why atoms are not valid states in the type spec?
image

This works just fine but I'm getting mismatching type in the dialyzer

  def submit_order(order, order_params) do
    {:ok, %{new: new}} =
      Multi.new()
      |> Fsmx.transition_multi(order, :new, :submitted, order_params)
      |> Repo.transaction()

    {:ok, Repo.reload(new)}
  end

Can you add git tags for ExDoc?

When looking at the </> buttons on hexdocs.pm, or any documentation tool (e.g. "Dash", or "Zeal"), ex_doc attempts to use the library version to construct a link, and assumes there's an equivalent git tag.

Could you push some git tags matching those versions so I can more easily review things when implementing? Main example is from when I need a reminder of what the use Fsmx.Fsm does.

If there's another way you'd prefer that's fine too. I'd submit a PR, but tags aren't PR-able, and anything else would probably be something you'd wanna discuss first ๐Ÿ˜…

Implementation for `transition_changeset/4` required for all transitions since 0.5.0?

There seems to be a change since version 0.5.0 that's breaking when there is no callback defined for a valid transition_changeset/4 callback. Previously the implementation of this callback was optional.

The documentation for before_transition/3 mentions this:

You only need to pattern-match on the scenarios you want to catch. No need to add a catch-all/do-nothing function at the end (the library already does that for you).

This also applied to the transition_changeset/4 callback before, but that has changed, resulting in a FunctionClauseError error when a valid transition is triggered.

I'm curious if this is intended. It's trivial to supply a default implementation for this callback, but I actually preferred the previous behaviour.

See this livebook for a reproduction (use the import functionality, choose "From URL", and paste this url):
https://gist.github.com/linusdm/a79c241c2f9b7a1c3ac158b1aa530ca2#file-repro-livemd

Thanks in advance for any feedback!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.