diff --git a/README.md b/README.md index 2faf0d3..86822b0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ User.can_make_admin?(confirmed_user) # => true admin = User.make_admin(confirmed_user) # Store changeset to the database -Repo.update(admin) +Repo.update(admin) # List all possible states @@ -99,6 +99,43 @@ end Now your state will be stored into `rules` column. +### Custom mapping between state name and field value + +By default, the atomic state names are stringified when generating the changeset. However, there are situations where the underlying Ecto schema value isn't simply a stringified version of the state name. You can customize the mapping by passing cast/load functions like so: + +```elixir +defmodule Dummy.WritingStyle do + use Dummy.Web, :model + + use EctoStateMachine, + states: [:upcased, :camel_cased], + cast_fn: fn + :upcased -> "UPCASED" + :camel_cased -> "camelCased" + end, + load_fn: fn + "UPCASED" -> :upcased + "camelCased" -> :camel_cased + end, + events: [ + [ + name: :upcase, + from: [:camel_cased], + to: :upcased + ], + [ + name: :camel_case, + from: [:upcased], + to: :camel_cased + ] + ] + + schema "writing_styles" do + field :state, :string + end +end +``` + ## Contributions 1. Install dependencies `mix deps.get` diff --git a/lib/ecto_state_machine.ex b/lib/ecto_state_machine.ex index fdd8422..d19c2b9 100644 --- a/lib/ecto_state_machine.ex +++ b/lib/ecto_state_machine.ex @@ -2,6 +2,17 @@ defmodule EctoStateMachine do defmacro __using__(opts) do column = Keyword.get(opts, :column, :state) sm_states = Keyword.get(opts, :states) + + cast_fn = + opts + |> Keyword.get(:cast_fn, quote do: &("#{&1}")) + |> Macro.escape() + + load_fn = + opts + |> Keyword.get(:load_fn, quote do: &(:"#{&1}")) + |> Macro.escape() + events = Keyword.get(opts, :events) |> Enum.map(fn(event) -> Keyword.put_new(event, :callback, quote do: fn(model) -> model end) @@ -16,7 +27,9 @@ defmodule EctoStateMachine do sm_states: sm_states, events: events, column: column, - function_prefix: function_prefix + function_prefix: function_prefix, + cast_fn: cast_fn, + load_fn: load_fn ] do alias Ecto.Changeset @@ -36,26 +49,36 @@ defmodule EctoStateMachine do end def unquote(event[:name])(model) do + casted = unquote(cast_fn).(unquote(event[:to])) + model - |> Changeset.change(%{ unquote(column) => "#{unquote(event[:to])}" }) + |> Changeset.change(%{ unquote(column) => casted }) |> unquote(event[:callback]).() |> validate_state_transition(unquote(event), model) end def unquote(:"can_#{event[:name]}?")(model) do - :"#{Map.get(model, unquote(column))}" in unquote(event[:from]) + loaded = + model + |> Map.get(unquote(column)) + |> unquote(load_fn).() + + loaded in unquote(event[:from]) end end) defp validate_state_transition(changeset, event, model) do - change = Map.get(model, unquote(column)) + loaded = + model + |> Map.get(unquote(column)) + |> unquote(load_fn).() - if :"#{change}" in event[:from] do + if loaded in event[:from] do changeset else changeset |> Changeset.add_error(unquote(column), - "You can't move state from :#{change} to :#{event[:to]}" + "You can't move state from :#{loaded} to :#{event[:to]}" ) end end diff --git a/priv/test/dummy/repo/migrations/20180511030316_create_writing_style.exs b/priv/test/dummy/repo/migrations/20180511030316_create_writing_style.exs new file mode 100644 index 0000000..95fab61 --- /dev/null +++ b/priv/test/dummy/repo/migrations/20180511030316_create_writing_style.exs @@ -0,0 +1,9 @@ +defmodule Dummy.Repo.Migrations.CreateWritingStyle do + use Ecto.Migration + + def change do + create table(:writing_styles) do + add :state, :string + end + end +end diff --git a/test/dummy/factories.ex b/test/dummy/factories.ex index 2e9109d..bf71608 100644 --- a/test/dummy/factories.ex +++ b/test/dummy/factories.ex @@ -6,4 +6,10 @@ defmodule Dummy.Factories do rules: "started" } end + + def writing_style_factory do + %Dummy.WritingStyle{ + state: nil + } + end end diff --git a/test/dummy/web/models/writing_style.ex b/test/dummy/web/models/writing_style.ex new file mode 100644 index 0000000..bc9c17e --- /dev/null +++ b/test/dummy/web/models/writing_style.ex @@ -0,0 +1,30 @@ +defmodule Dummy.WritingStyle do + use Dummy.Web, :model + + use EctoStateMachine, + states: [:upcased, :camel_cased], + cast_fn: fn + :upcased -> "UPCASED" + :camel_cased -> "camelCased" + end, + load_fn: fn + "UPCASED" -> :upcased + "camelCased" -> :camel_cased + end, + events: [ + [ + name: :upcase, + from: [:camel_cased], + to: :upcased + ], + [ + name: :camel_case, + from: [:upcased], + to: :camel_cased + ] + ] + + schema "writing_styles" do + field :state, :string + end +end diff --git a/test/ecto_state_machine_test.exs b/test/ecto_state_machine_test.exs index 2538522..32379c2 100644 --- a/test/ecto_state_machine_test.exs +++ b/test/ecto_state_machine_test.exs @@ -2,20 +2,21 @@ defmodule EctoStateMachineTest do use ExUnit.Case, async: true alias Dummy.User + alias Dummy.WritingStyle import Dummy.Factories - setup_all do - { - :ok, - unconfirmed_user: insert(:user, %{ rules: "unconfirmed" }), - confirmed_user: insert(:user, %{ rules: "confirmed" }), - blocked_user: insert(:user, %{ rules: "blocked" }), - admin: insert(:user, %{ rules: "admin" }) - } - end + describe "User" do + setup do + { + :ok, + unconfirmed_user: insert(:user, %{ rules: "unconfirmed" }), + confirmed_user: insert(:user, %{ rules: "confirmed" }), + blocked_user: insert(:user, %{ rules: "blocked" }), + admin: insert(:user, %{ rules: "admin" }) + } + end - describe "events" do test "#confirm", context do changeset = User.confirm(context[:unconfirmed_user]) assert changeset.valid? == true @@ -70,9 +71,7 @@ defmodule EctoStateMachineTest do assert changeset.valid? == false assert changeset.errors == [rules: {"You can't move state from :admin to :admin", []}] end - end - describe "can_?" do test "#can_confirm?", context do assert User.can_confirm?(context[:unconfirmed_user]) == true assert User.can_confirm?(context[:confirmed_user]) == false @@ -93,13 +92,60 @@ defmodule EctoStateMachineTest do assert User.can_make_admin?(context[:blocked_user]) == false assert User.can_make_admin?(context[:admin]) == false end - end - test "#states" do - assert User.rules_states == [:unconfirmed, :confirmed, :blocked, :admin] + test "#states" do + assert User.rules_states == [:unconfirmed, :confirmed, :blocked, :admin] + end + + test "#events" do + assert User.rules_events == [:confirm, :block, :make_admin] + end end - test "#events" do - assert User.rules_events == [:confirm, :block, :make_admin] + describe "WritingStyle" do + setup do + [ + upcased: insert(:writing_style, state: "UPCASED"), + camel_cased: insert(:writing_style, state: "camelCased") + ] + end + + test "upcase/1", %{upcased: upcased, camel_cased: camel_cased} do + changeset = WritingStyle.upcase(camel_cased) + assert changeset.valid? == true + assert changeset.changes.state == "UPCASED" + + changeset = WritingStyle.upcase(upcased) + assert changeset.valid? == false + assert changeset.errors == [state: {"You can't move state from :upcased to :upcased", []}] + end + + test "camel_case/1", %{upcased: upcased, camel_cased: camel_cased} do + changeset = WritingStyle.camel_case(camel_cased) + assert changeset.valid? == false + assert changeset.errors == [state: {"You can't move state from :camel_cased to :camel_cased", []}] + + changeset = WritingStyle.camel_case(upcased) + assert changeset.valid? == true + assert changeset.changes.state == "camelCased" + end + + test "can_upcase?/1", %{upcased: upcased, camel_cased: camel_cased} do + assert WritingStyle.can_upcase?(camel_cased) == true + assert WritingStyle.can_upcase?(upcased) == false + end + + test "can_camel_case?/1", %{upcased: upcased, camel_cased: camel_cased} do + assert WritingStyle.can_camel_case?(camel_cased) == false + assert WritingStyle.can_camel_case?(upcased) == true + end + + test "states" do + assert WritingStyle.states == [:upcased, :camel_cased] + end + + test "events" do + assert WritingStyle.events == [:upcase, :camel_case] + end end end