improvement: add first class support for enum types

This commit is contained in:
Zach Daniel 2021-04-21 13:32:06 -04:00
parent 64876c6e19
commit dd82fcc53b
3 changed files with 185 additions and 2 deletions

View file

@ -746,8 +746,6 @@ defmodule Ash.Query do
@doc """
Merge a map of values into the query context
Not much uses this currently.
"""
@spec set_context(t() | Ash.Resource.t(), map | nil) :: t()
def set_context(query, nil), do: to_query(query)

111
lib/ash/type/enum.ex Normal file
View file

@ -0,0 +1,111 @@
defmodule Ash.Type.Enum do
@moduledoc """
A type for abstracting enums into a single type.
For example, you might have:
```elixir
attribute :status, :atom, constraints: [:open, :closed]
```
But as that starts to spread around your system you may find that you want
to centralize that logic. To do that, use this module to define an Ash type
easily.
```elixir
defmodule MyApp.TicketStatus do
use Ash.Type.Enum, values: [:open, :closed]
end
```
Valid values are:
* The atom itself, e.g `:open`
* A string that matches the atom, e.g `"open"`
* A string that matches the atom after being downcased, e.g `"OPEN"` or `"oPeN"`
* A string that matches the stringified, downcased atom, after itself being downcased.
This allows for enum values like `:Open`, `:SomeState` and `:Some_State`
"""
@doc "The list of valid values (not all input types that match them)"
@callback values() :: [atom]
@doc "true if a given term matches a value"
@callback match?(term) :: boolean
@doc "finds the valid value that matches a given input term"
@callback match(term) :: {:ok, atom} | :error
defmacro __using__(opts) do
quote location: :keep, generated: true do
use Ash.Type
@behaviour unquote(__MODULE__)
@values unquote(opts[:values]) ||
raise("Must provide `values` option for `use #{inspect(unquote(__MODULE__))}`")
@string_values @values |> Enum.map(&to_string/1)
@impl unquote(__MODULE__)
def values, do: @values
@impl Ash.Type
def storage_type, do: :string
@impl Ash.Type
def cast_input(value, _) do
match(value)
end
@impl Ash.Type
def cast_stored(value, _) do
match(value)
end
@impl Ash.Type
def dump_to_native(value, _) do
{:ok, to_string(value)}
end
@impl unquote(__MODULE__)
@spec match?(term) :: boolean
def match?(term) do
case match(term) do
{:ok, _} -> true
_ -> false
end
end
@impl unquote(__MODULE__)
@spec match(term) :: {:ok, term} | :error
def match(value) when value in @values, do: {:ok, value}
def match(value) when value in @string_values, do: {:ok, String.to_existing_atom(value)}
def match(value) do
value =
value
|> to_string()
|> String.downcase()
match =
Enum.find_value(@values, fn valid_value ->
sanitized_valid_value =
valid_value
|> to_string()
|> String.downcase()
if sanitized_valid_value == value do
valid_value
end
end)
if match do
{:ok, match}
else
:error
end
rescue
_ ->
:error
end
defoverridable storage_type: 0
end
end
end

74
test/type/enum_test.exs Normal file
View file

@ -0,0 +1,74 @@
defmodule Ash.Test.Type.EnumTest do
@moduledoc false
use ExUnit.Case, async: true
require Ash.Query
defmodule Status do
use Ash.Type.Enum, values: [:open, :Closed, :NeverHappened, :Always_Was]
end
defmodule Post do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
attributes do
uuid_primary_key :id
attribute :status, Status
end
end
defmodule Api do
@moduledoc false
use Ash.Api
resources do
resource(Post)
end
end
import Ash.Changeset
test "it handles exact matches" do
Post
|> new(%{status: :open})
|> Api.create!()
end
test "it handles string matches" do
Post
|> new(%{status: "open"})
|> Api.create!()
end
test "it handles mixed case string matches" do
Post
|> new(%{status: "OpEn"})
|> Api.create!()
end
test "it handles mixed case string matches against mixed case atoms" do
Post
|> new(%{status: "nEveRHAppened"})
|> Api.create!()
end
test "it fails on mismatches" do
assert_raise Ash.Error.Invalid, fn ->
Post
|> new(%{status: "what"})
|> Api.create!()
end
end
test "the values are returned in the introspection function" do
assert Status.values() == [:open, :Closed, :NeverHappened, :Always_Was]
assert Status.match("OPEN") == {:ok, :open}
assert Status.match?(:always_was)
end
end