Skip to content
Open
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
349 changes: 349 additions & 0 deletions lib/zoi/ecto.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
if Code.ensure_loaded?(Ecto) do
defmodule Zoi.Ecto do
@moduledoc """
A bridge between Zoi parse errors and Ecto changesets.

Converts a list of `%Zoi.Error{}` structs (as returned by `Zoi.parse/2`)
into an `%Ecto.Changeset{}`. Nested error paths produce nested changesets,
matching the structure that `Phoenix`, `Absinthe`, and `LiveView` expect.

Error opts are structured to match Ecto's native changeset format: each
error carries `validation:` and (where applicable) `kind:` keys, plus
the original Zoi `code:` for programmatic matching. Message templates
retain `%{key}` placeholders so `Ecto.Changeset.traverse_errors/2` can
interpolate them using the standard pattern.

## Usage

case Zoi.parse(schema, input) do
{:ok, parsed} ->
{:ok, parsed}

{:error, errors} ->
{:error, Zoi.Ecto.errors_to_changeset(errors)}
end

## Ecto compatibility

The produced changeset errors use Ecto's `{template, keyword_list}` format.
The keyword list always includes:

- `:validation` -- maps to Ecto's validation name
- `:kind` -- present for numeric/length constraints
- `:code` -- the original Zoi error code atom is preserved
- Any interpolation params from the Zoi error (`:count`, `:type`, `:values`, etc.)

### Validation mapping

| Ecto validation | Zoi error code | Opts produced | Notes |
|---|---|---|---|
| `:required` | `required` | `validation: :required` | Exact match |
| `:cast` | `invalid_type` | `validation: :cast` | |
| `:format` | `invalid_format` | `validation: :format` | |
| `:inclusion` | `invalid_enum_value` | `validation: :inclusion, enum: [values]` | `enum:` from Zoi's `values:` list |
| `:number` | `greater_than`, `gte`, `less_than`, `lte` | `validation: :number, kind: :greater_than, number: N` | `number:` copied from Zoi's `count:` |
| `:length` | same size codes on string/array types | `validation: :length, kind: :min/:max/:is, type: :string/:list` | Zoi `:array` remapped to Ecto `:list` |
| `:custom` | `custom` | `validation: :custom` | From `Zoi.refine/2` |
| `:unknown_field` | `unrecognized_key` | `validation: :unknown_field` | |

Size codes (`greater_than`, `greater_than_or_equal_to`, `less_than`,
`less_than_or_equal_to`) are dispatched to `:number` or `:length` based
on Zoi's `type:` key in the error opts: `:string` and `:array` types
produce `:length`, everything else produces `:number`. Length `kind:` is
remapped to Ecto's convention (`:min`/`:max` instead of
`:greater_than_or_equal_to`/`:less_than_or_equal_to`).

### Ecto validations without a Zoi equivalent

| Ecto validation | Why no mapping |
|---|---|
| `:exclusion` | Zoi has no exclusion validator |
| `:subset` | Zoi has no subset validator |
| `:confirmation` | Field confirmation is an Ecto/Phoenix form concept |
| `:acceptance` | Checkbox acceptance is an Ecto/Phoenix form concept |
| `:unsafe_unique` | Database constraint, not a parse-time validation |

## Known limitations

- **Unknown codes:** Zoi error codes not in the mapping table are passed
through with `code:` only -- no `validation:` key is added.
- **Array errors:** Array item errors produce a list of changesets matching
Ecto's `embeds_many` convention. Positions without errors get empty valid
changesets because `Zoi.Ecto.errors_to_changeset/1` only receives the
error list, not partial parse data. Use `Zoi.Ecto.changeset/2` to get
partial data for valid items.
- **Numeric `number:` key derived from `count:`:** For numeric validation
errors, Ecto uses `number: target_value`. Zoi uses `count: value`. This
module copies `count:` into `number:` for Ecto compatibility -- both
keys are present in the output.
"""

@code_mapping %{
required: [validation: :required],
invalid_type: [validation: :cast],
invalid_format: [validation: :format],
invalid_enum_value: [validation: :inclusion],
invalid_length: [validation: :length, kind: :is],
greater_than: [kind: :greater_than],
greater_than_or_equal_to: [kind: :greater_than_or_equal_to],
less_than: [kind: :less_than],
less_than_or_equal_to: [kind: :less_than_or_equal_to],
unrecognized_key: [validation: :unknown_field],
custom: [validation: :custom]
}

@size_codes [
:greater_than,
:greater_than_or_equal_to,
:less_than,
:less_than_or_equal_to
]

@length_types [:string, :array]

@doc """
Parses input through a Zoi schema and returns an Ecto changeset.

On success, returns a valid changeset with the parsed data.
On failure, returns an invalid changeset with errors routed to the
correct fields (equivalent to calling `Zoi.parse/3` then
`errors_to_changeset/1`).

## Examples

iex> schema = Zoi.map(%{name: Zoi.string()})
iex> changeset = Zoi.Ecto.changeset(schema, %{name: "Paulo"})
iex> changeset.valid?
true

iex> schema = Zoi.map(%{name: Zoi.string()})
iex> changeset = Zoi.Ecto.changeset(schema, %{name: 123})
iex> changeset.valid?
false
"""
@spec changeset(struct(), map(), keyword()) :: Ecto.Changeset.t()
def changeset(schema, input, opts \\ []) do
ctx =
schema
|> Zoi.Context.new(input)
|> Zoi.Context.parse(opts)

case ctx do
%{valid?: true, parsed: parsed} ->
Ecto.Changeset.change({parsed, %{}})

%{valid?: false, errors: errors, parsed: parsed} ->
errors_to_changeset(errors, parsed)
end
end

@doc """
Converts Zoi parse errors into an Ecto changeset.

Returns a valid changeset when given an empty error list.
Returns an invalid changeset with properly routed errors when given errors.

## Examples

iex> schema = Zoi.map(%{name: Zoi.string()})
iex> {:error, errors} = Zoi.parse(schema, %{name: 123})
iex> changeset = Zoi.Ecto.errors_to_changeset(errors)
iex> changeset.valid?
false
"""
@spec errors_to_changeset([Zoi.Error.t()], map() | nil) :: Ecto.Changeset.t()
def errors_to_changeset(errors, parsed \\ nil)

def errors_to_changeset([], _parsed) do
empty_changeset()
end

def errors_to_changeset(errors, parsed) when is_list(errors) do
base =
if is_map(parsed) do
Ecto.Changeset.change({parsed, %{}})
else
empty_changeset()
end

errors
|> Enum.reduce(base, fn
%Zoi.Error{code: code, issue: {template, issue_opts}, path: path}, cs ->
ecto_opts = build_ecto_opts(code, template, issue_opts)
route_error(cs, path, template, ecto_opts)
end)
|> to_ecto_array_changes()
|> Map.put(:valid?, false)
end

defp build_ecto_opts(code, _template, issue_opts) do
base = Map.get(@code_mapping, code, [])

base =
if code in @size_codes do
validation = resolve_size_validation(issue_opts)

base
|> Keyword.put(:validation, validation)
|> remap_size_kind(validation)
else
base
end

validation = Keyword.get(base, :validation)

issue_opts
|> maybe_add_enum_list(code)
|> maybe_add_number_key(validation)
|> maybe_remap_array_type()
|> Kernel.++(base ++ [code: code])
end

# Ecto uses `enum: [list]` for inclusion errors.
# Zoi provides `values: [list]` natively -- pass it through as `enum:`.
defp maybe_add_enum_list(issue_opts, :invalid_enum_value) do
case Keyword.get(issue_opts, :values) do
values when is_list(values) ->
Keyword.put(issue_opts, :enum, values)

_ ->
issue_opts
end
end

defp maybe_add_enum_list(issue_opts, _code), do: issue_opts

defp resolve_size_validation(issue_opts) do
case Keyword.get(issue_opts, :type) do
type when type in @length_types -> :length
_ -> :number
end
end

# Ecto uses :min/:max for length kind, not :greater_than_or_equal_to etc.
@length_kind_map %{
greater_than: :min,
greater_than_or_equal_to: :min,
less_than: :max,
less_than_or_equal_to: :max
}

defp remap_size_kind(base, :length) do
case Keyword.fetch(base, :kind) do
{:ok, zoi_kind} -> Keyword.put(base, :kind, Map.get(@length_kind_map, zoi_kind, zoi_kind))
:error -> base
end
end

defp remap_size_kind(base, _validation), do: base

# Ecto uses `number: target_value` for numeric validations.
# Zoi provides `count: value` -- we add `number:` as Ecto expects.
defp maybe_add_number_key(issue_opts, :number) do
case Keyword.get(issue_opts, :count) do
nil -> issue_opts
count -> Keyword.put(issue_opts, :number, count)
end
end

defp maybe_add_number_key(issue_opts, _validation), do: issue_opts

# Ecto uses `type: :list` for array length errors; Zoi uses `type: :array`.
defp maybe_remap_array_type(issue_opts) do
case Keyword.get(issue_opts, :type) do
:array -> Keyword.put(issue_opts, :type, :list)
_ -> issue_opts
end
end

# Root-level error (e.g., from Zoi.refine)
defp route_error(cs, [], template, opts) do
Ecto.Changeset.add_error(cs, :base, template, opts)
end

# Leaf field -- direct error on this changeset
defp route_error(cs, [field], template, opts) when is_atom(field) do
Ecto.Changeset.add_error(cs, field, template, opts)
end

# Nested map -- recurse into a child changeset
defp route_error(cs, [field | rest], template, opts)
when is_atom(field) and is_atom(hd(rest)) do
child = Map.get(cs.changes, field, empty_changeset())

updated =
child
|> route_error(rest, template, opts)
|> Map.put(:valid?, false)

%{cs | changes: Map.put(cs.changes, field, updated)}
end

# Array item -- route through an index-keyed map of child changesets
defp route_error(cs, [field, index | rest], template, opts)
when is_atom(field) and is_integer(index) do
items = Map.get(cs.changes, field, %{})
child = Map.get(items, index, empty_changeset())

updated =
child
|> route_error(rest, template, opts)
|> Map.put(:valid?, false)

new_items = Map.put(items, index, updated)
%{cs | changes: Map.put(cs.changes, field, new_items)}
end

# During routing, array items are stored as index-keyed maps for O(1)
# lookup. After all errors are routed, convert to padded lists matching
# Ecto's embeds_many convention: one changeset per array position.
# When parsed data is available (from changeset/2 via Zoi.Context),
# valid array items carry their parsed data instead of empty changesets.

defp to_ecto_array_changes(%Ecto.Changeset{changes: changes} = cs) do
parsed_data = extract_parsed_data(cs)

updated =
Map.new(changes, fn
{field, %{} = index_map} when not is_struct(index_map) ->
parsed_array = Map.get(parsed_data, field, %{})
{field, index_map_to_list(index_map, parsed_array)}

{field, %Ecto.Changeset{} = nested} ->
{field, to_ecto_array_changes(nested)}

other ->
other
end)

%{cs | changes: updated}
end

defp extract_parsed_data(%Ecto.Changeset{data: data}) when is_map(data), do: data
defp extract_parsed_data(_cs), do: %{}

defp index_map_to_list(index_map, parsed_array) do
all_indices = Map.keys(index_map) ++ Map.keys(parsed_array)
max_index = Enum.max(all_indices, fn -> -1 end)

if max_index < 0 do
[]
else
Enum.map(0..max_index, fn i ->
case Map.get(index_map, i) do
nil ->
case Map.get(parsed_array, i) do
item when is_map(item) -> Ecto.Changeset.change({item, %{}})
_ -> empty_changeset()
end

cs ->
to_ecto_array_changes(cs)
end
end)
end
end

defp empty_changeset, do: Ecto.Changeset.change({%{}, %{}})
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ defmodule Zoi.MixProject do
defp deps do
[
{:decimal, "~> 2.0", optional: true},
{:ecto, "~> 3.0", optional: true},
{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", optional: true},
{:excoveralls, "~> 0.18", only: :test},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"},
Expand All @@ -14,4 +15,5 @@
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
}
Loading