-
Notifications
You must be signed in to change notification settings - Fork 5
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
base: master
Are you sure you want to change the base?
Changes from 6 commits
a58fd32
7ab01e1
ac3b2a9
fd92226
50514e7
5bc6fcb
dcef882
91104c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,130 +1,90 @@ | ||
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{} = 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 |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking about this DSL the other day and I think There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] | ||
import Interactor.Interaction # TODO, is this a good idea? assign/3 could conflict | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you worried about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Come to think of it, I'm having trouble figuring out why we're importing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RE conflicts: assigns/3 is also often imported by Plug.Conn and Phoenix.Socket. I have removed the import in favor of an alias. It is just a convenience. |
||
|
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm fascinated by what's happening between 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, "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 |
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 |
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 |
There was a problem hiding this comment.
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
to allow interactors to return their own
Interaction
with the failure state already established?There was a problem hiding this comment.
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.