lib/pow/ecto/schema/migration.ex

defmodule Pow.Ecto.Schema.Migration do
  @moduledoc """
  Generates schema migration content.

  ## Configuration options

    * `:repo` - the ecto repo to use. This value defaults to the derrived
      context base repo from the `context_base` argument in `gen/2`.

    * `:table` - the ecto table name, defaults to "users".

    * `:attrs` - list of attributes, defaults to the results from
      `Pow.Ecto.Schema.Fields.attrs/1`.

    * `:indexes` - list of indexes, defaults to the results from
      `Pow.Ecto.Schema.Fields.indexes/1`.
  """
  alias Pow.{Config, Ecto.Schema.Fields}

  @template """
  defmodule <%= inspect schema.repo %>.Migrations.<%= schema.migration_name %> do
    use Ecto.Migration

    def change do
      create table(:<%= schema.table %><%= if schema.binary_id do %>, primary_key: false<% end %>) do
  <%= if schema.binary_id do %>      add :id, :binary_id, primary_key: true
  <% end %><%= for {k, v} <- schema.attrs do %>      add <%= inspect k %>, <%= inspect v %><%= schema.migration_defaults[k] %>
  <% end %><%= for {_, i, _, s} <- schema.assocs do %>      add <%= if(String.ends_with?(inspect(i), "_id"), do: inspect(i), else: inspect(i) <> "_id") %>, references(<%= inspect(s) %>, on_delete: :nothing<%= if schema.binary_id do %>, type: :binary_id<% end %>)
  <% end %>
        timestamps()
      end
  <%= for index <- schema.indexes do %>
      <%= index %><% end %>
    end
  end
  """

  @doc """
  Generates migration file content.
  """
  @spec gen(map()) :: binary()
  def gen(schema) do
    EEx.eval_string(unquote(@template), schema: schema)
  end

  @doc """
  Generates migration schema map.
  """
  @spec new(atom(), binary(), Config.t()) :: map()
  def new(context_base, schema_plural, config \\ []) do
    repo           = Config.get(config, :repo, Module.concat([context_base, "Repo"]))
    attrs          = Config.get(config, :attrs, Fields.attrs(config))
    indexes        = Config.get(config, :indexes, Fields.indexes(config))
    migration_name = name(schema_plural)

    schema(context_base, repo, schema_plural, migration_name, attrs, indexes, binary_id: config[:binary_id])
  end

  defp name(schema_plural), do: "Create#{Macro.camelize(schema_plural)}"

  @doc """
  Generates a schema map to be used with the schema template.
  """
  @spec schema(atom(), atom(), binary(), binary(), list(), list(), Keyword.t()) :: map()
  def schema(context_base, repo, table, migration_name, attrs, indexes, opts) do
    migration_attrs    = migration_attrs(attrs)
    binary_id          = opts[:binary_id]
    migration_defaults = defaults(migration_attrs)
    {assocs, attrs}    = partition_attrs(context_base, migration_attrs)
    indexes            = migration_indexes(indexes, table)

    %{
      migration_name: migration_name,
      repo: repo,
      table: table,
      binary_id: binary_id,
      attrs: attrs,
      migration_defaults: migration_defaults,
      assocs: assocs,
      indexes: indexes
    }
  end

  defp migration_attrs(attrs) do
    :ok = Enum.each(attrs, &validate!/1)

    attrs
    |> Enum.reject(&is_virtual?/1)
    |> Enum.map(&normalize_migration_options/1)
    |> Enum.map(&to_migration_attr/1)
  end

  defp validate!({_name, _type, _field_options, _migration_options}), do: :ok
  defp validate!(value) do
    raise """
    The attribute is required to have the format `{name, type, field_options, migration_options}`.

    The attribute provided was: #{inspect value}
    """
  end

  defp is_virtual?({_name, _type, field_options, _migration_options}), do: Keyword.get(field_options, :virtual, false)

  defp normalize_migration_options({name, type, field_options, migration_options}) do
    options =
      field_options
      |> Keyword.take([:default])
      |> Keyword.merge(migration_options)

      {name, type, options}
  end

  defp to_migration_attr({name, type, []}) do
    {name, type, ""}
  end
  defp to_migration_attr({name, type, defaults}) do
    defaults = Enum.map_join(defaults, ", ", fn {k, v} -> "#{k}: #{inspect v}" end)

    {name, type, ", #{defaults}"}
  end

  defp defaults(attrs) do
    Enum.map(attrs, fn {key, _value, defaults} ->
      {key, defaults}
    end)
  end

  defp partition_attrs(context_base, attrs) do
    {assocs, attrs} =
      Enum.split_with(attrs, fn
        {_, {:references, _}, _} -> true
        _ -> false
      end)

    attrs  = Enum.map(attrs, fn {key_id, type, _defaults} -> {key_id, type} end)
    assocs =
      Enum.map(assocs, fn {key_id, {:references, source}, _} ->
        key = String.replace(Atom.to_string(key_id), "_id", "")
        context = Macro.camelize(source)
        schema = Macro.camelize(key)
        module = Module.concat([context_base, context, schema])

        {String.to_atom(key), key_id, inspect(module), source}
      end)

    {assocs, attrs}
  end

  defp migration_indexes(indexes, table) do
    Enum.map(indexes, &to_migration_index(table, &1))
  end

  defp to_migration_index(table, {key_or_keys, true}),
    do: "create unique_index(:#{table}, #{inspect(List.wrap(key_or_keys))})"
end