defmodule Kvasir.Command do @moduledoc ~S""" """ alias Kvasir.Command.{Encoder, Meta} import UTCDateTime, only: [utc_now: 0] @typedoc @moduledoc @type t :: struct @callback __command__(atom) :: any # @callback create(any) :: {:ok, t} | {:error, atom} # @callback create!(any) :: {:ok, t} | {:error, atom} # @callback factory(any) :: {:ok, map} | {:error, atom} @callback validate(t) :: :ok | {:error, atom} @doc @moduledoc defmacro __using__(_opts \\ []) do quote location: :keep do require Kvasir.Command import Kvasir.Command, only: [command: 2, command: 3] # import Kvasir.Command.Encodings.Raw, only: [decode: 2] end end defmacro field(name, type \\ :string, opts \\ []) do opts = Keyword.put_new(opts, :sensitive, false) quote do Module.put_attribute( __MODULE__, :command_fields, {unquote(name), unquote(Kvasir.Type.lookup(type)), unquote(opts) |> Keyword.put_new_lazy(:doc, fn -> case Module.delete_attribute(__MODULE__, :doc) do {_, doc} -> doc _ -> nil end end)} ) end end defmacro command(type, do: block), do: create_command(__CALLER__, type, block, []) defmacro command(type, opts), do: create_command(__CALLER__, type, nil, opts) defmacro command(type, opts, do: block), do: create_command(__CALLER__, type, block, opts) defp create_command(caller, type, block, opts) do emits = case opts[:emits] do nil -> [] e when is_list(e) -> Enum.map(e, &Macro.expand(&1, caller)) e -> [Macro.expand(e, caller)] end {emits, pre_fill} = if e = opts[:for] do e = Macro.expand(e, caller) fields = e.__event__(:fields) base = quote do @doc ~S""" Create an event based on the given command. Optionally allows for passing a keyword list to override or set values. ## Examples ```elixir iex> to_event() {:ok, } ``` """ @spec to_event(Kvasir.Command.t(), Keyword.t()) :: {:ok, Kvasir.Event.t()} | {:error, atom} def to_event(command, override \\ []) do unquote(Enum.map(fields, &elem(&1, 0))) |> Enum.reduce([], fn f, a -> case Map.fetch(command, f) do {:ok, v} when v != nil -> [{f, v} | a] _ -> a end end) |> Keyword.merge(override) |> unquote(e).create() end @doc ~S""" Create an event based on the given command. Optionally allows for passing a keyword list to override or set values. ## Examples ```elixir iex> to_event!() ``` """ @spec to_event!(Kvasir.Command.t(), Keyword.t()) :: Kvasir.Event.t() | no_return def to_event!(command, override \\ []) do unquote(Enum.map(fields, &elem(&1, 0))) |> Enum.reduce([], fn f, a -> case Map.fetch(command, f) do {:ok, v} when v != nil -> [{f, v} | a] _ -> a end end) |> Keyword.merge(override) |> unquote(e).create!() end end {[e | emits], Enum.reduce(fields, base, fn {n, t, o}, acc -> quote do unquote(acc) field unquote(n), unquote(t), unquote(Macro.escape(o)) end end)} else {emits, nil} end quote location: :keep do @command_type unquote(Kvasir.Util.name(type)) @command_emits unquote(emits) @before_compile Kvasir.Command Module.register_attribute(__MODULE__, :command_fields, accumulate: true) try do import Kvasir.Command, only: [field: 1, field: 2, field: 3] unquote(pre_fill) unquote(block) after :ok end Module.register_attribute(__MODULE__, :__command__, persist: true) Module.put_attribute(__MODULE__, :__command__, unquote(Kvasir.Util.name(type))) @sensitive_fields @command_fields |> Enum.filter(fn {_, _, o} -> o[:sensitive] end) |> Enum.map(&elem(&1, 0)) @struct_fields Enum.reverse(@command_fields) @instance_id Enum.find_value( @command_fields, :global, &if(elem(&1, 2)[:instance_id], do: elem(&1, 0)) ) defstruct Enum.map(@struct_fields, &elem(&1, 0)) ++ [__meta__: %Kvasir.Command.Meta{}] defimpl Jason.Encoder, for: __MODULE__ do alias Jason.EncodeError alias Jason.Encoder.Map alias Kvasir.Command.Encoder def encode(value, opts) do case Encoder.encode(value) do {:ok, data} -> Map.encode(data, opts) {:error, error} -> %EncodeError{message: "Command Encoding Error: #{error}"} end end end @doc false @impl Kvasir.Command @spec validate(struct) :: :ok | {:error, atom} def validate(command), do: :ok @behaviour Kvasir.Command defoverridable validate: 1 @doc false @spec create_from(map) :: {:ok, Kvasir.Command.t()} | {:error, atom} def create_from(data), do: Kvasir.Command.Encoder.decode(%{payload: data, type: __MODULE__}, process: :create) end end defmacro __before_compile__(env) do config = Mix.Project.config() app = Keyword.get(config, :app) version = Keyword.get(config, :version) docs = Keyword.get(config, :docs, []) package = Keyword.get(config, :package, []) dep_depth = config |> Keyword.get(:deps_path) |> Path.split() |> Enum.count() path = __CALLER__.file |> Path.split() |> Enum.slice((dep_depth + 1)..-1) |> Path.join() {hex, hexdocs} = case {Keyword.get(package, :name), Keyword.get(package, :organization)} do {nil, _} -> {nil, nil} {app, nil} -> {"https://hex.pm/packages/#{app}/#{version}", "https://hexdocs.pm/#{app}/#{version}/#{inspect(__CALLER__.module)}.html"} {app, org} -> {"https://hex.pm/packages/#{org}/#{app}/#{version}", "https://#{org}.hexdocs.pm/#{app}/#{version}/#{inspect(__CALLER__.module)}.html"} end source = case {Keyword.get(docs, :source_url), Keyword.get(docs, :source_ref)} do {nil, _} -> nil {url, nil} -> url <> "/src/#{path}" {url, ref} -> url <> "/src/#{ref}/#{path}" end arities = env.module |> Module.definitions_in(:def) |> Enum.filter(&(elem(&1, 0) == :factory)) |> Enum.map(&elem(&1, 1)) |> Enum.sort() quote location: :keep do unquote(creation(arities)) @doc false @impl Kvasir.Command @spec __command__(atom) :: term def __command__(:type), do: @command_type def __command__(:fields), do: @struct_fields def __command__(:emits), do: @command_emits def __command__(:instance_id), do: @instance_id def __command__(:create), do: unquote(arities) def __command__(:sensitive), do: @sensitive_fields def __command__(:app), do: {unquote(app), unquote(version)} def __command__(:doc), do: @moduledoc def __command__(:hex), do: unquote(hex) def __command__(:hexdocs), do: unquote(hexdocs) def __command__(:source), do: unquote(source) @field_type Map.new(@struct_fields, fn {k, v, _} -> {k, v} end) @doc false @spec __command__(atom, atom) :: term def __command__(:type, field), do: @field_type[field] defimpl Inspect, for: __MODULE__ do import Inspect.Algebra def inspect(data = %event{}, opts) do a = data |> Map.drop(~w(__meta__ __struct__)a) |> Map.new(fn {k, v} -> if v != nil and k in event.__command__(:sensitive) do {k, %Kvasir.Event.Sensitive{value: v, type: event.__command__(:type, k)}} else {k, v} end end) if a == %{} do concat([ {:doc_color, :doc_nil, [:reset]}, "⊰", inspect(data.__struct__), {:doc_color, :doc_nil, [:italic, :yellow]}, "<", data.__meta__.id || "NotDispatched", ">", {:doc_color, :doc_nil, :reset}, "⊱" ]) else concat([ {:doc_color, :doc_nil, [:reset]}, "⊰", inspect(data.__struct__), {:doc_color, :doc_nil, [:italic, :yellow]}, "<", data.__meta__.id || "NotDispatched", ">", {:doc_color, :doc_nil, :reset}, remove(to_doc(a, opts)), {:doc_color, :doc_nil, :reset}, "⊱" ]) end end defp remove({a, "%{", c}), do: {a, "{", c} defp remove({a, b, c}), do: {a, remove(b), c} defp remove({a, b, c, d}), do: {a, remove(b), c, d} end end end defp creation([]) do quote do @doc ~S""" Create this command based on the given data. ## Example ```elixir iex> create(key: value) {:ok, } ``` """ # @impl Kvasir.Command @spec create(term) :: {:ok, struct} | {:error, term} def create(data \\ []), do: Kvasir.Command.create(__MODULE__, [data]) @doc ~S""" Create this command based on the given data. See: `create/1`. ## Example ```elixir iex> create!(key: value) ``` """ # @impl Kvasir.Command @spec create!(term) :: struct | no_return def create!(data \\ []), do: Kvasir.Command.create!(__MODULE__, [data]) @doc false # @impl Kvasir.Command @spec factory(term) :: {:ok, term} | {:error, term} def factory(payload) when is_list(payload), do: {:ok, Map.new(payload)} def factory(payload), do: {:ok, payload} end end defp creation(arities) do Enum.reduce(arities, nil, fn arity, acc -> data = 1..arity |> Enum.map(&Macro.var(:"arg#{&1}", nil)) spec_create = {:@, [context: Elixir, import: Kernel], [ {:spec, [context: Elixir], [ {:"::", [], [ {:create, [], Enum.map(1..arity, fn _ -> {:term, [], Elixir} end)}, {:|, [], [ok: {:struct, [], Elixir}, error: {:term, [], Elixir}]} ]} ]} ]} spec_create! = {:@, [context: Elixir, import: Kernel], [ {:spec, [context: Elixir], [ {:"::", [], [ {:create!, [], Enum.map(1..arity, fn _ -> {:term, [], Elixir} end)}, {:|, [], [{:struct, [], Elixir}, error: {:term, [], Elixir}]} ]} ]} ]} quote do unquote(acc) @doc """ Create this command based on the given data. ## Example ```elixir iex> create(#{unquote(1..arity |> Enum.map(&"arg#{&1}") |> Enum.join(", "))}) {:ok, } ``` ## Note """ # @impl Kvasir.Command unquote(spec_create) def create(unquote_splicing(data)), do: Kvasir.Command.create(__MODULE__, unquote(data)) @doc """ Create this command based on the given data. See: `create/#{unquote(arity)}`. ## Example ```elixir iex> create!(#{unquote(1..arity |> Enum.map(&"arg#{&1}") |> Enum.join(", "))}) ``` """ # @impl Kvasir.Command unquote(spec_create!) def create!(unquote_splicing(data)), do: Kvasir.Command.create!(__MODULE__, unquote(data)) end end) end @doc ~S""" Create a command by passing the command module and payload. """ def create(command, data) do with {:ok, payload} <- apply(command, :factory, data), {:ok, result} <- decode(command, payload, %Meta{created: utc_now()}), :ok <- command.validate(result) do {:ok, result} end rescue KeyError -> {:error, :invalid_command_data} end def create!(command, data) do with {:ok, result} <- create(command, data) do result else {:error, reason} -> raise "Failed to create #{command}, reason: #{reason}" end end def is_command?(command) when is_atom(command), do: true def is_command?(_), do: false def instance(command) do case command.__meta__.scope do {:instance, i} -> i _ -> nil end end def set_executed(command), do: update_meta(command, :executed, utc_now()) def set_applied(command), do: update_meta(command, :applied, utc_now()) def set_offset(command, offset), do: update_meta(command, :offset, offset) def set_instance(command, instance), do: update_meta(command, :scope, {:instance, instance}) defp update_meta(command = %{__meta__: meta}, field, value), do: %{command | __meta__: Map.put(meta, field, value)} defdelegate encode(value, opts \\ []), to: Encoder defdelegate decode(value, opts \\ []), to: Encoder defdelegate decode(command, payload, meta), to: Encoder end