lib/pow/ecto/schema.ex

defmodule Pow.Ecto.Schema do
  @moduledoc """
  Handles the Ecto schema for user.

  The macro will create a `@pow_fields` module attribute, and append fields to
  it using the attributes from `Pow.Ecto.Schema.Fields.attrs/1`. Likewise, a
  `@pow_assocs` module attribute is also generated for associations. The
  `pow_user_fields/0` macro will use these attributes to create fields and
  associations in the ecto schema.

  The macro will add two overridable methods to your module; `changeset/2`
  and `verify_password/2`. See the customization section below for more.

  The following helper methods are added for changeset customization:

    - `pow_changeset/2`,
    - `pow_verify_password/2`
    - `pow_user_id_field_changeset/2`
    - `pow_password_changeset/2`,
    - `pow_current_password_changeset/2`,

  Finally `pow_user_id_field/0` method is added to the module that is used to
  fetch the user id field name.

  A `@pow_config` module attribute is created containing the options that were
  passed to the macro with the `use Pow.Ecto.Schema, ...` call.

  See `Pow.Ecto.Schema.Changeset` for more.

  ## Usage

  Configure `lib/my_project/users/user.ex` the following way:

      defmodule MyApp.Users.User do
        use Ecto.Schema
        use Pow.Ecto.Schema,
          user_id_field: :email,
          password_hash_methods: {&Pow.Ecto.Schema.Password.pbkdf2_hash/1,
                                  &Pow.Ecto.Schema.Password.pbkdf2_verify/2},
          password_min_length: 8,
          password_max_length: 4096

        schema "users" do
          field :custom_field, :string

          pow_user_fields()

          timestamps()
        end

        def changeset(user_or_changeset, attrs) do
          pow_changeset(user_or_changeset, attrs)
        end
      end

  Remember to add `user: MyApp.Users.User` to your configuration.

  ## Configuration options

  * `:user_id_field` - the field to use for user id. This value defaults to
    `:email`, and the changeset will automatically validate it as an e-mail.

  ## Customize Pow fields

  Pow fields can be overridden if the field name and type matches:

      defmodule MyApp.Users.User do
        use Ecto.Schema
        use Pow.Ecto.Schema

        schema "users" do
          field :encrypted_password, :string
          field :password_hash, :string, source: :encrypted_password

          pow_user_fields()

          timestamps()
        end
      end

  The same holds true for associations:

      defmodule MyApp.Users.User do
        use Ecto.Schema
        use Pow.Ecto.Schema

        @pow_assocs {:belongs_to, :invited_by, __MODULE__, []}
        @pow_assocs {:has_many, :invited, __MODULE__, []}

        schema "users" do
          belongs_to :invited_by, __MODULE__, foreign_key: :user_id

          pow_user_fields()

          timestamps()
        end
      end

  An `@after_compile` callback will emit IO warning if there are missing fields
  or associations. You can forego the `pow_user_fields/0` call, and write out
  the whole schema instead:

      defmodule MyApp.Users.User do
        use Ecto.Schema
        use Pow.Ecto.Schema, user_id_field: :email

        schema "users" do
          field :email,            :string
          field :password_hash,    :string
          field :current_password, :string, virtual: true
          field :password,         :string, virtual: true
          field :confirm_password, :string, virtual: true

          timestamps()
        end
      end

  If you would like these warnings to be raised during compilation you can add
  `elixirc_options: [warnings_as_errors: true]` to the project options in
  `mix.exs`.

  The warning is also emitted if the field has an invalid primitive Ecto type.
  It'll not be emitted for custom Ecto types.

  ## Customize Pow changeset

  You can extract individual changeset methods to modify the changeset flow
  entirely. As an example, this is how you can remove the validation check for
  confirm password in the changeset method:

      defmodule MyApp.Users.User do
        use Ecto.Schema
        use Pow.Ecto.Schema

        import Pow.Ecto.Schema.Changeset, only: [new_password_changeset: 3]

        # ...

        def changeset(user_or_changeset, attrs) do
          user_or_changeset
          |> pow_user_id_field_changeset(attrs)
          |> pow_current_password_changeset(attrs)
          |> new_password_changeset(attrs, @pow_config)
        end
      end

  Note that the changeset methods in `Pow.Ecto.Schema.Changeset` require the
  Pow ecto module configuration that is passed to the
  `use Pow.Ecto.Schema, ...` call. This can be fetched by using the
  `@pow_config` module attribute.
  """
  alias Ecto.{Changeset, Type}
  alias Pow.Config

  defmodule SchemaError do
    @moduledoc false
    defexception [:message]
  end

  @callback changeset(Ecto.Schema.t() | Changeset.t(), map()) :: Changeset.t()
  @callback verify_password(Ecto.Schema.t(), binary()) :: boolean()

  @doc false
  defmacro __using__(config) do
    quote do
      @behaviour unquote(__MODULE__)
      @pow_config unquote(config)

      def changeset(user_or_changeset, attrs), do: pow_changeset(user_or_changeset, attrs)

      def verify_password(user, password), do: pow_verify_password(user, password)

      defoverridable unquote(__MODULE__)

      unquote(__MODULE__).__pow_methods__()
      unquote(__MODULE__).__register_fields__()
      unquote(__MODULE__).__register_assocs__()
      unquote(__MODULE__).__register_user_id_field__()
      unquote(__MODULE__).__register_after_compile_validation__()
    end
  end

  @changeset_methods [:user_id_field_changeset, :password_changeset, :current_password_changeset]

  @doc false
  defmacro __pow_methods__ do
    quoted_changeset_methods =
      for method <- @changeset_methods do
        pow_method_name = String.to_atom("pow_#{method}")

        quote do
          def unquote(pow_method_name)(user_or_changeset, attrs) do
            unquote(__MODULE__).Changeset.unquote(method)(user_or_changeset, attrs, @pow_config)
          end
        end
      end

    quote do
      import unquote(__MODULE__), only: [pow_user_fields: 0]

      def pow_changeset(user_or_changeset, attrs) do
        user_or_changeset
        |> pow_user_id_field_changeset(attrs)
        |> pow_current_password_changeset(attrs)
        |> pow_password_changeset(attrs)
      end

      unquote(quoted_changeset_methods)

      def pow_verify_password(user, password) do
        unquote(__MODULE__).Changeset.verify_password(user, password, @pow_config)
      end
    end
  end

  @doc """
  A macro to add fields from the `@pow_fields` module attribute generated in
  `__using__/1`.

  The `@pow_fields` are populated by `Pow.Ecto.Schema.Fields.attrs/1`, and will
  have at minimum the following fields:

    * `:email` (if not changed with `:user_id_field` option)
    * `:password_hash`
    * `:current_password` (virtual)
    * `:password` (virtual)
  """
  defmacro pow_user_fields do
    quote do
      unquote(__MODULE__).__append_assocs__(@pow_assocs, @ecto_assocs)
      unquote(__MODULE__).__append_fields__(@pow_fields, @ecto_fields)
    end
  end

  defmacro __append_assocs__(assocs, ecto_assocs) do
    quote do
      unquote(assocs)
      |> unquote(__MODULE__).__filter_new_assocs__(unquote(ecto_assocs))
      |> Enum.each(fn
        {:belongs_to, name, queryable, options} ->
          belongs_to(name, queryable, options)

        {:has_many, name, queryable, options} ->
          has_many(name, queryable, options)
      end)
    end
  end

  @doc false
  def __filter_new_assocs__(assocs, existing_assocs) do
    Enum.reject(assocs, fn assoc ->
      Enum.any?(existing_assocs, &assocs_match?(elem(assoc, 0), elem(assoc, 1), &1))
    end)
  end

  defp assocs_match?(:has_many, name, {name, %Ecto.Association.Has{cardinality: :many}}), do: true
  defp assocs_match?(:belongs_to, name, {name, %Ecto.Association.BelongsTo{}}), do: true
  defp assocs_match?(_type, _name, _existing_assoc), do: false

  defmacro __append_fields__(fields, ecto_fields) do
    quote do
      unquote(fields)
      |> unquote(__MODULE__).__filter_new_fields__(unquote(ecto_fields))
      |> Enum.each(fn
        {name, type} ->
          field(name, type)

        {name, type, defaults} ->
          field(name, type, defaults)
      end)
    end
  end

  @doc false
  def __filter_new_fields__(fields, existing_fields) do
    Enum.filter(fields, &not Enum.member?(existing_fields, {elem(&1, 0), elem(&1, 1)}))
  end

  # TODO: Remove by 1.1.0
  @deprecated "No longer public method"
  def filter_new_fields(fields, existing_fields), do: __filter_new_fields__(fields, existing_fields)

  @doc false
  defmacro __register_fields__ do
    quote do
      Module.register_attribute(__MODULE__, :pow_fields, accumulate: true)

      @pow_config
      |> unquote(__MODULE__).Fields.attrs()
      |> Enum.each(fn {name, value, field_options, _migration_options} ->
        Module.put_attribute(__MODULE__, :pow_fields, {name, value, field_options})
      end)
    end
  end

  @doc false
  defmacro __register_assocs__ do
    quote do
      Module.register_attribute(__MODULE__, :pow_assocs, accumulate: true)
    end
  end

  @doc false
  defmacro __register_user_id_field__ do
    quote do
      @user_id_field unquote(__MODULE__).user_id_field(@pow_config)
      def pow_user_id_field, do: @user_id_field
    end
  end

  @doc """
  Get user id field key from changeset or configuration.

  Defaults to `:email`.
  """
  @default_user_id_field :email
  @spec user_id_field(Changeset.t() | Config.t()) :: atom()
  def user_id_field(%Changeset{data: %user_mod{}}), do: user_mod.pow_user_id_field()
  def user_id_field(config) when is_list(config), do: Config.get(config, :user_id_field, @default_user_id_field)
  def user_id_field(_any), do: @default_user_id_field

  @doc false
  defmacro __register_after_compile_validation__ do
    quote do
      def pow_validate_after_compilation!(env, _bytecode) do
        unquote(__MODULE__).__require_assocs__(__MODULE__)
        unquote(__MODULE__).__require_fields__(__MODULE__)
      end

      @after_compile {__MODULE__, :pow_validate_after_compilation!}
    end
  end

  @doc false
  def __require_assocs__(module) do
    ecto_assocs = Module.get_attribute(module, :ecto_assocs)

    module
    |> Module.get_attribute(:pow_assocs)
    |> Enum.map(&validate_assoc!/1)
    |> Enum.reverse()
    |> Enum.filter(fn {type, name, _queryable, _defaults} ->
      not Enum.any?(ecto_assocs, &assocs_match?(type, name, &1))
    end)
    |> Enum.map(fn
      {type, name, queryable, []}       -> "#{type} #{inspect name}, #{inspect queryable}"
      {type, name, queryable, defaults} -> "#{type} #{inspect name}, #{inspect queryable}, #{inspect defaults}"
    end)
    |> case do
      []         -> :ok
      assoc_defs -> warn_missing_assocs_error(module, assoc_defs)
    end
  end

  defp validate_assoc!({_type, _name, _module, _defaults} = assoc), do: assoc
  defp validate_assoc!(value) do
    raise """
    `@pow_assocs` is required to have the format `{type, field, module, defaults}`.

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

  defp warn_missing_assocs_error(module, assoc_defs) do
    IO.warn(
      """
      Please define the following association(s) in the schema for #{inspect module}:

      #{Enum.join(assoc_defs, "\n")}
      """)
  end

  @doc false
  def __require_fields__(module) do
    ecto_fields = Module.get_attribute(module, :ecto_fields)

    # TODO: Require Ecto 3.8.0 in 1.1.0 and remove `:changeset_fields`
    changeset_fields = Module.get_attribute(module, :ecto_changeset_fields) || Module.get_attribute(module, :changeset_fields)

    module
    |> Module.get_attribute(:pow_fields)
    |> Enum.map(&validate_field!/1)
    |> Enum.reverse()
    |> Enum.filter(&missing_field?(&1, ecto_fields, changeset_fields))
    |> Enum.map(fn
      {name, type, []}       -> "field #{inspect name}, #{inspect type}"
      {name, type, defaults} -> "field #{inspect name}, #{inspect type}, #{inspect defaults}"
    end)
    |> case do
      []         -> :ok
      field_defs -> warn_missing_fields_error(module, field_defs)
    end
  end


  defp validate_field!({_name, _type, _defaults} = assoc), do: assoc
  defp validate_field!(value) do
    raise """
    `@pow_fields` is required to have the format `{name, type, defaults}`.

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

  defp missing_field?({name, type, field_options}, ecto_fields, changeset_fields) do
    case field_options[:virtual] do
      true -> missing_field?(name, type, changeset_fields)
      _any -> missing_field?(name, type, ecto_fields)
    end
  end

  defp missing_field?(name, type, existing_fields) when is_atom(name) do
    not Enum.any?(existing_fields, fn
      {^name, ^type}  -> true
      {^name, e_type} -> not Type.primitive?(e_type)
      _any            -> false
    end)
  end

  defp warn_missing_fields_error(module, field_defs) do
    IO.warn(
      """
      Please define the following field(s) in the schema for #{inspect module}:

      #{Enum.join(field_defs, "\n")}
      """)
  end

  @doc """
  Normalizes the user id field.

  Keeps the user id field value case insensitive and removes leading and
  trailing whitespace.
  """
  @spec normalize_user_id_field_value(binary()) :: binary()
  def normalize_user_id_field_value(value) do
    value
    |> String.trim()
    |> String.downcase()
  end

  @doc false
  def __timestamp_for__(struct, column) do
    type = struct.__schema__(:type, column)

    __timestamp__(type)
  end

  @doc false
  def __timestamp__(:naive_datetime) do
    %{NaiveDateTime.utc_now() | microsecond: {0, 0}}
  end
  def __timestamp__(:naive_datetime_usec) do
    NaiveDateTime.utc_now()
  end
  def __timestamp__(:utc_datetime) do
    DateTime.from_unix!(System.system_time(:second), :second)
  end
  def __timestamp__(:utc_datetime_usec) do
    DateTime.from_unix!(System.system_time(:microsecond), :microsecond)
  end
  def __timestamp__(type) do
    type.from_unix!(System.system_time(:microsecond), :microsecond)
  end
end