From 5e853b1025c1a83594e407559ac6a09b64bbcba6 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 11 Sep 2026 17:07:36 +0000 Subject: [PATCH 1/4] Document and test JSONB column support Spectral already has everything needed to store typed values in a jsonb column: `:pre_encoded` and `:pre_decoded` meet Ecto at the map boundary, where the database driver does its own JSON serialization. Nothing in the library changed here, but that was not written down anywhere, so it kept getting reported as missing. Adds a README section covering the `Ecto.ParameterizedType` wrapper and three ways to handle a column whose type varies per row: - a self-describing union, with the discriminator inside the document - a type reference taken from a sibling column at call time - a discriminating codec, for one lookup instead of a linear scan Each pattern has a support module and tests, including a real JSON round trip standing in for the database driver. Three traps found while writing the tests and now documented: - `Ecto.Type` dispatches to parameterized types before its own nil shortcut, so `cast/2`, `load/3` and `dump/3` all receive nil for a NULL column. Plain `Ecto.Type` modules never see nil. - Recursive codec calls need a resolved type node from `Spectral.TypeInfo.get_type/3`. Passing a `{:type, name, arity}` tuple fails with a `type_mismatch` naming an unexpected type. - `schema/5` needs a catch-all returning `:continue`, or generating a schema for any other type in the codec module raises. The callback is declared to return a map, but spectra accepts `:continue` here. No Ecto dependency is added. None of the patterns need one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WvE2JYfi1tDoVW8pfrc4UU --- CHANGELOG.md | 3 + README.md | 200 ++++++++++++++++++++++++++++ mix.exs | 10 ++ test/spectral_jsonb_test.exs | 206 +++++++++++++++++++++++++++++ test/support/jsonb_notification.ex | 32 +++++ test/support/jsonb_shape_codec.ex | 91 +++++++++++++ test/support/jsonb_shapes.ex | 62 +++++++++ 7 files changed, 604 insertions(+) create mode 100644 test/spectral_jsonb_test.exs create mode 100644 test/support/jsonb_notification.ex create mode 100644 test/support/jsonb_shape_codec.ex create mode 100644 test/support/jsonb_shapes.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index f886566..170eaf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Documentation and tests for storing Spectral-typed values in database JSON columns (`jsonb`). Covers the `Ecto.ParameterizedType` wrapper and three ways to handle a column whose type varies per row: a self-describing tagged union, a type reference taken from a sibling column, and a discriminating codec. No library code changed — `:pre_encoded` and `:pre_decoded` already provide everything needed. + ## [0.13.0] - 2026-05-07 ### Added diff --git a/README.md b/README.md index fa6b2ff..e2a76f7 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,206 @@ config :spectra, :codecs, %{ `Range` and `Stream` do not have built-in codecs. Implement a custom `Spectral.Codec` if needed — PRs welcome. +## Database JSON Columns (`jsonb`) + +Spectral handles `jsonb` columns. The column type in Ecto is `:map`, and the database +driver does its own JSON serialization, so it hands Ecto an already-decoded map rather than +a JSON string. Use `:pre_encoded` and `:pre_decoded` to meet it there: + +```elixir +# What Ecto.Type.dump/3 should return — a map the driver will encode +{:ok, map} = Spectral.encode(value, MyApp.Settings, :t, :json, [:pre_encoded]) + +# What Ecto.Type.load/3 receives — the map the driver already decoded +{:ok, value} = Spectral.decode(map, MyApp.Settings, :t, :json, [:pre_decoded]) +``` + +That is the whole integration. Spectral has no Ecto dependency and needs none. + +### Wrapping it in an Ecto type + +When the column always holds the same type, an `Ecto.ParameterizedType` makes the +conversion automatic: + +```elixir +defmodule MyApp.JSONB do + use Ecto.ParameterizedType + + @impl true + def init(opts), do: {Keyword.fetch!(opts, :module), Keyword.get(opts, :type, :t)} + + @impl true + def type(_params), do: :map + + @impl true + def cast(nil, _params), do: {:ok, nil} + def cast(%mod{} = value, {mod, _type}), do: {:ok, value} + + def cast(data, {mod, type}) do + case Spectral.decode(data, mod, type, :json, [:pre_decoded]) do + {:ok, value} -> {:ok, value} + {:error, errors} -> {:error, message: Enum.map_join(errors, ", ", &Exception.message/1)} + end + end + + @impl true + def load(nil, _loader, _params), do: {:ok, nil} + + def load(data, _loader, {mod, type}) do + case Spectral.decode(data, mod, type, :json, [:pre_decoded]) do + {:ok, value} -> {:ok, value} + {:error, _errors} -> :error + end + end + + @impl true + def dump(nil, _dumper, _params), do: {:ok, nil} + + def dump(value, _dumper, {mod, type}) do + case Spectral.encode(value, mod, type, :json, [:pre_encoded]) do + {:ok, map} -> {:ok, map} + {:error, _errors} -> :error + end + end +end +``` + +Used as `field :settings, MyApp.JSONB, module: MyApp.Settings, type: :t`. + +Two things to know about this wrapper: + +- **The `nil` clauses are required.** `Ecto.Type` dispatches to parameterized types *before* + its own `nil` shortcut, so a `NULL` column arrives as `nil` in `cast/2`, `load/3`, and + `dump/3`. Plain `Ecto.Type` modules never see `nil`, which makes this easy to miss. +- **`load/3` and `dump/3` can only return `:error`**, with no reason, so Spectral's error + list is lost there. `cast/2` can return `{:error, message: ...}`, so validation errors + still reach the changeset. Bad data already in the column is a bug rather than user + input, so raising in `load/3` is often better than returning `:error`. + +### 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. There are three ways to handle a +column whose shape varies, and the right one depends on where the discriminator lives. + +| | Discriminator | Ecto field type | Cost per load | +|---|---|---|---| +| Self-describing union | inside the document | static | tries each variant in order | +| Sibling column | its own column | plain `:map` | one lookup, chosen by you | +| Discriminating codec | inside the document | static | one lookup | + +#### Self-describing union + +Put the tag in the document and pin it to a literal atom in each variant. The field type +stays static, so the `Ecto.ParameterizedType` above works unchanged: + +```elixir +defmodule MyApp.Shapes do + use Spectral + + defmodule Circle do + use Spectral + defstruct [:kind, :radius] + @type t :: %Circle{kind: :circle, radius: float()} + end + + defmodule Square do + use Spectral + defstruct [:kind, :side] + @type t :: %Square{kind: :square, side: float()} + end + + @type shape :: Circle.t() | Square.t() +end +``` + +**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 + +# schema +field :kind, Ecto.Enum, values: [:email, :sms] +field :payload, :map + +def payload(%__MODULE__{kind: kind, payload: payload}) do + Spectral.decode(payload, MyApp.Notification, kind, :json, [:pre_decoded]) +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. + +#### Discriminating codec + +A union tries each alternative until one matches, which costs more as variants are added and +produces a `no_match` error listing every failure. A codec reads the tag and jumps straight +to the right variant: + +```elixir +defmodule MyApp.ShapeCodec do + use Spectral.Codec + use Spectral + + alias MyApp.Shapes.Circle + alias MyApp.Shapes.Square + + @variants %{"circle" => Circle, "square" => Square} + @shape_ref {:type, :shape, 0} + + @type shape :: Circle.t() | Square.t() + + @impl Spectral.Codec + def decode(format, _caller_type_info, @shape_ref, _target_type, %{"kind" => kind} = input, config) + when is_map_key(@variants, kind) do + mod = Map.fetch!(@variants, kind) + type_info = mod.__spectra_type_info__() + type = Spectral.TypeInfo.get_type(type_info, :t, 0) + Spectral.Codec.decode(format, type_info, type, input, config) + end + + def decode(_format, _caller_type_info, _type_ref, _target_type, _input, _config), do: :continue +end +``` + +Two details are easy to get wrong here: + +- **Recursive codec calls take a resolved type node**, not a `{:type, name, arity}` + reference. Look the variant up with `Spectral.TypeInfo.get_type/3` first. Passing the + reference tuple produces a confusing `type_mismatch` error naming a type you did not + expect. +- **Give `schema/5` a fallthrough clause too.** A codec-owned type has no structural schema + fallback, so implement the callback for the discriminated type. Any *other* type in the + same module still reaches the callback, and without a catch-all returning `:continue` it + raises a `FunctionClauseError` during schema generation. + +Use this when the variant count is large enough for the linear scan to matter, or when you +want an error that names the unknown tag instead of listing every variant that failed. + +All three patterns are exercised in `test/spectral_jsonb_test.exs`, including a real JSON +round trip standing in for the database driver. + ## 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`. diff --git a/mix.exs b/mix.exs index 1cfcab1..f2e7235 100644 --- a/mix.exs +++ b/mix.exs @@ -78,6 +78,16 @@ defmodule Spectral.MixProject do DefaultValues, DefaultValues.Config, EctoUser, + JsonbShapes, + JsonbShapes.Circle, + JsonbShapes.Square, + JsonbUntaggedShapes, + JsonbUntaggedShapes.Name, + JsonbUntaggedShapes.NameAndSize, + JsonbNotification, + JsonbNotification.Email, + JsonbNotification.Sms, + JsonbShapeCodec, Perf.Address, Perf.User ] diff --git a/test/spectral_jsonb_test.exs b/test/spectral_jsonb_test.exs new file mode 100644 index 0000000..8308b3e --- /dev/null +++ b/test/spectral_jsonb_test.exs @@ -0,0 +1,206 @@ +defmodule SpectralJsonbTest do + @moduledoc """ + Proves the three ways of putting a Spectral-typed value in a JSONB column. + + A JSONB column never hands Elixir a JSON string. `Ecto.Type.load/3` receives + the map the database driver already decoded, and `Ecto.Type.dump/3` is + expected to return a map the driver will encode. So every test here works in + terms of maps, using `:pre_decoded` and `:pre_encoded`, and pushes the result + through a real JSON round trip to show it is something a driver can store. + """ + use ExUnit.Case, async: true + + alias JsonbShapes.Circle + alias JsonbShapes.Square + + # Stands in for the database driver: whatever `dump/3` returns gets encoded on + # the way in and decoded on the way out. + defp through_database(term) do + term |> :json.encode() |> IO.iodata_to_binary() |> :json.decode() + end + + describe "self-describing union: discriminator inside the document" do + test "dumps each variant to a map a driver can store" do + assert {:ok, dumped} = + Spectral.encode(%Circle{kind: :circle, radius: 1.5}, JsonbShapes, :shape, :json, [ + :pre_encoded + ]) + + assert dumped == %{"kind" => "circle", "radius" => 1.5} + assert dumped == through_database(dumped) + end + + test "loads each variant back from the stored map" do + assert {:ok, %Circle{kind: :circle, radius: 1.5}} = + Spectral.decode( + %{"kind" => "circle", "radius" => 1.5}, + JsonbShapes, + :shape, + :json, + [ + :pre_decoded + ] + ) + + assert {:ok, %Square{kind: :square, side: 2.0}} = + Spectral.decode(%{"kind" => "square", "side" => 2.0}, JsonbShapes, :shape, :json, [ + :pre_decoded + ]) + end + + test "round trips both variants through the database" do + for value <- [%Circle{kind: :circle, radius: 1.5}, %Square{kind: :square, side: 2.0}] do + {:ok, dumped} = Spectral.encode(value, JsonbShapes, :shape, :json, [:pre_encoded]) + + assert {:ok, ^value} = + Spectral.decode(through_database(dumped), JsonbShapes, :shape, :json, [ + :pre_decoded + ]) + end + end + + test "rejects a document whose tag matches no variant" do + assert {:error, [%Spectral.Error{type: :no_match}]} = + Spectral.decode( + %{"kind" => "triangle", "base" => 1.0}, + JsonbShapes, + :shape, + :json, + [:pre_decoded] + ) + end + + test "generates a schema without any extra wiring" do + schema = Spectral.schema(JsonbShapes, :shape, :json_schema, [:pre_encoded]) + + assert %{anyOf: variants} = schema + assert length(variants) == 2 + end + end + + describe "untagged union: why the discriminator field matters" do + test "the first structurally matching variant wins and extra keys are dropped" do + stored = %{"name" => "widget", "size" => 3} + + # The later variant describes this document exactly. + assert {:ok, %JsonbUntaggedShapes.NameAndSize{name: "widget", size: 3}} = + Spectral.decode(stored, JsonbUntaggedShapes.NameAndSize, :t, :json, [:pre_decoded]) + + # Through the union it still decodes as the earlier one, losing `size`. + assert {:ok, %JsonbUntaggedShapes.Name{name: "widget"}} = + Spectral.decode(stored, JsonbUntaggedShapes, :payload, :json, [:pre_decoded]) + end + end + + describe "type chosen by a sibling column" do + # The row carries the discriminator in its own column, so the payload has no + # tag of its own and the type reference is supplied at call time. + defp load_payload(%{kind: kind, payload: payload}) do + Spectral.decode(payload, JsonbNotification, kind, :json, [:pre_decoded]) + end + + defp dump_payload(%{kind: kind, payload: payload}) do + Spectral.encode(payload, JsonbNotification, kind, :json, [:pre_encoded]) + end + + test "loads the payload using the type named by the sibling column" do + row = %{kind: :email, payload: %{"to" => "a@example.com", "subject" => "Hi"}} + + assert {:ok, %JsonbNotification.Email{to: "a@example.com", subject: "Hi"}} = + load_payload(row) + end + + test "the same column loads a different type for a different discriminator" do + row = %{kind: :sms, payload: %{"number" => "+4670", "body" => "Hi"}} + + assert {:ok, %JsonbNotification.Sms{number: "+4670", body: "Hi"}} = load_payload(row) + end + + test "round trips through the database" do + payload = %JsonbNotification.Email{to: "a@example.com", subject: "Hi"} + + assert {:ok, dumped} = dump_payload(%{kind: :email, payload: payload}) + assert dumped == %{"to" => "a@example.com", "subject" => "Hi"} + + assert {:ok, ^payload} = + load_payload(%{kind: :email, payload: through_database(dumped)}) + end + + test "a payload stored under the wrong discriminator fails to load" do + row = %{kind: :sms, payload: %{"to" => "a@example.com", "subject" => "Hi"}} + + assert {:error, [_ | _]} = load_payload(row) + end + end + + describe "discriminating codec: one lookup instead of trying each variant" do + test "loads the variant named by the tag" do + assert {:ok, %Circle{kind: :circle, radius: 1.5}} = + Spectral.decode( + %{"kind" => "circle", "radius" => 1.5}, + JsonbShapeCodec, + :shape, + :json, + [:pre_decoded] + ) + + assert {:ok, %Square{kind: :square, side: 2.0}} = + Spectral.decode( + %{"kind" => "square", "side" => 2.0}, + JsonbShapeCodec, + :shape, + :json, + [:pre_decoded] + ) + end + + test "round trips through the database" do + value = %Square{kind: :square, side: 2.0} + + assert {:ok, dumped} = + Spectral.encode(value, JsonbShapeCodec, :shape, :json, [:pre_encoded]) + + assert dumped == %{"kind" => "square", "side" => 2.0} + + assert {:ok, ^value} = + Spectral.decode(through_database(dumped), JsonbShapeCodec, :shape, :json, [ + :pre_decoded + ]) + end + + test "reports an unknown tag against the discriminated type, not each variant" do + assert {:error, [%Spectral.Error{type: :type_mismatch}]} = + Spectral.decode( + %{"kind" => "triangle", "base" => 1.0}, + JsonbShapeCodec, + :shape, + :json, + [:pre_decoded] + ) + end + + test "rejects a value that is not one of the variants" do + assert {:error, [%Spectral.Error{type: :type_mismatch}]} = + Spectral.encode(%{not: "a shape"}, JsonbShapeCodec, :shape, :json, [:pre_encoded]) + end + + test "other types in the codec module fall through to structural handling" do + assert {:ok, "hello"} = + Spectral.decode("hello", JsonbShapeCodec, :note, :json, [:pre_decoded]) + + assert {:ok, "hello"} = + Spectral.encode("hello", JsonbShapeCodec, :note, :json, [:pre_encoded]) + + # `schema/5` needs its own fallthrough clause, or this raises. + assert %{type: "string"} = + Spectral.schema(JsonbShapeCodec, :note, :json_schema, [:pre_encoded]) + end + + test "still generates a schema through the optional callback" do + schema = Spectral.schema(JsonbShapeCodec, :shape, :json_schema, [:pre_encoded]) + + assert %{oneOf: variants} = schema + assert length(variants) == 2 + end + end +end diff --git a/test/support/jsonb_notification.ex b/test/support/jsonb_notification.ex new file mode 100644 index 0000000..f2b68e3 --- /dev/null +++ b/test/support/jsonb_notification.ex @@ -0,0 +1,32 @@ +defmodule JsonbNotification do + @moduledoc """ + Payloads for a JSONB column whose type is decided by a sibling column. + + Nothing inside the document says which variant it is, so the type reference + has to be supplied at call time. Each type is named after the value stored in + the discriminator column, which lets the caller pass that value straight + through as the `type_ref` argument. + """ + use Spectral + + defmodule Email do + @moduledoc false + use Spectral + + defstruct [:to, :subject] + + @type t :: %Email{to: String.t(), subject: String.t()} + end + + defmodule Sms do + @moduledoc false + use Spectral + + defstruct [:number, :body] + + @type t :: %Sms{number: String.t(), body: String.t()} + end + + @type email :: Email.t() + @type sms :: Sms.t() +end diff --git a/test/support/jsonb_shape_codec.ex b/test/support/jsonb_shape_codec.ex new file mode 100644 index 0000000..41c2e35 --- /dev/null +++ b/test/support/jsonb_shape_codec.ex @@ -0,0 +1,91 @@ +defmodule JsonbShapeCodec do + @moduledoc """ + A codec that reads the discriminator and jumps straight to the matching + variant, instead of letting the union try each alternative in turn. + + The declared type stays honest so that tooling which ignores the codec still + sees the real shape of the data. + """ + use Spectral.Codec + use Spectral + + alias JsonbShapes.Circle + alias JsonbShapes.Square + + @variants %{"circle" => Circle, "square" => Square} + @variant_modules Map.values(@variants) + @shape_ref {:type, :shape, 0} + + @type shape :: Circle.t() | Square.t() + + # An ordinary type in the same module. The codec returns `:continue` for it, + # so spectra falls through to its built-in structural handling. + @type note :: String.t() + + @impl Spectral.Codec + def encode(format, _caller_type_info, @shape_ref, _target_type, %mod{} = data, config) + when mod in @variant_modules do + {type_info, type} = variant_type(mod) + Spectral.Codec.encode(format, type_info, type, data, config) + end + + def encode(_format, _caller_type_info, @shape_ref, _target_type, data, _config) do + mismatch(data) + end + + def encode(_format, _caller_type_info, _type_ref, _target_type, _data, _config), do: :continue + + @impl Spectral.Codec + def decode( + format, + _caller_type_info, + @shape_ref, + _target_type, + %{"kind" => kind} = input, + config + ) + when is_map_key(@variants, kind) do + {type_info, type} = variant_type(Map.fetch!(@variants, kind)) + Spectral.Codec.decode(format, type_info, type, input, config) + end + + def decode(_format, _caller_type_info, @shape_ref, _target_type, input, _config) do + mismatch(input) + 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, @shape_ref, _target_type, config) do + %{ + oneOf: + Enum.map(@variant_modules, fn mod -> + {type_info, type} = variant_type(mod) + Spectral.Codec.schema(:json_schema, type_info, type, config) + end) + } + end + + # Without this clause, generating a schema for any other type in this module + # raises. The `schema/5` callback is declared to return a map, but spectra + # also accepts `:continue` and falls through to its structural schema. + def schema(_format, _caller_type_info, _type_ref, _target_type, _config), do: :continue + + defp mismatch(value) do + {:error, + [ + %Spectral.Error{ + type: :type_mismatch, + location: [], + context: %{type: @shape_ref, value: value} + } + ]} + end + + # Recursive codec calls take a resolved type node, not a {:type, name, arity} + # reference, so look the variant's `t/0` up in its own type_info. + defp variant_type(mod) do + type_info = mod.__spectra_type_info__() + {type_info, Spectral.TypeInfo.get_type(type_info, :t, 0)} + end +end diff --git a/test/support/jsonb_shapes.ex b/test/support/jsonb_shapes.ex new file mode 100644 index 0000000..517e32b --- /dev/null +++ b/test/support/jsonb_shapes.ex @@ -0,0 +1,62 @@ +defmodule JsonbShapes do + @moduledoc """ + Self-describing payloads for a JSONB column: the discriminator lives inside + the JSON document, so the column has a single static type. + + Each variant pins its `kind` field to a literal atom. That literal is what + makes the union unambiguous when spectra tries the alternatives in order. + """ + use Spectral + + defmodule Circle do + @moduledoc false + use Spectral + + defstruct [:kind, :radius] + + @type t :: %Circle{kind: :circle, radius: float()} + end + + defmodule Square do + @moduledoc false + use Spectral + + defstruct [:kind, :side] + + @type t :: %Square{kind: :square, side: float()} + end + + spectral(title: "Shape", description: "A shape stored in a JSONB column") + + @type shape :: Circle.t() | Square.t() +end + +defmodule JsonbUntaggedShapes do + @moduledoc """ + The same union without a discriminator field, kept to pin down the failure + mode documented in the README: unions are first-match-wins and extra JSON + keys are ignored, so a variant whose fields are a subset of another's + swallows payloads meant for the later variant. + """ + use Spectral + + defmodule Name do + @moduledoc false + use Spectral + + defstruct [:name] + + @type t :: %Name{name: String.t()} + end + + defmodule NameAndSize do + @moduledoc false + use Spectral + + defstruct [:name, :size] + + @type t :: %NameAndSize{name: String.t(), size: integer()} + end + + @type payload :: Name.t() | NameAndSize.t() +end From 4a0ad38b251ccfcf929684a588f09be765a50a15 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Fri, 11 Sep 2026 19:55:57 +0000 Subject: [PATCH 2/4] Address review: drop codec example, fix codec ref and schema specs Review feedback on the JSONB docs, plus the two library gaps it surfaced. Library fixes: - `Spectral.Codec.encode/5`, `decode/5` and `schema/4` now resolve a `{:type, name, arity}` or `{:record, name}` reference, as their `sp_type_or_ref()` specs have always claimed. Previously only a resolved `sp_type()` node worked; a reference fell through and failed with a `type_mismatch` naming a type the caller never asked about. - The `schema/5` callback is declared `map() | :continue`. Returning `:continue` for unhandled types was already documented in the README and supported by spectra, but the callback spec said `map()`. Both are covered by `test/spectral_codec_helpers_test.exs`, which fails against the previous implementation. Review changes: - Drop the discriminating codec pattern, its support module and its tests. It carried more weight than the point it made. - Give the tag field a struct default, so `%Circle{radius: 1.5}` encodes with `kind` filled in. No encode helper needed. - Rename the test helper to `json_round_trip`. It shows the dumped value survives JSON serialization; it is not a database and does not touch Ecto. Say so in the moduledoc instead of implying otherwise. - Retitle the README section to "Spectral and Ecto", and leave the `Ecto.ParameterizedType` wrapper to a separate `spectral_ecto` library that can depend on Ecto and test against a real Postgres. - Rename the sibling-column example function to `decode_payload/1`. - Correct the stale `schema/6` heading in the codec docs to `schema/5`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WvE2JYfi1tDoVW8pfrc4UU --- CHANGELOG.md | 6 +- README.md | 146 ++++++--------------------- lib/spectral/codec.ex | 32 +++++- mix.exs | 3 +- test/spectral_codec_helpers_test.exs | 62 ++++++++++++ test/spectral_jsonb_test.exs | 121 +++++----------------- test/support/codec_ref_module.ex | 57 +++++++++++ test/support/jsonb_shape_codec.ex | 91 ----------------- test/support/jsonb_shapes.ex | 9 +- 9 files changed, 218 insertions(+), 309 deletions(-) create mode 100644 test/spectral_codec_helpers_test.exs create mode 100644 test/support/codec_ref_module.ex delete mode 100644 test/support/jsonb_shape_codec.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 170eaf6..d523b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `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()`. + ### Added -- Documentation and tests for storing Spectral-typed values in database JSON columns (`jsonb`). Covers the `Ecto.ParameterizedType` wrapper and three ways to handle a column whose type varies per row: a self-describing tagged union, a type reference taken from a sibling column, and a discriminating codec. No library code changed — `:pre_encoded` and `:pre_decoded` already provide everything needed. +- Documentation and tests for storing Spectral-typed values in database JSON columns (`jsonb`). Covers 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 e2a76f7..428ce80 100644 --- a/README.md +++ b/README.md @@ -340,9 +340,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,7 +379,7 @@ config :spectra, :codecs, %{ `Range` and `Stream` do not have built-in codecs. Implement a custom `Spectral.Codec` if needed — PRs welcome. -## Database JSON Columns (`jsonb`) +## Spectral and Ecto Spectral handles `jsonb` columns. The column type in Ecto is `:map`, and the database driver does its own JSON serialization, so it hands Ecto an already-decoded map rather than @@ -395,82 +395,36 @@ a JSON string. Use `:pre_encoded` and `:pre_decoded` to meet it there: That is the whole integration. Spectral has no Ecto dependency and needs none. -### Wrapping it in an Ecto type +Packaging this as an `Ecto.ParameterizedType`, so that a schema can just declare +`field :settings, SpectralEcto.JSONB, module: MyApp.Settings, type: :t`, is the job of a +separate `spectral_ecto` library. It belongs there rather than here because it needs a real +Ecto dependency and a real Postgres instance to test against. -When the column always holds the same type, an `Ecto.ParameterizedType` makes the -conversion automatic: +Two things worth knowing before writing that wrapper yourself: -```elixir -defmodule MyApp.JSONB do - use Ecto.ParameterizedType - - @impl true - def init(opts), do: {Keyword.fetch!(opts, :module), Keyword.get(opts, :type, :t)} - - @impl true - def type(_params), do: :map - - @impl true - def cast(nil, _params), do: {:ok, nil} - def cast(%mod{} = value, {mod, _type}), do: {:ok, value} - - def cast(data, {mod, type}) do - case Spectral.decode(data, mod, type, :json, [:pre_decoded]) do - {:ok, value} -> {:ok, value} - {:error, errors} -> {:error, message: Enum.map_join(errors, ", ", &Exception.message/1)} - end - end - - @impl true - def load(nil, _loader, _params), do: {:ok, nil} - - def load(data, _loader, {mod, type}) do - case Spectral.decode(data, mod, type, :json, [:pre_decoded]) do - {:ok, value} -> {:ok, value} - {:error, _errors} -> :error - end - end - - @impl true - def dump(nil, _dumper, _params), do: {:ok, nil} - - def dump(value, _dumper, {mod, type}) do - case Spectral.encode(value, mod, type, :json, [:pre_encoded]) do - {:ok, map} -> {:ok, map} - {:error, _errors} -> :error - end - end -end -``` - -Used as `field :settings, MyApp.JSONB, module: MyApp.Settings, type: :t`. - -Two things to know about this wrapper: - -- **The `nil` clauses are required.** `Ecto.Type` dispatches to parameterized types *before* - its own `nil` shortcut, so a `NULL` column arrives as `nil` in `cast/2`, `load/3`, and +- **`nil` clauses are required.** `Ecto.Type` dispatches to parameterized types *before* its + own `nil` shortcut, so a `NULL` column arrives as `nil` in `cast/2`, `load/3` and `dump/3`. Plain `Ecto.Type` modules never see `nil`, which makes this easy to miss. - **`load/3` and `dump/3` can only return `:error`**, with no reason, so Spectral's error list is lost there. `cast/2` can return `{:error, message: ...}`, so validation errors - still reach the changeset. Bad data already in the column is a bug rather than user - input, so raising in `load/3` is often better than returning `:error`. + still reach the changeset. Bad data already in the column is a bug rather than user input, + so raising in `load/3` is often better than returning `:error`. ### 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. There are three ways to handle a -column whose shape varies, and the right one depends on where the discriminator lives. +the column value, and `init/1` runs at compile time. Where the discriminator lives decides +how to handle it. -| | Discriminator | Ecto field type | Cost per load | -|---|---|---|---| -| Self-describing union | inside the document | static | tries each variant in order | -| Sibling column | its own column | plain `:map` | one lookup, chosen by you | -| Discriminating codec | inside the document | static | one lookup | +| | 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 and pin it to a literal atom in each variant. The field type -stays static, so the `Ecto.ParameterizedType` above works unchanged: +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 @@ -478,20 +432,25 @@ defmodule MyApp.Shapes do defmodule Circle do use Spectral - defstruct [:kind, :radius] + defstruct kind: :circle, radius: nil @type t :: %Circle{kind: :circle, radius: float()} end defmodule Square do use Spectral - defstruct [:kind, :side] + 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(%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 @@ -521,7 +480,7 @@ end field :kind, Ecto.Enum, values: [:email, :sms] field :payload, :map -def payload(%__MODULE__{kind: kind, payload: payload}) do +def decode_payload(%__MODULE__{kind: kind, payload: payload}) do Spectral.decode(payload, MyApp.Notification, kind, :json, [:pre_decoded]) end ``` @@ -530,54 +489,7 @@ Use this when the discriminator must be queryable or indexable, or when you do n the document shape. The tradeoff is that decoding becomes an explicit step the schema does not enforce for you. -#### Discriminating codec - -A union tries each alternative until one matches, which costs more as variants are added and -produces a `no_match` error listing every failure. A codec reads the tag and jumps straight -to the right variant: - -```elixir -defmodule MyApp.ShapeCodec do - use Spectral.Codec - use Spectral - - alias MyApp.Shapes.Circle - alias MyApp.Shapes.Square - - @variants %{"circle" => Circle, "square" => Square} - @shape_ref {:type, :shape, 0} - - @type shape :: Circle.t() | Square.t() - - @impl Spectral.Codec - def decode(format, _caller_type_info, @shape_ref, _target_type, %{"kind" => kind} = input, config) - when is_map_key(@variants, kind) do - mod = Map.fetch!(@variants, kind) - type_info = mod.__spectra_type_info__() - type = Spectral.TypeInfo.get_type(type_info, :t, 0) - Spectral.Codec.decode(format, type_info, type, input, config) - end - - def decode(_format, _caller_type_info, _type_ref, _target_type, _input, _config), do: :continue -end -``` - -Two details are easy to get wrong here: - -- **Recursive codec calls take a resolved type node**, not a `{:type, name, arity}` - reference. Look the variant up with `Spectral.TypeInfo.get_type/3` first. Passing the - reference tuple produces a confusing `type_mismatch` error naming a type you did not - expect. -- **Give `schema/5` a fallthrough clause too.** A codec-owned type has no structural schema - fallback, so implement the callback for the discriminated type. Any *other* type in the - same module still reaches the callback, and without a catch-all returning `:continue` it - raises a `FunctionClauseError` during schema generation. - -Use this when the variant count is large enough for the linear scan to matter, or when you -want an error that names the unknown tag instead of listing every variant that failed. - -All three patterns are exercised in `test/spectral_jsonb_test.exs`, including a real JSON -round trip standing in for the database driver. +Both patterns are exercised in `test/spectral_jsonb_test.exs`. ## Type Parameters 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 f2e7235..4c2184d 100644 --- a/mix.exs +++ b/mix.exs @@ -87,7 +87,8 @@ defmodule Spectral.MixProject do JsonbNotification, JsonbNotification.Email, JsonbNotification.Sms, - JsonbShapeCodec, + 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/spectral_jsonb_test.exs b/test/spectral_jsonb_test.exs index 8308b3e..4febd52 100644 --- a/test/spectral_jsonb_test.exs +++ b/test/spectral_jsonb_test.exs @@ -1,33 +1,39 @@ defmodule SpectralJsonbTest do @moduledoc """ - Proves the three ways of putting a Spectral-typed value in a JSONB column. + Proves the two ways of putting a Spectral-typed value in a JSONB column. A JSONB column never hands Elixir a JSON string. `Ecto.Type.load/3` receives the map the database driver already decoded, and `Ecto.Type.dump/3` is expected to return a map the driver will encode. So every test here works in - terms of maps, using `:pre_decoded` and `:pre_encoded`, and pushes the result - through a real JSON round trip to show it is something a driver can store. + terms of maps, using `:pre_decoded` and `:pre_encoded`. + + Nothing here exercises Ecto or a database. The round trip below only shows + that an encoded value survives JSON serialization, which is the property a + driver needs. Testing the Ecto type itself needs Ecto and a real Postgres + instance, which is why that lives outside this repository. """ use ExUnit.Case, async: true alias JsonbShapes.Circle alias JsonbShapes.Square - # Stands in for the database driver: whatever `dump/3` returns gets encoded on - # the way in and decoded on the way out. - defp through_database(term) do + # Shows the dumped value is JSON-serializable. Not a database, not Ecto. + defp json_round_trip(term) do term |> :json.encode() |> IO.iodata_to_binary() |> :json.decode() end describe "self-describing union: discriminator inside the document" do - test "dumps each variant to a map a driver can store" do + test "dumps each variant to a JSON-serializable map" do assert {:ok, dumped} = - Spectral.encode(%Circle{kind: :circle, radius: 1.5}, JsonbShapes, :shape, :json, [ - :pre_encoded - ]) + Spectral.encode(%Circle{radius: 1.5}, JsonbShapes, :shape, :json, [:pre_encoded]) assert dumped == %{"kind" => "circle", "radius" => 1.5} - assert dumped == through_database(dumped) + assert dumped == json_round_trip(dumped) + end + + test "the tag comes from the struct default, so callers never write it" do + assert %Circle{kind: :circle} = %Circle{radius: 1.5} + assert %Square{kind: :square} = %Square{side: 2.0} end test "loads each variant back from the stored map" do @@ -48,12 +54,12 @@ defmodule SpectralJsonbTest do ]) end - test "round trips both variants through the database" do - for value <- [%Circle{kind: :circle, radius: 1.5}, %Square{kind: :square, side: 2.0}] do + test "round trips both variants" do + for value <- [%Circle{radius: 1.5}, %Square{side: 2.0}] do {:ok, dumped} = Spectral.encode(value, JsonbShapes, :shape, :json, [:pre_encoded]) assert {:ok, ^value} = - Spectral.decode(through_database(dumped), JsonbShapes, :shape, :json, [ + Spectral.decode(json_round_trip(dumped), JsonbShapes, :shape, :json, [ :pre_decoded ]) end @@ -95,11 +101,11 @@ defmodule SpectralJsonbTest do describe "type chosen by a sibling column" do # The row carries the discriminator in its own column, so the payload has no # tag of its own and the type reference is supplied at call time. - defp load_payload(%{kind: kind, payload: payload}) do + defp decode_payload(%{kind: kind, payload: payload}) do Spectral.decode(payload, JsonbNotification, kind, :json, [:pre_decoded]) end - defp dump_payload(%{kind: kind, payload: payload}) do + defp encode_payload(%{kind: kind, payload: payload}) do Spectral.encode(payload, JsonbNotification, kind, :json, [:pre_encoded]) end @@ -107,100 +113,29 @@ defmodule SpectralJsonbTest do row = %{kind: :email, payload: %{"to" => "a@example.com", "subject" => "Hi"}} assert {:ok, %JsonbNotification.Email{to: "a@example.com", subject: "Hi"}} = - load_payload(row) + decode_payload(row) end test "the same column loads a different type for a different discriminator" do row = %{kind: :sms, payload: %{"number" => "+4670", "body" => "Hi"}} - assert {:ok, %JsonbNotification.Sms{number: "+4670", body: "Hi"}} = load_payload(row) + assert {:ok, %JsonbNotification.Sms{number: "+4670", body: "Hi"}} = decode_payload(row) end - test "round trips through the database" do + test "round trips" do payload = %JsonbNotification.Email{to: "a@example.com", subject: "Hi"} - assert {:ok, dumped} = dump_payload(%{kind: :email, payload: payload}) + assert {:ok, dumped} = encode_payload(%{kind: :email, payload: payload}) assert dumped == %{"to" => "a@example.com", "subject" => "Hi"} assert {:ok, ^payload} = - load_payload(%{kind: :email, payload: through_database(dumped)}) + decode_payload(%{kind: :email, payload: json_round_trip(dumped)}) end test "a payload stored under the wrong discriminator fails to load" do row = %{kind: :sms, payload: %{"to" => "a@example.com", "subject" => "Hi"}} - assert {:error, [_ | _]} = load_payload(row) - end - end - - describe "discriminating codec: one lookup instead of trying each variant" do - test "loads the variant named by the tag" do - assert {:ok, %Circle{kind: :circle, radius: 1.5}} = - Spectral.decode( - %{"kind" => "circle", "radius" => 1.5}, - JsonbShapeCodec, - :shape, - :json, - [:pre_decoded] - ) - - assert {:ok, %Square{kind: :square, side: 2.0}} = - Spectral.decode( - %{"kind" => "square", "side" => 2.0}, - JsonbShapeCodec, - :shape, - :json, - [:pre_decoded] - ) - end - - test "round trips through the database" do - value = %Square{kind: :square, side: 2.0} - - assert {:ok, dumped} = - Spectral.encode(value, JsonbShapeCodec, :shape, :json, [:pre_encoded]) - - assert dumped == %{"kind" => "square", "side" => 2.0} - - assert {:ok, ^value} = - Spectral.decode(through_database(dumped), JsonbShapeCodec, :shape, :json, [ - :pre_decoded - ]) - end - - test "reports an unknown tag against the discriminated type, not each variant" do - assert {:error, [%Spectral.Error{type: :type_mismatch}]} = - Spectral.decode( - %{"kind" => "triangle", "base" => 1.0}, - JsonbShapeCodec, - :shape, - :json, - [:pre_decoded] - ) - end - - test "rejects a value that is not one of the variants" do - assert {:error, [%Spectral.Error{type: :type_mismatch}]} = - Spectral.encode(%{not: "a shape"}, JsonbShapeCodec, :shape, :json, [:pre_encoded]) - end - - test "other types in the codec module fall through to structural handling" do - assert {:ok, "hello"} = - Spectral.decode("hello", JsonbShapeCodec, :note, :json, [:pre_decoded]) - - assert {:ok, "hello"} = - Spectral.encode("hello", JsonbShapeCodec, :note, :json, [:pre_encoded]) - - # `schema/5` needs its own fallthrough clause, or this raises. - assert %{type: "string"} = - Spectral.schema(JsonbShapeCodec, :note, :json_schema, [:pre_encoded]) - end - - test "still generates a schema through the optional callback" do - schema = Spectral.schema(JsonbShapeCodec, :shape, :json_schema, [:pre_encoded]) - - assert %{oneOf: variants} = schema - assert length(variants) == 2 + assert {:error, [_ | _]} = decode_payload(row) 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 diff --git a/test/support/jsonb_shape_codec.ex b/test/support/jsonb_shape_codec.ex deleted file mode 100644 index 41c2e35..0000000 --- a/test/support/jsonb_shape_codec.ex +++ /dev/null @@ -1,91 +0,0 @@ -defmodule JsonbShapeCodec do - @moduledoc """ - A codec that reads the discriminator and jumps straight to the matching - variant, instead of letting the union try each alternative in turn. - - The declared type stays honest so that tooling which ignores the codec still - sees the real shape of the data. - """ - use Spectral.Codec - use Spectral - - alias JsonbShapes.Circle - alias JsonbShapes.Square - - @variants %{"circle" => Circle, "square" => Square} - @variant_modules Map.values(@variants) - @shape_ref {:type, :shape, 0} - - @type shape :: Circle.t() | Square.t() - - # An ordinary type in the same module. The codec returns `:continue` for it, - # so spectra falls through to its built-in structural handling. - @type note :: String.t() - - @impl Spectral.Codec - def encode(format, _caller_type_info, @shape_ref, _target_type, %mod{} = data, config) - when mod in @variant_modules do - {type_info, type} = variant_type(mod) - Spectral.Codec.encode(format, type_info, type, data, config) - end - - def encode(_format, _caller_type_info, @shape_ref, _target_type, data, _config) do - mismatch(data) - end - - def encode(_format, _caller_type_info, _type_ref, _target_type, _data, _config), do: :continue - - @impl Spectral.Codec - def decode( - format, - _caller_type_info, - @shape_ref, - _target_type, - %{"kind" => kind} = input, - config - ) - when is_map_key(@variants, kind) do - {type_info, type} = variant_type(Map.fetch!(@variants, kind)) - Spectral.Codec.decode(format, type_info, type, input, config) - end - - def decode(_format, _caller_type_info, @shape_ref, _target_type, input, _config) do - mismatch(input) - 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, @shape_ref, _target_type, config) do - %{ - oneOf: - Enum.map(@variant_modules, fn mod -> - {type_info, type} = variant_type(mod) - Spectral.Codec.schema(:json_schema, type_info, type, config) - end) - } - end - - # Without this clause, generating a schema for any other type in this module - # raises. The `schema/5` callback is declared to return a map, but spectra - # also accepts `:continue` and falls through to its structural schema. - def schema(_format, _caller_type_info, _type_ref, _target_type, _config), do: :continue - - defp mismatch(value) do - {:error, - [ - %Spectral.Error{ - type: :type_mismatch, - location: [], - context: %{type: @shape_ref, value: value} - } - ]} - end - - # Recursive codec calls take a resolved type node, not a {:type, name, arity} - # reference, so look the variant's `t/0` up in its own type_info. - defp variant_type(mod) do - type_info = mod.__spectra_type_info__() - {type_info, Spectral.TypeInfo.get_type(type_info, :t, 0)} - end -end diff --git a/test/support/jsonb_shapes.ex b/test/support/jsonb_shapes.ex index 517e32b..bc2b3ac 100644 --- a/test/support/jsonb_shapes.ex +++ b/test/support/jsonb_shapes.ex @@ -3,8 +3,9 @@ defmodule JsonbShapes do Self-describing payloads for a JSONB column: the discriminator lives inside the JSON document, so the column has a single static type. - Each variant pins its `kind` field to a literal atom. That literal is what - makes the union unambiguous when spectra tries the alternatives in order. + Each variant pins its `kind` field to a literal atom, and carries that atom as the + struct default so callers never write the tag by hand. The literal is what makes the + union unambiguous when spectra tries the alternatives in order. """ use Spectral @@ -12,7 +13,7 @@ defmodule JsonbShapes do @moduledoc false use Spectral - defstruct [:kind, :radius] + defstruct kind: :circle, radius: nil @type t :: %Circle{kind: :circle, radius: float()} end @@ -21,7 +22,7 @@ defmodule JsonbShapes do @moduledoc false use Spectral - defstruct [:kind, :side] + defstruct kind: :square, side: nil @type t :: %Square{kind: :square, side: float()} end From 6c95cb03f3636d7f9f971575e029fdadc852a409 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Sat, 12 Sep 2026 07:34:27 +0000 Subject: [PATCH 3/4] Address review: trim the Ecto section, drop the jsonb test module - Open the section with what a reader needs first: Ecto encodes and decodes jsonb values as Elixir maps, and `:pre_encoded` / `:pre_decoded` convert those maps to and from your types. The explanation of why the driver hands over a map was in the way. The library paragraph now follows directly. - Drop the two notes on writing the `Ecto.ParameterizedType` wrapper by hand. With `spectral_ecto` as the answer, there is no reason to coach people through rolling their own. Both points are recorded in #41, where whoever builds it will need them. - Remove `test/spectral_jsonb_test.exs` and its fixtures. The behaviour it covered is Spectral's existing encode and decode, and nothing in it touched Ecto or a database, so the real tests belong in `spectral_ecto`. The codec helper tests stay. They cover library changes, not jsonb. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WvE2JYfi1tDoVW8pfrc4UU --- CHANGELOG.md | 2 +- README.md | 32 ++----- mix.exs | 9 -- test/spectral_jsonb_test.exs | 141 ----------------------------- test/support/jsonb_notification.ex | 32 ------- test/support/jsonb_shapes.ex | 63 ------------- 6 files changed, 10 insertions(+), 269 deletions(-) delete mode 100644 test/spectral_jsonb_test.exs delete mode 100644 test/support/jsonb_notification.ex delete mode 100644 test/support/jsonb_shapes.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index d523b70..1d24ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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()`. ### Added -- Documentation and tests for storing Spectral-typed values in database JSON columns (`jsonb`). Covers 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. +- 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 428ce80..076bfbf 100644 --- a/README.md +++ b/README.md @@ -381,34 +381,22 @@ config :spectra, :codecs, %{ ## Spectral and Ecto -Spectral handles `jsonb` columns. The column type in Ecto is `:map`, and the database -driver does its own JSON serialization, so it hands Ecto an already-decoded map rather than -a JSON string. Use `:pre_encoded` and `:pre_decoded` to meet it there: +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 -# What Ecto.Type.dump/3 should return — a map the driver will encode +# Ecto.Type.dump/3 returns a map for Ecto to store {:ok, map} = Spectral.encode(value, MyApp.Settings, :t, :json, [:pre_encoded]) -# What Ecto.Type.load/3 receives — the map the driver already decoded +# Ecto.Type.load/3 receives the map Ecto read back {:ok, value} = Spectral.decode(map, MyApp.Settings, :t, :json, [:pre_decoded]) ``` -That is the whole integration. Spectral has no Ecto dependency and needs none. - -Packaging this as an `Ecto.ParameterizedType`, so that a schema can just declare -`field :settings, SpectralEcto.JSONB, module: MyApp.Settings, type: :t`, is the job of a -separate `spectral_ecto` library. It belongs there rather than here because it needs a real -Ecto dependency and a real Postgres instance to test against. - -Two things worth knowing before writing that wrapper yourself: - -- **`nil` clauses are required.** `Ecto.Type` dispatches to parameterized types *before* its - own `nil` shortcut, so a `NULL` column arrives as `nil` in `cast/2`, `load/3` and - `dump/3`. Plain `Ecto.Type` modules never see `nil`, which makes this easy to miss. -- **`load/3` and `dump/3` can only return `:error`**, with no reason, so Spectral's error - list is lost there. `cast/2` can return `{:error, message: ...}`, so validation errors - still reach the changeset. Bad data already in the column is a bug rather than user input, - so raising in `load/3` is often better than returning `:error`. +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 @@ -489,8 +477,6 @@ Use this when the discriminator must be queryable or indexable, or when you do n the document shape. The tradeoff is that decoding becomes an explicit step the schema does not enforce for you. -Both patterns are exercised in `test/spectral_jsonb_test.exs`. - ## 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`. diff --git a/mix.exs b/mix.exs index 4c2184d..16ab742 100644 --- a/mix.exs +++ b/mix.exs @@ -78,15 +78,6 @@ defmodule Spectral.MixProject do DefaultValues, DefaultValues.Config, EctoUser, - JsonbShapes, - JsonbShapes.Circle, - JsonbShapes.Square, - JsonbUntaggedShapes, - JsonbUntaggedShapes.Name, - JsonbUntaggedShapes.NameAndSize, - JsonbNotification, - JsonbNotification.Email, - JsonbNotification.Sms, CodecRefModule, CodecRefModule.Inner, Perf.Address, diff --git a/test/spectral_jsonb_test.exs b/test/spectral_jsonb_test.exs deleted file mode 100644 index 4febd52..0000000 --- a/test/spectral_jsonb_test.exs +++ /dev/null @@ -1,141 +0,0 @@ -defmodule SpectralJsonbTest do - @moduledoc """ - Proves the two ways of putting a Spectral-typed value in a JSONB column. - - A JSONB column never hands Elixir a JSON string. `Ecto.Type.load/3` receives - the map the database driver already decoded, and `Ecto.Type.dump/3` is - expected to return a map the driver will encode. So every test here works in - terms of maps, using `:pre_decoded` and `:pre_encoded`. - - Nothing here exercises Ecto or a database. The round trip below only shows - that an encoded value survives JSON serialization, which is the property a - driver needs. Testing the Ecto type itself needs Ecto and a real Postgres - instance, which is why that lives outside this repository. - """ - use ExUnit.Case, async: true - - alias JsonbShapes.Circle - alias JsonbShapes.Square - - # Shows the dumped value is JSON-serializable. Not a database, not Ecto. - defp json_round_trip(term) do - term |> :json.encode() |> IO.iodata_to_binary() |> :json.decode() - end - - describe "self-describing union: discriminator inside the document" do - test "dumps each variant to a JSON-serializable map" do - assert {:ok, dumped} = - Spectral.encode(%Circle{radius: 1.5}, JsonbShapes, :shape, :json, [:pre_encoded]) - - assert dumped == %{"kind" => "circle", "radius" => 1.5} - assert dumped == json_round_trip(dumped) - end - - test "the tag comes from the struct default, so callers never write it" do - assert %Circle{kind: :circle} = %Circle{radius: 1.5} - assert %Square{kind: :square} = %Square{side: 2.0} - end - - test "loads each variant back from the stored map" do - assert {:ok, %Circle{kind: :circle, radius: 1.5}} = - Spectral.decode( - %{"kind" => "circle", "radius" => 1.5}, - JsonbShapes, - :shape, - :json, - [ - :pre_decoded - ] - ) - - assert {:ok, %Square{kind: :square, side: 2.0}} = - Spectral.decode(%{"kind" => "square", "side" => 2.0}, JsonbShapes, :shape, :json, [ - :pre_decoded - ]) - end - - test "round trips both variants" do - for value <- [%Circle{radius: 1.5}, %Square{side: 2.0}] do - {:ok, dumped} = Spectral.encode(value, JsonbShapes, :shape, :json, [:pre_encoded]) - - assert {:ok, ^value} = - Spectral.decode(json_round_trip(dumped), JsonbShapes, :shape, :json, [ - :pre_decoded - ]) - end - end - - test "rejects a document whose tag matches no variant" do - assert {:error, [%Spectral.Error{type: :no_match}]} = - Spectral.decode( - %{"kind" => "triangle", "base" => 1.0}, - JsonbShapes, - :shape, - :json, - [:pre_decoded] - ) - end - - test "generates a schema without any extra wiring" do - schema = Spectral.schema(JsonbShapes, :shape, :json_schema, [:pre_encoded]) - - assert %{anyOf: variants} = schema - assert length(variants) == 2 - end - end - - describe "untagged union: why the discriminator field matters" do - test "the first structurally matching variant wins and extra keys are dropped" do - stored = %{"name" => "widget", "size" => 3} - - # The later variant describes this document exactly. - assert {:ok, %JsonbUntaggedShapes.NameAndSize{name: "widget", size: 3}} = - Spectral.decode(stored, JsonbUntaggedShapes.NameAndSize, :t, :json, [:pre_decoded]) - - # Through the union it still decodes as the earlier one, losing `size`. - assert {:ok, %JsonbUntaggedShapes.Name{name: "widget"}} = - Spectral.decode(stored, JsonbUntaggedShapes, :payload, :json, [:pre_decoded]) - end - end - - describe "type chosen by a sibling column" do - # The row carries the discriminator in its own column, so the payload has no - # tag of its own and the type reference is supplied at call time. - defp decode_payload(%{kind: kind, payload: payload}) do - Spectral.decode(payload, JsonbNotification, kind, :json, [:pre_decoded]) - end - - defp encode_payload(%{kind: kind, payload: payload}) do - Spectral.encode(payload, JsonbNotification, kind, :json, [:pre_encoded]) - end - - test "loads the payload using the type named by the sibling column" do - row = %{kind: :email, payload: %{"to" => "a@example.com", "subject" => "Hi"}} - - assert {:ok, %JsonbNotification.Email{to: "a@example.com", subject: "Hi"}} = - decode_payload(row) - end - - test "the same column loads a different type for a different discriminator" do - row = %{kind: :sms, payload: %{"number" => "+4670", "body" => "Hi"}} - - assert {:ok, %JsonbNotification.Sms{number: "+4670", body: "Hi"}} = decode_payload(row) - end - - test "round trips" do - payload = %JsonbNotification.Email{to: "a@example.com", subject: "Hi"} - - assert {:ok, dumped} = encode_payload(%{kind: :email, payload: payload}) - assert dumped == %{"to" => "a@example.com", "subject" => "Hi"} - - assert {:ok, ^payload} = - decode_payload(%{kind: :email, payload: json_round_trip(dumped)}) - end - - test "a payload stored under the wrong discriminator fails to load" do - row = %{kind: :sms, payload: %{"to" => "a@example.com", "subject" => "Hi"}} - - assert {:error, [_ | _]} = decode_payload(row) - end - end -end diff --git a/test/support/jsonb_notification.ex b/test/support/jsonb_notification.ex deleted file mode 100644 index f2b68e3..0000000 --- a/test/support/jsonb_notification.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule JsonbNotification do - @moduledoc """ - Payloads for a JSONB column whose type is decided by a sibling column. - - Nothing inside the document says which variant it is, so the type reference - has to be supplied at call time. Each type is named after the value stored in - the discriminator column, which lets the caller pass that value straight - through as the `type_ref` argument. - """ - use Spectral - - defmodule Email do - @moduledoc false - use Spectral - - defstruct [:to, :subject] - - @type t :: %Email{to: String.t(), subject: String.t()} - end - - defmodule Sms do - @moduledoc false - use Spectral - - defstruct [:number, :body] - - @type t :: %Sms{number: String.t(), body: String.t()} - end - - @type email :: Email.t() - @type sms :: Sms.t() -end diff --git a/test/support/jsonb_shapes.ex b/test/support/jsonb_shapes.ex deleted file mode 100644 index bc2b3ac..0000000 --- a/test/support/jsonb_shapes.ex +++ /dev/null @@ -1,63 +0,0 @@ -defmodule JsonbShapes do - @moduledoc """ - Self-describing payloads for a JSONB column: the discriminator lives inside - the JSON document, so the column has a single static type. - - Each variant pins its `kind` field to a literal atom, and carries that atom as the - struct default so callers never write the tag by hand. The literal is what makes the - union unambiguous when spectra tries the alternatives in order. - """ - use Spectral - - defmodule Circle do - @moduledoc false - use Spectral - - defstruct kind: :circle, radius: nil - - @type t :: %Circle{kind: :circle, radius: float()} - end - - defmodule Square do - @moduledoc false - use Spectral - - defstruct kind: :square, side: nil - - @type t :: %Square{kind: :square, side: float()} - end - - spectral(title: "Shape", description: "A shape stored in a JSONB column") - - @type shape :: Circle.t() | Square.t() -end - -defmodule JsonbUntaggedShapes do - @moduledoc """ - The same union without a discriminator field, kept to pin down the failure - mode documented in the README: unions are first-match-wins and extra JSON - keys are ignored, so a variant whose fields are a subset of another's - swallows payloads meant for the later variant. - """ - use Spectral - - defmodule Name do - @moduledoc false - use Spectral - - defstruct [:name] - - @type t :: %Name{name: String.t()} - end - - defmodule NameAndSize do - @moduledoc false - use Spectral - - defstruct [:name, :size] - - @type t :: %NameAndSize{name: String.t(), size: integer()} - end - - @type payload :: Name.t() | NameAndSize.t() -end From c7bafb0a1723ccdf1f98bccc0b373124e1a116d5 Mon Sep 17 00:00:00 2001 From: Andreas Hasselberg Date: Mon, 14 Sep 2026 05:59:34 +0000 Subject: [PATCH 4/4] Fix stale and broken README examples found in review All five are documentation correctness, verified against the code: - The custom codec example still used the pre-0.12.0 signatures: `encode/7`, `decode/7` and `schema/6` with a separate `params` argument. Copying it produced callbacks that do not match the declared behaviour. Updated to the current `encode/6`, `decode/6`, `schema/5`, matching the `Spectral.Codec` moduledoc, and given the `schema/5` catch-all the section itself recommends. - The `type_parameters` section described `params` as a callback argument. It was removed in 0.12.0; codecs read it with `:spectra_type.parameters/1` on `target_type`. - `%Circle{}` in the shapes example sat outside `MyApp.Shapes` with no alias, so it would not compile. Now fully qualified. - The sibling-column example closed its module before the `field` declarations and `decode_payload/1`, leaving them at top level with `__MODULE__` resolving to the wrong module. They now live in an `Ecto.Schema` module, which is also where a reader would put them. - The dump comment implied `Ecto.Type.dump/3` returns a bare map. It returns `{:ok, term}`, so the comment now describes the map as what goes inside that tuple. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WvE2JYfi1tDoVW8pfrc4UU --- CHANGELOG.md | 1 + README.md | 39 +++++++++++++++++++++++---------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d24ab1..1ec78b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `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. diff --git a/README.md b/README.md index 076bfbf..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 ``` @@ -385,10 +387,10 @@ Ecto encodes and decodes `jsonb` values as Elixir maps. To convert those maps to your types, use `:pre_encoded` and `:pre_decoded`: ```elixir -# Ecto.Type.dump/3 returns a map for Ecto to store +# The map to hand back from Ecto.Type.dump/3 as {:ok, map} {:ok, map} = Spectral.encode(value, MyApp.Settings, :t, :json, [:pre_encoded]) -# Ecto.Type.load/3 receives the map Ecto read back +# The map Ecto.Type.load/3 receives from the database {:ok, value} = Spectral.decode(map, MyApp.Settings, :t, :json, [:pre_decoded]) ``` @@ -434,7 +436,7 @@ defmodule MyApp.Shapes do end {:ok, %{"kind" => "circle", "radius" => 1.5}} = - Spectral.encode(%Circle{radius: 1.5}, MyApp.Shapes, :shape, :json, [:pre_encoded]) + 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. @@ -464,12 +466,17 @@ defmodule MyApp.Notification do @type sms :: Sms.t() end -# schema -field :kind, Ecto.Enum, values: [:email, :sms] -field :payload, :map +defmodule MyApp.Message do + use Ecto.Schema -def decode_payload(%__MODULE__{kind: kind, payload: payload}) do - Spectral.decode(payload, MyApp.Notification, kind, :json, [:pre_decoded]) + 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 ``` @@ -479,7 +486,7 @@ 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