Current section
Files
Jump to
Current section
Files
lib/pipehammer.ex
defmodule Pipehammer do
@moduledoc """
Pipehammer, the simplest pipeline management.
You may now declare regular functions, and Pipehammer will write boilerplate
for you.
Example:
```
defpipe safe_div(x, 0) do
{:error, :division_by_zero}
end
defpipe safe_div(x, y) do
{:ok, x / y}
end
```
This will define a function having this type signature:
```
@spec safe_div(any | {:ok, any} | {:error, any} ::
{:ok, any} |
{:error, :division_by_zero} |
{:error, any }
```
```
12 |> safe_div(6) # => {:ok, 2.0}
12 |> safe_div(0) # => {:error, :division_by_zero}
{:ok, 12} |> safe_div(6) # => {:ok, 2.0}
{:error, :not_a_number} |> safe_div(6) # => {:error, :not_a_number}
```
This has immediate application in building pipelines:
```
defp issue_referral_voucher(account_id, referrer_reference) do
find_account(account_id)
|> set_account_referrer(referrer_reference)
|> issue_referral_voucher()
|> create_activity()
|> publish_event()
|> handle_error(fn e ->
{:error, "bad error"}
end)
end
```
You can still use `with` macro if you wish:
```
defp issue_referral_voucher(account_id, referrer_reference) do
with {:ok, acc} <- find_account(account_id),
{:ok, acc} <- set_account_referrer(acc, referrer_reference),
{:ok, vou} <- issue_referral_voucher(acc),
{:ok, act} <- create_activity(vou) do
publish_event(act)
else
{:error, e} -> {:error, "bad error"}
end
end
```
You can still compose the functions as usual:
```
set_account_referrer(find_account(account_id), referrer_reference)
```
## Error handling
Pipe functions are expected to return `{:error, e}` tuples in error cases.
If additional information is required, consider wrapping it into a tuple:
`{:error, {:foo, :bar}}`
If pipe function returns anything other than `{:ok, any} | {:error, any}`
the behaviour of Pipehammer is undefined (means, the pipeline will crash in production))
## What boilerplate to expect:
```
defpipe partial(x, y) when is_integer(x) do
{:ok, x + y}
end
```
Will create these functions, in order:
```
def partial({:error, e}, _) do
{:error, e}
end
def partial({:ok, x}, y) when is_integer(x) do
partial(x, y)
end
def partial(x, y) when is_integer(x) do
{:ok, x + y}
end
```
Note that error case intentionally does not have guards, as it does not care
what you pass into the function, as long as 1st argument was an error. This
logic is essential to chaining the steps.
Pipehammer is smart to eliminate duplicate error declarations:
```
defpipe foo(x, 0) do
{:ok, x}
end
defpipe foo(x, 1) do
{:ok, x}
end
```
This will still write one error clause:
```
def foo({:error, e}, _) do
{:error, e}
end
```
"""
@doc """
Handles errors, but leaves ok tuples intact
"""
def handle_error(exp, handler) when is_function(handler, 1) do
case exp do
:ok -> :ok
{:ok, _} = ok -> ok
{:error, e} -> handler.(e)
end
end
@doc """
Declares function clauses that will handle `nil`, `{:ok, _}`, `{:error, _}`
in addition to its main clause.
"""
defmacro defpipe(decl, do: body) do
# Split declaration into a function declaration without guards, and a function
# to reconstruct it (or something else) into full declaration again.
#
# AST for declaration with guards looks like this:
# {:when, [], [
# {:partial, [], [{x, [], nil}]},
# {:is_list, [], [{x, [], nil}]}
# ]}
#
# This block will turn it into a pair of:
#
# {:partial, [], [{x, [], nil}]}
#
# and
#
# fn fun_decl ->
# {:when, [], [
# fun_decl,
# {:is_list, [], [{x, [], nil}]}
# ]}
#
# This pair is enough to reconstruct the full declaration intact, or
# make a modified declaration having the same guards
{{fun_name, ctx, args} = fun_decl, with_guards} =
case decl do
{:when, ctx, [fun_decl | guards]} ->
{fun_decl,
fn decl ->
{:when, ctx, [decl | guards]}
end}
_ ->
{decl, fn x -> x end}
end
function_name = function_name(fun_decl)
Module.register_attribute(__CALLER__.module, :default_cases, accumulate: true)
Module.register_attribute(__CALLER__.module, :error_cases, accumulate: true)
Module.register_attribute(__CALLER__.module, :ok_cases, accumulate: true)
# default case, that defines:
# def fun_name(x, y, ...) [when ...] do
# body
# end
case args do
[_ | _] ->
quote do
def unquote(decl), do: unquote(body)
end
|> register_definition(function_name, __CALLER__.module, :default_cases)
end
# universal error case, that defines (without guards or patterns):
# def fun_name({:error, e}, _, ...) do
# {:error, e}
# end
case args do
[_ | rgs] ->
quote do
def unquote({fun_name, ctx, [{:error, Macro.var(:e, nil)} | ignore_all(rgs)]}) do
{:error, unquote(Macro.var(:e, nil))}
end
end
|> register_definition(function_name, __CALLER__.module, :error_cases)
end
# universal ok case, that delegates to the original function. It defines:
# def fun_name({:ok, x}, y, ...) [when ...] do
# fun_name(x, y, ...)
# end
case ununderscore(args) do
[_ | rgs] ->
quote do
def unquote(with_guards.({fun_name, ctx, [{:ok, Macro.var(:x, nil)} | rgs]})) do
unquote({fun_name, ctx, [Macro.var(:x, nil) | rgs]})
end
end
|> register_definition(function_name, __CALLER__.module, :ok_cases)
end
# nothing is declared at this point: definitions are accumulated in
# module attributes for later compilation
quote do
end
end
defp register_definition(ast, function_name, module, attribute) do
Module.put_attribute(module, attribute, {function_name, ast})
end
@doc """
Alternative piping: it first step fails, try another one.
"""
defmacro exp <|> r do
quote do
case unquote(exp) do
:ok -> :ok
{:ok, _} = ok -> ok
{:error, _} -> unquote(r)
end
end
end
# replaces every argument with an '_' (this will ignore any pattern matchers too)
defp ignore_all(args) do
Enum.map(args, fn _ -> {:_, [], nil} end)
end
# replaces underscored variables with numbered ones in argument list
defp ununderscore(args) do
Macro.postwalk(args, 0, fn
{name, meta, r}, i when is_atom(r) ->
case to_string(name) do
"_" <> _ -> {{:"x#{i}", meta, r}, i + 1}
_ -> {{name, meta, r}, i}
end
ast, i ->
{ast, i}
end)
|> elem(0)
end
defp pick_definitions(defns, fun_name) do
Enum.flat_map(defns, fn
{^fun_name, defn} -> [defn]
_ -> []
end)
end
defp function_name({fun_name, _, args}) do
:"#{fun_name}/#{length(args)}"
end
@doc false
# reorder function case definitions (error and ok cases go before the regular ones)
# and injects them into the module
# > If it's a macro, its returned value will be injected at the end of the module
# > definition before the compilation starts.
defmacro __before_compile__(env) do
# extract all definitions, and sort them in order of declaration, which is reverse
[default_cases, ok_cases, error_cases] =
[:default_cases, :ok_cases, :error_cases]
|> Enum.map(fn attribute ->
Module.get_attribute(env.module, attribute)
|> List.wrap()
|> Enum.reverse()
|> Enum.uniq_by(&shape/1)
end)
# obtain function names from default cases, and pick their definitions in
# order of declaration, sorting error, ok, and default cases per function
# to keep their order
default_cases
|> Enum.map(fn {name, _} -> name end)
|> Enum.uniq()
|> Enum.flat_map(fn name ->
# concatenate definitions in that order: errors, ok's, regular declarations
[error_cases, ok_cases, default_cases]
|> Enum.flat_map(fn defns -> pick_definitions(defns, name) end)
end)
|> case do
# wrap the definitions into a block
defns -> {:__block__, [], defns}
end
# |> case do
# # emit AST to stdout if debug flag is set
# ast ->
# if Module.get_attribute(__CALLER__.module, :pipehammer_debug) do
# IO.inspect(ast)
# else
# ast
# end
# end
end
# rename the variables and erase context. Because it does not replace bound
# vars in body, it is only useful for identifying duplicate function implementations
# {:def, [context: Pipehammer, import: Kernel], [
# {:add, [line: 31], [{:error, {:e, [], nil}}, {:_y, [line: 31], nil}]}
# ]}}
# =>
# {:def, [], [{:add, [], [{:error, {:arg0, [], nil}}, {:arg1, [], nil}]}]}}
defp shape(defn) do
Macro.postwalk(defn, 0, fn
{_, _, nil}, i -> {{:"x#{i}", [], nil}, i + 1}
{a, _, b}, i -> {{a, [], b}, i}
ast, i -> {ast, i}
end)
|> elem(0)
end
defmacro __using__(opts \\ []) do
debug = Keyword.get(opts, :debug, false)
quote do
import Pipehammer
require Pipehammer
@pipehammer_debug unquote(debug)
@before_compile Pipehammer
end
end
end