feat: support arguments for actions

This commit is contained in:
Zach Daniel 2020-12-01 22:09:37 -05:00
parent 1e23199bec
commit 0abf03065a
14 changed files with 225 additions and 6 deletions

View file

@ -74,6 +74,7 @@ defmodule Ash.Actions.Create do
|> validate_required_belongs_to() |> validate_required_belongs_to()
|> add_validations() |> add_validations()
|> require_values() |> require_values()
|> Ash.Changeset.cast_arguments(action)
end end
defp require_values(changeset) do defp require_values(changeset) do

View file

@ -30,7 +30,8 @@ defmodule Ash.Actions.Destroy do
changeset = %{changeset | action_type: :destroy, api: api} 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 :ok <- validate_multitenancy(changeset) do
destroy_request = destroy_request =
Request.new( Request.new(

View file

@ -66,6 +66,7 @@ defmodule Ash.Actions.Update do
|> Relationships.handle_relationship_changes() |> Relationships.handle_relationship_changes()
|> set_defaults() |> set_defaults()
|> add_validations() |> add_validations()
|> Ash.Changeset.cast_arguments(action)
end end
defp run_action_changes(changeset, %{changes: changes}, actor) do defp run_action_changes(changeset, %{changes: changes}, actor) do

View file

@ -40,6 +40,7 @@ defmodule Ash.Changeset do
:resource, :resource,
:api, :api,
:tenant, :tenant,
arguments: %{},
context: %{}, context: %{},
after_action: [], after_action: [],
before_action: [], before_action: [],
@ -75,10 +76,12 @@ defmodule Ash.Changeset do
@type t :: %__MODULE__{} @type t :: %__MODULE__{}
alias Ash.Error.{ alias Ash.Error.{
Changes.InvalidArgument,
Changes.InvalidAttribute, Changes.InvalidAttribute,
Changes.InvalidRelationship, Changes.InvalidRelationship,
Changes.NoSuchAttribute, Changes.NoSuchAttribute,
Changes.NoSuchRelationship, Changes.NoSuchRelationship,
Changes.Required,
Invalid.NoSuchResource Invalid.NoSuchResource
} }
@ -203,6 +206,31 @@ defmodule Ash.Changeset do
%{changeset | context: Map.merge(changeset.context, map)} %{changeset | context: Map.merge(changeset.context, map)}
end 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 """ @doc """
Appends a record or a list of records to a relationship. Stacks with previous removals/additions. 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
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 """ @doc """
Force change an attribute if is not currently being changed, by calling the provided function Force change an attribute if is not currently being changed, by calling the provided function

View file

@ -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

View file

@ -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

View file

@ -1,11 +1,12 @@
defmodule Ash.Resource.Actions.Create do defmodule Ash.Resource.Actions.Create do
@moduledoc "Represents a create action on a resource." @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 t :: %__MODULE__{
type: :create, type: :create,
name: atom, name: atom,
accept: [atom], accept: [atom],
arguments: [Ash.Resource.Actions.Argument.t()],
primary?: boolean, primary?: boolean,
description: String.t() description: String.t()
} }

View file

@ -1,11 +1,21 @@
defmodule Ash.Resource.Actions.Destroy do defmodule Ash.Resource.Actions.Destroy do
@moduledoc "Represents a destroy action on a resource." @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 t :: %__MODULE__{
type: :destroy, type: :destroy,
name: atom, name: atom,
arguments: [Ash.Resource.Actions.Argument.t()],
primary?: boolean, primary?: boolean,
description: String.t() description: String.t()
} }

View file

@ -1,12 +1,13 @@
defmodule Ash.Resource.Actions.Update do defmodule Ash.Resource.Actions.Update do
@moduledoc "Represents a update action on a resource." @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 t :: %__MODULE__{
type: :update, type: :update,
name: atom, name: atom,
accept: [atom], accept: [atom],
arguments: [Ash.Resource.Actions.Argument.t()],
primary?: boolean, primary?: boolean,
description: String.t() description: String.t()
} }

View file

@ -19,4 +19,9 @@ defmodule Ash.Resource.Change.Builtins do
@doc "A helper for builting filter templates" @doc "A helper for builting filter templates"
def actor(value), do: {:_actor, value} 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 end

View file

@ -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

View file

@ -229,6 +229,22 @@ defmodule Ash.Resource.Dsl do
args: [:change] 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{ @create %Ash.Dsl.Entity{
name: :create, name: :create,
describe: """ describe: """
@ -242,6 +258,9 @@ defmodule Ash.Resource.Dsl do
entities: [ entities: [
changes: [ changes: [
@change @change
],
arguments: [
@action_argument
] ]
], ],
args: [:name] args: [:name]
@ -275,6 +294,9 @@ defmodule Ash.Resource.Dsl do
entities: [ entities: [
changes: [ changes: [
@change @change
],
arguments: [
@action_argument
] ]
], ],
target: Ash.Resource.Actions.Update, target: Ash.Resource.Actions.Update,
@ -293,6 +315,9 @@ defmodule Ash.Resource.Dsl do
entities: [ entities: [
changes: [ changes: [
@change @change
],
arguments: [
@action_argument
] ]
], ],
target: Ash.Resource.Actions.Destroy, target: Ash.Resource.Actions.Destroy,

View file

@ -214,7 +214,7 @@ defmodule Ash.Type do
Confirms if a casted value matches the provided constraints. Confirms if a casted value matches the provided constraints.
""" """
@spec apply_constraints(t(), term, constraints()) :: :ok | {:error, String.t()} @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) list_constraint_errors = list_constraint_errors(term, constraints)
case list_constraint_errors do case list_constraint_errors do

View file

@ -15,7 +15,12 @@ defmodule Ash.Test.Changeset.ChangesetTest do
actions do actions do
read :default read :default
create :default create :default, primary?: true
create :create_with_confirmation do
argument :confirm_name, :string
change confirm(:name, :confirm_name)
end
end end
attributes do attributes do
@ -739,4 +744,22 @@ defmodule Ash.Test.Changeset.ChangesetTest do
} = changeset } = changeset
end end
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 end