Skip to content
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
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
locals_without_parens = [
param: 1,
param: 2,
param: 3,
data: 1,
pipeline: 1
]
Expand Down
114 changes: 64 additions & 50 deletions lib/commandex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ defmodule Commandex do
The `command/1` macro will define a struct that looks like:

%RegisterUser{
__meta__: %{
pipelines: [:hash_password, :create_user, :send_welcome_email]
},
success: false,
halted: false,
errors: %{},
params: %{email: nil, password: nil},
data: %{password_hash: nil, user: nil},
pipelines: [:hash_password, :create_user, :send_welcome_email]
}

As well as two functions:
Expand Down Expand Up @@ -96,12 +98,16 @@ defmodule Commandex do

iex> GenerateReport.run()
%GenerateReport{
pipelines: [:fetch_data, :calculate_results],
__meta__: %{
params: %{},
pipelines: [:fetch_data, :calculate_results],
},
data: %{total_valid: 183220, total_invalid: 781215},
params: %{},
halted: false,
errors: %{},
success: true
halted: false,
params: %{},
success: true,
valid: true
}
"""

Expand Down Expand Up @@ -139,11 +145,13 @@ defmodule Commandex do
"""
@type command :: %{
__struct__: atom,
__meta__: %{
pipelines: [pipeline()]
},
data: map,
errors: map,
halted: boolean,
params: map,
pipelines: [pipeline()],
success: boolean
}

Expand All @@ -158,10 +166,6 @@ defmodule Commandex do
Module.register_attribute(__MODULE__, name, accumulate: true)
end

for field <- [{:success, false}, {:errors, %{}}, {:halted, false}] do
Module.put_attribute(__MODULE__, :struct_fields, field)
end

try do
import Commandex
unquote(block)
Expand All @@ -172,13 +176,20 @@ defmodule Commandex do

postlude =
quote unquote: false do
params = for pair <- Module.get_attribute(__MODULE__, :params), into: %{}, do: pair
data = for pair <- Module.get_attribute(__MODULE__, :data), into: %{}, do: pair
data = __MODULE__ |> Module.get_attribute(:data) |> Enum.into(%{})
params = __MODULE__ |> Module.get_attribute(:params) |> Enum.into(%{})
pipelines = __MODULE__ |> Module.get_attribute(:pipelines) |> Enum.reverse()

Module.put_attribute(__MODULE__, :struct_fields, {:params, params})
schema = %{params: params, pipelines: pipelines}

# Added in reverse order so the struct fields sort alphabetically.
Module.put_attribute(__MODULE__, :struct_fields, {:valid, false})
Module.put_attribute(__MODULE__, :struct_fields, {:success, false})
Module.put_attribute(__MODULE__, :struct_fields, {:params, %{}})
Module.put_attribute(__MODULE__, :struct_fields, {:halted, false})
Module.put_attribute(__MODULE__, :struct_fields, {:errors, %{}})
Module.put_attribute(__MODULE__, :struct_fields, {:data, data})
Module.put_attribute(__MODULE__, :struct_fields, {:pipelines, pipelines})
Module.put_attribute(__MODULE__, :struct_fields, {:__meta__, schema})

defstruct @struct_fields

@typedoc """
Expand All @@ -195,20 +206,23 @@ defmodule Commandex do
`true` if the command was not halted after running all of the pipelines.
"""
@type t :: %__MODULE__{
__meta__: %{
pipelines: [Commandex.pipeline()]
},
data: map,
errors: map,
halted: boolean,
params: map,
pipelines: [Commandex.pipeline()],
success: boolean
success: boolean | nil,
valid: boolean | nil
}

@doc """
Creates a new struct from given parameters.
"""
@spec new(map | Keyword.t()) :: t
def new(opts \\ []) do
Commandex.parse_params(%__MODULE__{}, opts)
def new(params \\ []) do
Commandex.Parameter.cast_params(%__MODULE__{}, params)
end

if Enum.empty?(params) do
Expand All @@ -217,8 +231,7 @@ defmodule Commandex do
"""
@spec run :: t
def run do
new()
|> run()
new() |> run()
end
end

Expand All @@ -229,7 +242,13 @@ defmodule Commandex do
or the command struct itself.
"""
@spec run(map | Keyword.t() | t) :: t
def run(%unquote(__MODULE__){pipelines: pipelines} = command) do
def run(%unquote(__MODULE__){valid: false} = command) do
command
|> halt()
|> Commandex.maybe_mark_successful()
end

def run(%unquote(__MODULE__){__meta__: %{pipelines: pipelines}} = command) do
pipelines
|> Enum.reduce_while(command, fn fun, acc ->
case acc do
Expand Down Expand Up @@ -267,9 +286,9 @@ defmodule Commandex do
end
"""
@spec param(atom, Keyword.t()) :: no_return
defmacro param(name, opts \\ []) do
defmacro param(name, type \\ :any, opts \\ []) do
quote do
Commandex.__param__(__MODULE__, unquote(name), unquote(opts))
Commandex.__param__(__MODULE__, unquote(name), unquote(type), unquote(opts))
end
end

Expand Down Expand Up @@ -348,7 +367,7 @@ defmodule Commandex do
@doc """
Sets error for given key and value.

`:errors` is a map. Putting an error on the same key will overwrite the previous value.
`:errors` is a map. Putting an error on the same key will create a list.

def hash_password(command, %{password: nil} = _params, _data) do
command
Expand All @@ -357,8 +376,12 @@ defmodule Commandex do
end
"""
@spec put_error(command, any, any) :: command
def put_error(%{errors: error} = command, key, val) do
%{command | errors: Map.put(error, key, val)}
def put_error(%{errors: errors} = command, key, val) do
case Map.get(errors, key) do
nil -> %{command | errors: Map.put(errors, key, val)}
vals when is_list(vals) -> %{command | errors: Map.put(errors, key, [val | vals])}
value -> %{command | errors: Map.put(errors, key, [val, value])}
end
end

@doc """
Expand All @@ -378,17 +401,11 @@ defmodule Commandex do

@doc false
def maybe_mark_successful(%{halted: false} = command), do: %{command | success: true}
def maybe_mark_successful(command), do: command
def maybe_mark_successful(command), do: %{command | success: false}

@doc false
def parse_params(%{params: p} = struct, params) when is_list(params) do
params = for {key, _} <- p, into: %{}, do: {key, Keyword.get(params, key, p[key])}
%{struct | params: params}
end

def parse_params(%{params: p} = struct, %{} = params) do
params = for {key, _} <- p, into: %{}, do: {key, get_param(params, key, p[key])}
%{struct | params: params}
def maybe_mark_invalid(command) do
%{command | valid: Enum.empty?(command.errors)}
end

@doc false
Expand All @@ -412,15 +429,22 @@ defmodule Commandex do
:erlang.apply(m, f, [command, params, data] ++ a)
end

def __param__(mod, name, opts) do
# If no type is defined, opts keyword list becomes third argument.
# Run this again with the :any type.
def __param__(mod, name, opts, []) when is_list(opts) do
__param__(mod, name, :any, opts)
end

def __param__(mod, name, type, opts) do
Commandex.Parameter.check_type!(name, type)

params = Module.get_attribute(mod, :params)

if List.keyfind(params, name, 0) do
if Enum.any?(params, fn {p_name, _opts} -> p_name == name end) do
raise ArgumentError, "param #{inspect(name)} is already set on command"
end

default = Keyword.get(opts, :default)
Module.put_attribute(mod, :params, {name, default})
Module.put_attribute(mod, :params, {name, {type, opts}})
end

def __data__(mod, name) do
Expand Down Expand Up @@ -456,14 +480,4 @@ defmodule Commandex do
def __pipeline__(_mod, name) do
raise ArgumentError, "pipeline #{inspect(name)} is not valid"
end

defp get_param(params, key, default) do
case Map.get(params, key) do
nil ->
Map.get(params, to_string(key), default)

val ->
val
end
end
end
78 changes: 78 additions & 0 deletions lib/commandex/parameter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
defmodule Commandex.Parameter do
@moduledoc false

@base ~w(any boolean float integer string)a

def check_type!(name, {_outer, inner}) do
check_type!(name, inner)
end

def check_type!(_name, type) when type in @base do
type
end

def check_type!(name, type) do
raise ArgumentError, "unknown type #{inspect(type)} for param #{inspect(name)}"
end

def cast_params(%{__meta__: %{params: schema_params}} = command, params)
when is_map(params) or is_list(params) do
schema_params
|> extract_params(params)
|> Enum.reduce(command, fn {key, val}, command ->
{type, _opts} = command.__meta__.params[key]

case Commandex.Type.cast(val, type) do
{:ok, :"$undefined"} ->
command

{:ok, cast_value} ->
put_param(command, key, cast_value)

:error ->
command
|> put_param(key, val)
|> Commandex.put_error(key, :invalid)
end
end)
|> validate_required()
|> Commandex.maybe_mark_invalid()
end

def validate_required(%{__meta__: %{params: schema_params}} = command) do
schema_params
|> Enum.reduce(command, fn {key, {_type, opts}}, command ->
case {Keyword.get(opts, :required), Map.has_key?(command.params, key)} do
{true, true} -> command
{true, false} -> Commandex.put_error(command, key, :required)
{_, _} -> command
end
end)
end

defp extract_params(schema_params, input_params) do
schema_params
|> Enum.map(fn {key, {_type, opts}} ->
default = Keyword.get(opts, :default, :"$undefined")
{key, get_param(input_params, key, default)}
end)
|> Enum.into(%{})
end

defp get_param(params, key, default) when is_list(params) do
Keyword.get(params, key, default)
end

defp get_param(params, key, default) when is_map(params) do
with nil <- Map.get(params, key),
nil <- Map.get(params, to_string(key)) do
default
else
value -> value
end
end

defp put_param(command, name, value) do
put_in(command.params[name], value)
end
end
63 changes: 63 additions & 0 deletions lib/commandex/type.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule Commandex.Type do
@undefined :"$undefined"

@callback cast(term) :: {:ok, term} | :error

@doc ~S"""
Casts value for given type.

## Examples

iex> cast([3, true], :array)
{:ok, [3, true]}

iex> cast([1, 2], {:array, :integer})
{:ok, [1,2]}

iex> cast([1, "what"], {:array, :integer})
:error

iex> cast(1, {:array, :integer})
:error

iex> cast(1, :any)
{:ok, 1}

iex> cast(true, :boolean)
{:ok, true}

iex> cast(1.5, :float)
{:ok, 1.5}

iex> cast(2, :integer)
{:ok, 2}

iex> cast("example", :string)
{:ok, "example"}

iex> cast(:"$undefined", :boolean)
{:ok, :"$undefined"}

iex> cast(1234, "not a type")
:error
"""
def cast(@undefined, _type), do: {:ok, @undefined}

def cast(value, :array), do: cast(value, {:array, :any})

def cast(value, {:array, type}) when is_list(value) do
if Enum.any?(value, fn val -> cast(val, type) == :error end),
do: :error,
else: {:ok, value}
end

def cast(_value, {:array, _type}), do: :error

def cast(value, :any), do: {:ok, value}
def cast(value, :boolean), do: Commandex.Type.Boolean.cast(value)
def cast(value, :float), do: Commandex.Type.Float.cast(value)
def cast(value, :integer), do: Commandex.Type.Integer.cast(value)
def cast(value, :string), do: Commandex.Type.String.cast(value)
def cast(value, module) when is_atom(module), do: module.cast(value)
def cast(_value, _type), do: :error
end
Loading