lib/ash/resource/validation.ex

defmodule Ash.Resource.Validation do
  @moduledoc """
  Represents a validation in Ash.

  See `Ash.Resource.Validation.Builtins` for a list of builtin validations.

  To write your own validation, define a module that implements the `c:init/1` callback
  to validate options at compile time, and `c:validate/2` callback to do the validation.

  Then, in a resource, you can say:

  ```
  validations do
    validate {MyValidation, [foo: :bar]}
  end
  ```

  To make it more readable, you can define a function in the module that returns that tuple,
  and import it into your resource.


  ```
  defmodule MyValidation do
    def my_validation(value) do
      {__MODULE__, foo: value}
    end
  end
  ```

  ```
  defmodule MyResource do
    ...

    import MyValidation

    validations do
      validate my_validation(:foo)
    end
  end
  ```
  """
  defstruct [
    :validation,
    :module,
    :opts,
    :expensive?,
    :description,
    :message,
    :before_action?,
    :where,
    on: []
  ]

  @type t :: %__MODULE__{
          validation: {atom(), list(atom())},
          module: atom(),
          opts: list(atom()),
          expensive?: boolean(),
          description: String.t() | nil,
          where: list({atom(), list(atom())}),
          on: list(atom())
        }

  @type path :: [atom | integer]
  @callback init(Keyword.t()) :: {:ok, Keyword.t()} | {:error, String.t()}
  @callback validate(Ash.Changeset.t(), Keyword.t()) :: :ok | {:error, term}

  @schema [
    validation: [
      type: {:ash_behaviour, Ash.Resource.Validation, Ash.Resource.Validation.Builtins},
      required: true,
      doc: "The module/opts pair of the validation",
      links: []
    ],
    where: [
      type: {:list, {:ash_behaviour, Ash.Resource.Validation, Ash.Resource.Validation.Builtins}},
      required: false,
      default: [],
      links: [
        modules: [
          "ash:module:Ash.Resource.Validation.Builtins"
        ]
      ],
      doc: """
      Validations that should pass in order for this validation to apply.
      These validations failing will not invalidate the changes, but will instead result in this validation being ignored.
      """
    ],
    on: [
      type: {:custom, __MODULE__, :on, []},
      default: [:create, :update],
      links: [],
      doc: """
      The action types the validation should run on.

      Many validations don't make sense in the context of deletion, so by default it is left out of the list.
      """
    ],
    expensive?: [
      type: :boolean,
      default: false,
      links: [],
      doc:
        "If a validation is expensive, it won't be run on invalid changes. All inexpensive validations are always run, to provide informative errors."
    ],
    message: [
      type: :string,
      doc: "If provided, overrides any message set by the validation error",
      links: []
    ],
    description: [
      type: :string,
      doc: "An optional description for the validation",
      links: []
    ],
    before_action?: [
      type: :boolean,
      default: false,
      links: [],
      doc: "If set to `true`, the validation will be run in a before_action hook"
    ]
  ]

  @action_schema Keyword.delete(@schema, :on)

  defmacro __using__(_) do
    quote do
      @behaviour Ash.Resource.Validation

      def init(opts), do: {:ok, opts}

      defoverridable init: 1
    end
  end

  @doc false
  def transform(%{validation: {module, opts}} = validation) do
    case module.init(opts) do
      {:ok, opts} -> {:ok, %{validation | validation: {module, opts}, module: module, opts: opts}}
      {:error, error} -> {:error, error}
    end
  end

  def opt_schema, do: @schema
  def action_schema, do: @action_schema

  def on(list) do
    list
    |> List.wrap()
    |> Enum.all?(&(&1 in [:create, :update, :destroy]))
    |> case do
      true ->
        {:ok, List.wrap(list)}

      false ->
        {:error, "Expected items of [:create, :update, :destroy], got: #{inspect(list)}"}
    end
  end
end