diff --git a/lib/ash/embeddable_type.ex b/lib/ash/embeddable_type.ex index 6f4f570d5..a5c95dfb7 100644 --- a/lib/ash/embeddable_type.ex +++ b/lib/ash/embeddable_type.ex @@ -526,6 +526,16 @@ defmodule Ash.EmbeddableType do {:cont, {:ok, Map.put(acc, attribute.source, dumped)}} error -> + error = + case Ash.Type.apply_constraints( + attribute.type, + value, + Map.get(attribute, :constraints) || [] + ) do + {:error, _} = constraint_error -> constraint_error + _ -> error + end + {:halt, Ash.Helpers.error_with_context(error, field: attribute.name)} end end diff --git a/lib/ash/error/error.ex b/lib/ash/error/error.ex index 25082e858..574323ced 100644 --- a/lib/ash/error/error.ex +++ b/lib/ash/error/error.ex @@ -53,6 +53,7 @@ defmodule Ash.Error do def to_ash_error(value, stacktrace \\ nil, opts \\ []) do value = value + |> normalize_error_input(opts) |> List.wrap() |> Enum.map(fn %struct{} = changeset @@ -60,7 +61,7 @@ defmodule Ash.Error do to_error_class(changeset, opts) other -> - other + normalize_error_input(other, opts) end) to_error(value, Keyword.put(opts, :stacktrace, stacktrace)) @@ -96,6 +97,7 @@ defmodule Ash.Error do def to_error_class(value, opts) do value = value + |> normalize_error_input(opts) |> List.wrap() |> Enum.map(fn %Ash.Changeset{} = changeset -> @@ -108,7 +110,7 @@ defmodule Ash.Error do to_error_class(action_input, opts) other -> - other + normalize_error_input(other, opts) end) class = to_class(value, opts) @@ -135,6 +137,62 @@ defmodule Ash.Error do |> remove_required_if_other_errors_exist() end + defp normalize_error_input(keyword, _opts) when is_list(keyword) do + if Keyword.keyword?(keyword) and supported_error_keyword?(keyword) do + keyword + |> maybe_convert_error_keyword() + |> maybe_set_error_path(keyword) + else + keyword + end + end + + defp normalize_error_input(other, _opts), do: other + + defp supported_error_keyword?(keyword) do + Enum.any?([:field, :fields, :message, :path, :index, :value, :private_vars], fn key -> + Keyword.has_key?(keyword, key) + end) + end + + defp maybe_convert_error_keyword(keyword) do + cond do + keyword[:field] -> + Ash.Error.Changes.InvalidAttribute.exception( + field: keyword[:field], + message: keyword[:message], + value: keyword[:value], + private_vars: keyword[:private_vars], + vars: keyword + ) + + keyword[:fields] -> + Ash.Error.Changes.InvalidChanges.exception( + fields: keyword[:fields], + message: keyword[:message], + value: keyword[:value], + vars: keyword + ) + + true -> + Ash.Error.Changes.InvalidChanges.exception( + message: keyword[:message], + value: keyword[:value], + vars: keyword + ) + end + end + + defp maybe_set_error_path(error, keyword) do + path = keyword[:path] || (keyword[:index] && [keyword[:index]]) + + if is_nil(path) or path == [] do + error + else + Ash.Error.set_path(error, path) + end + end + defp remove_required_if_other_errors_exist(%{errors: errors} = class) do {required, errors} = Enum.split_with(errors, fn diff --git a/test/embedded_resource_test.exs b/test/embedded_resource_test.exs index 19fe19321..f57e098c5 100644 --- a/test/embedded_resource_test.exs +++ b/test/embedded_resource_test.exs @@ -189,6 +189,63 @@ defmodule Ash.Test.Changeset.EmbeddedResourceTest do ] end + defmodule ConstrainedProperty do + use Ash.Resource, data_layer: :embedded + + attributes do + uuid_primary_key :id, writable?: true + + attribute :name, :string do + public?(true) + end + + attribute :data_type, :atom do + public?(true) + constraints one_of: [:string, :integer] + end + end + + validations do + validate present(:name) + end + end + + defmodule PropertyContainer do + use Ash.Resource, + domain: Ash.Test.Changeset.EmbeddedResourceTest.Domain, + data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + actions do + default_accept :* + defaults [:read] + create :create + + update :update do + require_atomic? false + argument :property, :map, allow_nil?: false + + change fn changeset, _ -> + input = Ash.Changeset.get_argument(changeset, :property) + embedded = struct(ConstrainedProperty, input) + + Ash.Changeset.change_attribute(changeset, :embedded_resources, [ + embedded | changeset.data.embedded_resources + ]) + end + end + end + + attributes do + uuid_primary_key :id, writable?: true + + attribute :embedded_resources, {:array, ConstrainedProperty}, public?: true + end + end + defmodule Author do use Ash.Resource, domain: Ash.Test.Changeset.EmbeddedResourceTest.Domain, @@ -247,9 +304,34 @@ defmodule Ash.Test.Changeset.EmbeddedResourceTest do resources do resource Author + resource PropertyContainer end end + test "embedded resource validation errors are surfaced as invalid attribute errors" do + parent = + PropertyContainer + |> Changeset.for_create(:create, %{embedded_resources: []}) + |> Ash.create!() + + assert {:error, %Ash.Error.Invalid{} = error} = + parent + |> Changeset.for_update(:update, %{property: %{name: "email", data_type: "strong"}}) + |> Ash.update() + + assert %Ash.Error.Changes.InvalidAttribute{ + field: :data_type, + path: [0], + message: message, + vars: vars + } = Enum.find(error.errors, &match?(%Ash.Error.Changes.InvalidAttribute{}, &1)) + + assert message =~ "atom must be one of" + assert vars[:value] == "strong" + assert vars[:atom_list] =~ "string" + assert vars[:atom_list] =~ "integer" + end + test "embedded resources can be created" do assert %{profile: %Profile{}, tags: [%Tag{name: "trainer"}, %Tag{name: "human"}]} = Changeset.for_create( diff --git a/test/error_test.exs b/test/error_test.exs index f4daaeaee..9ddad7006 100644 --- a/test/error_test.exs +++ b/test/error_test.exs @@ -333,6 +333,62 @@ defmodule Ash.Test.ErrorTest do end end + describe "keyword error inputs" do + test "converts a field payload to InvalidAttribute" do + assert %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changes.InvalidAttribute{ + field: :name, + message: "is invalid", + value: "bad" + } + ] + } = + Ash.Error.to_error_class( + field: :name, + message: "is invalid", + value: "bad" + ) + end + + test "converts a fields payload to InvalidChanges" do + assert %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changes.InvalidChanges{ + fields: [:first_name, :last_name], + message: "is invalid" + } + ] + } = + Ash.Error.to_error_class(fields: [:first_name, :last_name], message: "is invalid") + end + + test "preserves an index as the error path" do + assert %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changes.InvalidAttribute{field: :data_type, path: [0]} + ] + } = + Ash.Error.to_error_class(field: :data_type, message: "is invalid", index: 0) + end + + test "does not convert an unrelated keyword list to InvalidChanges" do + result = Ash.Error.to_error_class(foo: :bar) + + refute Enum.any?(result.errors, &match?(%Ash.Error.Changes.InvalidChanges{}, &1)) + end + + test "leaves an existing Ash exception unchanged" do + error = Ash.Error.Changes.InvalidAttribute.exception(field: :name, message: "is invalid") + + assert %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changes.InvalidAttribute{field: :name, message: "is invalid"} + ] + } = Ash.Error.to_error_class(error) + end + end + describe "assert_has_error" do test "raises if the value is :ok" do assert_raise ExUnit.AssertionError, ~r/it had no errors/, fn ->