Skip to content

feat: Support custom conversion between atomic name and DB value #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
35 changes: 29 additions & 6 deletions lib/ecto_state_machine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions test/dummy/factories.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ defmodule Dummy.Factories do
rules: "started"
}
end

def writing_style_factory do
%Dummy.WritingStyle{
state: nil
}
end
end
30 changes: 30 additions & 0 deletions test/dummy/web/models/writing_style.ex
Original file line number Diff line number Diff line change
@@ -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
80 changes: 63 additions & 17 deletions test/ecto_state_machine_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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