Current section

Files

Jump to
ecto_postgres_enum lib ecto_postgres_enum.ex
Raw

lib/ecto_postgres_enum.ex

defmodule EctoPostgresEnum do
@moduledoc """
Helper module to define enum for `ecto` and `PostgreSQL` with support for dynamic values.
## Usage
defmodule MyEnum do
values = [:my, :enum]
use EctoPostgresEnum, schema: :my_schema, type: :my_type, values: values
end
## Options
* `schema` - Allows to change [PostgreSQL Schema](https://www.postgresql.org/docs/current/ddl-schemas.html) for specified enum.
* `type` - Allows to change type identifier. It's useful for migrations and required by `c:Ecto.Type.type/0` callback.
Name | Type | Required | Default
:----- | :--------- | :------- | :------------------------------------------------
schema | atom | false | nil (fallbacks to `PostgreSQL` default: "public")
type | atom | false | atom (underscored last module part)
values | list(atom) | true | N/A
## Debug
This library automatically generates few useful functions to work with allowed values.
Example usage:
defmodule MyEnum do
values = [:my, :enum]
use EctoPostgresEnum, schema: :my_schema, type: :my_type, values: values
end
defmodule MyApp do
require MyEnum
def handle_user_input(input) when MyEnum.valid_string?(input) do
IO.puts "Alright, \#{input} is allowed value!"
end
def handle_user_input(input) do
IO.puts "Ooops, \#{input} is not allowed value! Please try again …"
end
end
MyApp.handle_user_input("my")
{:ok, "my"}
MyApp.handle_user_input("something")
{:error. "Wrong enum value!"}
"""
@doc false
def gen_default_type(module),
do: module |> Module.split() |> List.last() |> Macro.underscore() |> String.to_atom()
@doc false
def gen_type_ast(values), do: values |> Enum.reverse() |> do_gen_type_ast()
defp do_gen_type_ast([head | tail]), do: Enum.reduce(tail, head, &{:|, [], [&1, &2]})
@doc false
defmacro __using__(opts), do: [base(opts), database_block(), debug_block(), type()]
defp base(opts), do: [base_definitions_block(opts), base_checks_block(), base_rest_block()]
defp base_definitions_block(opts) do
quote bind_quoted: [opts: opts], unquote: false do
is_list(opts) || raise "Options should be a list!"
@behaviour Ecto.Type
@__schema__ opts[:schema]
@__type__ opts[:type] || EctoPostgresEnum.gen_default_type(__MODULE__)
@__values__ opts[:values]
end
end
defp base_checks_block do
quote do
@__values__ || raise "Option values (list) is required!"
is_atom(@__type__) || raise "Type needs to be an atom!"
@__values__ == Enum.uniq(@__values__) || raise "Duplicates are not allowed in enum values!"
Enum.count(@__values__) > 0 || raise "Valid enums requires at least 1 different values!"
Enum.all?(@__values__, &is_atom/1) || raise "All values must be atoms!"
@__schema__ && (is_atom(@__schema__) || raise "Option schema must be atom!")
end
end
defp base_rest_block do
quote unquote: false do
alias Ecto.Migration
type_sql = if is_nil(@__schema__), do: @__type__, else: :"#{@__schema__}.#{@__type__}"
values_sql = Enum.map_join(@__values__, ", ", &"'#{&1}'")
@__create_sql__ "CREATE TYPE #{type_sql} AS ENUM (#{values_sql})"
@__drop_sql__ "DROP TYPE #{type_sql}"
@__string_values__ Enum.map(@__values__, &Atom.to_string/1)
@__zipped_values__ Enum.zip(@__values__, @__string_values__)
@type t :: unquote(EctoPostgresEnum.gen_type_ast(@__values__))
end
end
defp database_block do
quote do
@doc "Creates database enum."
@spec create_db_enum :: :ok
def create_db_enum, do: Migration.execute(@__create_sql__)
@doc "Drops database enum."
@spec drop_db_enum :: :ok
def drop_db_enum, do: Migration.execute(@__drop_sql__)
end
end
defp debug_block do
quote do
@doc "Returns list of allowed atom values."
@spec atom_values :: [t]
def atom_values, do: @__values__
@doc "Returns list of allowed string values."
@spec string_values :: [String.t()]
def string_values, do: @__string_values__
@doc "Checks if given atom is allowed."
@spec valid_atom?(t) :: true
@spec valid_atom?(term) :: false
defguard valid_atom?(value) when value in @__values__
@doc "Checks if given string is allowed."
@spec valid_string?(String.t()) :: boolean
@spec valid?(term) :: false
defguard valid_string?(value) when value in @__string_values__
@doc "Checks if given atom or string is allowed."
@spec valid?(t) :: true
@spec valid?(String.t()) :: boolean
@spec valid?(term) :: false
defguard valid?(value) when valid_atom?(value) or valid_string?(value)
@doc "Returns zipped list of atom values with list of string values."
@spec values :: [term]
def values, do: @__zipped_values__
end
end
defp type, do: [type_cast(), type_dump(), type_rest()]
defp type_cast do
quote unquote: false do
@doc """
Casts the given input to the custom type.
This is callback implementation for: `c:Ecto.Type.cast/1`
"""
@impl Ecto.Type
@spec cast(String.t()) :: {:ok, t} | :error
for {atom, string} <- @__zipped_values__ do
@spec cast(unquote(atom)) :: {:ok, unquote(atom)}
def cast(unquote(atom)), do: {:ok, unquote(atom)}
def cast(unquote(string)), do: {:ok, unquote(atom)}
end
@spec cast(term) :: :error
def cast(_term), do: :error
end
end
defp type_dump do
quote unquote: false do
@doc """
Dumps the given term into an Ecto native type.
This is callback implementation for: `c:Ecto.Type.dump/1`
"""
@impl Ecto.Type
@spec dump(t) :: {:ok, String.t()}
@spec dump(String.t()) :: {:ok, String.t()} | :error
for {atom, string} <- @__zipped_values__ do
def dump(unquote(atom)), do: {:ok, unquote(string)}
def dump(unquote(string)), do: {:ok, unquote(string)}
end
@spec dump(term) :: :error
def dump(_term), do: :error
end
end
defp type_rest do
quote unquote: false do
@doc """
Loads the given term into a custom type.
This is callback implementation for: `c:Ecto.Type.load/1`
"""
@impl Ecto.Type
@spec load(String.t()) :: {:ok, t} | :error
for {atom, string} <- @__zipped_values__ do
def load(unquote(string)), do: {:ok, unquote(atom)}
end
@spec load(term) :: :error
def load(_value), do: :error
@doc """
Returns the underlying schema type for the custom type.
This is callback implementation for: `c:Ecto.Type.type/0`
"""
@impl Ecto.Type
@spec type :: atom
def type, do: @__type__
end
end
end