diff --git a/lib/ash/actions/create.ex b/lib/ash/actions/create.ex index b6877758..c758d2af 100644 --- a/lib/ash/actions/create.ex +++ b/lib/ash/actions/create.ex @@ -74,6 +74,7 @@ defmodule Ash.Actions.Create do |> validate_required_belongs_to() |> add_validations() |> require_values() + |> Ash.Changeset.cast_arguments(action) end defp require_values(changeset) do diff --git a/lib/ash/actions/destroy.ex b/lib/ash/actions/destroy.ex index 8799db90..99f53e00 100644 --- a/lib/ash/actions/destroy.ex +++ b/lib/ash/actions/destroy.ex @@ -30,7 +30,8 @@ defmodule Ash.Actions.Destroy do changeset = %{changeset | action_type: :destroy, api: api} - with :ok <- validate(changeset), + with %{valid?: true} <- Ash.Changeset.cast_arguments(changeset, action), + :ok <- validate(changeset), :ok <- validate_multitenancy(changeset) do destroy_request = Request.new( diff --git a/lib/ash/actions/update.ex b/lib/ash/actions/update.ex index 937d0505..17bad8ce 100644 --- a/lib/ash/actions/update.ex +++ b/lib/ash/actions/update.ex @@ -66,6 +66,7 @@ defmodule Ash.Actions.Update do |> Relationships.handle_relationship_changes() |> set_defaults() |> add_validations() + |> Ash.Changeset.cast_arguments(action) end defp run_action_changes(changeset, %{changes: changes}, actor) do diff --git a/lib/ash/changeset/changeset.ex b/lib/ash/changeset/changeset.ex index ec826868..a750efde 100644 --- a/lib/ash/changeset/changeset.ex +++ b/lib/ash/changeset/changeset.ex @@ -40,6 +40,7 @@ defmodule Ash.Changeset do :resource, :api, :tenant, + arguments: %{}, context: %{}, after_action: [], before_action: [], @@ -75,10 +76,12 @@ defmodule Ash.Changeset do @type t :: %__MODULE__{} alias Ash.Error.{ + Changes.InvalidArgument, Changes.InvalidAttribute, Changes.InvalidRelationship, Changes.NoSuchAttribute, Changes.NoSuchRelationship, + Changes.Required, Invalid.NoSuchResource } @@ -203,6 +206,31 @@ defmodule Ash.Changeset do %{changeset | context: Map.merge(changeset.context, map)} end + @doc false + def cast_arguments(changeset, action) do + Enum.reduce(action.arguments, %{changeset | arguments: %{}}, fn argument, new_changeset -> + value = Map.get(changeset.arguments, argument.name) + + if is_nil(value) && !argument.allow_nil? do + Ash.Changeset.add_error( + changeset, + Required.exception(field: argument.name, type: :argument) + ) + else + with {:ok, casted} <- Ash.Type.cast_input(argument.type, value), + :ok <- Ash.Type.apply_constraints(argument.type, casted, argument.constraints) do + %{new_changeset | arguments: Map.put(new_changeset.arguments, argument.name, value)} + else + _ -> + Ash.Changeset.add_error( + changeset, + InvalidArgument.exception(field: argument.name) + ) + end + end + end) + end + @doc """ Appends a record or a list of records to a relationship. Stacks with previous removals/additions. @@ -486,6 +514,31 @@ defmodule Ash.Changeset do end end + @doc """ + Add an argument to the changeset, which will be provided to the action + """ + def set_argument(changeset, argument, value) do + %{changeset | arguments: Map.put(changeset.arguments, argument, value)} + end + + @doc """ + Remove an argument from the changeset + """ + def delete_argument(changeset, argument_or_arguments) do + argument_or_arguments + |> List.wrap() + |> Enum.reduce(changeset, fn argument, changeset -> + %{changeset | arguments: Map.delete(changeset.arguments, argument)} + end) + end + + @doc """ + Merge a map of arguments to the arguments list + """ + def set_arguments(changeset, map) do + %{changeset | arguments: Map.merge(changeset.arguments, map)} + end + @doc """ Force change an attribute if is not currently being changed, by calling the provided function diff --git a/lib/ash/error/changes/invalid_argument.ex b/lib/ash/error/changes/invalid_argument.ex new file mode 100644 index 00000000..c48fecba --- /dev/null +++ b/lib/ash/error/changes/invalid_argument.ex @@ -0,0 +1,25 @@ +defmodule Ash.Error.Changes.InvalidArgument do + @moduledoc "Used when an invalid value is provided for an action argument" + use Ash.Error + + def_ash_error([:field, :message], class: :invalid) + + defimpl Ash.ErrorKind do + def id(_), do: Ecto.UUID.generate() + + def code(_), do: "invalid_argument" + + def message(error) do + "Invalid value provided#{for_field(error)}#{do_message(error)}" + end + + defp for_field(%{field: field}) when not is_nil(field), do: " for #{field}" + defp for_field(_), do: "" + + defp do_message(%{message: message}) when not is_nil(message) do + ": #{message}." + end + + defp do_message(_), do: "." + end +end diff --git a/lib/ash/resource/actions/argument.ex b/lib/ash/resource/actions/argument.ex new file mode 100644 index 00000000..1a18ed4e --- /dev/null +++ b/lib/ash/resource/actions/argument.ex @@ -0,0 +1,27 @@ +defmodule Ash.Resource.Actions.Argument do + @moduledoc "Represents an argument to an action" + defstruct [:allow_nil?, :type, :name, constraints: []] + + @type t :: %__MODULE__{} + + def schema do + [ + allow_nil?: [ + type: :boolean, + default: true + ], + type: [ + type: {:custom, Ash.OptionsHelpers, :ash_type, []}, + required: true + ], + name: [ + type: :atom, + required: true + ], + constraints: [ + type: :keyword_list, + default: [] + ] + ] + end +end diff --git a/lib/ash/resource/actions/create.ex b/lib/ash/resource/actions/create.ex index a44993fb..1a4b59e6 100644 --- a/lib/ash/resource/actions/create.ex +++ b/lib/ash/resource/actions/create.ex @@ -1,11 +1,12 @@ defmodule Ash.Resource.Actions.Create do @moduledoc "Represents a create action on a resource." - defstruct [:name, :primary?, :accept, :changes, :description, type: :create] + defstruct [:name, :primary?, :accept, :arguments, :changes, :description, type: :create] @type t :: %__MODULE__{ type: :create, name: atom, accept: [atom], + arguments: [Ash.Resource.Actions.Argument.t()], primary?: boolean, description: String.t() } diff --git a/lib/ash/resource/actions/destroy.ex b/lib/ash/resource/actions/destroy.ex index f5a040be..ade48de8 100644 --- a/lib/ash/resource/actions/destroy.ex +++ b/lib/ash/resource/actions/destroy.ex @@ -1,11 +1,21 @@ defmodule Ash.Resource.Actions.Destroy do @moduledoc "Represents a destroy action on a resource." - defstruct [:name, :primary?, :changes, :accept, :soft?, :description, type: :destroy] + defstruct [ + :name, + :primary?, + :arguments, + :changes, + :accept, + :soft?, + :description, + type: :destroy + ] @type t :: %__MODULE__{ type: :destroy, name: atom, + arguments: [Ash.Resource.Actions.Argument.t()], primary?: boolean, description: String.t() } diff --git a/lib/ash/resource/actions/update.ex b/lib/ash/resource/actions/update.ex index 671110f8..be7b62ec 100644 --- a/lib/ash/resource/actions/update.ex +++ b/lib/ash/resource/actions/update.ex @@ -1,12 +1,13 @@ defmodule Ash.Resource.Actions.Update do @moduledoc "Represents a update action on a resource." - defstruct [:name, :primary?, :accept, :changes, :description, type: :update] + defstruct [:name, :primary?, :accept, :changes, :arguments, :description, type: :update] @type t :: %__MODULE__{ type: :update, name: atom, accept: [atom], + arguments: [Ash.Resource.Actions.Argument.t()], primary?: boolean, description: String.t() } diff --git a/lib/ash/resource/change/builtins.ex b/lib/ash/resource/change/builtins.ex index bc6643e5..de21e6c9 100644 --- a/lib/ash/resource/change/builtins.ex +++ b/lib/ash/resource/change/builtins.ex @@ -19,4 +19,9 @@ defmodule Ash.Resource.Change.Builtins do @doc "A helper for builting filter templates" def actor(value), do: {:_actor, value} + + @doc "A helper to confirm the value of one field against another field, or an argument" + def confirm(field, confirmation) do + {Ash.Resource.Change.Confirm, [field: field, confirmation: confirmation]} + end end diff --git a/lib/ash/resource/change/confirm.ex b/lib/ash/resource/change/confirm.ex new file mode 100644 index 00000000..708a0668 --- /dev/null +++ b/lib/ash/resource/change/confirm.ex @@ -0,0 +1,46 @@ +defmodule Ash.Resource.Change.Confirm do + @moduledoc false + use Ash.Resource.Change + alias Ash.Changeset + alias Ash.Error.Changes.InvalidAttribute + + def init(opts) do + case opts[:field] do + nil -> + {:error, "Field is required"} + + field when is_atom(field) -> + case opts[:confirmation] do + nil -> + {:error, "Confirmation is required"} + + confirmation when is_atom(confirmation) -> + {:ok, [confirmation: confirmation, field: field]} + + confirmation -> + {:error, "Expected an atom for confirmation, got: #{inspect(confirmation)}"} + end + + field -> + {:error, "Expected an atom for field, got: #{inspect(field)}"} + end + end + + def change(changeset, opts, _) do + confirmation_value = + Map.get(changeset.arguments, opts[:confirmation]) || + Ash.Changeset.get_attribute(changeset, opts[:value]) + + if confirmation_value == Ash.Changeset.get_attribute(changeset, opts[:field]) do + changeset + else + Changeset.add_error( + changeset, + InvalidAttribute.exception( + field: opts[:field], + message: "Value did not match confirmation" + ) + ) + end + end +end diff --git a/lib/ash/resource/dsl.ex b/lib/ash/resource/dsl.ex index cf6e30c3..cea0c605 100644 --- a/lib/ash/resource/dsl.ex +++ b/lib/ash/resource/dsl.ex @@ -229,6 +229,22 @@ defmodule Ash.Resource.Dsl do args: [:change] } + @action_argument %Ash.Dsl.Entity{ + name: :argument, + describe: """ + Declares an argument on the action + + The type can be either a built in type (see `Ash.Type`) for more, or a module implementing + the `Ash.Type` behaviour. + """, + examples: [ + "argument :password_confirmation, :string" + ], + target: Ash.Resource.Actions.Argument, + args: [:name, :type], + schema: Ash.Resource.Actions.Argument.schema() + } + @create %Ash.Dsl.Entity{ name: :create, describe: """ @@ -242,6 +258,9 @@ defmodule Ash.Resource.Dsl do entities: [ changes: [ @change + ], + arguments: [ + @action_argument ] ], args: [:name] @@ -275,6 +294,9 @@ defmodule Ash.Resource.Dsl do entities: [ changes: [ @change + ], + arguments: [ + @action_argument ] ], target: Ash.Resource.Actions.Update, @@ -293,6 +315,9 @@ defmodule Ash.Resource.Dsl do entities: [ changes: [ @change + ], + arguments: [ + @action_argument ] ], target: Ash.Resource.Actions.Destroy, diff --git a/lib/ash/type/type.ex b/lib/ash/type/type.ex index 6399c0fd..a793b589 100644 --- a/lib/ash/type/type.ex +++ b/lib/ash/type/type.ex @@ -214,7 +214,7 @@ defmodule Ash.Type do Confirms if a casted value matches the provided constraints. """ @spec apply_constraints(t(), term, constraints()) :: :ok | {:error, String.t()} - def apply_constraints({:array, type}, term, constraints) when is_list(constraints) do + def apply_constraints({:array, type}, term, constraints) when is_list(term) do list_constraint_errors = list_constraint_errors(term, constraints) case list_constraint_errors do diff --git a/test/changeset/changeset_test.exs b/test/changeset/changeset_test.exs index 5cba09f2..549226f4 100644 --- a/test/changeset/changeset_test.exs +++ b/test/changeset/changeset_test.exs @@ -15,7 +15,12 @@ defmodule Ash.Test.Changeset.ChangesetTest do actions do read :default - create :default + create :default, primary?: true + + create :create_with_confirmation do + argument :confirm_name, :string + change confirm(:name, :confirm_name) + end end attributes do @@ -739,4 +744,22 @@ defmodule Ash.Test.Changeset.ChangesetTest do } = changeset end end + + describe "arguments" do + test "arguments can be used in valid changes" do + Category + |> Changeset.new(%{"name" => "foo"}) + |> Changeset.set_argument(:confirm_name, "foo") + |> Api.create!(action: :create_with_confirmation) + end + + test "arguments can be used in invalid changes" do + assert_raise Ash.Error.Invalid, ~r/Value did not match confirmation/, fn -> + Category + |> Changeset.new(%{"name" => "foo"}) + |> Changeset.set_argument(:confirm_name, "bar") + |> Api.create!(action: :create_with_confirmation) + end + end + end end