diff --git a/documentation/how_to/use_diffo_type.livemd b/documentation/how_to/use_diffo_type.livemd index 64d6c4b..e4df360 100644 --- a/documentation/how_to/use_diffo_type.livemd +++ b/documentation/how_to/use_diffo_type.livemd @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:diffo, "~> 0.2.0"}, + {:diffo, "~> 0.2.0"} ], consolidate_protocols: false ) @@ -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 | @@ -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: diff --git a/lib/diffo/type/dynamic.ex b/lib/diffo/type/dynamic.ex index 53ea9a8..2407d95 100644 --- a/lib/diffo/type/dynamic.ex +++ b/lib/diffo/type/dynamic.ex @@ -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) @@ -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, @@ -85,7 +107,14 @@ 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)}"} @@ -93,15 +122,16 @@ defmodule Diffo.Type.Dynamic do 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 @@ -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 diff --git a/test/type/dynamic_test.exs b/test/type/dynamic_test.exs index 77b10c0..9e07a24 100644 --- a/test/type/dynamic_test.exs +++ b/test/type/dynamic_test.exs @@ -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}}