Giter Site home page Giter Site logo

joaomdmoura / machinery Goto Github PK

View Code? Open in Web Editor NEW
512.0 13.0 54.0 534 KB

Elixir State machine thin layer for structs

License: Apache License 2.0

Elixir 100.00%
machinery state-machine phoenix elixir elixir-lang state-management statemachine ecto state machine

machinery's Introduction

Machinery

Build Status Module Version Hex Docs Total Download License

Machinery

Machinery is a lightweight State Machine library for Elixir with built-in Phoenix integration. It provides a simple DSL for declaring states and includes support for guard clauses and callbacks.

Table of Contents

Installing

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

def deps do
  [
    {:machinery, "~> 1.1.0"}
  ]
end

Create a state field (or a custom name) for the module you want to apply a state machine to, and ensure it's declared as part of your defstruct.

If using a Phoenix model, add it to the schema as a string and include it in the changeset/2 function:

defmodule YourProject.User do
  schema "users" do
    # ...
    field :state, :string
    # ...
  end

  def changeset(%User{} = user, attrs) do
    #...
    |> cast(attrs, [:state])
    #...
  end
end

Declaring States

Create a separate module for your State Machine logic. For example, if you want to add a state machine to your User model, create a UserStateMachine module.

Then import Machinery in this new module and declare states as arguments.

Machinery expects a Keyword as an argument with the keys field, states and transitions.

  • field: An atom representing your state field name (defaults to state)
  • states: A List of strings representing each state.
  • transitions: A Map for each state and its allowed next state(s).

Example

defmodule YourProject.UserStateMachine do
  use Machinery,
    field: :custom_state_name, # Optional, default value is `:field`
    states: ["created", "partial", "completed", "canceled"],
    transitions: %{
      "created" =>  ["partial", "completed"],
      "partial" => "completed",
      "*" => "canceled"
    }
end

You can use wildcards "*" to declare a transition that can happen from any state to a specific one.

Changing States

To transition a struct to another state, call Machinery.transition_to/3 or Machinery.transition_to/4.

Machinery.transition_to/3 or ``Machinery.transition_to/4`

It takes the following arguments:

  • struct: The struct you want to transition to another state.
  • state_machine_module: The module that holds the state machine logic, where Machinery is imported.
  • next_event: string of the next state you want the struct to transition to.
  • (optional) extra_metadata: map with any extra data you might want to access on any of the sate machine functions triggered by the state change
Machinery.transition_to(your_struct, YourStateMachine, "next_state")
# {:ok, updated_struct}

# OR

Machinery.transition_to(your_struct, YourStateMachine, "next_state", %{extra: "metadata"})
# {:ok, updated_struct}

Example

user = Accounts.get_user!(1)
{:ok, updated_user} = Machinery.transition_to(user, UserStateMachine, "completed")

Persist State

To persist the struct and state transition, you declare a persist/2 or /3 (in case you wanna access metadata passed on transition_to/4) function in the state machine module.

This function will receive the unchanged struct as the first argument and a string of the next state as the second one.

your persist/2 or persist/3 should always return the updated struct.

Example

defmodule YourProject.UserStateMachine do
  alias YourProject.Accounts

  use Machinery,
    states: ["created", "completed"],
    transitions: %{"created" => "completed"}
  
  # You can add an optional third argument for the extra metadata.
  def persist(struct, next_state) do
    # Updating a user on the database with the new state.
    {:ok, user} = Accounts.update_user(struct, %{state: next_stated})
    # `persist` should always return the updated struct
    user
  end
end

Logging Transitions

To log transitions, Machinery provides a log_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4) callback that is called on every transition, after the persist function is executed.

This function receives the unchanged struct as the first argument and a string of the next state as the second one.

log_transition/2 or log_transition/3 should always return the struct.

Example

defmodule YourProject.UserStateMachine do
  alias YourProject.Accounts

  use Machinery,
    states: ["created", "completed"],
    transitions: %{"created" => "completed"}

  # You can add an optional third argument for the extra metadata.
  def log_transition(struct, _next_state) do
    # Log transition here.
    # ...
    # `log_transition` should always return the struct
    struct
  end
end

Guard functions

Create guard conditions by adding guard_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4) function signatures to the state machine module. This function receives two arguments: the struct and a string of the state it will transition to.

Use the second argument for pattern matching the desired state you want to guard.

# The second argument is used to pattern match into the state
# and guard the transition to it.
#
# You can add an optional third argument for the extra metadata.
def guard_transition(struct, "guarded_state") do
 # Your guard logic here
end

Guard conditions will allow the transition if it returns anything other than a tuple with {:error, "cause"}:

  • {:error, "cause"}: Transition won't be allowed.
  • _ (anything else): Guard clause will allow the transition.

Example

defmodule YourProject.UserStateMachine do
  use Machinery,
    states: ["created", "completed"],
    transitions: %{"created" => "completed"}

  # Guard the transition to the "completed" state.
  def guard_transition(struct, "completed") do
    if Map.get(struct, :missing_fields) == true do
      {:error, "There are missing fields"}
    end
  end
end

When trying to transition a struct that is blocked by its guard clause, you will have the following return:

blocked_struct = %TestStruct{state: "created", missing_fields: true}
Machinery.transition_to(blocked_struct, TestStateMachineWithGuard, "completed")

# {:error, "There are missing fields"}

Before and After callbacks

You can also use before and after callbacks to handle desired side effects and reactions to a specific state transition.

You can declare before_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4) and after_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4), pattern matching the desired state you want to.

Before and After callbacks should return the struct.

# Before and After callbacks should return the struct.
# You can add an optional third argument for the extra metadata.
def before_transition(struct, "state"), do: struct
def after_transition(struct, "state"), do: struct

Example

defmodule YourProject.UserStateMachine do
  use Machinery,
    states: ["created", "partial", "completed"],
    transitions: %{
      "created" =>  ["partial", "completed"],
      "partial" => "completed"
    }

    def before_transition(struct, "partial") do
      # ... overall desired side effects
      struct
    end

    def after_transition(struct, "completed") do
      # ... overall desired side effects
      struct
    end
end

Copyright and License

Copyright (c) 2016 JoΓ£o M. D. Moura

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

machinery's People

Contributors

atonse avatar bgentry avatar blisscs avatar bragamat avatar dgamidov avatar ericsullivan avatar glennr avatar ianleeclark avatar jaimeiniesta avatar joaomdmoura avatar kelvinst avatar kianmeng avatar naps62 avatar nezteb avatar paralax avatar ponty96 avatar rhnonose avatar rjdellecese avatar sakshamgupta-idfy avatar sakshamgupta05 avatar sandeshsoni avatar slavovojacek avatar sobolevn avatar themushrr00m 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  avatar  avatar  avatar  avatar

machinery's Issues

mix test fails with compilation error

I have a phonix1.4 project. It works fine when calling mix compile. But fails for mix test.

$ mix test

==> machinery
Compiling 11 files (.ex)

== Compilation error in file lib/machinery/endpoint.ex ==
** (ArgumentError) invalid :json_decoder option. The module Poison is not loaded and could not be found
    (plug) lib/plug/parsers/json.ex:54: Plug.Parsers.JSON.validate_decoder!/1
    (plug) lib/plug/parsers/json.ex:32: Plug.Parsers.JSON.init/1
    (plug) lib/plug/parsers.ex:245: anonymous fn/3 in Plug.Parsers.convert_parsers/2
    (elixir) lib/enum.ex:1940: Enum."-reduce/3-lists^foldl/2-0-"/3
    (plug) lib/plug/parsers.ex:228: Plug.Parsers.convert_parsers/2
    (plug) lib/plug/parsers.ex:224: Plug.Parsers.init/1
    (plug) lib/plug/builder.ex:302: Plug.Builder.init_module_plug/4
    (plug) lib/plug/builder.ex:286: anonymous fn/5 in Plug.Builder.compile/3
could not compile dependency :machinery, "mix compile" failed. You can recompile thisdependency with "mix deps.compile machinery", update it with "mix deps.update machinery" or clean it with "mix deps.clean machinery"

I am using: {:machinery, "~> 0.17.0"},

Why a genserver?

I'm using this package for the first time. First of all, thank you for the work put into it πŸ‘

I'm not currently understanding why this is built as a GenServer. It seems one is started for every state machine module created, but it seems that's only used as a way to keep the parameters as state.

I would have expected to find this being done as module attributes and module variables. Why was that not the approach followed here?

Separate machinery and machinery_phoenix library

Machinery right now makes it difficult to use it with non-phoenix projects because of its hard dependency on phoenix and ecto, also its not Phoenix 1.4 and ecto 3.0 compatible yet. I think splitting machinery into two packages machinery and machinery_phoenix/machinery_webui will improve its flexibility. Ecto itself has done it with "ecto" and "ecto_sql". Do you think it would be worth refactoring ?

Let the field name be configurable

As of now, the field name has to be state and it's not possible to configure a custom name for the field to use in the state machine module.

It would be nice if we could:

defmodule YourProject.UserStateMachine do
  use Machinery,
    field: :custom_field,
    states: ["created", "partial", "complete", "canceled"],
    transitions: %{
      "created" =>  ["partial", "complete"],
      "partial" => "completed",
      "*" => "canceled
    }
end

This would also allow (I'm guessing) to have multiple state machines for the same struct.

Add option to store transitions

When transitioning resources to different states a pretty usual requirement is to store those transitions so it can be traced back down the road.
It would be really useful to provide a mechanism to automatically do it using machinery, otherwise people will need to do it using callbacks.

`transition_to` spec limits state to String type

In the spec definition for transition_to it specifies String.t() as the next_state's type.

@spec transition_to(struct, module, String.t()) :: {:ok, struct} | {:error, String.t()}

I believe this is incorrect. An atom is used in examples & typespecs for the state argument(s). I also confirmed locally that using atom states does not cause any errors.

Should a typespec be added for state? Possibly something like so:

@typedoc """
A state should be a string or an atom. Whichever corresponds with the underlying state field on the given struct.
"""
@type state :: atom | String.t()

Create setup mix task for phoenix apps

Create a Mix task that will setup the state field into the desired module, set the initial state as default on the database, and fill up the initial code for the developer.

Add search functionality to Machinery Dashboard

We currently have a machinery dashboard feature, as described here, this was added as feature based on issue #4.
Right now the Dashboard only shows you the resources and let you inspect their data, but having a search feature would be awesome.
Would be great if the result could also mention on what state the resource is, and if it could search on all string fields the resource have.

Enable users to transition states on the dashboard

We have the dashboard, as described here, this was added as feature based on issue #4.

The ultimate goal is for that to also enable ppl to move the resources around and change their states, so it would be a visual representation of the state machine itself.
That could be as fancy as dragging and dropping items around, but we have to make sure we user the machinery internal when transitioning states, so every callback and guard functions are run .

Add support for automatic persistence

In order to help ppl easily use Machinery with more complex applications, like Phoenix, Machinery needs to provide support for automatic persistence. Right now people need to use after_transition/2 to store their structs.

Would be nice to have some sort of config that ppl can use to set specific storages, so a user might want to use ETS but others might want to use Ecto + Database (for Phoenix apps).

Mix test fails with a specific seed

Hi I see that mix test --seed=244405 fails with:

Finished in 0.3 seconds
45 tests, 15 failures, 11 invalid

Randomized with seed 244405

I'm investigating on it, if anyone have some ideas to fix them, is welcome.

Customize transition error "reason"

I have some complex guard conditions and I'd like to take specific action depending on which one fails. I'd love to see support for returning a customized "reason" in the {:error, "reason"} tuple when transition_to/3 fails.

For example, if I have five guard conditions that need to be met in order to make a user active, I'd like to pattern match on the reason for failure to transition the user to a new state.

Love this library πŸ‘

Weird fallback implementation

Hi again (and sorry for the spam πŸ˜„ )

In addition to the first issue I just posted, I found another thing weird within the codebase.
The way each callback is implemented seems weird, and not at all what I'm used to in Elixir. I'd have expected Machinery to be a @behaviour implementing each function with a defoverridable. Instead, there's some runtime logic to try to call the function, rescue possible exceptions, and call a default function in those cases.

This is problematic for a few reasons:

  • It hides similar exceptions that might be happening within the call stack (e.g.: if, inside guard_transition/3, I call another non-existing function. The exception will be hidden and the fallback function will be called
  • I creates runtime complexity. Especially considering that exception rescuing is not great, performance-wise
  • Using exceptions as control flow is a big anti-pattern

Would you be willing to move this to a @behaviour as I suggest in the beginning? I'm happy to do the work, but I wouldn't want to start without a πŸ‘ , otherwise I might just be wasting time πŸ™‚

Raise may call an atom - undefined module

There is an issue in catching an exit here https://github.com/joaomdmoura/machinery/blob/master/lib/machinery.ex#L95

The raise expect a module or a string. However it may get an atom and Elixir will try to call exception/1 from that atom which obviously fails.

How to reproduce it:

Machinery.transition_to(A, :b_parma, :c_param)

Output:

17:15:41.494 [error] GenServer Machinery.Transitions terminating
** (UndefinedFunctionError) function :b_parma._machinery_initial_state/0 is undefined (module :b_parma is not available)
    :b_parma._machinery_initial_state()
    (machinery) lib/machinery/transitions.ex:24: Machinery.Transitions.handle_call/3
    (stdlib) gen_server.erl:661: :gen_server.try_handle_call/4
    (stdlib) gen_server.erl:690: :gen_server.handle_msg/6
    (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
Last message (from #PID<0.1595.0>): {:run, A, :b_parma, :c_param}
** (UndefinedFunctionError) function :undef.exception/1 is undefined (module :undef is not available)
    :undef.exception([])
    (machinery) lib/machinery.ex:113: Machinery.transition_to/3

As you can see I used undefined module and this gets to raise which tries to call :undef.exception/1 which is undefined as well.

Maybe as a quick fix changing raise to throw may work or removing catching at all?

Spike for consider using `gen_statem`

Erlang has gen_statem a generic state machine behavior.
Using it behind the scenes could be a good thing to enable ppl to use Machinery on a broader set of applications, but it's also a turning point that you push Machinery into a different direction.
Right now it looks more like an Ecto extension, just a thin layer people could use on top of a struct, nothing fancy and I kind like that better, but still think a spike here could have a good use.

Enable to list the desired states on the dashboard

Right now when using the Machinery Dashboard you will see all existing states, but that might not be the desired behavior, so would be nice to have a simple way the developer could use to tell machinery which states should be displayed in there.
I think the best way to implement this would be through a config.

Does it worth thinking about tracking transitioned states?

Hey, πŸ‘‹

I'm working in a complex sequential UI pattern where one component is shown after the other according to actions done by the user. Something like this:

image

LetΒ΄s suppose the first interaction opportunity for the user is chosen one of the options from the first level (A, B, C, and D). So, the user chooses D and another UI component, relative to the D option is presented to the user and now the user must choose one of the following options: S, T, U, V, and X.

So, the user chooses T and another UI component is presented to the user with the following options: 1 to 9.

Machinery allows me to build this easily with some transitions but I have a problem: When the second UI is presented, the first one is yet visible. When the third UI is presented, the first and second are yet visible.

So, I was able to manage that by:

  • My state field is actually a map like this %{state: "ready", path: ["ready"]}
  • When I transit from one state to other, I include the new state in state.path.

In my LiveView, I have all UI components within an if statement, like:

<% if Enum.member?(state.path, "") do %> ... <% end %> (Actually I'm using Surface UI so it is a bit different from that but you got the idea).

It works but I'm not sure if this is an elegant solution and also I started to wonder if worth adding this as a default machinery feature: a path field that returns all the transitioned states: ["ready", "D", "T"]. If the transition allows returning to a previous state, we could just cut the right elements from this same element in the list.

It would not be the complete transition history but more like the absolute path from the initial state until the current one.

Best,
Paulo

Persisting machinery fsm state with Ecto docs/examples?

There are some high-level docs about persisting state with machinery, but I couldn't find any docs or code in the repo referring to how to persist this state via ecto. The repo has the topic ecto, so it seems like a good idea to have these. πŸ˜…

Obviously the persistence in machinery is based on structs, and structs are easy enough to persist with ecto, but I've not found any existing blog posts, forum threads, or docs describing how to do that.

I personally would like to try my hand at implementing basic machinery/Ecto persistance and submitting a docs PR, so I figured I'd make an issue for it to see if anyone else:

  • Has already documented this
  • Has interest in assisting

πŸ˜„

Extend the machinery DSL to enable transitions from all states

Imagine you have a state machine for an Order:

%{
  "created"         => ["checkout", "canceled"],
  "checkout"        => ["submited", "canceled"],
  "submited"        => ["pick_up", "canceled"],
  "pick_up"         => ["shipped", "canceled"],
  "shipped"         => ["delivered", "canceled"]
}

as you can notice, all states can transition to canceled because the order can be canceled anytime, would be nice to provide a better DSL for those cases other then individually declare that transition

Refactor Machinery Dashboard interface

The interface itself is great and we should keep it as it is, but I'd like to maybe refactor de JS to use something more maintainable like react, not sure tough, would like to hear some ideas.

BasicAuth causing compilation error

Hey Joao long time no see!

I ran into an issue attempting to upgrade to phoenix 1.4 this morning and it seems that there is a perfect storm happening when attempting to build the project. In short, we have basic_auth as a dependency, but, for whatever reason, basic_auth isn't compiling before machinery. We're getting these compilation error:

ian@home ~/D/w/packlane> mix deps.compile machinery --include-children                                                
===> Compiling ranch                                                                                                  
==> poolboy (compile)                                                                                                 
warning: String.strip/1 is deprecated. Use String.trim/1 instead                                                      
  /home/ian/Development/work/packlane/deps/poison/mix.exs:4                                                           
                                                                                                                      
===> Compiling cowlib                                                                                                 
src/cow_multipart.erl:392: Warning: call to crypto:rand_bytes/1 will fail, since it was removed in 20.0; use crypto:st
rong_rand_bytes/1                                                                                                     
                                                                                                                      
===> Compiling cowboy                                                                                                 
==> machinery                                                                                                         
Compiling 11 files (.ex)                                                                                              
warning: function init/1 required by behaviour GenServer is not implemented (in module Machinery.Transitions).        
                                                                                                                      
We will inject a default implementation for now:                                                                      
                                                                                                                      
    def init(args) do                                                                                                 
      {:ok, args}                                                                                                     
    end                                                                                                               
                                                                                                                      
You can copy the implementation above or define your own that converts the arguments given to GenServer.start_link/3 t
o the server state.                                                                                                   
                                                                                                                      
  lib/machinery/transitions.ex:1                                                                                      
                                                                                                                      
                                                                                                                      
== Compilation error in file lib/machinery/endpoint.ex ==                                                             
** (UndefinedFunctionError) function BasicAuth.init/1 is undefined (module BasicAuth is not available)                
    BasicAuth.init([use_config: {:machinery, :authorization}])                                                        
    (plug) lib/plug/builder.ex:302: Plug.Builder.init_module_plug/4                                                   
    (plug) lib/plug/builder.ex:286: anonymous fn/5 in Plug.Builder.compile/3                                          
    (elixir) lib/enum.ex:1925: Enum."-reduce/3-lists^foldl/2-0-"/3                                                    
    (plug) lib/plug/builder.ex:284: Plug.Builder.compile/3                                                            
    (plug) expanding macro: Plug.Builder.__before_compile__/1                                                         
    lib/machinery/endpoint.ex:1: Machinery.Endpoint (module)                                                          
    (elixir) lib/kernel/parallel_compiler.ex:206: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/6           
could not compile dependency :machinery, "mix compile" failed. You can recompile this dependency with "mix deps.compil
e machinery", update it with "mix deps.update machinery" or clean it with "mix deps.clean machinery"    

How would you feel about explicitly requiring basic_auth as a dependency in the dependencies--I'd be happy to submit a PR for this (short example here: )

This behavior can be tested locally by doing a fresh checkout, setting authorization: true in the config, and mix deps.get && mix compile. I did a small test to verify this would work that can be seen here: https://github.com/GrappigPanda/machinery/pull/1/files

Thanks again and hope you're doing well!

Improve docs

Would be nice to improve the docs, make it clearer, simpler and easy to understand.

User-specified `field` does not work correctly

State transitions do not work as expected when I define field: :status.

Eg:

field: :status,
states: ["a", "b", "c"],
transitions: %{
  "b" =>  "c",
}

iex> Machinery.transition_to(%{status: "b"}, YourStateMachine, "c")
{:error, "Transition to this state isn't declared."}

Initial GUI for representing the Structs and states

This is a tricky one, it's supposed to be the second main part of Machinery, a GUI the reflects the existing structs, it's states and transitions.

The need for that came from my experience using state machines. Usually ppl would like to have an interface that could optionally be enabled to visually validate and track the structs. That can be helpful for developers debugging, of even as an internal (admin like) tool.

The desired interface here is something similar to had trello has, but I'm totally open for suggestions, as long its a good GUI to translate the states declared.

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.