diff --git a/CHANGELOG.md b/CHANGELOG.md index 17aabbf..2c4b06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `Spectral.AbstractCode` now handles the Elixir non-empty list shorthand, `[elem_type, ...]` (and bare `[...]`), matching the existing `nonempty_list(elem_type)` support. Previously these types failed to compile with `unsupported type AST`. - `encode/4-5`, `decode/4-5`, and `schema/3-4` rescue `error in ErlangError` to translate a few known spectra configuration errors into `ArgumentError`, but that rescue clause also binds every other exception Elixir normalizes a raw BEAM error into (e.g. `%BadMapError{}`, `%KeyError{}`), not just literal `%ErlangError{}` structs. Any unrelated crash inside spectra therefore hit `handle_erlang_error/4`'s single `%ErlangError{}` clause and failed with a misleading `FunctionClauseError` pointing at Spectral itself, discarding the original exception and stacktrace. Such crashes now reraise unchanged, with their original stacktrace intact. +- `Spectral.Codec.encode/5`, `Spectral.Codec.decode/5`, and `Spectral.Codec.schema/4` now accept a `{:type, name, arity}` or `{:record, name}` reference, as their `sp_type_or_ref()` specs always claimed. Previously only a resolved `sp_type()` node worked and a reference failed with a `type_mismatch` naming an unexpected type. +- The `schema/5` callback is declared to return `map() | :continue`. Returning `:continue` for types a codec does not handle was already documented and supported, but the callback spec said `map()`. +- The README's custom codec example used the pre-0.12.0 callback signatures (`encode/7`, `decode/7`, `schema/6` with a separate `params` argument). Copying it produced callbacks that did not match the behaviour. The example and the `type_parameters` section now show the current `encode/6`, `decode/6`, and `schema/5`, and read `type_parameters` via `:spectra_type.parameters/1`. + +### Added +- A "Spectral and Ecto" README section on storing Spectral-typed values in `jsonb` columns, including two ways to handle a column whose type varies per row: a self-describing tagged union, and a type reference taken from a sibling column. Packaging the `Ecto.ParameterizedType` wrapper itself is left to a separate `spectral_ecto` library, which can depend on Ecto and test against a real database. ## [0.13.0] - 2026-05-07 diff --git a/README.md b/README.md index fa6b2ff..c708540 100644 --- a/README.md +++ b/README.md @@ -302,33 +302,35 @@ defmodule MyGeoModule do @opaque point :: {float(), float()} @impl Spectral.Codec - def encode(_format, MyGeoModule, {:type, :point, 0}, {x, y}, _sp_type, _params, _config) + def encode(_format, _caller_type_info, {:type, :point, 0}, _target_type, {x, y}, _config) when is_number(x) and is_number(y) do {:ok, [x, y]} end - def encode(_format, MyGeoModule, {:type, :point, 0}, data, _sp_type, _params, _config) do + def encode(_format, _caller_type_info, {:type, :point, 0}, _target_type, data, _config) do {:error, [%Spectral.Error{type: :type_mismatch, location: [], context: %{type: {:type, :point, 0}, value: data}}]} end - def encode(_format, _module, _type_ref, _data, _sp_type, _params, _config), do: :continue + def encode(_format, _caller_type_info, _type_ref, _target_type, _data, _config), do: :continue @impl Spectral.Codec - def decode(_format, MyGeoModule, {:type, :point, 0}, [x, y], _sp_type, _params, _config) + def decode(_format, _caller_type_info, {:type, :point, 0}, _target_type, [x, y], _config) when is_number(x) and is_number(y) do {:ok, {x, y}} end - def decode(_format, MyGeoModule, {:type, :point, 0}, data, _sp_type, _params, _config) do + def decode(_format, _caller_type_info, {:type, :point, 0}, _target_type, data, _config) do {:error, [%Spectral.Error{type: :type_mismatch, location: [], context: %{type: {:type, :point, 0}, value: data}}]} end - def decode(_format, _module, _type_ref, _input, _sp_type, _params, _config), do: :continue + def decode(_format, _caller_type_info, _type_ref, _target_type, _input, _config), do: :continue @impl Spectral.Codec - def schema(:json_schema, MyGeoModule, {:type, :point, 0}, _sp_type, _params, _config) do + def schema(:json_schema, _caller_type_info, {:type, :point, 0}, _target_type, _config) do %{type: "array", items: %{type: "number"}, minItems: 2, maxItems: 2} end + + def schema(_format, _caller_type_info, _type_ref, _target_type, _config), do: :continue end ``` @@ -340,9 +342,9 @@ For container types that need to recursively encode or decode their elements, us Construct `%Spectral.Error{}` structs and always return them in `{:error, [%Spectral.Error{}]}` tuples (as shown above). Spectral collects errors from multiple locations and attaches path information as it traverses nested structures. See existing usages of `%Spectral.Error{}` in the codebase for examples. -### Optional `schema/6` callback +### Optional `schema/5` callback -The `schema/6` callback is optional. If a codec module does not export it, calling `Spectral.schema/3` for a type owned by that codec raises `{:schema_not_implemented, Module, TypeRef}`. Return `:continue` for types the codec does not handle. +The `schema/5` callback is optional. If a codec module does not export it, calling `Spectral.schema/3` for a type owned by that codec raises `{:schema_not_implemented, Module, TypeRef}`. Once you do export it, it receives every type defined in the codec module, so give it a catch-all clause returning `:continue` for the types the codec does not handle, exactly as with `encode/6` and `decode/6`. ### Codecs for third-party types @@ -379,9 +381,112 @@ config :spectra, :codecs, %{ `Range` and `Stream` do not have built-in codecs. Implement a custom `Spectral.Codec` if needed — PRs welcome. +## Spectral and Ecto + +Ecto encodes and decodes `jsonb` values as Elixir maps. To convert those maps to and from +your types, use `:pre_encoded` and `:pre_decoded`: + +```elixir +# The map to hand back from Ecto.Type.dump/3 as {:ok, map} +{:ok, map} = Spectral.encode(value, MyApp.Settings, :t, :json, [:pre_encoded]) + +# The map Ecto.Type.load/3 receives from the database +{:ok, value} = Spectral.decode(map, MyApp.Settings, :t, :json, [:pre_decoded]) +``` + +Packaging that into an `Ecto.ParameterizedType`, so a schema can declare +`field :settings, SpectralEcto.JSONB, module: MyApp.Settings, type: :t`, is the job of the +separate `spectral_ecto` library. It lives there because it needs a real Ecto dependency and +a real Postgres instance to test against. Spectral itself has no Ecto dependency and needs +none. + +### When the type varies per row + +An `Ecto.ParameterizedType` cannot pick the type from another column: `load/3` receives only +the column value, and `init/1` runs at compile time. Where the discriminator lives decides +how to handle it. + +| | Discriminator | Ecto field type | +|---|---|---| +| Self-describing union | inside the document | static | +| Sibling column | its own column | plain `:map` | + +#### Self-describing union + +Put the tag in the document, pin it to a literal atom in the type, and carry that atom as +the struct default so callers never write it by hand: + +```elixir +defmodule MyApp.Shapes do + use Spectral + + defmodule Circle do + use Spectral + defstruct kind: :circle, radius: nil + @type t :: %Circle{kind: :circle, radius: float()} + end + + defmodule Square do + use Spectral + defstruct kind: :square, side: nil + @type t :: %Square{kind: :square, side: float()} + end + + @type shape :: Circle.t() | Square.t() +end + +{:ok, %{"kind" => "circle", "radius" => 1.5}} = + Spectral.encode(%MyApp.Shapes.Circle{radius: 1.5}, MyApp.Shapes, :shape, :json, [:pre_encoded]) +``` + +The field type stays static, so an `Ecto.ParameterizedType` handles the column unchanged. + +**The literal `kind` field is not decoration.** Unions are first-match-wins and extra JSON +keys are ignored, so an untagged variant whose fields are a subset of another's will swallow +documents meant for the later variant and silently drop the extra keys. The literal atom is +what makes the alternatives mutually exclusive. + +Use this when you control the document shape. It is the only option that survives the value +being copied out of the database, since the payload describes itself. + +#### Type chosen by a sibling column + +When the row already carries the discriminator in its own column, leave the payload column +as a plain `:map` and pass the type reference at call time. `type_ref` is an ordinary +runtime argument, so naming each type after the discriminator value is enough: + +```elixir +defmodule MyApp.Notification do + use Spectral + + alias MyApp.Notification.Email + alias MyApp.Notification.Sms + + @type email :: Email.t() + @type sms :: Sms.t() +end + +defmodule MyApp.Message do + use Ecto.Schema + + schema "messages" do + field :kind, Ecto.Enum, values: [:email, :sms] + field :payload, :map + end + + def decode_payload(%__MODULE__{kind: kind, payload: payload}) do + Spectral.decode(payload, MyApp.Notification, kind, :json, [:pre_decoded]) + end +end +``` + +Use this when the discriminator must be queryable or indexable, or when you do not control +the document shape. The tradeoff is that decoding becomes an explicit step the schema does +not enforce for you. + ## Type Parameters -The `type_parameters` key in a `spectral` attribute attaches a static value to a type. This value is available to codecs as the `params` argument (6th argument to `encode/7` and `decode/7`, 5th to `schema/6`). When `type_parameters` is absent, `params` is `:undefined`. +The `type_parameters` key in a `spectral` attribute attaches a static value to a type. Codecs read it inside `encode/6`, `decode/6`, and `schema/5` by calling `:spectra_type.parameters/1` on the `target_type` argument. When `type_parameters` is absent, that call returns `:undefined`. ### String and binary constraints diff --git a/lib/spectral/codec.ex b/lib/spectral/codec.ex index 6444c80..38c1215 100644 --- a/lib/spectral/codec.ex +++ b/lib/spectral/codec.ex @@ -120,11 +120,17 @@ defmodule Spectral.Codec do Preserves the runtime `config` (cache mode, codecs) across the traversal, unlike `Spectral.encode/5` which starts a fresh traversal. + `type_ref` may be a resolved `sp_type()` node, such as one from + `Spectral.Type.type_args/1`, or a `{:type, name, arity}` / `{:record, name}` reference, + which is looked up in `type_info`. + Returns `{:ok, term()}` (a pre-encoded term) or `{:error, [Spectral.Error.t()]}`. """ @spec encode(atom(), Spectral.type_info(), Spectral.sp_type_or_ref(), term(), term()) :: {:ok, term()} | {:error, [Spectral.Error.t()]} def encode(format, type_info, type_ref, data, config) do + type_ref = resolve_type_ref(type_info, type_ref) + result = case format do :json -> @@ -158,6 +164,8 @@ defmodule Spectral.Codec do @spec decode(atom(), Spectral.type_info(), Spectral.sp_type_or_ref(), term(), term()) :: {:ok, term()} | {:error, [Spectral.Error.t()]} def decode(format, type_info, type_ref, input, config) do + type_ref = resolve_type_ref(type_info, type_ref) + result = case format do :json -> @@ -184,9 +192,24 @@ defmodule Spectral.Codec do """ @spec schema(atom(), Spectral.type_info(), Spectral.sp_type_or_ref(), term()) :: dynamic() def schema(:json_schema, type_info, type_ref, config) do - :spectra_json_schema.to_schema(type_info, type_ref, config) + :spectra_json_schema.to_schema(type_info, resolve_type_ref(type_info, type_ref), config) + end + + # The traversal functions take a resolved `sp_type()` node. A `{:type, name, arity}` + # or `{:record, name}` reference is also a valid `sp_type_or_ref()`, so look it up in + # `type_info` first rather than letting it fall through as an unrecognised term. + defp resolve_type_ref(type_info, {:type, name, arity}) + when is_atom(name) and is_integer(arity) do + type = :spectra_type_info.get_type(type_info, name, arity) + :spectra_util.type_replace_vars(type_info, type, %{}) end + defp resolve_type_ref(type_info, {:record, name}) when is_atom(name) do + :spectra_type_info.get_record(type_info, name) + end + + defp resolve_type_ref(_type_info, type), do: type + @doc """ Encodes `data` of the given `target_type_ref` to `format`. @@ -242,6 +265,11 @@ defmodule Spectral.Codec do `{:schema_not_implemented, module, type_ref}` when schema generation is requested for a type owned by this codec. + Once implemented, the callback receives *every* type defined in the codec module, not + only the ones the codec handles. Give it a catch-all clause returning `:continue` for + the rest, exactly as with `encode/6` and `decode/6`, or schema generation for those + types raises a `FunctionClauseError`. + `caller_type_info` is the type info of the module driving the traversal. `target_type` is the type node; use `:spectra_type.parameters/1` to read `type_parameters` (only reliable when invoked directly from a `Spectral` entry point). @@ -253,7 +281,7 @@ defmodule Spectral.Codec do target_type_ref :: Spectral.sp_type_reference(), target_type :: Spectral.sp_type_or_ref(), config :: term() - ) :: map() + ) :: map() | :continue @optional_callbacks schema: 5 diff --git a/mix.exs b/mix.exs index 1cfcab1..16ab742 100644 --- a/mix.exs +++ b/mix.exs @@ -78,6 +78,8 @@ defmodule Spectral.MixProject do DefaultValues, DefaultValues.Config, EctoUser, + CodecRefModule, + CodecRefModule.Inner, Perf.Address, Perf.User ] diff --git a/test/spectral_codec_helpers_test.exs b/test/spectral_codec_helpers_test.exs new file mode 100644 index 0000000..d71a169 --- /dev/null +++ b/test/spectral_codec_helpers_test.exs @@ -0,0 +1,62 @@ +defmodule SpectralCodecHelpersTest do + @moduledoc """ + Covers two things a codec author has no way to discover from the outside: that the + recursive helpers accept a type *reference*, and that `schema/5` may decline a type. + """ + use ExUnit.Case, async: true + + describe "recursive helpers accept a type reference" do + # `Spectral.sp_type_or_ref()` includes `{:type, name, arity}`, so a codec may pass one + # instead of a resolved node from `Spectral.Type.type_args/1`. + + test "decode/5 resolves the reference against the given type_info" do + assert {:ok, %CodecRefModule.Inner{name: "Alice"}} = + Spectral.decode(%{"name" => "Alice"}, CodecRefModule, :outer, :json, [ + :pre_decoded + ]) + end + + test "encode/5 resolves the reference against the given type_info" do + assert {:ok, %{"name" => "Alice"}} = + Spectral.encode( + %CodecRefModule.Inner{name: "Alice"}, + CodecRefModule, + :outer, + :json, + [ + :pre_encoded + ] + ) + end + + test "schema/4 resolves the reference against the given type_info" do + assert %{type: "object", properties: %{"name" => %{type: "string"}}} = + Spectral.schema(CodecRefModule, :outer, :json_schema, [:pre_encoded]) + end + + test "schema/4 also resolves a {:record, name} reference" do + assert %{type: "object"} = + Spectral.schema(CodecRefModule, :record_ref, :json_schema, [:pre_encoded]) + end + + test "errors from the resolved type still surface" do + assert {:error, [%Spectral.Error{} | _]} = + Spectral.decode(%{"name" => 42}, CodecRefModule, :outer, :json, [:pre_decoded]) + end + end + + describe "schema/5 may decline a type" do + # Once `schema/5` is implemented it receives every type in the codec module, including + # the ones the codec does not own. + + test "a :continue return falls through to the structural schema" do + assert %{type: "integer"} = + Spectral.schema(CodecRefModule, :plain, :json_schema, [:pre_encoded]) + end + + test "the declined type still encodes and decodes structurally" do + assert {:ok, 42} = Spectral.decode(42, CodecRefModule, :plain, :json, [:pre_decoded]) + assert {:ok, 42} = Spectral.encode(42, CodecRefModule, :plain, :json, [:pre_encoded]) + end + end +end diff --git a/test/support/codec_ref_module.ex b/test/support/codec_ref_module.ex new file mode 100644 index 0000000..1c82e47 --- /dev/null +++ b/test/support/codec_ref_module.ex @@ -0,0 +1,57 @@ +defmodule CodecRefModule do + @moduledoc """ + A codec that drives every recursive call with a `{:type, name, arity}` reference + rather than a resolved type node, and whose `schema/5` declines the types it does + not own. + """ + use Spectral.Codec + use Spectral + + defmodule Inner do + @moduledoc false + use Spectral + + defstruct [:name] + + @type t :: %Inner{name: String.t()} + end + + @type outer :: Inner.t() + + # Not handled by the codec. Every callback must decline it. + @type plain :: integer() + + # A `{:record, name}` reference is the other half of `sp_type_reference()`. + @type record_ref :: term() + + @impl Spectral.Codec + def encode(format, _caller_type_info, {:type, :outer, 0}, _target_type, data, config) do + Spectral.Codec.encode(format, Inner.__spectra_type_info__(), {:type, :t, 0}, data, config) + end + + def encode(_format, _caller_type_info, _type_ref, _target_type, _data, _config), do: :continue + + @impl Spectral.Codec + def decode(format, _caller_type_info, {:type, :outer, 0}, _target_type, input, config) do + Spectral.Codec.decode(format, Inner.__spectra_type_info__(), {:type, :t, 0}, input, config) + end + + def decode(_format, _caller_type_info, _type_ref, _target_type, _input, _config), do: :continue + + @impl Spectral.Codec + def schema(:json_schema, _caller_type_info, {:type, :outer, 0}, _target_type, config) do + Spectral.Codec.schema(:json_schema, Inner.__spectra_type_info__(), {:type, :t, 0}, config) + end + + def schema(:json_schema, _caller_type_info, {:type, :record_ref, 0}, _target_type, config) do + # Built here rather than taken from a module because this project has no Erlang + # source, so no compiled module carries a record for the helper to look up. + type_info = + Spectral.TypeInfo.new(:nomodule, false) + |> Spectral.TypeInfo.add_record(:point, {:sp_rec, :point, [], 1, %{}}) + + Spectral.Codec.schema(:json_schema, type_info, {:record, :point}, config) + end + + def schema(_format, _caller_type_info, _type_ref, _target_type, _config), do: :continue +end