improvement(Step.Switch): Add switch DSL and step type.

Signed-off-by: James Harton <james@harton.nz>
This commit is contained in:
James Harton 2023-07-10 16:01:53 +12:00
parent 512bf5c35c
commit e630c976d2
Signed by: james
GPG key ID: 90E82DAA13F624F4
9 changed files with 593 additions and 1 deletions

View file

@ -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
]

View file

@ -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
}

104
lib/reactor/dsl/switch.ex Normal file
View file

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

View file

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

View file

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

View file

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

165
lib/reactor/step/switch.ex Normal file
View file

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

View file

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

View file

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