Current section
Files
Jump to
Current section
Files
lib/commanded/middleware/uniqueness.ex
defmodule Commanded.Middleware.Uniqueness do
@behaviour Commanded.Middleware
@moduledoc """
Documentation for Commanded.Middleware.Uniqueness.
"""
@default_partition __MODULE__
defprotocol UniqueFields do
@fallback_to_any true
@doc """
Returns unique fields for a command as a list of tuples as:
`{field_name :: atom() | list(atom), error_message :: String.t(), owner :: term, opts :: keyword()}`,
where `opts` might contain none, one or multiple options:
`ignore_case: true` or `ignore_case: [:email, :username]` for multi-fields entities - binary-based
fields are downcased before comparison
`:label` - use this atom as error label
`:is_unique` - `(term, String.t(), term, keyword() -> boolean())`
`:partition` - use to set custom partition name
`:no_owner` - if true then ignore owner and check `field_name` - `field_value` pair uniqueness
in a `partition` scope. `release_by_value/3` must be used to release key-value pair in such case.
`:no_owner` option has sense when it is necessary to ensure uniquenesses in embedded structs.
"""
def unique(command)
end
defimpl UniqueFields, for: Any do
def unique(_command), do: []
end
@doc """
Returns default parition which is by default @Commanded.Middleware.Uniqueness
"""
def default_partition do
@default_partition
end
@doc """
Claims a `key`, `value`, `owner`, `partition` set
or reports that this combination has already been claimed.
If a `key`, `value`, `owner`, `partition` set has to be claimed
and an old value for the given owner exists it releases first.
If `partition` is ommited then default partition used.
"""
@spec claim(key :: term, value :: term, owner :: term, partition :: term) ::
:ok
| {:error, :already_exists}
| {:error, :unknown_error}
| {:error, :no_adapter}
def claim(key, value, owner, partition \\ @default_partition) do
case get_adapter() do
nil -> {:error, :no_adapter}
adapter -> adapter.claim(key, value, owner, partition)
end
end
@doc """
Claims a `key`, `value`, `partition` set
or reports that this combination has already been claimed.
If `partition` is ommited then default partition used.
"""
@spec claim_without_owner(key :: term, value :: term, partition :: term) ::
:ok
| {:error, :already_exists}
| {:error, :unknown_error}
| {:error, :no_adapter}
def claim_without_owner(key, value, partition \\ @default_partition) do
case get_adapter() do
nil -> {:error, :no_adapter}
adapter -> adapter.claim(key, value, partition)
end
end
@doc """
Releases a value record via `key`, `value`, `owner`, `partition` set
"""
@spec release(key :: term, value :: term, owner :: term, partition :: term) ::
:ok
| {:error, :claimed_by_another_owner}
| {:error, :unknown_error}
| {:error, :no_adapter}
def release(key, value, owner, partition \\ @default_partition) do
case get_adapter() do
nil -> {:error, :no_adapter}
adapter -> adapter.release(key, value, owner, partition)
end
end
@doc """
Releases a value record via `key`, `owner`, `partition` set
"""
@spec release_by_owner(key :: term, owner :: term, partition :: term) ::
:ok
| {:error, :unknown_error}
| {:error, :no_adapter}
def release_by_owner(key, owner, partition \\ @default_partition) do
case get_adapter() do
nil -> {:error, :no_adapter}
adapter -> adapter.release_by_owner(key, owner, partition)
end
end
@doc """
Releases a value record via `key`, `value`, `partition` set
"""
@spec release_by_value(key :: term, value :: term, partition :: term) ::
:ok
| {:error, :unknown_error}
| {:error, :no_adapter}
def release_by_value(key, value, partition \\ @default_partition) do
case get_adapter() do
nil -> {:error, :no_adapter}
adapter -> adapter.release_by_value(key, value, partition)
end
end
### Pipeline itself
alias Commanded.Middleware.Pipeline
import Pipeline
@doc false
def before_dispatch(%Pipeline{command: command} = pipeline) do
case ensure_uniqueness(command) do
:ok ->
pipeline
{:error, errors} ->
pipeline
|> respond({:error, :validation_failure, errors})
|> halt()
end
end
@doc false
def after_dispatch(pipeline), do: pipeline
@doc false
def after_failure(pipeline), do: pipeline
defp ensure_uniqueness(command) do
ensure_uniqueness(command, get_adapter())
end
defp ensure_uniqueness(_command, nil) do
require Logger
Logger.debug("No unique cache adapter defined in config! Assume the value is unique.",
label: "#{__MODULE__}"
)
:ok
end
defp ensure_uniqueness(command, adapter) do
command
|> UniqueFields.unique()
|> ensure_uniqueness(command, adapter, [], [])
end
defp ensure_uniqueness([record | rest], command, adapter, errors, to_release) do
{_, error_message, _, _} = record = expand_record(record)
label = get_label(record)
{errors, to_release} =
case claim_value(record, command, adapter) do
{key, value, owner, partition} ->
to_release = [{key, value, owner, partition} | to_release]
{errors, to_release}
_ ->
errors = [{label, error_message} | errors]
{errors, to_release}
end
ensure_uniqueness(rest, command, adapter, errors, to_release)
end
defp ensure_uniqueness([], _command, _adapter, [], _to_release), do: :ok
defp ensure_uniqueness([], _command, _adapter, errors, []), do: {:error, errors}
defp ensure_uniqueness([], command, adapter, errors, to_release) do
Enum.each(to_release, &release(&1, adapter))
ensure_uniqueness([], command, adapter, errors, [])
end
defp claim_value({fields, _, owner, opts}, command, adapter)
when is_list(fields) do
value =
fields
|> Enum.reduce([], fn field_name, acc ->
ignore_case = Keyword.get(opts, :ignore_case)
[get_field_value(command, field_name, ignore_case) | acc]
end)
key = Module.concat(fields)
command = %{key => value}
entity = {key, "", owner, opts}
claim_value(entity, command, adapter)
end
defp claim_value({field_name, _, owner, opts}, command, adapter)
when is_atom(field_name) do
ignore_case = Keyword.get(opts, :ignore_case)
value = get_field_value(command, field_name, ignore_case)
partition = get_partition(opts, command)
require Logger
claim_result =
case Keyword.get(opts, :no_owner, false) do
false ->
adapter.claim(field_name, value, owner, partition)
true ->
adapter.claim(field_name, value, partition)
_ ->
raise ArgumentError,
"Commanded.Middleware.Uniqueness.UniqueFields :no_owner option can only be either true or false"
end
case claim_result do
:ok ->
case external_check(field_name, value, owner, command, opts) do
true ->
{field_name, value, owner, partition}
_ ->
release({field_name, value, owner, partition}, adapter)
{:error, :external_check_failed}
end
error ->
error
end
end
defp release({key, value, owner, partition}, adapter),
do: adapter.release(key, value, owner, partition)
defp external_check(field_name, value, owner, command, opts) when is_list(opts),
do: external_check(field_name, value, owner, command, get_external_checker(opts))
defp external_check(field_name, value, owner, _command, {checker, opts})
when is_function(checker, 4),
do: checker.(field_name, value, owner, opts)
defp external_check(_field_name, _value, _owner, _command, {nil, _}), do: true
defp external_check(_field_name, _value, _owner, %{__struct__: module}, _opts),
do:
raise(
"#{__MODULE__}: The ':is_unique' option for the #{module} command has incorrect value. It should be only a function with 4 arguments"
)
defp expand_record({one, two, three}), do: {one, two, three, []}
defp expand_record(entity), do: entity
defp get_field_value(command, field_name, ignore_case)
defp get_field_value(command, field_name, ignore_case) when is_list(ignore_case),
do: get_field_value(command, field_name, Enum.any?(ignore_case, &(&1 == field_name)))
defp get_field_value(command, field_name, field_name),
do: get_field_value(command, field_name, true)
defp get_field_value(command, field_name, true),
do: command |> get_field_value(field_name, false) |> downcase()
defp get_field_value(command, field_name, _), do: Map.get(command, field_name)
defp downcase(value) when is_binary(value), do: String.downcase(value)
defp downcase(value), do: value
defp get_label({entity, _, _, opts}), do: Keyword.get(opts, :label, entity)
defp get_external_checker(opts), do: {Keyword.get(opts, :is_unique), opts}
defp get_partition(opts, command), do: get_partition(opts, command, use_command_as_partition?())
defp get_partition(opts, %command{}, true), do: Keyword.get(opts, :partition, command)
defp get_partition(opts, _, _), do: Keyword.get(opts, :partition, default_partition())
defp get_adapter, do: Application.get_env(:commanded_uniqueness_middleware, :adapter)
defp use_command_as_partition?,
do: Application.get_env(:commanded_uniqueness_middleware, :use_command_as_partition)
end