Skip to content

Commit 0d9ebea

Browse files
committed
feat: validate field types in AshDynamo.EmbeddedType cast_input and cast_stored
1 parent 6b3bbd3 commit 0d9ebea

File tree

2 files changed

+96
-13
lines changed

2 files changed

+96
-13
lines changed

lib/ash_dynamo/embedded_type.ex

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ defmodule AshDynamo.EmbeddedType do
5656
3. Dumping the struct back to a plain map for DynamoDB storage (`dump_to_native/2`)
5757
4. Returning `cast_in_query?: false` to prevent Ash from loading through the type
5858
59+
## Type Validation
60+
61+
Both `cast_input/2` and `cast_stored/2` validate each field through their respective
62+
`Ash.Type` casting functions. If any field fails validation, the entire cast returns
63+
`:error`. This ensures invalid data is caught on both writes and reads — a corrupted
64+
DynamoDB record will raise an error immediately rather than propagating bad data.
65+
5966
## ExAws.Dynamo.Encodable
6067
6168
The macro automatically implements the `ExAws.Dynamo.Encodable` protocol for the
@@ -77,7 +84,8 @@ defmodule AshDynamo.EmbeddedType do
7784
alias unquote(resource), as: Resource
7885

7986
@resource unquote(resource)
80-
@fields @resource |> Ash.Resource.Info.attributes() |> Enum.map(& &1.name)
87+
@attributes Ash.Resource.Info.attributes(@resource)
88+
@fields Enum.map(@attributes, & &1.name)
8189

8290
@impl Ash.Type
8391
def storage_type(_constraints), do: :map
@@ -87,12 +95,7 @@ defmodule AshDynamo.EmbeddedType do
8795
def cast_input(%Resource{} = value, _constraints), do: {:ok, value}
8896

8997
def cast_input(%{} = map, _constraints) do
90-
attrs =
91-
Map.new(@fields, fn field ->
92-
{field, map[field] || map[to_string(field)]}
93-
end)
94-
95-
{:ok, struct!(Resource, attrs)}
98+
cast_fields(map, &Ash.Type.cast_input/3)
9699
end
97100

98101
def cast_input(_value, _constraints), do: :error
@@ -102,12 +105,7 @@ defmodule AshDynamo.EmbeddedType do
102105
def cast_stored(%Resource{} = value, _constraints), do: {:ok, value}
103106

104107
def cast_stored(%{} = map, _constraints) do
105-
attrs =
106-
Map.new(@fields, fn field ->
107-
{field, map[field] || map[to_string(field)]}
108-
end)
109-
110-
{:ok, struct!(Resource, attrs)}
108+
cast_fields(map, &Ash.Type.cast_stored/3)
111109
end
112110

113111
def cast_stored(_value, _constraints), do: :error
@@ -125,6 +123,27 @@ defmodule AshDynamo.EmbeddedType do
125123
@impl Ash.Type
126124
def cast_in_query?(_constraints), do: false
127125

126+
defp cast_fields(map, cast_fn) do
127+
@attributes
128+
|> Enum.reduce_while(%{}, fn attr, acc ->
129+
raw =
130+
case Map.fetch(map, attr.name) do
131+
{:ok, value} -> value
132+
:error -> map[to_string(attr.name)]
133+
end
134+
135+
case cast_fn.(attr.type, raw, attr.constraints) do
136+
{:ok, value} -> {:cont, Map.put(acc, attr.name, value)}
137+
:error -> {:halt, :error}
138+
{:error, _reason} -> {:halt, :error}
139+
end
140+
end)
141+
|> case do
142+
:error -> :error
143+
attrs -> {:ok, struct!(Resource, attrs)}
144+
end
145+
end
146+
128147
unless Enumerable.impl_for(struct!(Resource, %{})) do
129148
defimpl ExAws.Dynamo.Encodable, for: Resource do
130149
def encode(value, options) do

test/embedded_type_test.exs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,44 @@ defmodule AshDynamo.EmbeddedTypeTest do
77

88
setup :migrate!
99

10+
alias AshDynamo.Test.Types.Tag, as: TagType
11+
12+
describe "type casting" do
13+
test "when map has valid values, casts successfully" do
14+
assert {:ok, %Tag{name: "elixir", color: "purple"}} =
15+
TagType.cast_input(%{name: "elixir", color: "purple"}, [])
16+
end
17+
18+
test "when map has string keys, casts successfully" do
19+
assert {:ok, %Tag{name: "elixir", color: "purple"}} =
20+
TagType.cast_stored(%{"name" => "elixir", "color" => "purple"}, [])
21+
end
22+
23+
test "when map has invalid type for a field, returns error" do
24+
assert :error = TagType.cast_input(%{name: 123, color: "purple"}, [])
25+
end
26+
27+
test "when map has invalid type from storage, returns error" do
28+
assert :error = TagType.cast_stored(%{"name" => 123, "color" => "purple"}, [])
29+
end
30+
31+
test "when value is nil, returns ok nil" do
32+
assert {:ok, nil} = TagType.cast_input(nil, [])
33+
assert {:ok, nil} = TagType.cast_stored(nil, [])
34+
end
35+
36+
test "when value is already a struct, returns it as-is" do
37+
tag = %Tag{name: "elixir", color: "purple"}
38+
assert {:ok, ^tag} = TagType.cast_input(tag, [])
39+
assert {:ok, ^tag} = TagType.cast_stored(tag, [])
40+
end
41+
42+
test "when value is not a map, returns error" do
43+
assert :error = TagType.cast_input("not a map", [])
44+
assert :error = TagType.cast_stored("not a map", [])
45+
end
46+
end
47+
1048
describe "embedded type with AshDynamo.EmbeddedType" do
1149
test "when creating with embedded structs, persists and reads back correctly" do
1250
tags = [
@@ -69,5 +107,31 @@ defmodule AshDynamo.EmbeddedTypeTest do
69107
assert tag.name == "test"
70108
assert tag.color == "red"
71109
end
110+
111+
test "when creating with invalid embedded field type, returns error" do
112+
result =
113+
Article
114+
|> Ash.Changeset.for_create(:create, %{
115+
slug: "invalid-tag",
116+
title: "Invalid Tag",
117+
tags: [%{name: 123, color: "red"}]
118+
})
119+
|> Ash.create()
120+
121+
assert {:error, %Ash.Error.Invalid{}} = result
122+
end
123+
124+
test "when DynamoDB contains corrupted embedded data, reading raises error" do
125+
ExAws.Dynamo.put_item("articles", %{
126+
"slug" => "corrupted",
127+
"title" => "Corrupted",
128+
"tags" => [%{"name" => 123, "color" => "red"}]
129+
})
130+
|> ExAws.request!()
131+
132+
assert_raise Ash.Error.Unknown, fn ->
133+
Ash.get!(Article, "corrupted")
134+
end
135+
end
72136
end
73137
end

0 commit comments

Comments
 (0)