Skip to content
Merged
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
20 changes: 18 additions & 2 deletions documentation/how_to/use_diffo_type.livemd
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT
```elixir
Mix.install(
[
{:diffo, "~> 0.2.0"},
{:diffo, "~> 0.2.0"}
],
consolidate_protocols: false
)
Expand Down Expand Up @@ -47,7 +47,7 @@ Built-in implementations:
| `Diffo.Type.Primitive` | returns the primitive value |
| `Diffo.Type.Dynamic` | delegates to inner `:value` |
| `Ash.CiString` | returns the comparable string |
| `Ash.NotLoaded` | raises — field was not loaded |
| `Ash.NotLoaded` | raises — field was not loaded |
| `List` | unwraps each element |
| `Any` | returns the value unchanged |

Expand Down Expand Up @@ -254,6 +254,22 @@ Diffo.Unwrap.unwrap(result)
:ok
```

### Checking type compatibility

`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`:

```elixir
Dynamic.is_valid?(MyApp.Patch)
```

```elixir
Dynamic.is_valid?(Ash.Type.Date)
```

```elixir
Dynamic.is_valid?(NonExistent.Module)
```

### Constraint validation

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:
Expand Down
67 changes: 56 additions & 11 deletions lib/diffo/type/dynamic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ defmodule Diffo.Type.Dynamic do
iex> Ash.Type.cast_stored(Diffo.Type.Dynamic, nil, [])
{:ok, nil}

## Invalid types

Scalar Ash types and modules that are not `Ash.Type.NewType` with `storage_type: :map` are
rejected at cast time:

iex> Ash.Type.cast_input(Diffo.Type.Dynamic, %Diffo.Type.Dynamic{type: Ash.Type.Date, value: ~D[2026-01-01]}, [])
{:error, "Dynamic type Ash.Type.Date must be an Ash.Type.NewType with storage_type :map"}

iex> Ash.Type.cast_input(Diffo.Type.Dynamic, %Diffo.Type.Dynamic{type: Diffo.Type.NonExistent, value: nil}, [])
{:error, "Dynamic type Diffo.Type.NonExistent must be an Ash.Type.NewType with storage_type :map"}

## Checking type compatibility

Use `is_valid?/1` to check whether a module is usable as a Dynamic type before
constructing a `%Diffo.Type.Dynamic{}`:

iex> Diffo.Type.Dynamic.is_valid?(Ash.Type.Date)
false

iex> Diffo.Type.Dynamic.is_valid?(Diffo.Type.NonExistent)
false

## Constraints

iex> Diffo.Type.Dynamic.dynamic_constraints(nil)
Expand Down Expand Up @@ -68,7 +90,7 @@ defmodule Diffo.Type.Dynamic do

def dynamic_constraints(type) when is_atom(type) do
cond do
Ash.Type.NewType.new_type?(type) ->
is_valid?(type) ->
[
fields: [
type: @type_field_constraints,
Expand All @@ -85,23 +107,31 @@ defmodule Diffo.Type.Dynamic do
def dynamic_constraints(_), do: []

@impl true
def apply_constraints(%__MODULE__{} = value, _constraints), do: {:ok, value}
def apply_constraints(%__MODULE__{type: type} = value, _constraints) do
if is_valid?(type) do
{:ok, value}
else
{:error, "Dynamic type #{inspect(type)} must be an Ash.Type.NewType with storage_type :map"}
end
end

def apply_constraints(nil, _constraints), do: {:ok, nil}
def apply_constraints(value, _constraints), do: {:error, "is invalid: #{inspect(value)}"}

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

def cast_input(%__MODULE__{type: type, value: value}, _constraints) do
constraints = dynamic_constraints(type)
result = Ash.Type.cast_input(type, value, constraints[:fields][:value][:constraints] || [])

case result do
{:ok, cast_value} ->
{:ok, %__MODULE__{type: type, value: cast_value}}

error ->
error
if is_valid?(type) do
constraints = dynamic_constraints(type)
result = Ash.Type.cast_input(type, value, constraints[:fields][:value][:constraints] || [])

case result do
{:ok, cast_value} -> {:ok, %__MODULE__{type: type, value: cast_value}}
error -> error
end
else
{:error, "Dynamic type #{inspect(type)} must be an Ash.Type.NewType with storage_type :map"}
end
end

Expand Down Expand Up @@ -142,6 +172,21 @@ defmodule Diffo.Type.Dynamic do
end
end

@doc """
Returns true if the module is a valid Dynamic type — an `Ash.Type.NewType` with
`storage_type: :map`. Returns false for unloaded modules, non-NewTypes, and scalar
Ash types such as `Ash.Type.Date`.
"""
def is_valid?(type) when is_atom(type) do
Code.ensure_loaded?(type) and
Ash.Type.NewType.new_type?(type) and
Ash.Type.storage_type(type, []) == :map
rescue
_ -> false
end

def is_valid?(_), do: false

defimpl Diffo.Unwrap do
def unwrap(%{value: value}), do: Diffo.Unwrap.unwrap(value)
end
Expand Down
45 changes: 45 additions & 0 deletions test/type/dynamic_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,51 @@ defmodule Diffo.Type.DynamicTest do
alias Diffo.Test.Patch
alias Diffo.Test.CardValue

describe "dynamic type validation" do
test "cast_input rejects non-NewType scalar Ash type" do
value = %Dynamic{type: Ash.Type.Date, value: ~D[2026-01-01]}
assert {:error, msg} = Ash.Type.cast_input(Dynamic, value, [])
assert msg =~ "storage_type :map"
end

test "cast_input rejects unloaded module" do
value = %Dynamic{type: Diffo.Type.NonExistent, value: nil}
assert {:error, msg} = Ash.Type.cast_input(Dynamic, value, [])
assert msg =~ "storage_type :map"
end

test "apply_constraints rejects invalid type" do
value = %Dynamic{type: Ash.Type.Date, value: ~D[2026-01-01]}
assert {:error, msg} = Ash.Type.apply_constraints(Dynamic, value, [])
assert msg =~ "storage_type :map"
end

test "is_valid? returns false for non-NewType" do
assert Dynamic.is_valid?(Ash.Type.Date) == false
end

test "is_valid? returns false for unloaded module" do
assert Dynamic.is_valid?(Diffo.Type.NonExistent) == false
end

test "is_valid? returns true for valid map-storage NewType" do
assert Dynamic.is_valid?(Patch) == true
end

test "dynamic_constraints returns [] for non-NewType" do
assert Dynamic.dynamic_constraints(Ash.Type.Date) == []
end

test "dynamic_constraints returns [] for unloaded module" do
assert Dynamic.dynamic_constraints(Diffo.Type.NonExistent) == []
end

test "valid map-storage NewType still works" do
value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}
assert {:ok, %Dynamic{type: Patch}} = Ash.Type.cast_input(Dynamic, value, [])
end
end

describe "dynamic cast and dump" do
test "cast_input from struct" do
value = %Dynamic{type: Patch, value: %Patch{aEnd: 1, zEnd: 42}}
Expand Down
Loading