lib/one_piece/commanded/helpers.ex

defmodule OnePiece.Commanded.Helpers do
  @moduledoc """
  A Swiss Army Knife Helper Module.
  """

  alias Commanded.Event.FailureContext

  @type error_context :: FailureContext.t() | map()

  @type commanded_dispatch_response ::
          :ok
          | {:ok, aggregate_state :: struct()}
          | {:ok, aggregate_version :: non_neg_integer()}
          | {:ok, execution_result :: Commanded.Commands.ExecutionResult.t()}
          | {:error, :unregistered_command}
          | {:error, :consistency_timeout}
          | {:error, reason :: term()}

  @doc """
  Deprecated, it has the same behavior as `OnePiece.Commanded.Id.new/0`.
  """
  @spec generate_uuid :: String.t()
  @deprecated "Use `OnePiece.Commanded.Id.new/0` instead."
  defdelegate generate_uuid, to: OnePiece.Commanded.Id, as: :new

  @doc """
  Transforms the given `source` map or struct into the `target` struct.
  """
  @spec struct_from(source :: struct(), target :: struct()) :: struct()
  def struct_from(%_{} = source, target) do
    struct(target, Map.from_struct(source))
  end

  @spec struct_from(attrs :: map(), target :: module()) :: struct()
  def struct_from(attrs, target) do
    struct(target, attrs)
  end

  @doc false
  @spec defines_struct?(mod :: module()) :: boolean()
  def defines_struct?(mod) do
    :functions
    |> mod.__info__()
    |> Keyword.get(:__struct__)
    |> Kernel.!=(nil)
  end

  @doc """
  Copy the information from the `source` map into the given `target` map.

      iex> OnePiece.Commanded.Helpers.cast_to(%{}, %{name: "ubi-wan", last_name: "kenobi"}, [:last_name])
      %{last_name: "kenobi"}
  """
  @spec cast_to(target :: map, source :: map, keys :: [Map.key()]) :: map
  def cast_to(target, source, keys) do
    Map.merge(target, Map.take(source, keys))
  end

  @doc """
  Returns a keyword list containing the "correlation id" and "causation id" tracing.

      iex> OnePiece.Commanded.Helpers.tracing_from_metadata(%{
      ...>   event_id: "26eb06fe-9ba6-4f58-a2dd-2bdba73de4f2",
      ...>   correlation_id: "f634ba94-145c-4fa7-bf7f-0d73dd83b446"
      ...> })
      ...>
      [causation_id: "26eb06fe-9ba6-4f58-a2dd-2bdba73de4f2", correlation_id: "f634ba94-145c-4fa7-bf7f-0d73dd83b446"]

  Useful when dispatching commands to copy-forward `t:Commanded.Event.Handler.metadata/0` tracing information.

      defmodule MyProcessor do
          application: MyApp,
        use Commanded.Event.Handler,
          name: "my_processor"

        alias OnePiece.Commanded.Helpers

        def handle(%MyEvent{} = event, metadata) do
          MyApp.dispatch(
            %MyCommand{},
            # copy-forward the information
            Helpers.tracing_from_metadata(metadata)
          )
        end
      end
  """
  @spec tracing_from_metadata(metadata :: Commanded.Event.Handler.metadata()) :: [
          causation_id: String.t(),
          correlation_id: String.t()
        ]
  def tracing_from_metadata(metadata) do
    [causation_id: metadata.event_id, correlation_id: metadata.correlation_id]
  end

  @doc """
  Adds the "correlation id" and "causation id" tracing to an existing keyword list configuration option.

      iex> OnePiece.Commanded.Helpers.tracing_from_metadata([timeout: 30_000], %{
      ...>   event_id: "26eb06fe-9ba6-4f58-a2dd-2bdba73de4f2",
      ...>   correlation_id: "f634ba94-145c-4fa7-bf7f-0d73dd83b446"
      ...> })
      ...>
      [timeout: 30_000, causation_id: "26eb06fe-9ba6-4f58-a2dd-2bdba73de4f2", correlation_id: "f634ba94-145c-4fa7-bf7f-0d73dd83b446"]

  Useful when dispatching commands to copy-forward the `t:Commanded.Event.Handler.metadata/0` tracing information and
  wants to also add other keyword list options.

      defmodule MyProcessor do
        use Commanded.Event.Handler,
          application: MyApp,
          name: "my_processor"

        alias OnePiece.Commanded.Helpers

        def handle(%MyEvent{} = event, metadata) do
          MyApp.dispatch(
            %MyCommand{},
            # copy-forward the information
            Helpers.tracing_from_metadata([timeout: 30_000], metadata)
          )
        end
      end
  """
  @spec tracing_from_metadata(opts :: keyword, metadata :: Commanded.Event.Handler.metadata()) :: [
          causation_id: String.t(),
          correlation_id: String.t()
        ]
  def tracing_from_metadata(opts, metadata) do
    Keyword.merge(opts, tracing_from_metadata(metadata))
  end

  @doc """
  Returns `skip` or a `retry` response.

  When the `c:Commanded.Application.dispatch/1` or `c:Commanded.Application.dispatch/2` returns an `:skip` otherwise,
  returns a `:retry` response. Useful when you are doing error handling in your `c:Commanded.Event.Handler.error/3`.

      iex> success_dispatch = fn _ -> :ok end
      ...> OnePiece.Commanded.Helpers.skip_or_retry(success_dispatch.(%{}), %{})
      :skip

      iex> success_dispatch = fn _ -> {:ok, %{}} end
      ...> OnePiece.Commanded.Helpers.skip_or_retry(success_dispatch.(%{}), %{})
      :skip

      iex> failure_dispatch = fn _ -> {:error, :ooops} end
      ...> OnePiece.Commanded.Helpers.skip_or_retry(failure_dispatch.(%{}), %{failures: 1})
      {:retry, %{failures: 1}}
  """
  @spec skip_or_retry(tuple_response :: commanded_dispatch_response, context :: error_context) ::
          :skip | {:retry, error_context}
  def skip_or_retry(:ok, _context), do: :skip
  def skip_or_retry({:ok, _}, _context), do: :skip
  def skip_or_retry(_, context), do: {:retry, context}

  @doc """
  Returns `skip` or a `retry` response with a given delay.

  When the `c:Commanded.Application.dispatch/1` or `c:Commanded.Application.dispatch/2` returns an `:skip` otherwise,
  returns a `:retry` response. Useful when you are doing error handling in your `c:Commanded.Event.Handler.error/3`.

      iex> success_dispatch = fn _ -> :ok end
      ...> OnePiece.Commanded.Helpers.skip_or_retry(success_dispatch.(%{}), 5_000, %{})
      :skip

      iex> success_dispatch = fn _ -> {:ok, %{}} end
      ...> OnePiece.Commanded.Helpers.skip_or_retry(success_dispatch.(%{}), 5_000, %{})
      :skip

      iex> failure_dispatch = fn _ -> {:error, :ooops} end
      ...> OnePiece.Commanded.Helpers.skip_or_retry(failure_dispatch.(%{}), 5_000, %{failures: 1})
      {:retry, 5_000, %{failures: 1}}
  """
  @spec skip_or_retry(
          tuple_response :: commanded_dispatch_response,
          delay :: non_neg_integer(),
          context :: error_context
        ) ::
          :skip | {:retry, non_neg_integer(), error_context}
  def skip_or_retry(:ok, _delay, _context), do: :skip
  def skip_or_retry({:ok, _}, _delay, _context), do: :skip
  def skip_or_retry(_, delay, context), do: {:retry, delay, context}

  @doc """
  Increase the failure counter from `t:Commanded.Event.FailureContext.t/0` context by one.

        iex> OnePiece.Commanded.Helpers.increase_failure_counter(%Commanded.Event.FailureContext{context: %{failures_count: 1}})
        %{failures_count: 2}
  """
  @spec increase_failure_counter(failure_context :: FailureContext.t()) :: map()
  def increase_failure_counter(%FailureContext{} = failure_context) do
    Map.update(failure_context.context, :failures_count, 1, &(&1 + 1))
  end

  @doc """
  Ignores a specific error from a command dispatch result.

  This function takes a dispatch result and an error term. If the result represents an error that matches the provided
  error term, the function returns `:ok`. Otherwise, it returns the original result.

  This is useful when an error condition should be treated as a successful operation under specific circumstances.

  ## Examples

      iex> OnePiece.Commanded.Helpers.ignore_error({:error, :idempotency_failure}, :idempotency_failure)
      :ok

      iex> OnePiece.Commanded.Helpers.ignore_error({:error, :something_went_wrong}, :idempotency_failure)
      {:error, :something_went_wrong}

      iex> OnePiece.Commanded.Helpers.ignore_error(:ok, :idempotency_failure)
      :ok

      iex> OnePiece.Commanded.Helpers.ignore_error({:ok, %{name: "Billy"}}, :idempotency_failure)
      {:ok, %{name: "Billy"}}
  """
  @spec ignore_error(result :: commanded_dispatch_response, error: any) :: commanded_dispatch_response
  def ignore_error({:error, expected_error}, expected_error), do: :ok
  def ignore_error(result, _expected_error), do: result

  @doc false
  def get_primary_key({identifier, identifier_type}) do
    {identifier, identifier_type}
  end

  def get_primary_key(identifier) when is_atom(identifier) do
    {identifier, :string}
  end
end