mirror of
https://github.com/ash-project/reactor.git
synced 2024-09-19 12:53:19 +12:00
improvement(Step.Switch): Add switch
DSL and step type.
Signed-off-by: James Harton <james@harton.nz>
This commit is contained in:
parent
512bf5c35c
commit
e630c976d2
9 changed files with 593 additions and 1 deletions
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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
104
lib/reactor/dsl/switch.ex
Normal 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
|
17
lib/reactor/dsl/switch/default.ex
Normal file
17
lib/reactor/dsl/switch/default.ex
Normal 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
|
23
lib/reactor/dsl/switch/match.ex
Normal file
23
lib/reactor/dsl/switch/match.ex
Normal 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
|
22
lib/reactor/step/return_argument.ex
Normal file
22
lib/reactor/step/return_argument.ex
Normal 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
165
lib/reactor/step/switch.ex
Normal 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
|
44
test/reactor/dsl/switch_test.exs
Normal file
44
test/reactor/dsl/switch_test.exs
Normal 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
|
115
test/reactor/step/switch_test.exs
Normal file
115
test/reactor/step/switch_test.exs
Normal 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
|
Loading…
Reference in a new issue