lib/xema/builder.ex

defmodule Xema.Builder do
  @moduledoc """
  This module contains some convenience functions to generate schemas. Beside
  the type-functions the module contains the combinator-functions `all_of/2`,
  `any_of/2` and `one_of/2`.

  ## Examples

      iex> import Xema.Builder
      ...> schema = Xema.new integer(minimum: 1)
      ...> Xema.valid?(schema, 6)
      true
      ...> Xema.valid?(schema, 0)
      false
  """

  alias Xema.{CastError, Schema, ValidationError}

  @types Xema.Schema.types()

  @types
  |> Enum.filter(fn x -> x not in [nil, true, false, :struct] end)
  |> Enum.each(fn fun ->
    @doc """
    Returns :#{fun}.
    """
    @spec unquote(fun)() :: unquote(fun)
    def unquote(fun)() do
      unquote(fun)
    end

    @doc """
    Returns a tuple of `:#{fun}` and the given keyword list.

    ## Examples

        iex> Xema.Builder.#{fun}(key: 42)
        {:#{fun}, key: 42}
    """
    @spec unquote(fun)(keyword) :: {unquote(fun), keyword}
    def unquote(fun)(keywords) when is_list(keywords) do
      {unquote(fun), keywords}
    end
  end)

  @xema_deprecated_message """
  Xema.Builder.xema/1 with :field arguments is deprecated.
  Use Xema.Builder.xema_struct/1 to define a struct instead.

  Example:

  defmoudle MyApp.User do
    use Xema

    xema_struct do
      field :name, :string
    end
  end
  """

  @doc """
  Returns a tuple with the given `type` (default `:any`) and the given schemas
  tagged by the keyword `:any_of`. This function provides a shortcut for
  something like `integer(any_of: [...])` or `any(any_of: [...])`.

  ## Examples

      iex> Xema.Builder.any_of([:integer, :string])
      {:any, any_of: [:integer, :string] }

      iex> Xema.Builder.any_of(:integer, [[minimum: 10], [maximum: 5]])
      {:integer, any_of: [[minimum: 10], [maximum: 5]]}

  ```elixir
  defmodule MySchema do
    use Xema

    xema do
      any_of [
        list(items: integer(minimum: 1, maximum: 66)),
        list(items: integer(minimum: 33, maximum: 100))
      ]
    end
  end

  MySchema.valid?([20, 30]) #=> true
  MySchema.valid?([40, 50]) #=> true
  MySchema.valid?([60, 70]) #=> true
  MySchema.valid?([10, 90]) #=> false
  ```
  """
  @spec any_of(type, [schema]) :: {type, any_of: [schema]}
        when type: Schema.type(), schema: Schema.t() | Schema.type() | tuple | atom | keyword
  def any_of(type \\ :any, schemas) when type in @types and is_list(schemas) do
    {type, any_of: schemas}
  end

  @doc """
  Returns a tuple with the given `type` (default `:any`) and the given schemas
  tagged by the keyword `:all_of`. This function provides a shortcut for
  something like `integer(all_of: [...])` or `any(all_of: [...])`.

  ## Examples

      iex> Xema.Builder.all_of([:integer, :string])
      {:any, all_of: [:integer, :string] }

      iex> Xema.Builder.all_of(:integer, [[minimum: 10], [maximum: 5]])
      {:integer, all_of: [[minimum: 10], [maximum: 5]]}

  ```elixir
  defmodule MySchema do
    use Xema

    xema do
      all_of [
        list(items: integer(minimum: 1, maximum: 66)),
        list(items: integer(minimum: 33, maximum: 100))
      ]
    end
  end

  MySchema.valid?([20, 30]) #=> false
  MySchema.valid?([40, 50]) #=> true
  MySchema.valid?([60, 70]) #=> false
  MySchema.valid?([10, 90]) #=> false
  ```
  """
  @spec all_of(type, [schema]) :: {type, all_of: [schema]}
        when type: Schema.type(), schema: Schema.t() | Schema.type() | tuple | atom | keyword
  def all_of(type \\ :any, schemas) when type in @types and is_list(schemas) do
    {type, all_of: schemas}
  end

  @doc """
  Returns a tuple with the given `type` (default `:any`) and the given schemas
  tagged by the keyword `:one_of`. This function provides a shortcut for
  something like `integer(one_of: [...])` or `any(one_of: [...])`.

  ## Examples

      iex> Xema.Builder.one_of([:integer, :string])
      {:any, one_of: [:integer, :string] }

      iex> Xema.Builder.one_of(:integer, [[minimum: 10], [maximum: 5]])
      {:integer, one_of: [[minimum: 10], [maximum: 5]]}

  ```elixir
  defmodule MySchema do
    use Xema

    xema do
      one_of [
        list(items: integer(minimum: 1, maximum: 66)),
        list(items: integer(minimum: 33, maximum: 100))
      ]
    end
  end

  MySchema.valid?([20, 30]) #=> true
  MySchema.valid?([40, 50]) #=> false
  MySchema.valid?([60, 70]) #=> true
  MySchema.valid?([10, 90]) #=> false
  ```
  """
  @spec one_of(type, [schema]) :: {type, one_of: [schema]}
        when type: Schema.type(), schema: Schema.t() | Schema.type() | tuple | atom | keyword
  def one_of(type \\ :any, schemas) when type in @types and is_list(schemas) do
    {type, one_of: schemas}
  end

  @doc """
  Returns the tuple `{:ref, ref}`.
  """
  def ref(ref) when is_binary(ref), do: {:ref, ref}

  @doc """
  Returns `:struct`.
  """
  @spec strux :: :struct
  def strux, do: :struct

  @doc """
  Returns a tuple of `:struct` and the given keyword list when the function gets
  a keyword list.

  Returns the tuple `{:struct, module: module}` when the function gets an atom.
  """
  @spec strux(keyword) :: {:struct, keyword}
  def strux(keywords) when is_list(keywords), do: {:struct, keywords}

  @spec strux(atom) :: {:struct, module: module}
  def strux(module) when is_atom(module), do: strux(module: module)

  def strux(module, keywords) when is_atom(module),
    do: keywords |> Keyword.put(:module, module) |> strux()

  @doc """
  Creates a `schema`.
  """
  defmacro xema(do: {:field, _context, _args} = schema) do
    warn(@xema_deprecated_message, __CALLER__)
    do_xema(schema)
  end

  defmacro xema(do: {:__block__, _context_1, [{:field, _context_2, _args} | _ast]} = schema) do
    warn(@xema_deprecated_message, __CALLER__)
    do_xema(schema)
  end

  defmacro xema(do: schema) do
    do_xema(schema)
  end

  defp do_xema(schema) do
    quote do
      xema :__xema_default_schema__ do
        unquote(schema)
      end
    end
  end

  @doc """
  Creates a `schema` with the given name.
  """
  defmacro xema(name, do: {:filed, _context, _args} = schema) do
    unless name == :__xema_default_schema__, do: warn(@xema_deprecated_message, __CALLER__)
    do_xema(name, schema)
  end

  defmacro xema(
             name,
             do: {:__block__, _context_1, [{:field, _context_2, _args} | _ast]} = schema
           ) do
    unless name == :__xema_default_schema__, do: warn(@xema_deprecated_message, __CALLER__)
    do_xema(name, schema)
  end

  defmacro xema(name, do: schema) do
    do_xema(name, schema)
  end

  defp do_xema(name, schema) do
    schema = dep_xema_struct(schema)

    quote do
      Module.register_attribute(__MODULE__, :xemas, accumulate: true)

      multi = Module.get_attribute(__MODULE__, :multi)

      if Module.get_attribute(__MODULE__, :default) == true do
        message = """
        The attribute `@default true` to mark a schema as default is deprecated.
        Found in module #{inspect(__MODULE__)} for xema #{inspect(unquote(name))}.
        To set a default add the option `:default` to `use Xema`.

        Example:

        defmoudle MyApp.Schemas do
          use Xema, multi: true, default: :s1

          xema :s1 do
            ...
          end

          xema :s2 do
            ...
          end
        end
        """

        IO.warn(IO.ANSI.format([IO.ANSI.yellow(), "#{message}"]), [])
        Module.delete_attribute(__MODULE__, :default)
      end

      default = Module.get_attribute(__MODULE__, :__xema_default__)

      if multi == nil do
        raise "Use `use Xema` to use the `xema/2` macro."
      end

      if !multi && length(@xemas) > 0 do
        raise "Use `use Xema, multi: true` to setup multiple schema in a module."
      end

      Module.put_attribute(
        __MODULE__,
        :xemas,
        {unquote(name), Xema.new(add_new_module(unquote(schema), __MODULE__))}
      )

      if multi do
        if length(@xemas) == 1 do
          if default == nil do
            unquote(xema_funs(:header_without_default))
          else
            unquote(xema_funs(:header_with_default))
          end
        end

        if unquote(name) == default do
          unquote(xema_funs(:default, name))
        end

        unquote(xema_funs(:by_name, name))
      else
        if unquote(name) == :__xema_default_schema__ do
          unquote(xema_funs(:single, name))
        else
          unquote(xema_funs(:header_with_default))
          unquote(xema_funs(:default, name))
          unquote(xema_funs(:by_name, name))
        end
      end
    end
  end

  @doc """
  Creates a `schema` and defines the corresponding struct.
  """
  defmacro xema_struct(do: schema) do
    do_xema(schema)
  end

  defp xema_funs(:header_with_default) do
    quote do
      @doc """
      Returns true if the specified `data` is valid against the schema
      defined under `name`, otherwise false.
      """
      @spec valid?(atom, term) :: boolean
      def valid?(name \\ :default, data)

      @doc """
      Validates the given `data` against the schema defined under `name`.

      Returns `:ok` for valid data, otherwise an `:error` tuple.
      """
      @spec validate(atom, term) :: :ok | {:error, ValidationError.t()}
      def validate(name \\ :default, data)

      @doc """
      Validates the given `data` against the schema defined under `name`.

      Returns `:ok` for valid data, otherwise a `Xema.ValidationError` is
      raised.
      """
      @spec validate!(atom, term) :: :ok
      def validate!(name \\ :default, data)

      @doc """
      Converts the given `data` according to the schema defined under `name`.

      Returns an `:ok` tuple with the converted data for valid `data`, otherwise
      an `:error` tuple is returned.
      """
      @spec cast(atom, term) ::
              {:ok, term} | {:error, ValidationError.t() | CastError.t()}
      def cast(name \\ :default, data)

      @doc """
      Converts the given `data` according to the schema defined under `name`.

      Returns converted data for valid `data`, otherwise a `Xema.CastError` or
      `Xema.ValidationError` is raised.
      """
      @spec cast!(atom, term) :: {:ok, term}
      def cast!(name \\ :default, data)

      @doc false
      def xema(name \\ :default)
    end
  end

  defp xema_funs(:header_without_default) do
    quote do
      @doc """
      Returns true if the specified `data` is valid against the schema
      defined under `name`, otherwise false.
      """
      @spec valid?(atom, term) :: boolean
      def valid?(name, data)

      @doc """
      Validates the given `data` against the schema defined under `name`.

      Returns `:ok` for valid data, otherwise an `:error` tuple.
      """
      @spec validate(atom, term) :: :ok | {:error, ValidationError.t()}
      def validate(name, data)

      @doc """
      Validates the given `data` against the schema defined under `name`.

      Returns `:ok` for valid data, otherwise a `Xema.ValidationError` is
      raised.
      """
      @spec validate!(atom, term) :: :ok
      def validate!(name, data)

      @doc """
      Converts the given `data` according to the schema defined under `name`.

      Returns an `:ok` tuple with the converted data for valid `data`, otherwise
      an `:error` tuple is returned.
      """
      @spec cast(atom, term) ::
              {:ok, term} | {:error, ValidationError.t() | CastError.t()}
      def cast(name, data)

      @doc """
      Converts the given `data` according to the schema defined under `name`.

      Returns converted data for valid `data`, otherwise a `Xema.CastError` or
      `Xema.ValidationError` is raised.
      """
      @spec cast!(atom, term) :: {:ok, term}
      def cast!(name, data)

      @doc false
      def xema(name)
    end
  end

  defp xema_funs(:by_name, name) do
    quote do
      def valid?(unquote(name), data),
        do: Xema.valid?(@xemas[unquote(name)], data)

      def validate(unquote(name), data),
        do: Xema.validate(@xemas[unquote(name)], data)

      def validate!(unquote(name), data),
        do: Xema.validate!(@xemas[unquote(name)], data)

      def cast(unquote(name), data),
        do: Xema.cast(@xemas[unquote(name)], data)

      def cast!(unquote(name), data),
        do: Xema.cast!(@xemas[unquote(name)], data)

      @doc false
      def xema(unquote(name)),
        do: @xemas[unquote(name)]
    end
  end

  defp xema_funs(:default, name) do
    quote do
      def valid?(:default, data),
        do: Xema.valid?(@xemas[unquote(name)], data)

      def validate(:default, data),
        do: Xema.validate(@xemas[unquote(name)], data)

      def validate!(:default, data),
        do: Xema.validate!(@xemas[unquote(name)], data)

      def cast(:default, data),
        do: Xema.cast(@xemas[unquote(name)], data)

      def cast!(:default, data),
        do: Xema.cast!(@xemas[unquote(name)], data)

      @doc false
      def xema(:default),
        do: @xemas[unquote(name)]
    end
  end

  defp xema_funs(:single, name) do
    quote do
      @doc """
      Returns true if the given `data` valid against the defined schema,
      otherwise false.
      """
      @spec valid?(term) :: boolean
      def valid?(data),
        do: Xema.valid?(@xemas[unquote(name)], data)

      @doc """
      Validates the given `data` against the defined schema.

      Returns `:ok` for valid data, otherwise an `:error` tuple.
      """
      @spec validate(term) :: :ok | {:error, ValidationError.t()}
      def validate(data),
        do: Xema.validate(@xemas[unquote(name)], data)

      @doc """
      Validates the given `data` against the defined schema.

      Returns `:ok` for valid data, otherwise a `Xema.ValidationError` is
      raised.
      """
      @spec validate!(term) :: :ok
      def validate!(data),
        do: Xema.validate!(@xemas[unquote(name)], data)

      @doc """
      Converts the given `data` according to the defined schema.

      Returns an `:ok` tuple with the converted data for valid `data`, otherwise
      an `:error` tuple is returned.
      """
      @spec cast(term) :: {:ok, term} | {:error, ValidationError.t() | CastError.t()}
      def cast(data),
        do: Xema.cast(@xemas[unquote(name)], data)

      @doc """
      Converts the given `data` according to the defined schema.

      Returns converted data for valid `data`, otherwise a `Xeam.CastError` or
      `Xema.ValidationError` is raised.
      """
      @spec cast!(term) :: term
      def cast!(data),
        do: Xema.cast!(@xemas[unquote(name)], data)

      @doc false
      def xema,
        do: @xemas[unquote(name)]
    end
  end

  defp dep_xema_struct({:field, _context, _args} = data), do: do_dep_xema_struct([data])

  defp dep_xema_struct({:__block__, _context, data}), do: do_dep_xema_struct(data)

  defp dep_xema_struct(data), do: data

  defp do_dep_xema_struct(data) do
    data =
      data
      |> Enum.group_by(fn
        {name, _, _} when name in [:required, :field] -> name
        _ -> :rest
      end)
      |> Map.put_new(:field, [])
      |> Map.put_new(:required, nil)
      |> Map.put_new(:rest, nil)

    quote do
      unquote(data.rest)

      defstruct unquote(Enum.map(data.field, &xema_field_name/1))

      {:struct,
       [
         properties: Map.new(unquote(Enum.map(data.field, &xema_field/1))),
         keys: :atoms
       ]
       |> Keyword.merge(unquote(xema_required(data.required)))}
    end
  end

  defp xema_field({:field, _context, [name | _]} = field) do
    quote do
      {unquote(name), unquote(field)}
    end
  end

  defp xema_field_name({:field, _context, [name, _type, opts]}) do
    default = Keyword.get(opts, :default, nil)

    quote do
      unquote({name, default})
    end
  end

  defp xema_field_name({:field, _context, [name | _]}) do
    quote do
      unquote({name, nil})
    end
  end

  @doc """
  Specifies a field. This function will be used inside `xema/0`.

  Arguments:

  + `name`: the name of the field.
  + `type`: the type of the field. The `type` can also be a `struct` or another
     schema.
  + `opts`: the rules for the field.

  ## Examples

      iex> defmodule User do
      ...>   use Xema
      ...>
      ...>   xema_struct do
      ...>     field :name, :string, min_length: 1
      ...>   end
      ...> end
      ...>
      iex> %{"name" => "Tim"} |> User.cast!() |> Map.from_struct()
      %{name: "Tim"}

  For more examples see "[Examples: Struct](examples.html#struct)".
  """
  @spec field(atom, Schema.type() | module, keyword) :: {atom() | [atom()], keyword()}
  def field(name, type, opts \\ []) do
    case check_field_type!(name, type) do
      {:xema, module} ->
        allow(module.xema(), opts)

      {:module, module} ->
        allow(:struct, Keyword.put(opts, :module, module))

      {:type, type} ->
        allow(type, opts)
    end
  end

  defp allow(type, opts) when is_atom(type) do
    case Keyword.has_key?(opts, :allow) do
      true -> allow([type], opts)
      false -> {type, opts}
    end
  end

  defp allow(types, opts) when is_list(types) do
    case Keyword.pop(opts, :allow, :undefined) do
      {:undefined, opts} ->
        {types, opts}

      {value, opts} when is_list(value) ->
        {Enum.concat(types, value), opts}

      {value, opts} ->
        {[value | types], opts}
    end
  end

  defp allow(%Xema{schema: schema} = xema, opts) do
    schema =
      case Keyword.get(opts, :allow, :undefined) do
        :undefined -> schema
        value -> update_allow(schema, value)
      end

    %Xema{xema | schema: schema}
  end

  defp update_allow(schema, types) when is_list(types) do
    Map.update!(schema, :type, fn type -> [type | types] end)
  end

  defp update_allow(schema, type), do: update_allow(schema, [type])

  defp check_field_type!(field, types) when is_list(types) do
    Enum.each(types, fn type -> check_field_type!(field, type) end)
    {:type, types}
  end

  defp check_field_type!(_field, type) when type in @types, do: {:type, type}

  defp check_field_type!(_field, module) when is_atom(module) do
    case Xema.behaviour?(module) do
      true -> {:xema, module}
      false -> {:module, module}
    end
  end

  defp check_field_type!(field, type),
    do: raise(ArgumentError, "invalid type #{inspect(type)} for field #{inspect(field)}")

  defp xema_required([required]) do
    quote do
      unquote(required)
    end
  end

  defp xema_required(nil) do
    quote do: []
  end

  defp xema_required(_) do
    raise ArgumentError, "the required function can only be called once per xema"
  end

  @doc """
  Sets the list of required fields.  Specifies a field. This function will be
  used inside `xema/0`.

  ## Examples

      iex> defmodule Person do
      ...>   use Xema
      ...>
      ...>   xema_struct do
      ...>     field :name, :string, min_length: 1
      ...>     required [:name]
      ...>   end
      ...> end
      ...>
      iex> %{"name" => "Tim"} |> Person.cast!() |> Map.from_struct()
      %{name: "Tim"}
  """
  @spec required([atom]) :: term
  def required(fields), do: [required: fields]

  @doc false
  def add_new_module({:struct, keywords}, module),
    do: {:struct, Keyword.put_new(keywords, :module, module)}

  def add_new_module(schema, _module), do: schema

  defp warn(message, %{file: file, line: line}) do
    IO.warn(IO.ANSI.format([IO.ANSI.yellow(), "#{message}\nat: #{file}:#{line}"]), [])
    :ok
  end
end