ecto_postgres_enum.ex 6.73 KB
Newer Older
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
1
defmodule EctoPostgresEnum do
Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
2
3
  @moduledoc """
  Helper module to define enum for `ecto` and `PostgreSQL` with support for dynamic values.
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
4

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
5
  ## Usage
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
6

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
7
8
9
10
      defmodule MyEnum do
        values = [:my, :enum]
        use EctoPostgresEnum, schema: :my_schema, type: :my_type, values: values
      end
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
11

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
12
  ## Options
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
13

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
14
15
  * `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.
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
16

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
17
18
19
20
21
  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
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
22

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
23
24
  ## Debug
  This library automatically generates few useful functions to work with allowed values.
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
25

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
26
27
28
29
30
  Example usage:

      defmodule MyEnum do
        values = [:my, :enum]
        use EctoPostgresEnum, schema: :my_schema, type: :my_type, values: values
31
32
      end

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
      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()]
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
63

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
  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."
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
106
107
108
      @spec create_db_enum :: :ok
      def create_db_enum, do: Migration.execute(@__create_sql__)

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
109
      @doc "Drops database enum."
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
110
111
      @spec drop_db_enum :: :ok
      def drop_db_enum, do: Migration.execute(@__drop_sql__)
Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
112
113
    end
  end
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
114

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
  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
180
181
182
183
        def dump(unquote(atom)), do: {:ok, unquote(string)}
        def dump(unquote(string)), do: {:ok, unquote(string)}
      end

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
184
      @spec dump(term) :: :error
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
185
      def dump(_term), do: :error
Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
186
187
    end
  end
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
188

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
189
190
191
192
193
194
195
196
197
198
  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
199
200
201
        def load(unquote(string)), do: {:ok, unquote(atom)}
      end

Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
202
      @spec load(term) :: :error
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
203
204
205
      def load(_value), do: :error

      @doc """
Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
206
207
208
      Returns the underlying schema type for the custom type.

      This is callback implementation for: `c:Ecto.Type.type/0`
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
209
      """
Tomasz Marek Sulima's avatar
1.1.0    
Tomasz Marek Sulima committed
210
      @impl Ecto.Type
Tomasz Marek Sulima's avatar
Tomasz Marek Sulima committed
211
212
213
214
215
      @spec type :: atom
      def type, do: @__type__
    end
  end
end