Skip to content

Commit 06e7dff

Browse files
Merge pull request #76 from diffo-dev/68-improve-dynamic-validation
is_valid
2 parents ed22fdb + b263ef0 commit 06e7dff

3 files changed

Lines changed: 119 additions & 13 deletions

File tree

documentation/how_to/use_diffo_type.livemd

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT
99
```elixir
1010
Mix.install(
1111
[
12-
{:diffo, "~> 0.2.0"},
12+
{:diffo, "~> 0.2.0"}
1313
],
1414
consolidate_protocols: false
1515
)
@@ -47,7 +47,7 @@ Built-in implementations:
4747
| `Diffo.Type.Primitive` | returns the primitive value |
4848
| `Diffo.Type.Dynamic` | delegates to inner `:value` |
4949
| `Ash.CiString` | returns the comparable string |
50-
| `Ash.NotLoaded` | raises — field was not loaded |
50+
| `Ash.NotLoaded` | raises — field was not loaded |
5151
| `List` | unwraps each element |
5252
| `Any` | returns the value unchanged |
5353

@@ -254,6 +254,22 @@ Diffo.Unwrap.unwrap(result)
254254
:ok
255255
```
256256

257+
### Checking type compatibility
258+
259+
`Dynamic.is_valid?/1` lets you check whether a module is usable as a Dynamic type before constructing a value. It returns `true` only for `Ash.Type.NewType` modules with `storage_type: :map`:
260+
261+
```elixir
262+
Dynamic.is_valid?(MyApp.Patch)
263+
```
264+
265+
```elixir
266+
Dynamic.is_valid?(Ash.Type.Date)
267+
```
268+
269+
```elixir
270+
Dynamic.is_valid?(NonExistent.Module)
271+
```
272+
257273
### Constraint validation
258274

259275
Dynamic enforces the constraints defined on the inner type during casting. Here `MyApp.Patch` requires both fields to be `>= 0`, so passing a negative value returns an error:

lib/diffo/type/dynamic.ex

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ defmodule Diffo.Type.Dynamic do
2626
iex> Ash.Type.cast_stored(Diffo.Type.Dynamic, nil, [])
2727
{:ok, nil}
2828
29+
## Invalid types
30+
31+
Scalar Ash types and modules that are not `Ash.Type.NewType` with `storage_type: :map` are
32+
rejected at cast time:
33+
34+
iex> Ash.Type.cast_input(Diffo.Type.Dynamic, %Diffo.Type.Dynamic{type: Ash.Type.Date, value: ~D[2026-01-01]}, [])
35+
{:error, "Dynamic type Ash.Type.Date must be an Ash.Type.NewType with storage_type :map"}
36+
37+
iex> Ash.Type.cast_input(Diffo.Type.Dynamic, %Diffo.Type.Dynamic{type: Diffo.Type.NonExistent, value: nil}, [])
38+
{:error, "Dynamic type Diffo.Type.NonExistent must be an Ash.Type.NewType with storage_type :map"}
39+
40+
## Checking type compatibility
41+
42+
Use `is_valid?/1` to check whether a module is usable as a Dynamic type before
43+
constructing a `%Diffo.Type.Dynamic{}`:
44+
45+
iex> Diffo.Type.Dynamic.is_valid?(Ash.Type.Date)
46+
false
47+
48+
iex> Diffo.Type.Dynamic.is_valid?(Diffo.Type.NonExistent)
49+
false
50+
2951
## Constraints
3052
3153
iex> Diffo.Type.Dynamic.dynamic_constraints(nil)
@@ -68,7 +90,7 @@ defmodule Diffo.Type.Dynamic do
6890

6991
def dynamic_constraints(type) when is_atom(type) do
7092
cond do
71-
Ash.Type.NewType.new_type?(type) ->
93+
is_valid?(type) ->
7294
[
7395
fields: [
7496
type: @type_field_constraints,
@@ -85,23 +107,31 @@ defmodule Diffo.Type.Dynamic do
85107
def dynamic_constraints(_), do: []
86108

87109
@impl true
88-
def apply_constraints(%__MODULE__{} = value, _constraints), do: {:ok, value}
110+
def apply_constraints(%__MODULE__{type: type} = value, _constraints) do
111+
if is_valid?(type) do
112+
{:ok, value}
113+
else
114+
{:error, "Dynamic type #{inspect(type)} must be an Ash.Type.NewType with storage_type :map"}
115+
end
116+
end
117+
89118
def apply_constraints(nil, _constraints), do: {:ok, nil}
90119
def apply_constraints(value, _constraints), do: {:error, "is invalid: #{inspect(value)}"}
91120

92121
@impl true
93122
def cast_input(nil, _constraints), do: {:ok, nil}
94123

95124
def cast_input(%__MODULE__{type: type, value: value}, _constraints) do
96-
constraints = dynamic_constraints(type)
97-
result = Ash.Type.cast_input(type, value, constraints[:fields][:value][:constraints] || [])
98-
99-
case result do
100-
{:ok, cast_value} ->
101-
{:ok, %__MODULE__{type: type, value: cast_value}}
102-
103-
error ->
104-
error
125+
if is_valid?(type) do
126+
constraints = dynamic_constraints(type)
127+
result = Ash.Type.cast_input(type, value, constraints[:fields][:value][:constraints] || [])
128+
129+
case result do
130+
{:ok, cast_value} -> {:ok, %__MODULE__{type: type, value: cast_value}}
131+
error -> error
132+
end
133+
else
134+
{:error, "Dynamic type #{inspect(type)} must be an Ash.Type.NewType with storage_type :map"}
105135
end
106136
end
107137

@@ -142,6 +172,21 @@ defmodule Diffo.Type.Dynamic do
142172
end
143173
end
144174

175+
@doc """
176+
Returns true if the module is a valid Dynamic type — an `Ash.Type.NewType` with
177+
`storage_type: :map`. Returns false for unloaded modules, non-NewTypes, and scalar
178+
Ash types such as `Ash.Type.Date`.
179+
"""
180+
def is_valid?(type) when is_atom(type) do
181+
Code.ensure_loaded?(type) and
182+
Ash.Type.NewType.new_type?(type) and
183+
Ash.Type.storage_type(type, []) == :map
184+
rescue
185+
_ -> false
186+
end
187+
188+
def is_valid?(_), do: false
189+
145190
defimpl Diffo.Unwrap do
146191
def unwrap(%{value: value}), do: Diffo.Unwrap.unwrap(value)
147192
end

test/type/dynamic_test.exs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,51 @@ defmodule Diffo.Type.DynamicTest do
1010
alias Diffo.Test.Patch
1111
alias Diffo.Test.CardValue
1212

13+
describe "dynamic type validation" do
14+
test "cast_input rejects non-NewType scalar Ash type" do
15+
value = %Dynamic{type: Ash.Type.Date, value: ~D[2026-01-01]}
16+
assert {:error, msg} = Ash.Type.cast_input(Dynamic, value, [])
17+
assert msg =~ "storage_type :map"
18+
end
19+
20+
test "cast_input rejects unloaded module" do
21+
value = %Dynamic{type: Diffo.Type.NonExistent, value: nil}
22+
assert {:error, msg} = Ash.Type.cast_input(Dynamic, value, [])
23+
assert msg =~ "storage_type :map"
24+
end
25+
26+
test "apply_constraints rejects invalid type" do
27+
value = %Dynamic{type: Ash.Type.Date, value: ~D[2026-01-01]}
28+
assert {:error, msg} = Ash.Type.apply_constraints(Dynamic, value, [])
29+
assert msg =~ "storage_type :map"
30+
end
31+
32+
test "is_valid? returns false for non-NewType" do
33+
assert Dynamic.is_valid?(Ash.Type.Date) == false
34+
end
35+
36+
test "is_valid? returns false for unloaded module" do
37+
assert Dynamic.is_valid?(Diffo.Type.NonExistent) == false
38+
end
39+
40+
test "is_valid? returns true for valid map-storage NewType" do
41+
assert Dynamic.is_valid?(Patch) == true
42+
end
43+
44+
test "dynamic_constraints returns [] for non-NewType" do
45+
assert Dynamic.dynamic_constraints(Ash.Type.Date) == []
46+
end
47+
48+
test "dynamic_constraints returns [] for unloaded module" do
49+
assert Dynamic.dynamic_constraints(Diffo.Type.NonExistent) == []
50+
end
51+
52+
test "valid map-storage NewType still works" do
53+
value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}
54+
assert {:ok, %Dynamic{type: Patch}} = Ash.Type.cast_input(Dynamic, value, [])
55+
end
56+
end
57+
1358
describe "dynamic cast and dump" do
1459
test "cast_input from struct" do
1560
value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}

0 commit comments

Comments
 (0)