mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 13:33:20 +12:00
feat: support arguments for actions
This commit is contained in:
parent
1e23199bec
commit
0abf03065a
14 changed files with 225 additions and 6 deletions
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
25
lib/ash/error/changes/invalid_argument.ex
Normal file
25
lib/ash/error/changes/invalid_argument.ex
Normal 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
|
27
lib/ash/resource/actions/argument.ex
Normal file
27
lib/ash/resource/actions/argument.ex
Normal 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
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
46
lib/ash/resource/change/confirm.ex
Normal file
46
lib/ash/resource/change/confirm.ex
Normal 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
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue