From dd75458c76a3b931ca646d69b408b39c868eb7ae Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Wed, 12 Jul 2023 07:36:06 +1200 Subject: [PATCH] improvement: Add "subpaths" to templates. (#31) --- lib/reactor/argument.ex | 6 ++ lib/reactor/dsl.ex | 6 ++ lib/reactor/dsl/argument.ex | 34 ++++++--- lib/reactor/executor/step_runner.ex | 85 ++++++++++++++++------ lib/reactor/template/input.ex | 4 +- lib/reactor/template/result.ex | 4 +- test/reactor/executor/step_runner_test.exs | 44 ++++++++++- 7 files changed, 144 insertions(+), 39 deletions(-) diff --git a/lib/reactor/argument.ex b/lib/reactor/argument.ex index fdbd8b0..3686c9c 100644 --- a/lib/reactor/argument.ex +++ b/lib/reactor/argument.ex @@ -88,4 +88,10 @@ defmodule Reactor.Argument do Validate that the argument has a transform. """ defguard has_transform(argument) when is_transform(argument.transform) + + @doc """ + Validate that the argument source has a sub_path + """ + defguard has_sub_path(argument) + when is_list(argument.source.sub_path) and argument.source.sub_path != [] end diff --git a/lib/reactor/dsl.ex b/lib/reactor/dsl.ex index f42f8a6..fb73f32 100644 --- a/lib/reactor/dsl.ex +++ b/lib/reactor/dsl.ex @@ -67,6 +67,9 @@ defmodule Reactor.Dsl do argument :name, input(:name) """, """ + argument :year, input(:date, [:year]) + """, + """ argument :user, result(:create_user) """, """ @@ -75,6 +78,9 @@ defmodule Reactor.Dsl do end """, """ + argument :user_id, result(:create_user, [:id]) + """, + """ argument :three, value(3) """ ], diff --git a/lib/reactor/dsl/argument.ex b/lib/reactor/dsl/argument.ex index bae393f..0a69d3c 100644 --- a/lib/reactor/dsl/argument.ex +++ b/lib/reactor/dsl/argument.ex @@ -40,11 +40,18 @@ defmodule Reactor.Dsl.Argument do end end ``` + + ## Extracting nested values + + You can provide a list of keys to extract from a data structure, similar to + `Kernel.get_in/2` with the condition that the input value is either a struct + or implements the `Access` protocol. """ - @spec input(atom) :: Template.Input.t() - def input(input_name) do - %Template.Input{name: input_name} - end + @spec input(atom, [any]) :: Template.Input.t() + def input(input_name, sub_path \\ []) + + def input(input_name, sub_path), + do: %Template.Input{name: input_name, sub_path: List.wrap(sub_path)} @doc ~S""" The `result` template helper for the Reactor DSL. @@ -71,11 +78,18 @@ defmodule Reactor.Dsl.Argument do end end ``` + + ## Extracting nested values + + You can provide a list of keys to extract from a data structure, similar to + `Kernel.get_in/2` with the condition that the result is either a struct or + implements the `Access` protocol. """ - @spec result(atom) :: Template.Result.t() - def result(link_name) do - %Template.Result{name: link_name} - end + @spec result(atom, [any]) :: Template.Result.t() + def result(step_name, sub_path \\ []) + + def result(step_name, sub_path), + do: %Template.Result{name: step_name, sub_path: List.wrap(sub_path)} @doc ~S""" The `value` template helper for the Reactor DSL. @@ -101,9 +115,7 @@ defmodule Reactor.Dsl.Argument do ``` """ @spec value(any) :: Template.Value.t() - def value(value) do - %Template.Value{value: value} - end + def value(value), do: %Template.Value{value: value} defimpl Argument.Build do def build(argument) do diff --git a/lib/reactor/executor/step_runner.ex b/lib/reactor/executor/step_runner.ex index 15365c3..16558d4 100644 --- a/lib/reactor/executor/step_runner.ex +++ b/lib/reactor/executor/step_runner.ex @@ -90,32 +90,71 @@ defmodule Reactor.Executor.StepRunner do end defp get_step_arguments(reactor, step) do - reduce_while_ok(step.arguments, %{}, fn - argument, arguments when is_from_input(argument) -> - case Map.fetch(reactor.context.private.inputs, argument.source.name) do - {:ok, value} -> - {:ok, Map.put(arguments, argument.name, value)} - - :error -> - {:error, - "Step `#{inspect(step.name)}` argument `#{inspect(argument.name)}` relies on missing input `#{argument.source.name}`"} - end - - argument, arguments when is_from_result(argument) -> - case Map.fetch(reactor.intermediate_results, argument.source.name) do - {:ok, value} -> - {:ok, Map.put(arguments, argument.name, value)} - - :error -> - {:error, - "Step `#{inspect(step.name)}` argument `#{inspect(argument.name)}` is missing"} - end - - argument, arguments when is_from_value(argument) -> - {:ok, Map.put(arguments, argument.name, argument.source.value)} + reduce_while_ok(step.arguments, %{}, fn argument, arguments -> + with {:ok, value} <- fetch_argument(reactor, step, argument), + {:ok, value} <- subpath_argument(value, argument) do + {:ok, Map.put(arguments, argument.name, value)} + end end) end + defp fetch_argument(reactor, step, argument) when is_from_input(argument) do + with :error <- Map.fetch(reactor.context.private.inputs, argument.source.name) do + {:error, + "Step `#{inspect(step.name)}` argument `#{inspect(argument.name)}` relies on missing input `#{argument.source.name}`"} + end + end + + defp fetch_argument(reactor, step, argument) when is_from_result(argument) do + with :error <- Map.fetch(reactor.intermediate_results, argument.source.name) do + {:error, "Step `#{inspect(step.name)}` argument `#{inspect(argument.name)}` is missing"} + end + end + + defp fetch_argument(_reactor, _step, argument) when is_from_value(argument) do + {:ok, argument.source.value} + end + + defp subpath_argument(value, argument) when has_sub_path(argument), + do: perform_argument_subpath(value, argument.name, argument.source.sub_path, []) + + defp subpath_argument(value, _argument), do: {:ok, value} + + defp perform_argument_subpath(value, _, [], _), do: {:ok, value} + + defp perform_argument_subpath(value, name, remaining, done) when is_struct(value), + do: value |> Map.from_struct() |> perform_argument_subpath(name, remaining, done) + + defp perform_argument_subpath(value, name, [head | tail], []) do + case access_fetch_with_rescue(value, head) do + {:ok, value} -> + perform_argument_subpath(value, name, tail, [head]) + + :error -> + {:error, + "Unable to resolve subpath for argument `#{inspect(name)}` at key `[#{inspect(head)}]`"} + end + end + + defp perform_argument_subpath(value, name, [head | tail], done) do + case access_fetch_with_rescue(value, head) do + {:ok, value} -> + perform_argument_subpath(value, name, tail, [head]) + + :error -> + path = Enum.reverse([head | done]) + + {:error, + "Unable to resolve subpath for argument `#{inspect(name)}` at key `#{inspect(path)}`"} + end + end + + defp access_fetch_with_rescue(container, key) do + Access.fetch(container, key) + rescue + FunctionClauseError -> :error + end + defp build_context(reactor, step, concurrency_key) do context = step.context diff --git a/lib/reactor/template/input.ex b/lib/reactor/template/input.ex index d57b83b..8d3d6c6 100644 --- a/lib/reactor/template/input.ex +++ b/lib/reactor/template/input.ex @@ -3,7 +3,7 @@ defmodule Reactor.Template.Input do The `input` template. """ - defstruct name: nil + defstruct name: nil, sub_path: [] - @type t :: %__MODULE__{name: atom} + @type t :: %__MODULE__{name: atom, sub_path: [atom]} end diff --git a/lib/reactor/template/result.ex b/lib/reactor/template/result.ex index 3330f5a..0c1e579 100644 --- a/lib/reactor/template/result.ex +++ b/lib/reactor/template/result.ex @@ -3,7 +3,7 @@ defmodule Reactor.Template.Result do The `result` template. """ - defstruct name: nil + defstruct name: nil, sub_path: [] - @type t :: %__MODULE__{name: atom} + @type t :: %__MODULE__{name: atom, sub_path: [atom]} end diff --git a/test/reactor/executor/step_runner_test.exs b/test/reactor/executor/step_runner_test.exs index c35b0ec..55fdd16 100644 --- a/test/reactor/executor/step_runner_test.exs +++ b/test/reactor/executor/step_runner_test.exs @@ -1,7 +1,7 @@ defmodule Reactor.Executor.StepRunnerTest do @moduledoc false use ExUnit.Case, async: true - alias Reactor.{Argument, Builder} + alias Reactor.{Argument, Builder, Template} import Reactor.Executor.StepRunner use Mimic @@ -23,6 +23,48 @@ defmodule Reactor.Executor.StepRunnerTest do assert reason =~ "argument `:current_year` is missing" end + test "when the required argument cannot be subpathed, it returns an error", %{ + reactor: reactor + } do + {:ok, reactor} = Builder.add_step(reactor, :time_circuits, Example.Step.Undoable) + + argument = %Argument{ + name: :current_year, + source: %Template.Result{name: :time_circuits, sub_path: [:year]} + } + + {:ok, reactor} = Builder.add_step(reactor, :marty, Example.Step.Doable, [argument]) + step = reactor.steps |> hd() + reactor = %{reactor | intermediate_results: %{time_circuits: 1985}} + + assert {:error, reason} = run(reactor, step, nil) + assert reason == "Unable to resolve subpath for argument `:current_year` at key `[:year]`" + end + + test "when the required argument can be subpathed, it calls the step with the correct arguments", + %{reactor: reactor} do + {:ok, reactor} = Builder.add_step(reactor, :time_circuits, Example.Step.Undoable) + + argument = %Argument{ + name: :current_year, + source: %Template.Result{name: :time_circuits, sub_path: [:year]} + } + + {:ok, reactor} = Builder.add_step(reactor, :marty, Example.Step.Doable, [argument]) + [marty, time_circuits] = reactor.steps + reactor = %{reactor | intermediate_results: %{time_circuits.name => ~D[1985-10-26]}} + + Example.Step.Doable + |> expect(:run, fn arguments, _, _ -> + assert Map.keys(arguments) == [:current_year] + assert arguments.current_year == 1985 + + {:ok, :marty} + end) + + assert {:ok, :marty, []} = run(reactor, marty, nil) + end + test "it calls the step with the correct arguments", %{reactor: reactor} do {:ok, reactor} = Builder.add_step(reactor, :time_circuits, Example.Step.Undoable) argument = Argument.from_result(:current_year, :time_circuits)