lib/types/map_type.ex

defmodule Talos.Types.MapType do
  @moduledoc """
  MapType for validation maps

  `custom options`:

      * `required_any_one`: true/false, if true - validation check if map has any valid field
      * `required_groups`: list("key1", "key2", ...), list of keys from required group,
       required group can be simple field or field with dependendencies,
       you need to specify all field from the group to check dependencies

  Fields are tuples `{key, type, options \\ []}`:

    `key` - string or atom, key of map

    `type` - Talos defined Type

    `options`:

      * `optional`: true/false, if false - there will be error on key missing
      * `depends_on`: list("key1", "key2", ...), list of keys on which field depends,
        field pass validation only if this field and all dependent fields pass validation

  For example:
  ```elixir

    iex> import Talos
    iex> Talos.valid?(map(), %{foo: :bar})
    true
    iex> user_params = map(fields: [
    ...>  field(key: "email", type: string(min_length: 5, max_length: 255, regexp: ~r/.*@.*/)),
    ...>  field(key: "age", type: integer(gteq: 18, allow_nil: true)),
    ...>  field(key: "interests", type: list(type: string()), optional: true)
    ...> ])
    iex> Talos.valid?(user_params, %{})
    false
    iex> Talos.valid?(user_params, %{"email" => "bob@gmail.com", "age" => 30})
    true
    iex> Talos.valid?(user_params, %{"email" => "bob@gmail.com", "age" => 30, interests: ["elixir"]})
    true
    iex> deps = map(fields: [
    ...>  field(
    ...>   key: "lastname",
    ...>   type: string(),
    ...>   depends_on: ["birthdate", "firstname"]
    ...>  ),
    ...>  field(
    ...>   key: "firstname",
    ...>   type: string(),
    ...>   depends_on: ["birthdate", "lastname"]
    ...>  ),
    ...>  field(
    ...>   key: "birthdate",
    ...>   type: string(),
    ...>   depends_on: ["lastname", "firstname"]
    ...>  )
    ...>  ])
    iex> Talos.valid?(deps, %{})
    false
    iex> Talos.valid?(deps, %{"firstname" => "John", "lastname" => "Galt"})
    false
    iex> Talos.valid?(deps, %{"firstname" => "John", "lastname" => "Galt", "birthdate" => "1957-01-01"})
    true
  """

  defstruct [
    :fields,
    allow_nil: false,
    allow_blank: false,
    required_any_one: false,
    required_groups: nil
  ]

  @behaviour Talos.Types

  alias Talos.Types.MapType.Field

  @type t :: %{
          __struct__: atom,
          allow_nil: boolean,
          allow_blank: boolean,
          required_any_one: boolean(),
          required_groups: list(),
          fields: list(Field.t()) | nil
        }

  @spec valid?(Talos.Types.MapType.t(), any) :: boolean
  def valid?(%__MODULE__{} = module, value) do
    errors(module, value) == %{}
  end

  def errors(%__MODULE__{allow_nil: true}, nil) do
    %{}
  end

  def errors(%__MODULE__{fields: _fields}, map) when not is_map(map) do
    [inspect(map), "should be MapType"]
  end

  def errors(
        %__MODULE__{
          fields: fields,
          allow_blank: allow_blank,
          required_any_one: any_one,
          required_groups: groups
        },
        map
      ) do
    keys = Map.keys(map)

    cond do
      Enum.empty?(keys) && allow_blank ->
        %{}

      any_one == true && Enum.empty?(keys) ->
        ["one of keys should exist"]

      any_one == true && has_values(map) ->
        keys
        |> fields_by_key(fields)
        |> find_field_errors(map)

      is_list(groups) && Enum.empty?(groups) ->
        ["list should have value"]

      is_list(groups) && !Enum.empty?(groups) && !Enum.empty?(keys) ->
        groups
        |> Enum.filter(fn key -> Enum.member?(keys, key) end)
        |> fields_by_key(fields)
        |> find_field_errors(map)

      true ->
        (fields || [])
        |> find_field_errors(map)
    end
  end

  defp fields_by_key(keys, fields) do
    keys
    |> Enum.map(fn key -> Enum.find(fields, &(&1.key == key)) end)
    |> Enum.filter(&(!is_nil(&1)))
  end

  defp find_field_errors(fields, map) do
    fields
    |> Enum.map(fn field -> field_errors(field, map) end)
    |> Enum.reject(fn {_key, errors} -> errors == [] || errors == %{} end)
    |> Map.new()
  end

  defp has_values(map) do
    if map == %{} do
      false
    else
      Enum.all?(map, fn {_k, value} -> !is_nil(value) end)
    end
  end

  @spec permit(Talos.Types.MapType.t(), any) :: any
  def permit(%__MODULE__{fields: fields, allow_nil: allow_nil, allow_blank: allow_blank}, value) do
    cond do
      allow_blank && is_map(value) && Map.keys(value) == [] ->
        value

      allow_nil && is_nil(value) ->
        value

      is_nil(fields) ->
        value

      Enum.empty?(fields) ->
        value

      is_map(value) ->
        map = value

        Enum.reduce(fields, %{}, fn %Field{key: key, type: type}, acc ->
          if Map.has_key?(value, key) do
            Map.put(acc, key, Talos.permit(type, map[key]))
          else
            acc
          end
        end)

      true ->
        value
    end
  end

  defp field_errors(%Field{} = field, map) do
    Talos.errors(field, map)
  end
end