Skip to content

Commit

Permalink
feat: add enum type (#156)
Browse files Browse the repository at this point in the history
feat: add onepiece commanded enum
  • Loading branch information
yordis authored Nov 3, 2024
1 parent ce1fa3c commit babed55
Show file tree
Hide file tree
Showing 4 changed files with 292 additions and 3 deletions.
149 changes: 149 additions & 0 deletions apps/one_piece_commanded/lib/one_piece/commanded/enum.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
defmodule OnePiece.Commanded.Enum do
@moduledoc """
Enum module with added macros to define a type and check supported values.
"""

defmacro __using__(opts) do
values = Keyword.fetch!(opts, :values)
type_ast = Enum.reduce(values, &{:|, [], [&1, &2]})

value_functions_ast =
for value <- values do
quote do
def unquote(value)(), do: %__MODULE__{value: unquote(value)}
end
end

load_functions_ast =
for value <- values do
quote do
@impl Ecto.Type
def load(unquote(Atom.to_string(value))) do
{:ok, %__MODULE__{value: unquote(value)}}
end
end
end

dump_functions_ast =
for value <- values do
value_string = Atom.to_string(value)

quote do
@impl Ecto.Type
def dump(%__MODULE__{value: unquote(value)}) do
{:ok, unquote(value_string)}
end
end
end

cast_as_function_ast =
for value <- values do
value_string = Atom.to_string(value)

quote do
@impl Ecto.Type
def cast(unquote(value_string)) do
{:ok, %__MODULE__{value: unquote(value)}}
end
end
end

quote generated: true do
alias OnePiece.Commanded.ValueObject
alias Ecto.Changeset

use Ecto.Schema
use Ecto.Type

@primary_key false
@enforce_keys [:value]
embedded_schema do
field :value, Ecto.Enum, values: unquote(values)
end

@type value :: unquote(type_ast)
@type t :: %__MODULE__{value: value()}

@doc """
Creates a `t:t/0`.
"""
@spec new(attrs :: %{required(:value) => value()}) :: {:ok, %__MODULE__{}} | {:error, Ecto.Changeset.t()}
def new(attrs) when is_map(attrs) do
ValueObject.__new__(__MODULE__, attrs)
end

@spec new(value :: value()) :: {:ok, %__MODULE__{}} | {:error, Ecto.Changeset.t()}
def new(value) do
ValueObject.__new__(__MODULE__, %{value: value})
end

@doc """
Creates a `t:t/0`.
"""
@spec new!(attrs :: %{required(:value) => value()}) :: %__MODULE__{}
def new!(attrs) when is_map(attrs) do
ValueObject.__new__!(__MODULE__, attrs)
end

@spec new!(value :: value()) :: %__MODULE__{}
def new!(value) do
ValueObject.__new__!(__MODULE__, %{value: value})
end

@doc """
Returns an `t:Ecto.Changeset.t/0` for a given `t:t/0` value object.
"""
@spec changeset(message :: %__MODULE__{}, attrs :: %{required(:value) => value()}) :: Ecto.Changeset.t()
def changeset(message, attrs) do
message
|> Changeset.cast(attrs, [:value])
|> Changeset.validate_required([:value])
end

@spec values() :: [unquote(type_ast)]
def values, do: unquote(values)

unquote_splicing(value_functions_ast)

@impl Ecto.Type
def type, do: :string

@impl Ecto.Type
def cast(value) when is_struct(value, __MODULE__) do
{:ok, value}
end

unquote_splicing(cast_as_function_ast)

@impl Ecto.Type
def cast(value) when value in unquote(values) do
{:ok, %__MODULE__{value: value}}
end

@impl Ecto.Type
def cast(_), do: :error

unquote_splicing(load_functions_ast)
@impl Ecto.Type
def load(_), do: :error

unquote_splicing(dump_functions_ast)
@impl Ecto.Type
def dump(_), do: :error

@impl Ecto.Type
def equal?(%__MODULE__{value: value1}, %__MODULE__{value: value1}) do
true
end

@impl Ecto.Type
def equal?(_term1, _term2), do: false

defimpl Jason.Encoder do
def encode(v, opts) do
Jason.Encode.value(v.value, opts)
end
end
end
end
end
134 changes: 134 additions & 0 deletions apps/one_piece_commanded/test/one_piece/commanded/enum_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
defmodule OnePiece.Commanded.EnumTest do
use ExUnit.Case, async: true

alias TestSupport.CommandRouterExample.BankAccountType

describe "new/1" do
test "returns a value object" do
assert {:ok, value} = BankAccountType.new(:business)
assert value == %BankAccountType{value: :business}
end

test "returns a value object when passing a map" do
assert {:ok, value} = BankAccountType.new(%{value: :business})
assert value == %BankAccountType{value: :business}
end

test "returns an error when a validation fails" do
assert {:error, changeset} = BankAccountType.new(nil)
assert %{value: ["can't be blank"]} = TestSupport.errors_on(changeset)
end

test "with an invalid value" do
assert {:error, changeset} = BankAccountType.new(:invalid)
assert %{value: ["is invalid"]} = TestSupport.errors_on(changeset)
end
end

describe "new!/1" do
test "creates a value object" do
assert %BankAccountType{value: :business} = BankAccountType.new!(:business)
end

test "creates a value object when passing a map" do
assert %BankAccountType{value: :business} = BankAccountType.new!(%{value: :business})
end

test "raises an error when a validation fails" do
assert_raise Ecto.InvalidChangesetError, fn ->
BankAccountType.new!(nil)
end
end

test "raises an error with an invalid value" do
assert_raise Ecto.InvalidChangesetError, fn ->
BankAccountType.new!(:invalid)
end
end
end

test "values/0" do
assert BankAccountType.values() == [:business, :personal]
end

test "specific enum values" do
assert BankAccountType.business() == %BankAccountType{value: :business}
assert BankAccountType.personal() == %BankAccountType{value: :personal}
end

describe "type/1" do
test "returns the type of the enum" do
assert BankAccountType.type() == :string
end
end

describe "cast/1" do
test "casts atom values" do
assert BankAccountType.cast(:business) == {:ok, %BankAccountType{value: :business}}
assert BankAccountType.cast(:personal) == {:ok, %BankAccountType{value: :personal}}
assert BankAccountType.cast(:invalid) == :error
end

test "casts string values" do
assert BankAccountType.cast("business") == {:ok, %BankAccountType{value: :business}}
assert BankAccountType.cast("personal") == {:ok, %BankAccountType{value: :personal}}
assert BankAccountType.cast("invalid") == :error
end

test "casts existing struct" do
existing = %BankAccountType{value: :business}
assert BankAccountType.cast(existing) == {:ok, existing}
end
end

describe "load/1" do
test "loads string values" do
assert BankAccountType.load("business") == {:ok, %BankAccountType{value: :business}}
assert BankAccountType.load("personal") == {:ok, %BankAccountType{value: :personal}}
assert BankAccountType.load("invalid") == :error
end

test "returns error for non-string values" do
assert BankAccountType.load(:business) == :error
assert BankAccountType.load(123) == :error
assert BankAccountType.load(%{}) == :error
end
end

describe "dump/1" do
test "dumps struct to string" do
assert BankAccountType.dump(%BankAccountType{value: :business}) == {:ok, "business"}
assert BankAccountType.dump(%BankAccountType{value: :personal}) == {:ok, "personal"}
end

test "returns error for invalid values" do
assert BankAccountType.dump(%BankAccountType{value: :invalid}) == :error
assert BankAccountType.dump("business") == :error
assert BankAccountType.dump(:business) == :error
assert BankAccountType.dump(%{}) == :error
end
end

describe "equal?/2" do
test "returns true for matching values" do
assert BankAccountType.equal?(%BankAccountType{value: :business}, %BankAccountType{value: :business})
assert BankAccountType.equal?(%BankAccountType{value: :personal}, %BankAccountType{value: :personal})
end

test "returns false for different values" do
refute BankAccountType.equal?(%BankAccountType{value: :business}, %BankAccountType{value: :personal})
end

test "returns false for non-struct values" do
refute BankAccountType.equal?(%BankAccountType{value: :business}, :business)
refute BankAccountType.equal?(:business, %BankAccountType{value: :business})
refute BankAccountType.equal?("business", "business")
end
end

describe "Jason.Encoder" do
test "encodes to the value" do
assert Jason.encode!(%BankAccountType{value: :business}) == ~s("business")
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ defmodule OnePiece.Commanded.ValueObjectTest do
end

test "validates a key enforce" do
{:error, changeset} = TestSupport.MessageTwo.new(%{})
assert {:error, changeset} = TestSupport.MessageTwo.new(%{})
assert %{title: ["can't be blank"]} = TestSupport.errors_on(changeset)
end

test "validates a key enforce for embed fields" do
{:error, changeset} = TestSupport.MessageThree.new(%{})
assert {:error, changeset} = TestSupport.MessageThree.new(%{})
assert %{target: ["can't be blank"]} = TestSupport.errors_on(changeset)
end

Expand All @@ -27,7 +27,7 @@ defmodule OnePiece.Commanded.ValueObjectTest do
end

test "validates casting embed fields with a wrong value" do
{:error, changeset} = TestSupport.MessageThree.new(%{target: "a wrong value"})
assert {:error, changeset} = TestSupport.MessageThree.new(%{target: "a wrong value"})
assert %{target: ["is invalid"]} = TestSupport.errors_on(changeset)
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
defmodule TestSupport.CommandRouterExample.BankAccountType do
@moduledoc false

use OnePiece.Commanded.Enum,
values: [:business, :personal]
end

0 comments on commit babed55

Please sign in to comment.