Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/ash/embeddable_type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 60 additions & 2 deletions lib/ash/error/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,15 @@ 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
when struct in [Ash.Changeset, Ash.Query, Ash.ActionInput] ->
to_error_class(changeset, opts)

other ->
other
normalize_error_input(other, opts)
end)

to_error(value, Keyword.put(opts, :stacktrace, stacktrace))
Expand Down Expand Up @@ -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 ->
Expand All @@ -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)
Expand All @@ -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 ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bit of a code smell, us having to detect a special kind of keyword list. In general I think this should be detected in the code for embedded types and handled at the source of these errors.

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
Expand Down
82 changes: 82 additions & 0 deletions test/embedded_resource_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions test/error_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Loading