defmodule Ash.Reactor.Dsl.ActionTransformer do @moduledoc """ Responsible for transforming actions. """ alias Ash.{Domain, Resource} alias Reactor.Utils alias Spark.{Dsl, Dsl.Transformer, Error.DslError} use Transformer @doc false @impl true @spec before?(module) :: boolean def before?(Reactor.Dsl.Transformer), do: true def before?(_), do: false @doc false @impl true @spec transform(Dsl.t()) :: {:ok, Dsl.t()} | {:error, DslError.t()} def transform(dsl_state) do dsl_state |> Transformer.get_entities([:reactor]) |> Utils.reduce_while_ok(dsl_state, fn entity, dsl_state -> case transform_step(entity, dsl_state) do {:ok, entity, dsl_state} -> {:ok, Transformer.replace_entity(dsl_state, [:reactor], entity)} {:error, reason} -> {:error, reason} :ignore -> {:ok, dsl_state} end end) end defp transform_step(entity, dsl_state) when entity.action_step? do with {:ok, entity} <- transform_entity_domain(entity, dsl_state), :ok <- validate_entity_domain(entity, dsl_state), :ok <- validate_entity_resource(entity, dsl_state), {:ok, action} <- get_entity_resource_action(entity, dsl_state), entity <- %{entity | action: action.name}, :ok <- validate_entity_input_names(entity, action, dsl_state), :ok <- validate_entity_input_dupes(entity, dsl_state), :ok <- validate_entity_input_empty(entity, dsl_state), :ok <- maybe_validate_upsert_identity(entity, dsl_state) do transform_nested_steps(entity, dsl_state) end end defp transform_step(entity, dsl_state) when entity.type == :transaction do with :ok <- validate_entity_resources(entity, dsl_state) do transform_nested_steps(entity, dsl_state) end end defp transform_step(entity, dsl_state) when entity.type == :load do default_domain = Transformer.get_option(dsl_state, [:ash], :default_domain) {:ok, %{entity | domain: entity.domain || default_domain}, dsl_state} end defp transform_step(_entity, _dsl_state), do: :ignore defp transform_nested_steps(entity, dsl_state) when is_list(entity.steps) do entity.steps |> Enum.reduce_while({:ok, %{entity | steps: []}, dsl_state}, fn step, {:ok, entity, dsl_state} -> case transform_step(step, dsl_state) do {:ok, step, dsl_state} -> {:cont, {:ok, %{entity | steps: [step | entity.steps]}, dsl_state}} :ignore -> {:cont, {:ok, %{entity | steps: [step | entity.steps]}, dsl_state}} {:error, reason} -> {:halt, {:error, reason}} end end) |> case do {:ok, entity, dsl_state} -> {:ok, %{entity | steps: Enum.reverse(entity.steps)}, dsl_state} other -> other end end defp transform_nested_steps(entity, dsl_state), do: {:ok, entity, dsl_state} defp transform_entity_domain(entity, dsl_state) do resource_domain = Resource.Info.domain(entity.resource) default_domain = Transformer.get_option(dsl_state, [:ash], :default_domain) {:ok, %{entity | domain: entity.domain || resource_domain || default_domain}} end defp validate_entity_domain(entity, dsl_state) do if entity.domain.spark_is() == Domain do :ok else {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: "The #{entity.type} step `#{inspect(entity.name)}` has its domain set to `#{inspect(entity.domain)}` but it is not a valid `Ash.Domain`." )} end end defp validate_entity_resource(entity, dsl_state) do if entity.resource.spark_is() == Resource do :ok else {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: "The #{entity.type} step `#{inspect(entity.name)}` has its resource set to `#{inspect(entity.resource)}` but it is not a valid Ash resource." )} end end defp validate_entity_resources(entity, dsl_state) do entity.resources |> Enum.reject(&(&1.spark_is() == Resource)) |> case do [] -> :ok [resource] -> {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: "The #{entity.type} step `#{inspect(entity.name)}` has its resources set to `#{inspect(resource)}` but it is not a valid Ash resource." )} resources -> resources = Enum.map_join(resources, ", ", &"`#{inspect(&1)}`") {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: "The #{entity.type} step `#{inspect(entity.name)}` has its resources set to #{resources} but they are not valid Ash resources." )} end end defp validate_entity_input_dupes(%{inputs: [_]} = _entity, _dsl_state), do: :ok defp validate_entity_input_dupes(%{inputs: [_ | _]} = entity, dsl_state) do entity.inputs |> Enum.map(&MapSet.new(Map.keys(&1.template))) |> Enum.reduce(&MapSet.intersection/2) |> Enum.to_list() |> case do [] -> :ok [key] -> message = """ The #{entity.type} step `#{inspect(entity.name)}` defines multiple inputs for `#{key}`. """ {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: message )} keys -> keys_sentence = keys |> Enum.map(&"`#{&1}`") |> to_sentence(final_sep: " and ") message = """ The #{entity.type} step `#{inspect(entity.name)}` defines multiple inputs for the keys #{keys_sentence}. """ {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: message )} end end defp validate_entity_input_dupes(_entity, _dsl_state), do: :ok defp validate_entity_input_empty(entity, dsl_state) when is_map_key(entity, :inputs) do entity.inputs |> Enum.filter(&Enum.empty?(&1.template)) |> case do [] -> :ok [_] -> message = """ The #{entity.type} step `#{inspect(entity.name)}` defines an empty input template. """ {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: message )} _ -> message = """ The #{entity.type} step `#{inspect(entity.name)}` defines empty input templates. """ {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: message )} end end defp validate_entity_input_empty(_, _), do: :ok defp validate_entity_input_names(entity, action, dsl_state) when is_map_key(entity, :inputs) do argument_names = Enum.map(action.arguments, & &1.name) allowed_input_names = entity.resource |> Resource.Info.attributes() |> Enum.map(& &1.name) |> maybe_accept_inputs(action) |> maybe_reject_inputs(action) |> Enum.concat(argument_names) |> MapSet.new() provided_input_names = entity.inputs |> Enum.filter(&is_nil(&1.transform)) |> Enum.flat_map(&Map.keys(&1.template)) |> MapSet.new() provided_input_names |> MapSet.difference(allowed_input_names) |> Enum.to_list() |> case do [] -> :ok [extra] -> suggestions = allowed_input_names |> Enum.map(&to_string/1) |> sorted_suggestions(extra) message = """ The #{entity.type} step `#{inspect(entity.name)} refers to an input named `#{extra}` which doesn't exist. #{suggestions} """ {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: message )} extras -> suggestions = allowed_input_names |> Enum.map(&to_string/1) |> sorted_suggestions(hd(extras)) extras_sentence = extras |> Enum.map(&"`#{&1}`") |> to_sentence(final_sep: " and ") message = """ The #{entity.type} step `#{inspect(entity.name)} refers to an inputs named #{extras_sentence} which don't exist. #{suggestions} """ {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: message )} end end defp validate_entity_input_names(_entity, _action, _dsl_state), do: :ok defp maybe_accept_inputs(input_names, action) when length(action.accepts) > 0, do: Enum.filter(input_names, &(&1 in action.accepts)) defp maybe_accept_inputs(input_names, _), do: input_names defp maybe_reject_inputs(input_names, action) when length(action.rejects) > 0, do: Enum.filter(input_names, &(&1 in action.rejects)) defp maybe_reject_inputs(input_names, _), do: input_names defp get_entity_resource_action(entity, dsl_state) when is_nil(entity.action) do entity.resource |> Resource.Info.actions() |> Enum.find(&(&1.type == entity.type && &1.primary?)) |> case do nil -> suggestions = entity.resource |> Resource.Info.actions() |> Enum.filter(&(&1.type == entity.type)) |> Enum.map(&to_string(&1.name)) |> sorted_suggestions(entity.action, prefix: "Available #{entity.type} actions are ", suffix: ".", final_sep: " and " ) {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: "The step `#{inspect(entity.name)}` has no action name specified and no primary #{entity.type} action." <> suggestions )} action -> {:ok, action} end end defp get_entity_resource_action(entity, dsl_state) do case Resource.Info.action(entity.resource, entity.action, action_type(entity.type)) do nil -> suggestions = entity.resource |> Resource.Info.actions() |> Enum.filter(&(&1.type == entity.type)) |> Enum.map(&to_string(&1.name)) |> sorted_suggestions(entity.action) {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: "The #{entity.type} step `#{inspect(entity.name)}` refers to an action named `#{entity.action}` which doesn't exist." <> suggestions )} action -> {:ok, action} end end defp action_type(:bulk_create), do: :create defp action_type(:bulk_update), do: :update defp action_type(:create), do: :create defp action_type(:read), do: :read defp action_type(:update), do: :update defp action_type(:destroy), do: :destroy defp action_type(:action), do: :action defp maybe_validate_upsert_identity(entity, dsl_state) when entity.upsert? and entity.upsert_identity do if Resource.Info.identity(entity.resource, entity.upsert_identity) do :ok else suggestions = entity.resource |> Resource.Info.identities() |> Enum.map(& &1.name) |> sorted_suggestions(entity.upsert_identity) {:error, DslError.exception( module: Transformer.get_persisted(dsl_state, :module), path: [:reactor, entity.type, entity.name], message: "The #{entity.type} step `#{inspect(entity.name)}` refers to an identity named `#{entity.upsert_identity}` but it does not exist." <> suggestions )} end end defp maybe_validate_upsert_identity(_entity, _dsl_state), do: :ok defp sorted_suggestions(suggestions, tried, options \\ []) defp sorted_suggestions([], _, _), do: "" defp sorted_suggestions(suggestions, tried, options) do tried = to_string(tried) prefix = Keyword.get(options, :prefix, "Did you mean ") suffix = Keyword.get(options, :suffix, "?") suggestions |> Enum.map(&to_string/1) |> Enum.sort_by(&String.jaro_distance(&1, tried)) |> Enum.map(&"`#{&1}`") |> to_sentence(options) |> then(&"\n\n#{prefix}#{&1}#{suffix}") end defp to_sentence([value], _opts), do: to_string(value) defp to_sentence([_ | _] = values, opts) do [last | rest] = Enum.reverse(values) sep = Keyword.get(opts, :sep, ", ") final_sep = Keyword.get(opts, :final_sep, " or ") rest = rest |> Enum.reverse() |> Enum.join(sep) "#{rest}#{final_sep}#{last}" end end