diff --git a/lib/interactor.ex b/lib/interactor.ex index 9487392..b7bc8c4 100644 --- a/lib/interactor.ex +++ b/lib/interactor.ex @@ -1,130 +1,92 @@ defmodule Interactor do use Behaviour - alias Interactor.TaskSupervisor + alias Interactor.Interaction @moduledoc """ A tool for modeling events that happen in your application. - TODO: More on interactor concept + #TODO: Docs, Examples, WHY - Interactor provided a behaviour and functions to execute the behaviours. - - To use simply `use Interactor` in a module and implement the `handle_call/1` - callback. When `use`-ing you can optionaly include a Repo option which will - be used to execute any Ecto.Changesets or Ecto.Multi structs you return. - - Interactors supports three callbacks: - - * `before_call/1` - Useful for manipulating input etc. - * `handle_call/1` - The meat, usually returns an Ecto.Changeset or Ecto.Multi. - * `after_call/1` - Useful for metrics, publishing events, etc - - Interactors can be called in three ways: - - * `Interactor.call/2` - Executes callbacks, optionaly insert, and return results. - * `Interactor.call_task/2` - Same as call, but returns a `Task` that can be awated on. - * `Interactor.call_aysnc/2` - Same as call, but does not return results. - - Example: - - defmodule CreateArticle do - use Interactor, repo: Repo - - def handle_call(%{attributes: attrs, author: author}) do - cast(%Article{}, attrs, [:title, :body]) - |> put_change(:author_id, author.id) - end - end - - Interactor.call(CreateArticle, %{attributes: params, author: current_user}) """ - @doc """ - The primary callback. Typically returns an Ecto.Changeset or an Ecto.Multi. - """ - @callback handle_call(map) :: any + @type opts :: binary | tuple | atom | integer | float | [opts] | %{opts => opts} @doc """ - A callback executed before handle_call. Useful for normalizing inputs. - """ - @callback before_call(map) :: map + Primary interactor callback. - @doc """ - A callback executed after handle_call and after the Repo executes. + #TODO: Docs, Examples, explain return values and assign_to - Useful for publishing events, tracking metrics, and other non-transaction - worthy calls. """ - @callback after_call(any) :: any + @callback call(Interaction.t, opts) :: Interaction.t | {:ok, any} | {:error, any} | any @doc """ - Executes the `before_call/1`, `handle_call/1`, and `after_call/1` callbacks. - - If an Ecto.Changeset or Ecto.Multi is returned by `handle_call/1` and a - `repo` options was passed to `use Interactor` the changeset or multi will be - executed and the results returned. - """ - @spec call_task(module, map) :: Task.t - def call(interactor, context) do - context - |> interactor.before_call - |> interactor.handle_call - |> Interactor.Handler.handle(interactor.__repo) - |> interactor.after_call - end - - @doc """ - Wraps `call/2` in a supervised Task. Returns the Task. - - Useful if you want async, but want to await results. + Optional callback to be executed if interactors up the chain return an error. When using Interaction.Builder prefer the `rollback` option. """ - @spec call_task(module, map) :: Task.t - def call_task(interactor, map) do - Task.Supervisor.async(TaskSupervisor, Interactor, :call, [interactor, map]) - end + @callback rollback(Interaction.t) :: Interaction.t + @optional_callbacks rollback: 1 @doc """ - Executes `call/2` asynchronously via a supervised task. Returns {:ok, pid}. - - Primary use case is task you want completely asynchronos with no care for - return values. - - Async can be disabled in tests by setting (will still return {:ok, pid}): + Call an Interactor. - config :interactor, - force_syncronous_tasks: true + #TODO: Docs, Examples """ - @spec call_async(module, map) :: {:ok, pid} - def call_async(interactor, map) do - if sync_tasks do - t = Task.Supervisor.async(TaskSupervisor, Interactor, :call, [interactor, map]) - Task.await(t) - {:ok, t.pid} - else - Task.Supervisor.start_child(TaskSupervisor, Interactor, :call, [interactor, map]) + @spec call(module | {module, atom}, Interaction.t | map, Keyword.t) :: Interaction.t + def call(interactor, interaction, opts \\ []) + def call({module, fun}, %Interaction{} = interaction, opts), + do: do_call(module, fun, interaction, opts[:strategy], opts) + def call(module, %Interaction{} = i, opts), + do: call({module, :call}, i, opts) + def call(interactor, assigns, opts), + do: call(interactor, %Interaction{assigns: assigns}, opts) + + defp do_call(module, fun, interaction, :sync, opts), + do: do_call(module, fun, interaction, Interactor.Strategy.Sync, opts) + defp do_call(module, fun, interaction, nil, opts), + do: do_call(module, fun, interaction, Interactor.Strategy.Sync, opts) + defp do_call(module, fun, interaction, :async, opts), + do: do_call(module, fun, interaction, Interactor.Strategy.Async, opts) + defp do_call(module, fun, interaction, :task, opts), + do: do_call(module, fun, interaction, Interactor.Strategy.Task, opts) + defp do_call(module, fun, interaction, strategy, opts) do + assign_to = determine_assign_to(module, fun, opts[:assign_to]) + rollback = determine_rollback(module, fun, opts[:rollback]) + case strategy.execute(module, fun, interaction, opts) do + %Interaction{success: false} = interaction -> + Interaction.rollback(interaction) + %Interaction{} = interaction -> + Interaction.add_rollback(interaction, rollback) + {:error, error} -> + Interaction.rollback(%{interaction | success: false, error: error}) + {:ok, other} -> + interaction + |> Interaction.assign(assign_to, other) + |> Interaction.add_rollback(rollback) + other -> + interaction + |> Interaction.assign(assign_to, other) + |> Interaction.add_rollback(rollback) end end - defmacro __using__(opts) do - quote do - @behaviour Interactor - @doc false - def __repo, do: unquote(opts[:repo]) - unquote(define_callback_defaults) - end + defp determine_assign_to(module, :call, nil) do + module + |> Atom.to_string + |> String.split(".") + |> Enum.reverse + |> hd + |> Macro.underscore + |> String.to_atom end + defp determine_assign_to(_module, fun, nil), do: fun + defp determine_assign_to(_module, _fun, assign_to), do: assign_to - defp define_callback_defaults do - quote do - def before_call(c), do: c - def after_call(r), do: r - - defoverridable [before_call: 1, after_call: 1] + defp determine_rollback(module, :call, nil) do + if {:rollback, 1} in module.__info__(:functions) do + {module, :rollback} end end + defp determine_rollback(_module, _fun, nil), do: nil + defp determine_rollback(module, _fun, rollback), do: {module, rollback} - defp sync_tasks do - Application.get_env(:interactor, :force_syncronous_tasks, false) - end end diff --git a/lib/interactor/builder.ex b/lib/interactor/builder.ex new file mode 100644 index 0000000..032ac1e --- /dev/null +++ b/lib/interactor/builder.ex @@ -0,0 +1,130 @@ +defmodule Interactor.Builder do + + @moduledoc """ + + + The Interactor.Builer module functionality and code is **heavily** influenced + and copied from the Plug.Builder code. + TODO. + + Example: + + def Example.CreatePost do + use Interactor.Interaction + import Ecto.Changeset + + interactor :post_changeset + interactor Interactor.Ecto, from: :post_changeset, to: post + interactor Example.SyncToSocket, async: true + interactor :push_to_rss_service, async: true + + def post_changeset(%{assigns: %{attributes: attrs}}, _) do + cast(%Example.Post, attrs, [:title, :body]) + end + + def push_to_rss_service(interaction, _) do + # ... External service call ... + interaction + end + end + + """ + + @type interactor :: module | atom + + @doc """ + + """ + defmacro interactor(interactor, opts \\ []) do + quote do + @interactors {unquote(interactor), unquote(opts), true} + end + end + + @doc false + defmacro __using__(_opts) do + quote do + @behaviour Interactor + import Interactor.Builder, only: [interactor: 1, interactor: 2] + alias Interactor.Interaction + + def call(interaction, opts) do + interactor_builder_call(interaction, opts) + end + + defoverridable [call: 2] + + Module.register_attribute(__MODULE__, :interactors, accumulate: true) + @before_compile Interactor.Builder + end + end + + @doc false + defmacro __before_compile__(env) do + interactors = Module.get_attribute(env.module, :interactors) + {interaction, body} = Interactor.Builder.compile(env, interactors) + + quote do + defp interactor_builder_call(unquote(interaction), _), do: unquote(body) + end + end + + @doc false + #@spec compile(Macro.Env.t, [{interactor, Interactor.opts, Macro.t}]) :: {Macro.t, Macro.t} + def compile(env, pipeline) do + interaction = quote do: interaction + {interaction, Enum.reduce(pipeline, interaction, "e_interactor(&1, &2, env))} + end + + # `acc` is a series of nested interactor calls in the form of + # interactor3(interactor2(interactor1(interaction))). + # `quote_interactor` wraps a new interactor around that series of calls. + defp quote_interactor({interactor, opts, guards}, acc, _env) do + call = quote_interactor_call(interactor, opts) + + {fun, meta, [arg, [do: clauses]]} = + quote do + case unquote(compile_guards(call, guards)) do + %Interactor.Interaction{success: false} = interaction -> interaction + %Interactor.Interaction{} = interaction -> unquote(acc) + end + end + + generated? = :erlang.system_info(:otp_release) >= '19' + + clauses = Enum.map(clauses, fn {:->, meta, args} -> + if generated? do + {:->, [generated: true] ++ meta, args} + else + {:->, Keyword.put(meta, :line, -1), args} + end + end) + + {fun, meta, [arg, [do: clauses]]} + end + + # Use Interactor.call to execute the Interactor. + # Always returns an interaction, but handles async strategies, assigning + # values, etc. + defp quote_interactor_call(interactor, opts) do + case Atom.to_char_list(interactor) do + ~c"Elixir." ++ _ -> + quote do: Interactor.call({unquote(interactor), :call}, interaction, unquote(Macro.escape(opts))) + _ -> + quote do: Interactor.call({__MODULE__, unquote(interactor)}, interaction, unquote(Macro.escape(opts))) + end + end + + defp compile_guards(call, true) do + call + end + + defp compile_guards(call, guards) do + quote do + case true do + true when unquote(guards) -> unquote(call) + true -> conn + end + end + end +end diff --git a/lib/interactor/ecto.ex b/lib/interactor/ecto.ex new file mode 100644 index 0000000..9b26e2c --- /dev/null +++ b/lib/interactor/ecto.ex @@ -0,0 +1,24 @@ +defmodule Interactor.Ecto do + @behaviour Interactor + + @moduledoc """ + An interactor which will insert/update/transact your changesets and multis. + """ + + # TODO: Better name for source option? :from, :changeset, :multi ? + def call(interaction, opts) do + case {opts[:source], opts[:repo]} do + {nil, _} -> raise "Interactor.Ecto requires a :source option to indicate which assign field should be attempted to be inserted" + {_, nil} -> raise "Interactor.Ecto requires a :repo option to use to insert or transact with." + {source, repo} -> execute(interaction.assigns[source], repo) + end + end + + defp execute(nil, _), do: raise "Interactor.Ecto could not find given source" + defp execute(%{__struct__: Ecto.Multi} = multi, repo) do + repo.transaction(multi) + end + defp execute(%{__struct__: Ecto.Changeset} = changeset, repo) do + repo.insert_or_update(changeset) + end +end diff --git a/lib/interactor/interaction.ex b/lib/interactor/interaction.ex new file mode 100644 index 0000000..40a589a --- /dev/null +++ b/lib/interactor/interaction.ex @@ -0,0 +1,46 @@ +defmodule Interactor.Interaction do + @moduledoc """ + An interaction holds the state to be passed between Interactors. + """ + + defstruct [assigns: %{}, success: true, error: nil, rollback: []] + + @type t :: %__MODULE__{ + assigns: Map.t, + success: boolean, + error: nil | any, + rollback: [{module, atom}], + } + + @doc """ + Assign a value to the interaction's assigns map. + """ + @spec assign(Interaction.t, atom, any) :: Interaction.t + def assign(%__MODULE__{} = interaction, key, val) do + Map.update!(interaction, :assigns, &(Map.put(&1, key, val))) + end + + @doc """ + Push a rollback function into the interaction's rollback list. + """ + @spec add_rollback(Interaction.t, nil | {module, atom}) :: Interaction.t + def add_rollback(%__MODULE__{} = interaction, nil), do: interaction + def add_rollback(%__MODULE__{} = interaction, {module, fun}) do + Map.update!(interaction, :rollback, &([{module, fun} | &1])) + end + + @doc """ + Execute all rollback functions in reverse of the order they were added. + + Called when an interactor up the chain returns {:error, anyvalue}. + + NOTE: Rollback for the interactor that fails is not called, only previously + successful interactors have rollback called. + """ + @spec rollback(Interaction.t) :: Interaction.t + def rollback(%__MODULE__{} = interaction) do + Enum.reduce interaction.rollback, interaction, fn({mod, fun}, i) -> + apply(mod, fun, [i]) + end + end +end diff --git a/lib/interactor/legacy.ex b/lib/interactor/legacy.ex new file mode 100644 index 0000000..43778e6 --- /dev/null +++ b/lib/interactor/legacy.ex @@ -0,0 +1,135 @@ +defmodule Interactor.Legacy do + use Behaviour + alias Interactor.TaskSupervisor + + @moduledoc """ + Legacy Interactor Behaviour. + + When updating from 0.1.0 to 0.2.0 you can replace `use Interactor` with + `use Interactor.Legacy`. You can also `alias Interactor.Legacy, as: Interactor` + to ensure `Interactor.call/2`, `Interactor.call_async/2`, and + `Interactor.call_task/2` continue working as expected. + + A tool for modeling events that happen in your application. + + Interactor provided a behaviour and functions to execute the behaviours. + + To use simply `use Interactor` in a module and implement the `handle_call/1` + callback. When `use`-ing you can optionaly include a Repo option which will + be used to execute any Ecto.Changesets or Ecto.Multi structs you return. + + Interactors supports three callbacks: + + * `before_call/1` - Useful for manipulating input etc. + * `handle_call/1` - The meat, usually returns an Ecto.Changeset or Ecto.Multi. + * `after_call/1` - Useful for metrics, publishing events, etc + + Interactors can be called in three ways: + + * `Interactor.call/2` - Executes callbacks, optionaly insert, and return results. + * `Interactor.call_task/2` - Same as call, but returns a `Task` that can be awated on. + * `Interactor.call_aysnc/2` - Same as call, but does not return results. + + Example: + + defmodule CreateArticle do + use Interactor, repo: Repo + + def handle_call(%{attributes: attrs, author: author}) do + cast(%Article{}, attrs, [:title, :body]) + |> put_change(:author_id, author.id) + end + end + + Interactor.call(CreateArticle, %{attributes: params, author: current_user}) + """ + + @doc """ + The primary callback. Typically returns an Ecto.Changeset or an Ecto.Multi. + """ + @callback handle_call(map) :: any + + @doc """ + A callback executed before handle_call. Useful for normalizing inputs. + """ + @callback before_call(map) :: map + + @doc """ + A callback executed after handle_call and after the Repo executes. + + Useful for publishing events, tracking metrics, and other non-transaction + worthy calls. + """ + @callback after_call(any) :: any + + @doc """ + Executes the `before_call/1`, `handle_call/1`, and `after_call/1` callbacks. + + If an Ecto.Changeset or Ecto.Multi is returned by `handle_call/1` and a + `repo` options was passed to `use Interactor` the changeset or multi will be + executed and the results returned. + """ + @spec call_task(module, map) :: Task.t + def call(interactor, context) do + context + |> interactor.before_call + |> interactor.handle_call + |> Interactor.Handler.handle(interactor.__repo) + |> interactor.after_call + end + + @doc """ + Wraps `call/2` in a supervised Task. Returns the Task. + + Useful if you want async, but want to await results. + """ + @spec call_task(module, map) :: Task.t + def call_task(interactor, map) do + Task.Supervisor.async(TaskSupervisor, __MODULE__, :call, [interactor, map]) + end + + @doc """ + Executes `call/2` asynchronously via a supervised task. Returns {:ok, pid}. + + Primary use case is task you want completely asynchronos with no care for + return values. + + Async can be disabled in tests by setting (will still return {:ok, pid}): + + config :interactor, + force_syncronous_tasks: true + + """ + @spec call_async(module, map) :: {:ok, pid} + def call_async(interactor, map) do + if sync_tasks do + t = Task.Supervisor.async(TaskSupervisor, __MODULE__, :call, [interactor, map]) + Task.await(t) + {:ok, t.pid} + else + Task.Supervisor.start_child(TaskSupervisor, __MODULE__, :call, [interactor, map]) + end + end + + defmacro __using__(opts) do + quote do + @behaviour Interactor.Legacy + @doc false + def __repo, do: unquote(opts[:repo]) + unquote(define_callback_defaults) + end + end + + defp define_callback_defaults do + quote do + def before_call(c), do: c + def after_call(r), do: r + + defoverridable [before_call: 1, after_call: 1] + end + end + + defp sync_tasks do + Application.get_env(:interactor, :force_syncronous_tasks, false) + end +end diff --git a/lib/interactor/strategy.ex b/lib/interactor/strategy.ex new file mode 100644 index 0000000..50fca79 --- /dev/null +++ b/lib/interactor/strategy.ex @@ -0,0 +1,46 @@ +defmodule Interactor.Strategy do + use Behaviour + + @moduledoc """ + An interactor strategy is how the interactor is executed. + + Built in strategies are: + + * :sync - Interactor.Strategy.Sync - default + * :async - Interactor.Strategy.Async + * :task - Interactor.Strategy.Task + + Strategies are determined with the `strategy` option, eg: + + Interactor.call(SimpleInteractor, %{foo: :bar}, strategy: :task) + + Or with Interactor.Builder: + + interactor :create_user + interactor :send_email, strategy: :async + + The default strategy is `:sync` which simply executes the interactor in the + current process and assigns the results. See docs on each strategy for more. + + Custom strategies can be used by passing a module implementing the + Interactor.Strategy behaviour as the strategy option. For example: + + interactor :create_user + interactor :send_email, strategy: MyApp.Exq + + """ + + @doc """ + Execute the interactor. + + Receives the module and function of the interactor, the interaction and the + opts. The simplest possible implementation is just to apply the function + inline, which is in fact what the :sync strategy does. + + apply(module, fun, [interaction, opts]) + + This callback should return either the %Interaction{}, {:ok, value}, or + {:error, error}. + """ + @callback execute(module, atom, Interaction.t, Keyword.t) :: Interaction.t | {:ok, any} | {:error, any} +end diff --git a/lib/interactor/strategy/async.ex b/lib/interactor/strategy/async.ex new file mode 100644 index 0000000..babcc96 --- /dev/null +++ b/lib/interactor/strategy/async.ex @@ -0,0 +1,45 @@ +defmodule Interactor.Strategy.Async do + @behaviour Interactor.Strategy + alias Interactor.TaskSupervisor + + @moduledoc """ + Execute interaction asynchronously, only return value is PID. + + To use: + + interactor :update_view_count, strategy: :async + + or: + + interactor :update_view_count, strategy: Interactor.Strategy.Async + + + When running tests async interactors can be forced to run synchronously by setting the following config. Return values will still be pids. + + config :interactor, + force_syncronous_tasks: true + """ + + @doc """ + Execute interactor asynchronously in a supervised fashion. + + Returns pid for assignment in interaction. + """ + def execute(module, fun, interaction, opts) do + if sync_tasks do + task = Task.Supervisor.async(TaskSupervisor, fn() -> + apply(module, fun, [interaction, opts]) + end) + Task.await(task) + {:ok, task.pid} + else + Task.Supervisor.start_child(TaskSupervisor, fn() -> + apply(module, fun, [interaction, opts]) + end) + end + end + + defp sync_tasks do + Application.get_env(:interactor, :force_syncronous_tasks, false) + end +end diff --git a/lib/interactor/strategy/sync.ex b/lib/interactor/strategy/sync.ex new file mode 100644 index 0000000..6ad1c6a --- /dev/null +++ b/lib/interactor/strategy/sync.ex @@ -0,0 +1,14 @@ +defmodule Interactor.Strategy.Sync do + @behaviour Interactor.Strategy + + @moduledoc """ + Execute interaction synchronously. Default strategy. + """ + + @doc """ + Execute interactor in current process. + """ + def execute(module, fun, interaction, opts) do + apply(module, fun, [interaction, opts]) + end +end diff --git a/lib/interactor/strategy/task.ex b/lib/interactor/strategy/task.ex new file mode 100644 index 0000000..2759565 --- /dev/null +++ b/lib/interactor/strategy/task.ex @@ -0,0 +1,45 @@ +defmodule Interactor.Strategy.Task do + @behaviour Interactor.Strategy + alias Interactor.TaskSupervisor + import Interactor.Interaction + + @moduledoc """ + Execute interaction in a task, return value is a Task which is assigned to interaction. + + To use: + + interactor :do_work, strategy: :task + + or: + + interactor :do_work, strategy: Interactor.Strategy.Task + + Interaction with %Task{} in assigns can be all waited on with Interactor.Strategy.Task.await/1. + """ + + @doc """ + Execute interactor in a supervised task. + + Task is returned for assignment in interaction. + """ + def execute(module, fun, interaction, opts) do + Task.Supervisor.async TaskSupervisor, fn() -> + apply(module, fun, [interaction, opts]) + end + end + + @doc """ + Await all tasks in assigns, return interaction with fulfilled values replacing tasks. + """ + def await(interaction) do + Enum.reduce interaction.assigns, interaction, fn + {k, %Task{} = t}, interaction -> + val = case Task.await(t) do + {:ok, val} -> val + val -> val + end + assign(interaction, k, val) + _, interaction -> interaction + end + end +end diff --git a/test/interactor/builder_test.exs b/test/interactor/builder_test.exs new file mode 100644 index 0000000..b23b046 --- /dev/null +++ b/test/interactor/builder_test.exs @@ -0,0 +1,60 @@ +defmodule Interactor.BuilderTest do + use ExUnit.Case + + defmodule Two do + use Interactor.Builder + + interactor :two + interactor :three + interactor :four, assign_to: :for + + def two(i,_), do: Interaction.assign(i, :two, "two") + def three(_,_), do: "three" + def four(_,_), do: "four" + end + + + defmodule One do + use Interactor.Builder + + interactor :one + interactor Two + interactor :five + + def one(_,_), do: {:ok, "one"} + def five(_,_), do: "five" + end + + defmodule FailOne do + use Interactor.Builder + + interactor :one + interactor :two + interactor :three + + def one(_,_), do: {:ok, "one"} + def two(_,_), do: {:error, "error"} + def three(_,_), do: {:ok, "three"} + end + + test "success assigns" do + interaction = %Interactor.Interaction{} = Interactor.call(One, %{}) + assert interaction.success + assert interaction.assigns == %{ + one: "one", + two: "two", + three: "three", + for: "four", + five: "five", + } + end + + test "failure assigns" do + interaction = %Interactor.Interaction{} = Interactor.call(FailOne, %{}) + refute interaction.success + assert interaction.assigns == %{ + one: "one", + } + assert interaction.error == "error" + end +end diff --git a/test/interactor/interaction_test.exs b/test/interactor/interaction_test.exs new file mode 100644 index 0000000..e611594 --- /dev/null +++ b/test/interactor/interaction_test.exs @@ -0,0 +1,30 @@ +defmodule Interactor.InteractionTest do + use ExUnit.Case + alias Interactor.Interaction + import Interaction + + test "assigns" do + assert %Interaction{assigns: %{foo: :bar}} == + assign(%Interaction{}, :foo, :bar) + end + + test "add_rollback" do + assert %Interaction{rollback: []} == + add_rollback(%Interaction{}, nil) + + assert %Interaction{rollback: [{Foo, :bar}]} == + add_rollback(%Interaction{}, {Foo, :bar}) + end + + test "rollback" do + interaction = %Interaction{} + |> add_rollback({__MODULE__, :rollback1}) + |> add_rollback({__MODULE__, :rollback2}) + + assert %Interaction{assigns: %{one: 1, two: 2}} = + rollback(interaction) + end + + def rollback1(int), do: assign(int, :one, 1) + def rollback2(int), do: assign(int, :two, 2) +end diff --git a/test/interactor/legacy_test.exs b/test/interactor/legacy_test.exs new file mode 100644 index 0000000..04f2e8e --- /dev/null +++ b/test/interactor/legacy_test.exs @@ -0,0 +1,82 @@ +defmodule Interactor.LegacyTest do + use ExUnit.Case + doctest Interactor + + defmodule Foo do + use Ecto.Schema + + schema "foos" do + field :foo, :string + end + end + + # We don't need to test ecto, just handle repo calls and return something. + defmodule FakeRepo do + def insert_or_update(changeset) do + %Foo{foo: Ecto.Changeset.get_field(changeset, :foo)} + end + + def transaction(fun) when is_function(fun), do: fun.() + def transaction(%Ecto.Multi{} = multi) do + foos = multi + |> Ecto.Multi.to_list + |> Enum.reduce(%{}, fn({key, {_, cs, _}}, m) -> + Map.put(m, key, insert_or_update(cs)) + end) + {:ok, foos} + end + end + + defmodule SimpleExample do + use Interactor.Legacy + def handle_call(%{foo: bar}), do: {:ok, "foo" <> bar} + end + + defmodule ChangesetExample do + use Interactor.Legacy, repo: FakeRepo + import Ecto.Changeset + def handle_call(params), do: cast(%Foo{}, params, [:foo]) + end + + defmodule MultiExample do + use Interactor.Legacy, repo: FakeRepo + alias Ecto.Multi + def handle_call(%{foo1: foo1, foo2: foo2}) do + Multi.new + |> Multi.insert(:foo1, ChangesetExample.handle_call(%{foo: foo1})) + |> Multi.insert(:foo2, ChangesetExample.handle_call(%{foo: foo2})) + end + end + + test "simple - calling call" do + assert {:ok, "foobar"} = Interactor.Legacy.call(SimpleExample, %{foo: "bar"}) + end + + test "simple - calling call_task" do + task = Interactor.Legacy.call_task(SimpleExample, %{foo: "bar"}) + assert {:ok, "foobar"} = Task.await(task) + end + + test "changeset - calling call" do + foo = Interactor.Legacy.call(ChangesetExample, %{foo: "bar"}) + assert foo.foo == "bar" + end + + test "changeset - calling call_task" do + task = Interactor.Legacy.call_task(ChangesetExample, %{foo: "bar"}) + foo = Task.await(task) + assert foo.foo == "bar" + end + + test "multi - calling call_async" do + results = Interactor.Legacy.call_async(MultiExample, %{foo1: "bar", foo2: "baz"}) + assert {:ok, _} = results + end + + test "multi - calling call_task" do + task = Interactor.Legacy.call_task(MultiExample, %{foo1: "bar", foo2: "baz"}) + assert {:ok, %{foo1: foo1, foo2: foo2}} = Task.await(task) + assert foo1.foo == "bar" + assert foo2.foo == "baz" + end +end diff --git a/test/interactor_test.exs b/test/interactor_test.exs index dc0f099..8d90e06 100644 --- a/test/interactor_test.exs +++ b/test/interactor_test.exs @@ -1,82 +1,61 @@ defmodule InteractorTest do use ExUnit.Case doctest Interactor + alias Interactor.Interaction - defmodule Foo do - use Ecto.Schema - - schema "foos" do - field :foo, :string - end - end - - # We don't need to test ecto, just handle repo calls and return something. - defmodule FakeRepo do - def insert_or_update(changeset) do - %Foo{foo: Ecto.Changeset.get_field(changeset, :foo)} - end - - def transaction(fun) when is_function(fun), do: fun.() - def transaction(%Ecto.Multi{} = multi) do - foos = multi - |> Ecto.Multi.to_list - |> Enum.reduce(%{}, fn({key, {_, cs, _}}, m) -> - Map.put(m, key, insert_or_update(cs)) - end) - {:ok, foos} - end + defmodule One do + @behaviour Interactor + def call(%Interaction{} = int, _opts), do: Interaction.assign(int, :one, 1) end - defmodule SimpleExample do - use Interactor - def handle_call(%{foo: bar}), do: {:ok, "foo" <> bar} + defmodule Two do + @behaviour Interactor + def call(_interaction, _opts), do: {:ok, 2} + def rollback(interaction), do: Interaction.assign(interaction, :two, 0) end - defmodule ChangesetExample do - use Interactor, repo: FakeRepo - import Ecto.Changeset - def handle_call(params), do: cast(%Foo{}, params, [:foo]) + defmodule Fail do + @behaviour Interactor + def call(_interaction, _opts), do: {:error, "error"} end - defmodule MultiExample do - use Interactor, repo: FakeRepo - alias Ecto.Multi - def handle_call(%{foo1: foo1, foo2: foo2}) do - Multi.new - |> Multi.insert(:foo1, ChangesetExample.handle_call(%{foo: foo1})) - |> Multi.insert(:foo2, ChangesetExample.handle_call(%{foo: foo2})) - end + test "call/2 - %Interaction{} returned" do + assert %Interaction{assigns: assigns} = Interactor.call(One, %{zero: 0}) + assert assigns == %{zero: 0, one: 1} end - test "simple - calling call" do - assert {:ok, "foobar"} = Interactor.call(SimpleExample, %{foo: "bar"}) + test "call/2 - {:ok, 2} returned" do + assert %Interaction{assigns: assigns} = Interactor.call(Two, %{zero: 0}) + assert assigns == %{zero: 0, two: 2} end - test "simple - calling call_task" do - task = Interactor.call_task(SimpleExample, %{foo: "bar"}) - assert {:ok, "foobar"} = Task.await(task) + test "call/3 - {:ok, 2} returned - with assign to" do + assert %Interaction{assigns: assigns} = Interactor.call(Two, %{zero: 0}, assign_to: :too) + assert assigns == %{zero: 0, too: 2} end - test "changeset - calling call" do - foo = Interactor.call(ChangesetExample, %{foo: "bar"}) - assert foo.foo == "bar" + test "call/2 - {:error, 2} returned" do + assert %Interaction{success: false, error: "error", assigns: %{zero: 0}} = + Interactor.call(Fail, %{zero: 0}) end - test "changeset - calling call_task" do - task = Interactor.call_task(ChangesetExample, %{foo: "bar"}) - foo = Task.await(task) - assert foo.foo == "bar" + test "call/2 - async - Interaction returned" do + assert %Interaction{assigns: assigns} = Interactor.call(One, %{zero: 0}, strategy: :async) + assert %{zero: 0, one: pid} = assigns + assert is_pid(pid) end - test "multi - calling call_async" do - results = Interactor.call_async(MultiExample, %{foo1: "bar", foo2: "baz"}) - assert {:ok, _} = results + test "call/2 - task - Interaction returned" do + assert %Interaction{} = int = Interactor.call(Two, %{zero: 0}, strategy: :task) + assert %{zero: 0, two: %Task{}} = int.assigns + int = Interactor.Strategy.Task.await(int) + assert %{zero: 0, two: 2} = int.assigns end - test "multi - calling call_task" do - task = Interactor.call_task(MultiExample, %{foo1: "bar", foo2: "baz"}) - assert {:ok, %{foo1: foo1, foo2: foo2}} = Task.await(task) - assert foo1.foo == "bar" - assert foo2.foo == "baz" + test "rollback/1" do + assert %Interaction{} = int = Interactor.call(Two, %{zero: 0}) + assert %{zero: 0, two: 2} = int.assigns + int = Interaction.rollback(int) + assert %{zero: 0, two: 0} = int.assigns end end