From e630c976d2140b1f0dd04046b63c3ca2a3a612e4 Mon Sep 17 00:00:00 2001 From: James Harton Date: Mon, 10 Jul 2023 16:01:53 +1200 Subject: [PATCH] improvement(Step.Switch): Add `switch` DSL and step type. Signed-off-by: James Harton --- .formatter.exs | 7 ++ lib/reactor/dsl.ex | 97 +++++++++++++++- lib/reactor/dsl/switch.ex | 104 ++++++++++++++++++ lib/reactor/dsl/switch/default.ex | 17 +++ lib/reactor/dsl/switch/match.ex | 23 ++++ lib/reactor/step/return_argument.ex | 22 ++++ lib/reactor/step/switch.ex | 165 ++++++++++++++++++++++++++++ test/reactor/dsl/switch_test.exs | 44 ++++++++ test/reactor/step/switch_test.exs | 115 +++++++++++++++++++ 9 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 lib/reactor/dsl/switch.ex create mode 100644 lib/reactor/dsl/switch/default.ex create mode 100644 lib/reactor/dsl/switch/match.ex create mode 100644 lib/reactor/step/return_argument.ex create mode 100644 lib/reactor/step/switch.ex create mode 100644 test/reactor/dsl/switch_test.exs create mode 100644 test/reactor/step/switch_test.exs diff --git a/.formatter.exs b/.formatter.exs index 46c6a1c..bf05606 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -13,16 +13,23 @@ spark_locals_without_parens = [ compensate: 1, compose: 2, compose: 3, + default: 0, + default: 1, group: 1, group: 2, input: 1, input: 2, + matches?: 1, + matches?: 2, max_retries: 1, + on: 1, return: 1, run: 1, step: 1, step: 2, step: 3, + switch: 1, + switch: 2, transform: 1, undo: 1 ] diff --git a/lib/reactor/dsl.ex b/lib/reactor/dsl.ex index 31ca2e6..4401420 100644 --- a/lib/reactor/dsl.ex +++ b/lib/reactor/dsl.ex @@ -346,6 +346,101 @@ defmodule Reactor.Dsl do ] } + @switch_match %Entity{ + name: :matches?, + describe: """ + A group of steps to run when the predicate matches. + """, + target: Dsl.Switch.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 %Entity{ + name: :default, + describe: """ + If none of the `matches?` branches match the input, then the `default` + steps will be run if provided. + """, + target: Dsl.Switch.Default, + entities: [steps: []], + schema: [ + return: [ + type: :atom, + required: false, + doc: """ + Specify which step result to return upon completion. + """ + ] + ] + } + + @switch %Entity{ + name: :switch, + describe: """ + Use a predicate to determine which steps should be executed. + """, + target: Dsl.Switch, + args: [:name], + identifier: :name, + imports: [Dsl.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: + {:or, + [{:struct, Template.Input}, {:struct, Template.Result}, {:struct, Template.Value}]}, + required: true, + doc: """ + The value to match against. + """ + ] + ] + } + @reactor %Section{ name: :reactor, describe: "The top-level reactor DSL", @@ -358,7 +453,7 @@ defmodule Reactor.Dsl do """ ] ], - entities: [@around, @group, @input, @step, @compose], + entities: [@around, @group, @input, @step, @switch, @compose], top_level?: true } diff --git a/lib/reactor/dsl/switch.ex b/lib/reactor/dsl/switch.ex new file mode 100644 index 0000000..b71ce4d --- /dev/null +++ b/lib/reactor/dsl/switch.ex @@ -0,0 +1,104 @@ +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.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() + } + + 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 diff --git a/lib/reactor/dsl/switch/default.ex b/lib/reactor/dsl/switch/default.ex new file mode 100644 index 0000000..083555f --- /dev/null +++ b/lib/reactor/dsl/switch/default.ex @@ -0,0 +1,17 @@ +defmodule Reactor.Dsl.Switch.Default do + @moduledoc """ + The `default` DSL entity struct. + + See `d:Reactor.switch.default`. + """ + + defstruct __identifier__: nil, return: nil, steps: [] + + alias Reactor.Dsl + + @type t :: %__MODULE__{ + __identifier__: any, + return: nil | atom, + steps: [Dsl.Step.t()] + } +end diff --git a/lib/reactor/dsl/switch/match.ex b/lib/reactor/dsl/switch/match.ex new file mode 100644 index 0000000..c238759 --- /dev/null +++ b/lib/reactor/dsl/switch/match.ex @@ -0,0 +1,23 @@ +defmodule Reactor.Dsl.Switch.Match do + @moduledoc """ + The `matches?` DSL entity struct. + + See `d:Reactor.switch.matches?`. + """ + + defstruct __identifier__: nil, + allow_async?: true, + predicate: nil, + return: nil, + steps: [] + + alias Reactor.Dsl.Step + + @type t :: %__MODULE__{ + __identifier__: any, + allow_async?: boolean, + predicate: (any -> any), + return: nil | atom, + steps: [Step.t()] + } +end diff --git a/lib/reactor/step/return_argument.ex b/lib/reactor/step/return_argument.ex new file mode 100644 index 0000000..b8fb0f4 --- /dev/null +++ b/lib/reactor/step/return_argument.ex @@ -0,0 +1,22 @@ +defmodule Reactor.Step.ReturnArgument do + @moduledoc """ + A very simple step which simply returns the named argument, if provided. + + ## Options. + + * `argument` - the name of the argument to return. + """ + + use Reactor.Step + + @doc false + @impl true + def run(arguments, _, options) do + with {:ok, argument} <- Keyword.fetch(options, :argument), + {:ok, value} <- Map.fetch(arguments, argument) do + {:ok, value} + else + :error -> {:error, "Unable to find argument"} + end + end +end diff --git a/lib/reactor/step/switch.ex b/lib/reactor/step/switch.ex new file mode 100644 index 0000000..af6efd8 --- /dev/null +++ b/lib/reactor/step/switch.ex @@ -0,0 +1,165 @@ +defmodule Reactor.Step.Switch do + @moduledoc """ + Conditionally decide which steps should be run at runtime. + + ## Options + + * `matches` - a list of match consisting of predicates and a list of steps to + execute if the predicate returns a truthy value. See `t:matches` for more + information. Required. + * `default` - a list of steps to execute if none of the predicates match. + Optional. + * `allow_async?` - a boolean indicating whether to allow the steps to be + executed asynchronously. Optional. Defaults to `true`. + * `on` - the name of the argument to pass into the predicates. If this + argument is not provided to this step, then an error will be returned. + + ## Branching behaviour + + Each of the predicates in `matches` are tried in order, until either one + returns a truthy value, or all the matches are exhausted. + + If there is a match, then the matching steps are emitted into the parent + running Reactor. + + In the case that no match is found, then the steps provided in the `default` + option are emitted. If no default is provided, then an error is returned. + + > #### Tip {: .tip} + > + > Execution of predicates stops once the first match is found. This means + > that if multiple predicates potentially match, the subsequent ones will + > never be called. + + ## Returning + + By default the step returns `nil` as it's result. + + You can have the step return the result of a branch by adding a step to the + branch with the same name as the switch which returns the expected value. + This will be handled by normal Reactor step emission rules. + """ + + use Reactor.Step + alias Reactor.Step + import Reactor.Utils + + @typedoc """ + A list of predicates and steps to execute if the predicate returns a truthy + value. + """ + @type matches :: [{predicate, [Step.t()]}] + + @typedoc """ + A predicate is a 1-arity function. It can return anything. Any result which + is not `nil` or `false` is considered true. + """ + @type predicate :: (any -> any) + + @type options :: [match_option | default_option | allow_async_option | on_option] + + @type match_option :: {:matches, matches} + @type default_option :: {:default, [Step.t()]} + @type allow_async_option :: {:allow_async?, boolean} + @type on_option :: {:on, atom} + + @doc false + @spec run(Reactor.inputs(), Reactor.context(), options) :: {:ok, any} | {:error, any} + def run(arguments, _context, options) do + allow_async? = Keyword.get(options, :allow_async?, true) + + with {:ok, on} <- fetch_on(arguments, options), + {:ok, matches} <- fetch_matches(options), + :no_match <- find_match(matches, on), + {:ok, defaults} <- fetch_defaults(options) do + {:ok, nil, maybe_rewrite_async(defaults, allow_async?)} + else + {:match, steps} -> {:ok, nil, maybe_rewrite_async(steps, allow_async?)} + {:error, reason} -> {:error, reason} + end + end + + defp find_match(matches, value) do + Enum.reduce_while(matches, :no_match, fn {predicate, steps}, :no_match -> + if predicate.(value) do + {:halt, {:match, steps}} + else + {:cont, :no_match} + end + end) + end + + defp fetch_defaults(options) do + with {:ok, steps} <- Keyword.fetch(options, :default), + {:ok, steps} <- validate_steps(steps) do + {:ok, steps} + else + {:error, reason} -> + {:error, reason} + + :error -> + {:error, "No branch matched in switch and no default branch is set"} + end + end + + defp fetch_on(arguments, options) do + case Keyword.fetch(options, :on) do + {:ok, on} when is_atom(on) and is_map_key(arguments, on) -> + {:ok, Map.get(arguments, on)} + + {:ok, _on} -> + {:error, + argument_error(:options, "Expected `on` option to match a provided argument", options)} + + :error -> + {:error, argument_error(:options, "Missing `on` option.")} + end + end + + defp fetch_matches(options) do + case Keyword.fetch(options, :matches) do + {:ok, matches} -> map_while_ok(matches, &validate_match/1, true) + :error -> {:error, argument_error(:options, "Missing `matches` option.")} + end + end + + defp validate_match({predicate, steps}) do + with {:ok, predicate} <- capture(predicate), + {:ok, steps} <- validate_steps(steps) do + {:ok, {predicate, steps}} + end + end + + defp validate_steps(steps) do + if Enum.all?(steps, &is_struct(&1, Step)), + do: {:ok, steps}, + else: {:error, argument_error(:steps, "Expected all steps to be a `Reactor.Step` struct.")} + end + + defp capture(predicate) when is_function(predicate, 1), do: {:ok, predicate} + + defp capture({m, f, []}) when is_atom(m) and is_atom(f), + do: ensure_exported(m, f, 1, fn -> {:ok, Function.capture(m, f, 1)} end) + + defp capture({m, f, a}) when is_atom(m) and is_atom(f) and is_list(a), + do: + ensure_exported(m, f, length(a) + 1, fn -> + {:ok, fn input -> apply(m, f, [input | a]) end} + end) + + defp capture(predicate), + do: + {:error, + argument_error(:predicate, "Expected `predicate` to be a 1 arity function", predicate)} + + defp ensure_exported(m, f, arity, callback) do + if Code.ensure_loaded?(m) && function_exported?(m, f, arity) do + callback.() + else + {:error, "Expected `#{inspect(m)}.#{f}/#{arity}` to be exported."} + end + end + + defp maybe_rewrite_async(steps, true), do: steps + defp maybe_rewrite_async(steps, false), do: Enum.map(steps, &%{&1 | async?: false}) +end diff --git a/test/reactor/dsl/switch_test.exs b/test/reactor/dsl/switch_test.exs new file mode 100644 index 0000000..a643cd0 --- /dev/null +++ b/test/reactor/dsl/switch_test.exs @@ -0,0 +1,44 @@ +defmodule Reactor.Dsl.SwitchTest do + @moduledoc false + use ExUnit.Case, async: true + + defmodule Noop do + @moduledoc false + use Reactor.Step + + def run(_, context, _), do: {:ok, context.current_step.name} + end + + defmodule SwitchReactor do + @moduledoc false + use Reactor + + input :value + + switch :is_truthy? do + on input(:value) + + matches? &(&1 in [nil, false]) do + step :falsy, Noop + + return :falsy + end + + default do + step :truthy, Noop + + return :truthy + end + end + + return :is_truthy? + end + + test "when provided a falsy value it works" do + assert {:ok, :falsy} = Reactor.run(SwitchReactor, value: nil) + end + + test "when provided a truthy value it works" do + assert {:ok, :truthy} = Reactor.run(SwitchReactor, value: :marty) + end +end diff --git a/test/reactor/step/switch_test.exs b/test/reactor/step/switch_test.exs new file mode 100644 index 0000000..ce3f364 --- /dev/null +++ b/test/reactor/step/switch_test.exs @@ -0,0 +1,115 @@ +defmodule Reactor.Step.SwitchTest do + @moduledoc false + use ExUnit.Case, async: true + alias Reactor.{Builder, Step.Switch} + + defmodule Noop do + @moduledoc false + use Reactor.Step + + def run(_, context, _), do: {:ok, context.current_step.name} + end + + setup do + context = %{current_step: %{name: :marty}} + + matches = [ + {&is_nil(&1), [Builder.new_step!(:is_nil, Noop, [])]}, + {&(&1 == false), [Builder.new_step!(:is_false, Noop, [])]} + ] + + default = [Builder.new_step!(:is_other, Noop, [])] + + {:ok, + context: context, + matches: matches, + default: default, + options: [matches: matches, default: default, on: :value]} + end + + describe "run/3" do + test "when passed an `on` option which does not match an argument, it returns an error", %{ + context: context, + matches: matches, + default: default + } do + assert {:error, error} = + Switch.run(%{}, context, matches: matches, default: default, on: :foo) + + assert Exception.message(error) =~ ~r/expected `on` option to match a provided argument/i + end + + test "when passed no `matches` option, it returns an error", %{ + context: context, + default: default + } do + assert {:error, error} = Switch.run(%{value: 1}, context, default: default, on: :value) + assert Exception.message(error) =~ ~r/missing `matches` option/i + end + + test "when passed `matches` which have invalid predicates, it returns an error", %{ + context: context, + matches: matches + } do + matches = + matches + |> Enum.map(fn {_predicate, steps} -> + {&Map.get/3, steps} + end) + + assert {:error, error} = Switch.run(%{value: 1}, context, matches: matches, on: :value) + assert Exception.message(error) =~ ~r/expected `predicate` to be a 1 arity function/i + end + + test "when passed `matches` which have invalid steps, it returns an error", %{ + context: context, + matches: matches + } do + matches = + matches + |> Enum.map(fn {predicate, _steps} -> + {predicate, [URI.parse("http://example.com")]} + end) + + assert {:error, error} = Switch.run(%{value: 1}, context, matches: matches, on: :value) + assert Exception.message(error) =~ ~r/to be a `Reactor.Step` struct/i + end + + test "when passed a `default` which contains invalid steps, it returns an error", %{ + context: context, + matches: matches + } do + assert {:error, error} = + Switch.run(%{value: 1}, context, + matches: matches, + default: [URI.parse("http://example.com")], + on: :value + ) + + assert Exception.message(error) =~ ~r/to be a `Reactor.Step` struct/i + end + + test "it works", %{context: context, options: options} do + assert {:ok, nil, [%{name: :is_nil}]} = Switch.run(%{value: nil}, context, options) + assert {:ok, nil, [%{name: :is_false}]} = Switch.run(%{value: false}, context, options) + assert {:ok, nil, [%{name: :is_other}]} = Switch.run(%{value: 13}, context, options) + end + + test "when passed the `allow_async?` false option, it rewrites the returned steps", %{ + context: context, + options: options + } do + assert {:ok, nil, [%{async?: false}]} = + Switch.run(%{value: 13}, context, Keyword.put(options, :allow_async?, false)) + end + + test "when not passed a default and no matches are found, it returns an error", %{ + context: context, + matches: matches + } do + assert {:error, error} = Switch.run(%{value: 13}, context, matches: matches, on: :value) + + assert error =~ ~r/no default branch/i + end + end +end