Skip to content

Commit d69ffb8

Browse files
committed
Add Schemecto.map_of for maps of arbitrary keys
map_of/2 mirrors one/2 and many/2 with nested field definitions and a :with validation function. map_of/1 accepts a bare Ecto type for the values. Both emit JSON Schema objects with additionalProperties.
1 parent f2d09f7 commit d69ffb8

4 files changed

Lines changed: 389 additions & 1 deletion

File tree

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,31 @@ Custom Ecto types and parameterized types are also supported as long as
112112
they emit one of the types above. More types can be added in the future too.
113113

114114
`Schemecto.one/2` and `Schemecto.many/2` should be preferred instead of
115-
`:map` when the fields are known upfront.
115+
`:map` when the fields are known upfront. When the keys are arbitrary but
116+
the values have a known shape, use `Schemecto.map_of/1` with a value type
117+
or `Schemecto.map_of/2` with nested fields:
118+
119+
```elixir
120+
fields = [
121+
%{
122+
name: :flags,
123+
type: Schemecto.map_of(:boolean)
124+
},
125+
%{
126+
name: :endpoints,
127+
type: Schemecto.map_of(
128+
[
129+
%{name: :host, type: :string},
130+
%{name: :port, type: :integer, default: 443}
131+
],
132+
with: &Example.validate_endpoint/1
133+
)
134+
}
135+
]
136+
```
137+
138+
Both emit JSON Schema objects with `additionalProperties` describing
139+
the values.
116140

117141
## License
118142

lib/schemecto.ex

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,79 @@ defmodule Schemecto do
134134
})
135135
end
136136

137+
@doc """
138+
Defines a validation for maps of arbitrary keys to values of the given type.
139+
140+
Values are cast with `Ecto.Type.cast/2`, so any castable Ecto type is
141+
supported, including `{:array, type}`, `Ecto.Enum`, and nested
142+
`Schemecto.one/2` types.
143+
144+
## Examples
145+
146+
fields = [
147+
%{name: :flags, type: Schemecto.map_of(:boolean)}
148+
]
149+
150+
changeset = Schemecto.new(fields, params)
151+
152+
"""
153+
def map_of(fields) when is_list(fields) do
154+
raise ArgumentError,
155+
"a list of fields requires a :with option, use map_of(fields, with: fun)"
156+
end
157+
158+
def map_of(type) do
159+
Ecto.ParameterizedType.init(Schemecto.MapOf, %{type: type})
160+
end
161+
162+
@doc """
163+
Defines a validation for maps of arbitrary keys to nested values.
164+
165+
## Parameters
166+
167+
* `fields` - List of field definitions for each nested value
168+
* `opts` - Keyword list of options:
169+
* `:with` - A 1-arity function that receives a changeset
170+
with the parameters already cast into them (if any) (required)
171+
172+
## Examples
173+
174+
def validate_endpoint(changeset) do
175+
changeset
176+
|> Ecto.Changeset.validate_required([:host])
177+
|> Ecto.Changeset.validate_number(:port, greater_than: 0, less_than: 65_536)
178+
end
179+
180+
fields = [
181+
%{
182+
name: :endpoints,
183+
type: Schemecto.map_of(
184+
[
185+
%{name: :host, type: :string},
186+
%{name: :port, type: :integer, default: 443}
187+
],
188+
with: &validate_endpoint/1
189+
)
190+
}
191+
]
192+
193+
changeset = Schemecto.new(fields, params)
194+
195+
"""
196+
def map_of(fields, opts) when is_list(fields) and is_list(opts) do
197+
function = Keyword.fetch!(opts, :with)
198+
199+
if not is_function(function, 1) do
200+
raise ArgumentError,
201+
"expected :with option to be a 1-arity function, got: #{inspect(function)}"
202+
end
203+
204+
Ecto.ParameterizedType.init(Schemecto.MapOf, %{
205+
changeset: build_changeset(fields),
206+
with: function
207+
})
208+
end
209+
137210
# Builds a changeset from field definitions
138211
defp build_changeset(fields) do
139212
{types, defaults, metadata_validations} = extract_field_info(fields)
@@ -265,6 +338,22 @@ defmodule Schemecto do
265338
}
266339
end
267340

341+
defp type_to_json_schema(
342+
{:parameterized, {Schemecto.MapOf, %{changeset: changeset, with: fun}}}
343+
) do
344+
%{
345+
"type" => "object",
346+
"additionalProperties" => changeset |> fun.() |> to_json_schema()
347+
}
348+
end
349+
350+
defp type_to_json_schema({:parameterized, {Schemecto.MapOf, %{type: type}}}) do
351+
%{
352+
"type" => "object",
353+
"additionalProperties" => type_to_json_schema(type)
354+
}
355+
end
356+
268357
# For all other types, get the underlying type using Ecto.Type.type/1
269358
defp type_to_json_schema(type) do
270359
try do

lib/schemecto/map_of.ex

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
defmodule Schemecto.MapOf do
2+
@moduledoc false
3+
use Ecto.ParameterizedType
4+
5+
@impl true
6+
def type(_params), do: :map
7+
8+
@impl true
9+
def init(state) do
10+
state
11+
end
12+
13+
@impl true
14+
def cast(nil, _params), do: {:ok, nil}
15+
16+
def cast(value, %{changeset: changeset, with: fun}) when is_map(value) do
17+
keys = Map.keys(changeset.types)
18+
19+
{valid_values, all_errors} =
20+
Enum.reduce(value, {%{}, []}, fn {key, inner}, {valid_acc, error_acc} ->
21+
if is_map(inner) do
22+
changeset =
23+
if inner == %{} do
24+
changeset
25+
else
26+
Ecto.Changeset.cast(changeset, inner, keys)
27+
end
28+
29+
case fun.(changeset) do
30+
%Ecto.Changeset{valid?: true} = cs ->
31+
{Map.put(valid_acc, key, Ecto.Changeset.apply_changes(cs)), error_acc}
32+
33+
%Ecto.Changeset{valid?: false, errors: errors} ->
34+
{valid_acc, [{key, errors} | error_acc]}
35+
end
36+
else
37+
{valid_acc, [{key, []} | error_acc]}
38+
end
39+
end)
40+
41+
if all_errors == [] do
42+
{:ok, valid_values}
43+
else
44+
{:error, [errors: Enum.sort(all_errors)]}
45+
end
46+
end
47+
48+
def cast(value, %{type: type}) when is_map(value) do
49+
{valid_values, all_errors} =
50+
Enum.reduce(value, {%{}, []}, fn {key, inner}, {valid_acc, error_acc} ->
51+
case Ecto.Type.cast(type, inner) do
52+
{:ok, cast_value} -> {Map.put(valid_acc, key, cast_value), error_acc}
53+
_ -> {valid_acc, [{key, []} | error_acc]}
54+
end
55+
end)
56+
57+
if all_errors == [] do
58+
{:ok, valid_values}
59+
else
60+
{:error, [errors: Enum.sort(all_errors)]}
61+
end
62+
end
63+
64+
def cast(_value, _params), do: :error
65+
66+
@impl true
67+
def load(nil, _loader, _params), do: {:ok, nil}
68+
def load(value, _loader, _params) when is_map(value), do: {:ok, value}
69+
def load(_value, _loader, _params), do: :error
70+
71+
@impl true
72+
def dump(nil, _dumper, _params), do: {:ok, nil}
73+
def dump(value, _dumper, _params) when is_map(value), do: {:ok, value}
74+
def dump(_value, _dumper, _params), do: :error
75+
76+
@impl true
77+
def equal?(nil, nil, _params), do: true
78+
def equal?(a, b, _params) when is_map(a) and is_map(b), do: a == b
79+
def equal?(_a, _b, _params), do: false
80+
end

0 commit comments

Comments
 (0)