Skip to content

Commit

Permalink
Merge pull request #77 from gjaldon/refactor-internals
Browse files Browse the repository at this point in the history
Refactor internals and add requested features
  • Loading branch information
gjaldon committed Jun 27, 2019
2 parents a82488b + 73397a4 commit d4a4c21
Show file tree
Hide file tree
Showing 15 changed files with 504 additions and 195 deletions.
1 change: 1 addition & 0 deletions .formatter.exs
Expand Up @@ -4,6 +4,7 @@ locals_without_parens = [
]

[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
locals_without_parens: locals_without_parens,
export: [locals_without_parens: locals_without_parens]
]
18 changes: 13 additions & 5 deletions .travis.yml
@@ -1,11 +1,19 @@
sudo: false
language: elixir
elixir:
- 1.4.0
- 1.5.0
# Report documentation coverage to InchCI.
after_script:
- mix inch.report
- 1.9.0
- 1.8.2
- 1.7.4
services:
- postgresql
- mysql
before_install:
- mysql -e 'CREATE DATABASE ecto_test;'
before_script:
- psql -c 'create database ecto_test;' -U postgres
script:
- mix test
- MIX_ENV=mysql mix test
# Report documentation coverage to InchCI.
after_script:
- mix inch.report
5 changes: 5 additions & 0 deletions CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog

## 1.3.0
- Refactored internals to make it easier to support `use`ing feature and string-backed enums.
- Add `use`ing functionality so we can use `EctoEnum` or `EctoEnum.Postgres` to define Ecto Enums.
- Support for string-backed enums!

## 1.2.0
- Update formatter config to allow use of `defenum/2` and `defenum/3` without parens.
- Enum function `create_type/0` is now reversible and can be used in `change` in migration files.
Expand Down
49 changes: 48 additions & 1 deletion README.md
Expand Up @@ -15,13 +15,15 @@ First, we add `ecto_enum` to `mix.exs`:
```elixir
def deps do
[
{:ecto_enum, "~> 1.2"}
{:ecto_enum, "~> 1.3"}
]
end
```

Run `mix deps.get` to install `ecto_enum`.

### Creating an Ecto Enum with `defenum/2` macro

We will then have to define our enum. We can do this in a separate file since defining
an enum is just defining a module. We do it like:

Expand All @@ -32,6 +34,14 @@ import EctoEnum
defenum StatusEnum, registered: 0, active: 1, inactive: 2, archived: 3
```

Note that we can also use string-backed enums by doing the following:

```elixir
defenum StatusEnum, registered: "registered", active: "active", inactive: "active", archived: "archived"
# short-cut way of using string-backed enums
defenum StatusEnum, "registered", "active", "active", "archived"
```

Once defined, `EctoEnum` can be used like any other `Ecto.Type` by passing it to a field
in your model's schema block. For example:

Expand Down Expand Up @@ -65,6 +75,43 @@ iex> from(u in User, where: u.status == ^:registered) |> Repo.all() |> length

Passing a value that the custom Enum type does not recognize will result in an error.

### Creating an Ecto Enum by `use`ing `EctoEnum`

Another way to create an Ecto Enum is by `use`ing the `EctoEnum` or the `EctoEnum.Postgres`
modules.

To `use` `EctoEnum` with integer-backed storage:

```elixir
defmodule CustomEnum do
use EctoEnum, ready: 0, set: 1, go: 2
end
```

To `use` `EctoEnum` with string-backed storage:

```elixir
defmodule CustomEnum do
use EctoEnum, "ready", "set", "go"
end
```

To `use` `EctoEnum` with Postgres user-defined types:

```elixir
defmodule PostgresType do
use EctoEnum, type: :new_type, enums: [:ready, :set, :go]
end
```

We can also `use` `EctoEnum.Postgres` directly like:

```elixir
defmodule NewType do
use EctoEnum.Postgres, type: :new_type, enums: [:ready, :set, :go]
end
```

### Reflection

The enum type `StatusEnum` will also have a reflection function for inspecting the
Expand Down
128 changes: 48 additions & 80 deletions lib/ecto_enum.ex
@@ -1,11 +1,34 @@
defmodule EctoEnum do
@moduledoc """
Provides `defenum/2` macro for defining an Enum Ecto type.
Provides `defenum/2` and `defenum/3` macro for defining an Enum Ecto type.
This module can also be `use`d to create an Ecto Enum like:
defmodule CustomEnum do
use EctoEnum, ready: 0, set: 1, go: 2
end
Or in place of using `EctoEnum.Postgres` like:
defmodule PostgresType do
use EctoEnum, type: :new_type, enums: [:ready, :set, :go]
end
The difference between the above two examples is that the previous one would use an
integer column in the database while the latter one would use a custom type in PostgreSQL.
Note that only PostgreSQL is supported for custom data types at the moment.
"""

@doc """
Defines an enum custom `Ecto.Type`.
For second argument, it accepts either a list of strings or a keyword list with keyword
values that are either strings or integers. Below are examples of a valid argument:
[registered: 0, active: 1, inactive: 2, archived: 3]
[registered: "registered", active: "active", inactive: "inactive", archived: "archived"]
["registered", "active", "inactive", "archived"]
It can be used like any other `Ecto.Type` by passing it to a field in your model's
schema block. For example:
Expand Down Expand Up @@ -58,98 +81,43 @@ defmodule EctoEnum do
[registered: 0, active: 1, inactive: 2, archived: 3]
"""

defmacro __using__(opts) do
quote do
opts = unquote(opts)

if opts[:type] && opts[:enums] do
use EctoEnum.Postgres.Use, unquote(opts)
else
use EctoEnum.Use, unquote(opts)
end
end
end

defmacro defenum(module, type, enum, options \\ []) do
EctoEnum.Postgres.defenum(module, type, enum, options)
end

defmacro defenum(module, enum) do
quote do
kw = unquote(enum) |> Macro.escape()

defmodule unquote(module) do
@behaviour Ecto.Type

@atom_int_kw kw
@int_atom_map for {atom, int} <- kw, into: %{}, do: {int, atom}
@string_int_map for {atom, int} <- kw, into: %{}, do: {Atom.to_string(atom), int}
@string_atom_map for {atom, int} <- kw, into: %{}, do: {Atom.to_string(atom), atom}
@valid_values Keyword.values(@atom_int_kw) ++
Keyword.keys(@atom_int_kw) ++ Map.keys(@string_int_map)
enum = Macro.escape(unquote(enum))
[h | _t] = enum

def type, do: :integer
enum =
cond do
Keyword.keyword?(enum) ->
enum

def cast(term) do
EctoEnum.Type.cast(term, @int_atom_map, @string_atom_map)
end
is_binary(h) ->
Enum.map(enum, fn value -> {String.to_atom(value), value} end)

def load(int) when is_integer(int) do
Map.fetch(@int_atom_map, int)
true ->
raise "Enum must be a keyword list or a list of strings"
end

def dump(term) do
case EctoEnum.Type.dump(term, @atom_int_kw, @string_int_map, @int_atom_map) do
:error ->
msg =
"Value `#{inspect(term)}` is not a valid enum for `#{inspect(__MODULE__)}`. " <>
"Valid enums are `#{inspect(__valid_values__())}`"

raise Ecto.ChangeError,
message: msg

value ->
value
end
end

def valid_value?(value) do
Enum.member?(@valid_values, value)
end

# Reflection
def __enum_map__(), do: @atom_int_kw
def __valid_values__(), do: @valid_values
end
end
end

defmodule Type do
@spec cast(any, map, map) :: {:ok, atom} | :error
def cast(atom, int_atom_map, _) when is_atom(atom) do
if atom in Map.values(int_atom_map) do
{:ok, atom}
else
:error
end
end

def cast(string, _, string_atom_map) when is_binary(string) do
Map.fetch(string_atom_map, string)
end

def cast(int, int_atom_map, _) when is_integer(int) do
Map.fetch(int_atom_map, int)
end

def cast(_, _, _), do: :error

@spec dump(any, [{atom(), any()}], map, map) :: {:ok, integer} | :error
def dump(integer, _, _, int_atom_map) when is_integer(integer) do
if int_atom_map[integer] do
{:ok, integer}
else
:error
defmodule unquote(module) do
use EctoEnum.Use, enum
end
end

def dump(atom, atom_int_kw, _, _) when is_atom(atom) do
Keyword.fetch(atom_int_kw, atom)
end

def dump(string, _, string_int_map, _) when is_binary(string) do
Map.fetch(string_int_map, string)
end

def dump(_), do: :error
end

alias Ecto.Changeset
Expand Down

0 comments on commit d4a4c21

Please sign in to comment.