lib/dsv.ex

defmodule Dsv do
  @moduledoc """
  The `Dsv` module provides a set of functions for validating user data of various types.
  In addition to simple data types such as strings, numbers, and dates, this module offers functionality for validating complex data structures like maps, lists, and structs.


  ## Getting Started

  The simplest way to utilize this library is by using the `Dsv.validate/2` function with specified validation options.
  This function supports various validators, which you can explore in the "Basic Validators" section of the documentation.
  These validators include `:length`, `:number`, `:format`, and more.

  ### Example - validate a simple value with multiple validators.

      "This is an example of validating a simple string value"
      |> Dsv.validate(length: [min: 10, max: 100], format: ~r/[A-Z][a-z]+/)

  In this example, the string value is checked against several constraints: its length must be at least 10 and at most 100 graphemes, and it must start with an uppercase letter followed by at least one lowercase letter.

  ### Basic validators

  Here is a list of basic validators and their corresponding modules (the name on the left is also a name that need to be used in the `Dsv.validate/2` and `Dsv.valid?/2` functions):

  - length: `Dsv.Length`
  - number: `Dsv.Number`
  - format: `Dsv.Format`
  - any   : `Dsv.Any`
  - all   : `Dsv.All`
  - none  : `Dsv.None`
  - date  : `Dsv.Date`
  - in    : `Dsv.Inclusion`
  - not_in: `Dsv.Exclusion`
  - email : `Dsv.Email`
  - not_empty: `Dsv.NotEmpty`
  - equal: `Dsv.Equal`
  - custom: `Dsv.Custom`
  - position: `Dsv.At`
  - or: `Dsv.Or`

  For detailed descriptions of each validator and their available options, refer to the respective validator's documentation.

  ## Validating complex data structures

  The preferred way to validate complex data structures is by following these steps:
  1. Call `Dsv.validation/1` with the data to validate as the first argument.
  2. Add validators for each field by specifying the path in the second argument of `Dsv.add_validator/3` function and validators options in the third argument.
  3. Finally, run the validation by calling `Dsv.validate/1`


  ### Example - validate nested maps with multiple validators.

      %{a: "First value", b: %{c: "This is a nested map", d: 10, e: [1, 2, 3]}}
      |> Dsv.validation
      |> Dsv.add_validator([:a], length: [min: 1, max: 20], format: ~r/.*e/)
      |> Dsv.add_validator([:b, :d], number: [gt: 4])
      |> Dsv.add_validator([:b, :e], position: ["2": [equal: 2]])
      |> Dsv.validate

  You can also validate the same data by specifying map with validators that reflects the structure of the validated data.

  ### Example - validate nested maps with multiple validators with validators as a map.

  Same validator as in the above example can be written in the form of a map that reflects the validated data structure.

      %{a: "First value", b: %{c: "This is a nested map", d: 10, e: [1, 2, 3]}}
      |> Dsv.validate(%{
        :a => [length: [min: 1, max: 20], format: ~r/.*e/],
        :b => %{
          :d => [number: [gt: 4]],
          :e => [position: ["2": [equal: 2]]]
        }
      })

  ### Interchangeable approaches
  The two approaches mentioned above are interchangeable, and it's up to the user to choose which one to use.

  For more ways of writing validators look at: [**How to validate**](how_to_validate.md)


  """
  alias Dsv.Validators
  alias MapToolbox
  alias FieldComparator

  @typedoc """
  A structure containing data for validation alongside the related validators.

  ## Fields
  * data - input data to validate
  * validators - map containing paths to data in `data` field with related validators
  * comparators - list with comparators definitions
  * message - custom messaege returned in case of validation failure
  """
  @type validators :: %Validators{
          data: map() | none(),
          validators: nonempty_list(),
          comparators: list(list()) | none(),
          message: String.t() | none()
        }

  @typedoc """
  A non-empty list containing elements of the path for the field in the data to validate.

  ### Example

      %{"user" => %{
          "name" => "User name", "last_name" => "User last name", "age" => 30, "address" => %{
            "street" => "Street", "postal_code" => "00900"
          }
        }
      }

  Path to the postal code field is the list of all keys in the map that carry on to the posta_code field. In this case the path will look like this:
      ["user", "address", "postal_code"]

  """
  @type path :: nonempty_list(String.t() | atom() | list())

  @typedoc """
  A keyword list or map representing validation options.

  Options can be provided in one of the two forms:
  * `map()` - this form is intended for input data where input data is treated as multiple fields to validate.
  * `keyword()` - this for is intended for input data where input data is treated as one whole value to validate
  """
  @type options :: keyword() | map()

  @typedoc """
  Data to validate of any type.
  """
  @type data :: any()

  @typedoc """
  A map of validation errors.
  """
  @type errors :: map()

  @typedoc """
  A result of the validation, which can be :ok for success or {:error, errors} for failure.
  """
  @type validation_result :: :ok | {:error, errors()}

  @validators Map.merge(
                %{
                  length: Dsv.Length,
                  not_empty: Dsv.NotEmpty,
                  date: Dsv.Date,
                  number: Dsv.Number,
                  in: Dsv.Inclusion,
                  not_in: Dsv.Exclusion,
                  format: Dsv.Format,
                  equal: Dsv.Equal,
                  custom: Dsv.Custom,
                  position: Dsv.At,
                  any: Dsv.Any,
                  all: Dsv.All,
                  none: Dsv.None,
                  email: Dsv.Email,
                  or: Dsv.Or
                },
                Application.get_env(:dsv, :validators, %{})
              )

  @doc """
  Transform a list of paths and validator descriptions into a map of validators.

  ## Example
      iex> Dsv.paths([
        ["name", length: [min: 1, max: 3], format: ~r/(B|b)r[a-z]{3,5}k/],
        ["age", not_empty: true, number: [gt: 10, lt: 30]]
      ])
      %{
        name: [length: [min: 1, max:3], format: ~r/(B|b)r[a-z]{3,5}k/],
        age: [not_empty: true, number: [gt: 10, lt: 30]]
      }
  """
  def paths(list_of_paths) do
    ValidationSteps.path_validators_to_map_validators(list_of_paths)
  end

  defmodule Validators do
    defstruct data: %{}, validators: [], comparators: [], message: nil
  end

  @doc """
  Run validation for `%Validators{}`
  """
  def validation(data), do: %Validators{data: data}

  @doc """
  Add a validator definition for the input data.

  ## Example
      validators = %Validators{}
      add_validator(validators, [:path, :to, :the, :validated, :field], length: [min: 1, max: 10], not_empty: :true)

  """
  @spec add_validator(any(), path(), options()) :: validators()
  def add_validator(
        %Validators{data: _data, validators: current_validators, comparators: _comparators} =
          validators,
        path,
        new_validators
      ),
      do: %{validators | validators: [[{:path, path} | new_validators] | current_validators]}

  def add_validator(data, path, new_validators),
    do: validation(data) |> add_validator(path, new_validators)

  @doc """
  Add a validator definition for the input data, with a second path for value that will be used as an argument in the validator option.

  ## Example
      validators = %Validators{}
      add_validator(validators, [:path, :to, :the, :validated, :field], [:path, :to, :expected, :length], length: [min: 1, max: 10], not_empty: :true)
  """
  @spec add_validator(validators(), path(), path(), options()) :: validators()
  def add_validator(
        %Validators{
          data: _data,
          validators: _current_validators,
          comparators: current_comparators
        } = validators,
        path,
        path_to_compare,
        new_validators
      ) do
    new_comparator = [path | [path_to_compare | [new_validators]]]
    %{validators | comparators: [new_comparator | current_comparators]}
  end

  @doc """
  Set a custom message for the validation.
  """
  @spec set_custom_message(%Validators{}, String.t()) :: %Validators{}
  def set_custom_message(%Validators{} = validators, message),
    do: %Validators{validators | message: message}

  @doc """
  Run validation based on the data in the `validators()` struct.

  ### Parameters
  * `validators()` - `%Validators{}` struct containing all the information needed to run validation.

  ### Return `validation_result()`
  * `:ok` - on successful validation, where all validation rules are met by the validated data.
  * `{:error, errrors()}` - on failure, when any fo the validation ciriteria are not met by the validated data.

  ### Example

      %Validators{data: %{"user" => %{"name" => "User name"}}, validators: %{"user" => "name" => [length: [min: 2]]}} |> Dsv.validate()
      :ok
  """
  @spec validate(%Validators{}) :: validation_result()
  def validate(%Validators{data: data, validators: validators, comparators: [], message: nil}) do
    Dsv.validate(data, paths(validators))
  end

  def validate(%Validators{
        data: data,
        validators: validators,
        comparators: comparators,
        message: nil
      }) do
    Dsv.validate(data, paths(validators), comparators)
  end

  def validate(%Validators{data: data, validators: validators, comparators: [], message: message}) do
    Dsv.validate(data, paths(validators), message: message)
  end

  def validate(%Validators{
        data: data,
        validators: validators,
        comparators: comparators,
        message: message
      }) do
    Dsv.validate(data, paths(validators), comparators, message: message)
  end

  @doc """
  Validate input data according to the provided validation options.

  ### Parameters
  * `data()` - data to validate
  * `options()` - validation options

  ### Returns `validation_result()`
  * `:ok` - on successful validation, where all validation rules are met by the validated data.
  * `{:error, errrors()}` - on failure, when any fo the validation ciriteria are not met by the validated data.

  ### Example
      defmodule Person do
        defstruct [:name, :age, :eyes]
      end

      person = %Person{
        name: "Martin",
        age: 30,
        eyes: :blue
      }

      iex> Dsv.validate(person , %{name: [length: [min: 2, max: 20], format: ~r/[A-Z][0-9]+/]})
      [
        errors: %{
          name: [error: "Value Martin does not match pattern [A-Z][0-9]+"]
        }
      ]

  As in the example, validators can be joined to create a more complex check (in this case length and format).
  `Dsv.validate/2` allow to validate any kind of data from numbers and strings to lists, maps and structs.

  For more information how to use `Dsv.validate/2` function go to [**How to validate**](how_to_validate.md)
  """
  @spec validate(data(), options()) :: validation_result()
  def validate(data, options)
  def validate(data, %{} = options), do: _validate(data, options) |> ValidationSteps.response()

  def validate(data, options) do
    {message, options} = split_message_and_options(options)

    _validate(data, options)
    |> ValidationSteps.response()
    |> custom_message(data, options, message)
  end

  @doc """
  Validate input data according to the provided validation options.

  ### Parameters
  * data - data to validate
  * options - validation options
  * message - custom message that will be return in case of failure

  ### Returns `validation_result()`
  * `:ok` - on successful validation, where all validation rules are met by the validated data.
  * `{:error, message}` - on failure, when any fo the validation ciriteria are not met by the validated data.

  ### Example
      defmodule Person do
        defstruct [:name, :age, :eyes]
      end

      person = %Person{
        name: "Martin",
        age: 30,
        eyes: :blue
      }

      iex> Dsv.validate(person , %{name: [length: [min: 2, max: 20], format: ~r/[A-Z][0-9]+/]}, message: "Something goes wrong! Fix your data and try again.")
      {:error, "Something goes wrong! Fix your data and try again."}


  As in the example, validators can be joined to create a more complex check (in this case length and format).
  `Dsv.validate/2` allow to validate any kind of data from numbers and strings to lists, maps and structs.

  For more information how to use `Dsv.validate/2` function go to [**How to validate**](how_to_validate.md)
  """
  @spec validate(map(), map(), keyword(tuple())) :: validation_result()
  def validate(%{} = data, %{} = options, [{:message, message}]) do
    validate(data, options) |> custom_message(data, options, message)
  end

  @doc """
  This function works like `validate/2` with an additional list of comparators that define validators using values from other input fields as their option values.



  """
  def validate(%{} = data, options, comparators) when is_list(comparators) do
    FieldComparator.prepare(data, comparators, @validators)
    |> List.foldr(options, fn {path, validators}, acc ->
      MapToolbox.put_nested(acc, path, validators)
    end)
    |> (&validate(data, &1)).()
  end

  @doc """
  Validate input data according to the provided validation options and return a custom message in case of failure.


  ### Parameters
  * data - data to validate
  * options - validation options
  * comparators - list of comparators to use in validators
  * message - custom message that will be return in case of failure

  ### Returns `validation_result()`
  * `:ok` - on successful validation, where all validation rules are met by the validated data.
  * `{:error, message}` - on failure, when any fo the validation ciriteria are not met by the validated data.

  ### Example
      defmodule Person do
        defstruct [:name, :age, :eyes]
      end

      person = %Person{
        name: "Martin",
        age: 30,
        eyes: :blue
      }

      iex> Dsv.validate(person , %{name: [length: [min: 2, max: 20], format: ~r/[A-Z][0-9]+/]}, [[:name], [:eyes], [:equal]], message: "Something goes wrong! Fix your data and try again.")
      {:error, "Something goes wrong! Fix your data and try again."}


  As in the example, validators can be joined to create a more complex check (in this case length and format).
  `Dsv.validate/2` allow to validate any kind of data from numbers and strings to lists, maps and structs.

  For more information how to use `Dsv.validate/2` function go to [**How to validate**](how_to_validate.md)
  """
  def validate(%{} = data, options, comparators, [{:message, message}])
      when is_list(comparators) do
    FieldComparator.prepare(data, comparators, @validators)
    |> List.foldr(options, fn {path, validators}, acc ->
      MapToolbox.put_nested(acc, path, validators)
    end)
    |> (&validate(data, &1, [{:message, message}])).()
  end

  @doc """
  Run all validators defined in the second argument agains data provided as the first argument.

  ### Parameters
  * data (`data()`) - data provided by the user
  * options (`keyword()`) - validation options, describe the rules that will be checked against input `data`

  ### Returns
  * :true Represents successful validation, where all validation rules are met by the validated data.
  * :false is returned when any of the validation criteria are not met by the validated data.

  ### Example

      Dsv.valid?("Simple string", length: [min: 2, max: 20])
      :true

      Dsv.valid?("Simple string", length: [min: 2, max: 10])
      :false

  """
  @spec valid?(data(), options()) :: boolean()
  def valid?(data, options) when is_tuple(options), do: valid?(data, [options])

  def valid?(data, options) when is_list(options) do
    options
    |> Enum.map(fn {validator, opts} -> get_validator(validator).valid?(data, opts) end)
    |> Enum.all?(fn result -> result == true end)
  end

  def valid?(%{} = data, %{} = options), do: _valid?(data, options)

  def valid?(%{} = data, options) do
    options
    |> Enum.all?(fn [field_name | validators] ->
      Dsv.valid?(Map.get(data, field_name), validators) == true
    end)
  end

  @doc """
  Run validation based on the data in the `validators()` struct.

  ### Parameters
  * validators - %Validators{} struct containing all information needed to run validation. Look at `%Validators{}` definition for more information.
  Run validators set up for the input data.
  """
  @spec valid?(validators()) :: boolean()
  def valid?(%Validators{} = validators),
    do: Dsv.valid?(validators.data, paths(validators.validators))

  defp _validate(data, %{} = options) do
    options
    |> Enum.map(fn {field_name, value} -> validate_field(field_name, value, data) end)
    |> Enum.filter(fn {_, result} -> result != [] and result != %{} end)
    |> Enum.reduce(%{}, &ValidationSteps.group_errors/2)
  end

  defp _validate(data, options) when is_list(options) do
    options
    |> Enum.map(fn {validator_name, validator_options} ->
      get_validator(validator_name).validate(data, validator_options)
    end)
    |> Enum.filter(&(&1 != :ok))
    |> Enum.map(fn {:error, errors} -> errors end)
  end

  defp _valid?(%{} = data, %{} = options) do
    options
    |> Enum.map(fn {field_name, value} -> _valid?(Map.get(data, field_name), value) end)
    |> Enum.all?()
  end

  defp _valid?(data, options) do
    valid?(data, options)
  end

  defp custom_message(:ok, _, _, _), do: :ok

  defp custom_message({:error, errors}, data, options, message) when is_bitstring(message) do
    {:error, EEx.eval_string(message, data: data, options: options, errors: errors)}
  end

  defp custom_message({:error, errors}, data, options, message) when is_function(message),
    do: {:error, message.(data, options, errors)}

  defp custom_message({:error, errors}, _, _, message) when is_nil(message), do: {:error, errors}

  defp split_message_and_options(options),
    do: if(Keyword.keyword?(options), do: Keyword.pop(options, :message), else: {nil, options})

  defp get_validator(name) when is_atom(name), do: @validators[name]

  defp get_element(%{} = data, field), do: Map.get(data, field)
  defp get_element(data, position) when is_integer(position), do: ValueAt.at(data, position)

  defp get_element(data, position) when is_bitstring(position),
    do: ValueAt.at(data, String.to_integer(position))

  defp get_element(data, position),
    do: ValueAt.at(data, String.to_integer(Atom.to_string(position)))

  defp validate_field(field_name, value, data),
    do: {field_name, _validate(get_element(data, field_name), value)}
end