lib/reactor/dsl/switch.ex

defmodule Reactor.Dsl.Switch do
  @moduledoc """
  The `switch` DSL entity struct.

  See `d:Reactor.switch`.
  """
  defstruct __identifier__: nil,
            allow_async?: true,
            default: nil,
            matches: [],
            name: nil,
            on: nil

  alias Reactor.{
    Dsl.Argument,
    Dsl.Build,
    Dsl.Switch,
    Dsl.Switch.Default,
    Dsl.Switch.Match,
    Step,
    Template
  }

  @type t :: %Switch{
          __identifier__: any,
          allow_async?: boolean,
          default: nil | Default.t(),
          matches: [Match.t()],
          name: atom,
          on: Template.Input.t() | Template.Result.t() | Template.Value.t()
        }

  @switch_match %Spark.Dsl.Entity{
    name: :matches?,
    describe: """
    A group of steps to run when the predicate matches.
    """,
    target: Match,
    args: [:predicate],
    entities: [steps: []],
    schema: [
      predicate: [
        type: {:mfa_or_fun, 1},
        required: true,
        doc: """
        A one-arity function which is used to match the switch input. If the switch returns a truthy value, then the nested steps will be run.
        """
      ],
      allow_async?: [
        type: :boolean,
        required: false,
        default: true,
        doc: """
        Whether the emitted steps should be allowed to run asynchronously.
        """
      ],
      return: [
        type: :atom,
        required: false,
        doc: """
        Specify which step result to return upon completion.
        """
      ]
    ]
  }

  @switch_default %Spark.Dsl.Entity{
    name: :default,
    describe: """
    If none of the `matches?` branches match the input, then the `default`
    steps will be run if provided.
    """,
    target: Default,
    entities: [steps: []],
    schema: [
      return: [
        type: :atom,
        required: false,
        doc: """
        Specify which step result to return upon completion.
        """
      ]
    ]
  }

  def __entity__,
    do: %Spark.Dsl.Entity{
      name: :switch,
      describe: """
      Use a predicate to determine which steps should be executed.
      """,
      target: Switch,
      args: [:name],
      identifier: :name,
      imports: [Argument],
      entities: [matches: [@switch_match], default: [@switch_default]],
      singleton_entity_keys: [:default],
      recursive_as: :steps,
      schema: [
        name: [
          type: :atom,
          required: true,
          doc: """
          A unique name for the switch.
          """
        ],
        allow_async?: [
          type: :boolean,
          required: false,
          default: true,
          doc: """
          Whether the emitted steps should be allowed to run asynchronously.
          """
        ],
        on: [
          type: Template.type(),
          required: true,
          doc: """
          The value to match against.
          """
        ]
      ]
    }

  defimpl Build do
    import Reactor.Utils
    alias Reactor.{Argument, Builder, Planner}
    alias Spark.{Dsl.Verifier, Error.DslError}

    def build(switch, reactor) do
      with {:ok, matches} <- build_matches(switch, reactor),
           {:ok, default} <- build_default(switch, reactor) do
        Builder.add_step(
          reactor,
          switch.name,
          {Step.Switch,
           on: :value, matches: matches, default: default, allow_async?: switch.allow_async?},
          [%Argument{name: :value, source: switch.on}],
          async?: switch.allow_async?,
          max_retries: 0,
          ref: :step_name
        )
      end
    end

    def verify(switch, dsl_state) when switch.matches == [] do
      {:error,
       DslError.exception(
         module: Verifier.get_persisted(dsl_state, :module),
         path: [:reactor, :switch, :matches?, switch.name],
         message: "No match branches provided for switch"
       )}
    end

    def verify(_switch, _dsl_state), do: :ok

    def transform(_switch, dsl_state), do: {:ok, dsl_state}

    defp build_matches(switch, reactor) do
      map_while_ok(switch.matches, &build_match(&1, switch, reactor), true)
    end

    defp build_match(match, switch, reactor) do
      with {:ok, reactor} <- build_steps(match.steps, reactor),
           {:ok, reactor} <- maybe_build_return_step(match.return, switch, reactor),
           {:ok, _} <- Planner.plan(reactor) do
        {:ok, {match.predicate, reactor.steps}}
      end
    end

    defp build_default(switch, _reactor) when is_nil(switch.default), do: {:ok, []}

    defp build_default(switch, reactor) do
      with {:ok, reactor} <- build_steps(switch.default.steps, reactor),
           {:ok, reactor} <- maybe_build_return_step(switch.default.return, switch, reactor),
           {:ok, _} <- Planner.plan(reactor) do
        {:ok, reactor.steps}
      end
    end

    defp build_steps(steps, reactor), do: reduce_while_ok(steps, reactor, &Build.build/2)

    defp maybe_build_return_step(nil, _, reactor), do: {:ok, reactor}

    defp maybe_build_return_step(return_name, switch, reactor) do
      Builder.add_step(
        reactor,
        switch.name,
        {Step.ReturnArgument, argument: :value},
        [Argument.from_result(:value, return_name)],
        async?: switch.allow_async?,
        max_retries: 0,
        ref: :step_name
      )
    end
  end
end