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()
|> add_validations()
|> require_values()
|> Ash.Changeset.cast_arguments(action)
end
defp require_values(changeset) do

View file

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

View file

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

View file

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

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
@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()
}

View file

@ -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()
}

View file

@ -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()
}

View file

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

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]
}
@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,

View file

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

View file

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