lib/ash/resource/change/change.ex

defmodule Ash.Resource.Change do
  @moduledoc """
  The behaviour for an action-specific resource change.

  `c:init/1` is defined automatically by `use Ash.Resource.Change`, but can be implemented if you want to validate/transform any
  options passed to the module.

  The main function is `c:change/3`. It takes the changeset, any options that were provided
  when this change was configured on a resource, and the context, which currently only has
  the actor.
  """
  defstruct [:change, :on, :only_when_valid?, :description, where: []]

  @type t :: %__MODULE__{}
  @type ref :: {module(), Keyword.t()} | module()

  @doc false
  def schema do
    [
      on: [
        type: {:custom, __MODULE__, :on, []},
        default: [:create, :update],
        doc: """
        The action types the validation should run on. Destroy actions are omitted by default as most changes don't make sense for a destroy.
        """
      ],
      only_when_valid?: [
        type: :boolean,
        default: false,
        doc: """
        If the change should only be run on valid changes. By default, all changes are run unless stated otherwise here.
        """
      ],
      description: [
        type: :string,
        doc: "An optional description for the change"
      ],
      change: [
        type:
          {:spark_function_behaviour, Ash.Resource.Change, Ash.Resource.Change.Builtins,
           {Ash.Resource.Change.Function, 2}},
        doc: """
        The module and options for a change.
        Also accepts a function that takes the changeset and the context.

        See `Ash.Resource.Change.Builtins` for more.
        """,
        required: true
      ],
      where: [
        type:
          {:list,
           {:spark_function_behaviour, Ash.Resource.Validation, Ash.Resource.Validation.Builtins,
            {Ash.Resource.Validation.Function, 1}}},
        required: false,
        default: [],
        doc: """
        Validations that should pass in order for this validation to apply.
        These validations failing will not invalidate the changes, but instead just result in this change being ignored.
        Also accepts functions take the changeset.
        """
      ]
    ]
  end

  @doc false
  def action_schema do
    Keyword.delete(schema(), :on)
  end

  @doc false
  def change({module, opts}) when is_atom(module) do
    if Keyword.keyword?(opts) do
      {:ok, {module, opts}}
    else
      {:error, "Expected opts to be a keyword, got: #{inspect(opts)}"}
    end
  end

  def change(module) when is_atom(module), do: {:ok, {module, []}}

  def change(other) do
    {:error, "Expected a module and opts, got: #{inspect(other)}"}
  end

  @doc false
  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

  @type context :: %{
          optional(:actor) => Ash.Resource.record(),
          optional(any) => any
        }

  @callback init(Keyword.t()) :: {:ok, Keyword.t()} | {:error, term}
  @callback change(Ash.Changeset.t(), Keyword.t(), context) :: Ash.Changeset.t()
  @callback batch_change([Ash.Changeset.t()], Keyword.t(), context) ::
              Enumerable.t(Ash.Changeset.t() | Ash.Notifier.Notification.t())

  @callback before_batch([Ash.Changeset.t()], Keyword.t(), context) ::
              Enumerable.t(Ash.Changeset.t() | Ash.Notifier.Notification.t())

  @callback after_batch(
              [{Ash.Changeset.t(), Ash.Resource.record()}],
              Keyword.t(),
              context
            ) ::
              Enumerable.t(
                {:ok, Ash.Resource.record()}
                | {:error, Ash.Error.t()}
                | Ash.Notifier.Notification.t()
              )

  @optional_callbacks before_batch: 3, after_batch: 3, batch_change: 3

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

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

      defoverridable init: 1
    end
  end
end