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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
125 changes: 115 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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`.
Comment thread
andreashasse marked this conversation as resolved.

### Codecs for third-party types

Expand Down Expand Up @@ -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

Expand Down
32 changes: 30 additions & 2 deletions lib/spectral/codec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down Expand Up @@ -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 ->
Expand All @@ -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`.

Expand Down Expand Up @@ -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).
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ defmodule Spectral.MixProject do
DefaultValues,
DefaultValues.Config,
EctoUser,
CodecRefModule,
CodecRefModule.Inner,
Perf.Address,
Perf.User
]
Expand Down
62 changes: 62 additions & 0 deletions test/spectral_codec_helpers_test.exs
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions test/support/codec_ref_module.ex
Original file line number Diff line number Diff line change
@@ -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
Loading