Skip to content

Commit

Permalink
Merge pull request #16 from gjaldon/pg-enum-type
Browse files Browse the repository at this point in the history
Pg enum type
  • Loading branch information
gjaldon committed Aug 10, 2016
2 parents e4cf8ec + adcb24a commit 357584c
Show file tree
Hide file tree
Showing 11 changed files with 357 additions and 31 deletions.
52 changes: 47 additions & 5 deletions README.md
Expand Up @@ -9,7 +9,7 @@ First, we add `ecto_enum` to `mix.exs`:

```elixir
def deps do
[{:ecto_enum, "~> 0.3.0"}]
[{:ecto_enum, "~> 0.4.0"}]
end
```

Expand Down Expand Up @@ -56,19 +56,61 @@ 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.

```elixir
iex> Repo.insert!(%User{status: :none})
** (Elixir.EctoEnum.Error) :none is not a valid enum value
```
### Reflection

The enum type `StatusEnum` will also have a reflection function for inspecting the
enum map in runtime.

```elixir
iex> StatusEnum.__enum_map__()
[registered: 0, active: 1, inactive: 2, archived: 3]
iex> StatusEnum.__valid_values()
[0, 1, 2, 3, :registered, :active, :inactive, :archived, "active", "archived",
"inactive", "registered"]
```

### Using Postgres's Enum Type

[Enumerated Types in Postgres](https://www.postgresql.org/docs/current/static/datatype-enum.html) are now supported. To use Postgres's Enum Type with EctoEnum, use the `defenum/3` macro
instead of `defenum/2`. We do it like:

```elixir
# lib/my_app/ecto_enums.ex

import EctoEnum
defenum StatusEnum, :status, [:registered, :active, :inactive, :archived]
```

The second argument is the name you want used for the new type you are creating in Postgres.
Note that `defenum/3` expects a list of atoms(could be strings) instead of a keyword
list unlike in `defenum/2`. Another notable difference is that you can no longer
use integers in place of atoms or strings as values in your enum type. Given the
above code, this means that you can only pass the following values:

```elixir
[:registered, :active, :inactive, :archived, "registered", "active", "inactive", "archived"]
```

In your migrations, you can make use of helper functions like:

```elixir
def up do
StatusEnum.create_type
create table(:users_pg) do
add :status, :status
end
end

def down do
StatusEnum.drop_type
drop_table(:users_pg)
end
```

`create_type/0` and `drop_type/0` are automatically defined for you in
your custom Enum module.


## Important links

* [Documentation](http://hexdocs.pm/ecto_enum)
Expand Down
23 changes: 12 additions & 11 deletions lib/ecto_enum.ex
Expand Up @@ -47,8 +47,9 @@ defmodule EctoEnum do
`Repo` functions.
iex> Repo.insert!(%User{status: :none})
** (Ecto.ChangeError) value `:none` for `MyApp.User.status` in `insert`
does not match type MyApp.MyEnumEnum
** (Ecto.ChangeError) `"none"` is not a valid enum value for `EctoEnumTest.StatusEnum`.
Valid enum values are `[0, 1, 2, 3, :registered, :active, :inactive, :archived, "active",
"archived", "inactive", "registered"]`
The enum type `StatusEnum` will also have a reflection function for inspecting the
enum map in runtime.
Expand All @@ -57,6 +58,10 @@ defmodule EctoEnum do
[registered: 0, active: 1, inactive: 2, archived: 3]
"""

defmacro defenum(module, type, enum) when is_list(enum) do
EctoEnum.Postgres.defenum(module, type, enum)
end

defmacro defenum(module, enum) when is_list(enum) do
quote do
kw = unquote(enum) |> Macro.escape
Expand All @@ -77,7 +82,7 @@ defmodule EctoEnum do
end

def load(int) when is_integer(int) do
{:ok, @int_atom_map[int]}
Map.fetch(@int_atom_map, int)
end

def dump(term) do
Expand Down Expand Up @@ -108,10 +113,10 @@ defmodule EctoEnum do
end
end
def cast(string, _, string_atom_map) when is_binary(string) do
error_check(string_atom_map[string])
Map.fetch(string_atom_map, string)
end
def cast(int, int_atom_map, _) when is_integer(int) do
error_check(int_atom_map[int])
Map.fetch(int_atom_map, int)
end
def cast(_, _, _), do: :error

Expand All @@ -125,14 +130,10 @@ defmodule EctoEnum do
end
end
def dump(atom, atom_int_kw, _, _) when is_atom(atom) do
error_check(atom_int_kw[atom])
Keyword.fetch(atom_int_kw, atom)
end
def dump(string, _, string_int_map, _) when is_binary(string) do
error_check(string_int_map[string])
Map.fetch(string_int_map, string)
end
def dump(_), do: :error


defp error_check(nil), do: :error
defp error_check(value), do: {:ok, value}
end
81 changes: 81 additions & 0 deletions lib/ecto_enum/postgres.ex
@@ -0,0 +1,81 @@
defmodule EctoEnum.Postgres do
@moduledoc false

def defenum(module, type, list) do
list = if Enum.all?(list, &is_atom/1) do
list
else
Enum.map(list, &String.to_atom/1)
end

quote do
type = unquote(type) |> Macro.escape
list = unquote(list) |> Macro.escape

defmodule unquote(module) do
@behaviour Ecto.Type
alias EctoEnum.Postgres

@atom_list list
@atom_string_map for atom <- list, into: %{}, do: {atom, Atom.to_string(atom)}
@string_atom_map for atom <- list, into: %{}, do: {Atom.to_string(atom), atom}
@valid_values list ++ Map.values(@atom_string_map)

def type, do: unquote(type)

def cast(term) do
Postgres.cast(term, @valid_values, @string_atom_map)
end

def load(value) when is_binary(value) do
Map.fetch(@string_atom_map, value)
end

def dump(term) do
Postgres.dump(term, @valid_values, @atom_string_map)
end

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

def create_type() do
types = Enum.join(unquote(list), ", ")
sql = "CREATE TYPE #{unquote type} AS ENUM (#{types})"
Ecto.Migration.execute sql
end

def drop_type() do
sql = "DROP TYPE #{unquote type}"
Ecto.Migration.execute sql
end
end
end
end


def cast(atom, valid_values, _) when is_atom(atom) do
if atom in valid_values 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(_, _, _), do: :error


def dump(atom, _, atom_string_map) when is_atom(atom) do
Map.fetch(atom_string_map, atom)
end
def dump(string, valid_values, _) when is_binary(string) do
if string in valid_values do
{:ok, string}
else
:error
end
end
def dump(_, _, _), do: :error
end
6 changes: 5 additions & 1 deletion mix.exs
@@ -1,20 +1,24 @@
defmodule EctoEnum.Mixfile do
use Mix.Project

@version "0.3.2"
@version "0.4.0"

def project do
[app: :ecto_enum,
version: @version,
elixir: "~> 1.0",
deps: deps,
description: "Ecto extension to support enums in models",
test_paths: test_paths(Mix.env),
package: package,
name: "EctoEnum",
docs: [source_ref: "v#{@version}",
source_url: "https://github.com/gjaldon/ecto_enum"]]
end

defp test_paths(:mysql), do: ["test/mysql"]
defp test_paths(_), do: ["test/pg"]

defp package do
[contributors: ["Gabriel Jaldon"],
licenses: ["MIT"],
Expand Down
2 changes: 0 additions & 2 deletions test/ecto_enum_test.exs → test/mysql/ecto_enum_test.exs
Expand Up @@ -90,5 +90,3 @@ defmodule EctoEnumTest do
" \"active\", \"archived\", \"inactive\", \"registered\"]`"
end
end

# TODO: configure to return either string or atom
File renamed without changes.
16 changes: 4 additions & 12 deletions test/test_helper.exs → test/mysql/test_helper.exs
Expand Up @@ -2,17 +2,10 @@ ExUnit.start()

alias Ecto.Integration.TestRepo

if Mix.env == :mysql do
Application.put_env(:ecto, TestRepo,
adapter: Ecto.Adapters.MySQL,
url: "ecto://root@localhost/ecto_test",
pool: Ecto.Adapters.SQL.Sandbox)
else
Application.put_env(:ecto, TestRepo,
adapter: Ecto.Adapters.Postgres,
url: "ecto://postgres:postgres@localhost/ecto_test",
pool: Ecto.Adapters.SQL.Sandbox)
end
Application.put_env(:ecto, TestRepo,
adapter: Ecto.Adapters.MySQL,
url: "ecto://root@localhost/ecto_test",
pool: Ecto.Adapters.SQL.Sandbox)

defmodule Ecto.Integration.TestRepo do
use Ecto.Repo, otp_app: :ecto
Expand All @@ -30,4 +23,3 @@ Code.require_file "ecto_migration.exs", __DIR__

:ok = Ecto.Migrator.up(TestRepo, 0, Ecto.Integration.Migration, log: false)
Process.flag(:trap_exit, true)

92 changes: 92 additions & 0 deletions test/pg/ecto_enum_test.exs
@@ -0,0 +1,92 @@
defmodule EctoEnumTest do
use ExUnit.Case

import Ecto.Changeset
import EctoEnum
defenum StatusEnum, registered: 0, active: 1, inactive: 2, archived: 3

defmodule User do
use Ecto.Schema

schema "users" do
field :status, StatusEnum
end
end

alias Ecto.Integration.TestRepo

test "accepts int, atom and string on save" do
user = TestRepo.insert!(%User{status: 0})
user = TestRepo.get(User, user.id)
assert user.status == :registered

user = Ecto.Changeset.change(user, status: :active)
user = TestRepo.update! user
assert user.status == :active

user = Ecto.Changeset.change(user, status: "inactive")
user = TestRepo.update! user
assert user.status == "inactive"

user = TestRepo.get(User, user.id)
assert user.status == :inactive

TestRepo.insert!(%User{status: :archived})
user = TestRepo.get_by(User, status: :archived)
assert user.status == :archived
end

test "casts int and binary to atom" do
%{changes: changes} = cast(%User{}, %{"status" => "active"}, ~w(status), [])
assert changes.status == :active

%{changes: changes} = cast(%User{}, %{"status" => 3}, ~w(status), [])
assert changes.status == :archived

%{changes: changes} = cast(%User{}, %{"status" => :inactive}, ~w(status), [])
assert changes.status == :inactive
end

test "raises when input is not in the enum map" do
error = {:status, "is invalid"}

changeset = cast(%User{}, %{"status" => "retroactive"}, ~w(status), [])
assert error in changeset.errors

changeset = cast(%User{}, %{"status" => :retroactive}, ~w(status), [])
assert error in changeset.errors

changeset = cast(%User{}, %{"status" => 4}, ~w(status), [])
assert error in changeset.errors

assert_raise Ecto.ChangeError, custom_error_msg("retroactive"), fn ->
TestRepo.insert!(%User{status: "retroactive"})
end

assert_raise Ecto.ChangeError, custom_error_msg(:retroactive), fn ->
TestRepo.insert!(%User{status: :retroactive})
end

assert_raise Ecto.ChangeError, custom_error_msg(5), fn ->
TestRepo.insert!(%User{status: 5})
end
end

test "reflection" do
assert StatusEnum.__enum_map__() == [registered: 0, active: 1, inactive: 2, archived: 3]
assert StatusEnum.__valid_values__() == [0, 1, 2, 3,
:registered, :active, :inactive, :archived,
"active", "archived", "inactive", "registered"]
end

test "defenum/2 can accept variables" do
x = 0
defenum TestEnum, zero: x
end

def custom_error_msg(value) do
"`#{inspect value}` is not a valid enum value for `EctoEnumTest.StatusEnum`." <>
" Valid enum values are `[0, 1, 2, 3, :registered, :active, :inactive, :archived," <>
" \"active\", \"archived\", \"inactive\", \"registered\"]`"
end
end
14 changes: 14 additions & 0 deletions test/pg/ecto_migration.exs
@@ -0,0 +1,14 @@
defmodule Ecto.Integration.Migration do
use Ecto.Migration

def change do
create table(:users) do
add :status, :integer
end

execute "CREATE TYPE status AS ENUM ('registered', 'active', 'inactive', 'archived')"
create table(:users_pg) do
add :status, :status
end
end
end

0 comments on commit 357584c

Please sign in to comment.