Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP - Interactor.Builder #15

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 61 additions & 99 deletions lib/interactor.ex
Original file line number Diff line number Diff line change
@@ -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})

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I dig the whole rollback sequence.
Would it make sense to also have

%Interaction{success: false} = interaction ->
  Interaction.rollback(interaction)

to allow interactors to return their own Interaction with the failure state already established?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yeah, good call. I will add that.

{: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
130 changes: 130 additions & 0 deletions lib/interactor/builder.ex
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about this DSL the other day and I think interactor here is kind of misleading. It seems like it's really a step within an interator, rather than actually calling an interactor itself. Is there a reason you went with that instead of a verb like perform or execute or a noun like step or action?

Copy link
Contributor Author

@alanpeabody alanpeabody Nov 29, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think of it exactly like I think about Plug. A Plug is either a module or a function that accepts a Plug.Conn and returns a Plug.Conn. The DSL is simply listing which plugs (either module or function) to be called in what order.

An interactor is the exact same thing, either a module or a function that accepts an Interaction and returns an Interaction*. The DSL is just which interactors are to be called.

* It can return other values too, which are assigned to the Interaction.

Does thinking about it that way change how you perceive it?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the analogy, but I think maybe what I'm hung up on is "what is an interactor?". Is CreatePost the interactor or is post_changeset? My understanding was that CreatePost is the interactor, and this DSL is telling the interactor to perform a task. I'm basing that on https://github.com/collectiveidea/interactor#what-is-an-interactor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To continue the analogy: "what is a plug?"

The answer to me is an interactor module or function that receives an interaction and does something with it. It is simply a pattern to help build easy to follow, simple, maintainable code.

Either way I think this is a minor naming issue compared to verifying the actual functionality and figuring out he best way forward with this very breaking change.

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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fascinated by what's happening between Interactor.Builder.compile/2 and interactor_builder_call/2. It looks like it's taking all the interactors declared in the builder and wrapping them up in a big old nest, while also unpacking their individual guard clauses to catch them in the success field? That's pretty cool.

For my own edification, I'll have to pull down this PR and try to expand the macros to see what it all compiles out to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is pretty much exactly how Plug.Builder does things, with a few tweaks to. I honestly haven't tried any guard stuff here, but I kept it around.

It is basically a huge nested case statement when compiled, the reduce building up the AST with each interactor added.


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, &quote_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
24 changes: 24 additions & 0 deletions lib/interactor/ecto.ex
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions lib/interactor/interaction.ex
Original file line number Diff line number Diff line change
@@ -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
Loading