diff --git a/.formatter.exs b/.formatter.exs index 6c20ae7e..e4245671 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -175,6 +175,7 @@ spark_locals_without_parens = [ sensitive?: 1, short_name: 1, simple_notifiers: 1, + skip_global_validations?: 1, soft?: 1, sort: 1, source: 1, diff --git a/documentation/topics/actions.md b/documentation/topics/actions.md index dc198e6b..0a578b7e 100644 --- a/documentation/topics/actions.md +++ b/documentation/topics/actions.md @@ -192,7 +192,7 @@ The following steps are run when calling `Ash.Changeset.for_create/4`, `Ash.Chan - Require any accepted attributes that are `allow_nil?` false - Set any default values for attributes - Run action changes & validations -- Run validations, or add them in `before_action` hooks if using `d:Ash.Resource.Dsl.actions.create.validate|before_action?` +- Run validations, or add them in `before_action` hooks if using `d:Ash.Resource.Dsl.actions.create.validate|before_action?`. Any global validations are skipped if the action has `skip_global_validations?` set to `true`. #### Running the Create/Update/Destroy Action diff --git a/lib/ash/changeset/changeset.ex b/lib/ash/changeset/changeset.ex index f54d812f..131f0a64 100644 --- a/lib/ash/changeset/changeset.ex +++ b/lib/ash/changeset/changeset.ex @@ -1257,18 +1257,22 @@ defmodule Ash.Changeset do defp default(:update, %{update_default: value}), do: value defp add_validations(changeset, tracer, metadata, actor) do - changeset.resource - # We use the `changeset.action_type` to support soft deletes - # Because a delete is an `update` with an action type of `update` - |> Ash.Resource.Info.validations(changeset.action_type) - |> then(fn validations -> - if changeset.action.delay_global_validations? do - Enum.map(validations, &%{&1 | before_action?: true}) - else - validations - end - end) - |> Enum.reduce(changeset, &validate(&2, &1, tracer, metadata, actor)) + if changeset.action.skip_global_validations? do + changeset + else + changeset.resource + # We use the `changeset.action_type` to support soft deletes + # Because a delete is an `update` with an action type of `update` + |> Ash.Resource.Info.validations(changeset.action_type) + |> then(fn validations -> + if changeset.action.delay_global_validations? do + Enum.map(validations, &%{&1 | before_action?: true}) + else + validations + end + end) + |> Enum.reduce(changeset, &validate(&2, &1, tracer, metadata, actor)) + end end defp validate(changeset, validation, tracer, metadata, actor) do diff --git a/lib/ash/resource/actions/create.ex b/lib/ash/resource/actions/create.ex index b7adb10d..a8a094bc 100644 --- a/lib/ash/resource/actions/create.ex +++ b/lib/ash/resource/actions/create.ex @@ -11,6 +11,7 @@ defmodule Ash.Resource.Actions.Create do touches_resources: [], require_attributes: [], delay_global_validations?: false, + skip_global_validations?: false, upsert?: false, upsert_identity: nil, arguments: [], @@ -29,6 +30,7 @@ defmodule Ash.Resource.Actions.Create do manual: module | nil, upsert?: boolean, delay_global_validations?: boolean, + skip_global_validations?: boolean, upsert_identity: atom | nil, allow_nil_input: list(atom), touches_resources: list(atom), diff --git a/lib/ash/resource/actions/destroy.ex b/lib/ash/resource/actions/destroy.ex index 06a27672..40a181ac 100644 --- a/lib/ash/resource/actions/destroy.ex +++ b/lib/ash/resource/actions/destroy.ex @@ -12,6 +12,7 @@ defmodule Ash.Resource.Actions.Destroy do arguments: [], touches_resources: [], delay_global_validations?: false, + skip_global_validations?: false, accept: nil, changes: [], reject: [], @@ -27,6 +28,7 @@ defmodule Ash.Resource.Actions.Destroy do manual: module | nil, arguments: list(Ash.Resource.Actions.Argument.t()), delay_global_validations?: boolean, + skip_global_validations?: boolean, touches_resources: list(atom), primary?: boolean, description: String.t() diff --git a/lib/ash/resource/actions/shared_options.ex b/lib/ash/resource/actions/shared_options.ex index 993078ba..dc9c13dc 100644 --- a/lib/ash/resource/actions/shared_options.ex +++ b/lib/ash/resource/actions/shared_options.ex @@ -45,6 +45,13 @@ defmodule Ash.Resource.Actions.SharedOptions do on the resource. """ ], + skip_global_validations?: [ + type: :boolean, + default: false, + doc: """ + If true, global validations will be skipped. Useful for manual actions. + """ + ], reject: [ type: {:or, [in: [:all], list: :atom]}, doc: """ diff --git a/lib/ash/resource/actions/update.ex b/lib/ash/resource/actions/update.ex index b0bbcd74..c9c5d398 100644 --- a/lib/ash/resource/actions/update.ex +++ b/lib/ash/resource/actions/update.ex @@ -11,6 +11,7 @@ defmodule Ash.Resource.Actions.Update do manual?: false, require_attributes: [], delay_global_validations?: false, + skip_global_validations?: false, arguments: [], changes: [], reject: [], @@ -27,6 +28,7 @@ defmodule Ash.Resource.Actions.Update do accept: list(atom), arguments: list(Ash.Resource.Actions.Argument.t()), delay_global_validations?: boolean, + skip_global_validations?: boolean, primary?: boolean, touches_resources: list(atom), description: String.t() diff --git a/lib/ash/resource/builder.ex b/lib/ash/resource/builder.ex index 22a7860d..d328e3ca 100644 --- a/lib/ash/resource/builder.ex +++ b/lib/ash/resource/builder.ex @@ -98,7 +98,7 @@ defmodule Ash.Resource.Builder do end @doc """ - Builds an action + Builds a relationship """ @spec build_relationship( type :: Ash.Resource.Relationships.type(), @@ -118,6 +118,60 @@ defmodule Ash.Resource.Builder do end end + @doc """ + Builds and adds a new identity unless an identity with that name already exists + """ + @spec add_new_identity( + Spark.Dsl.Builder.input(), + name :: atom, + fields :: atom | list(atom), + opts :: Keyword.t() + ) :: + Spark.Dsl.Builder.result() + defbuilder add_new_identity(dsl_state, name, fields, opts \\ []) do + if Ash.Resource.Info.identity(dsl_state, name) do + dsl_state + else + add_identity(dsl_state, name, fields, opts) + end + end + + @doc """ + Builds and adds an identity + """ + @spec add_identity( + Spark.Dsl.Builder.input(), + name :: atom, + fields :: atom | list(atom), + opts :: Keyword.t() + ) :: + Spark.Dsl.Builder.result() + defbuilder add_identity(dsl_state, name, fields, opts \\ []) do + with {:ok, identity} <- build_identity(name, fields, opts) do + Transformer.add_entity(dsl_state, [:identities], identity) + end + end + + @doc """ + Builds an action + """ + @spec build_relationship( + name :: atom, + fields :: atom | list(atom), + opts :: Keyword.t() + ) :: + {:ok, Ash.Resource.Relationships.relationship()} | {:error, term} + def build_identity(name, fields, opts \\ []) do + with {:ok, opts} <- handle_nested_builders(opts, [:changes, :arguments, :metadata]) do + Transformer.build_entity( + Ash.Resource.Dsl, + [:identities], + :identity, + Keyword.merge(opts, name: name, keys: fields) + ) + end + end + @doc """ Builds and adds a change """ diff --git a/test/actions/create_test.exs b/test/actions/create_test.exs index 2d872b1f..b55cdbb7 100644 --- a/test/actions/create_test.exs +++ b/test/actions/create_test.exs @@ -304,6 +304,40 @@ defmodule Ash.Test.Actions.CreateTest do end end + defmodule GlobalValidation do + @moduledoc false + use Ash.Resource, + data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + actions do + defaults [:create, :read, :update, :destroy] + + create :manual do + skip_global_validations?(true) + + manual fn changeset, _ -> + Ash.Changeset.apply_attributes(changeset) + end + end + end + + validations do + validate compare(:foo, greater_than: 10) + end + + attributes do + uuid_primary_key :id + + attribute :foo, :integer do + allow_nil? false + end + end + end + defmodule Registry do @moduledoc false use Ash.Registry @@ -317,6 +351,7 @@ defmodule Ash.Test.Actions.CreateTest do entry(PostLink) entry(Authorized) entry(GeneratedPkey) + entry(GlobalValidation) end end @@ -903,4 +938,16 @@ defmodule Ash.Test.Actions.CreateTest do assert [] = Api.read!(Authorized) end end + + describe "global validations" do + test "they can be skipped" do + assert %{errors: [%Ash.Error.Changes.InvalidAttribute{field: :foo}]} = + GlobalValidation + |> Ash.Changeset.for_create(:create, %{foo: 5}) + + assert %{errors: []} = + GlobalValidation + |> Ash.Changeset.for_create(:manual, %{foo: 5}) + end + end end