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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,31 @@ Custom Ecto types and parameterized types are also supported as long as
they emit one of the types above. More types can be added in the future too.

`Schemecto.one/2` and `Schemecto.many/2` should be preferred instead of
`:map` when the fields are known upfront.
`:map` when the fields are known upfront. When the keys are arbitrary but
the values have a known shape, use `Schemecto.map_of/1` with a value type
or `Schemecto.map_of/2` with nested fields:

```elixir
fields = [
%{
name: :flags,
type: Schemecto.map_of(:boolean)
},
%{
name: :endpoints,
type: Schemecto.map_of(
[
%{name: :host, type: :string},
%{name: :port, type: :integer, default: 443}
],
with: &Example.validate_endpoint/1
)
}
]
```

Both emit JSON Schema objects with `additionalProperties` describing
the values.

## License

Expand Down
89 changes: 89 additions & 0 deletions lib/schemecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,79 @@ defmodule Schemecto do
})
end

@doc """
Defines a validation for maps of arbitrary keys to values of the given type.

Values are cast with `Ecto.Type.cast/2`, so any castable Ecto type is
supported, including `{:array, type}`, `Ecto.Enum`, and nested
`Schemecto.one/2` types.

## Examples

fields = [
%{name: :flags, type: Schemecto.map_of(:boolean)}
]

changeset = Schemecto.new(fields, params)

"""
def map_of(fields) when is_list(fields) do
raise ArgumentError,
"a list of fields requires a :with option, use map_of(fields, with: fun)"
end

def map_of(type) do
Ecto.ParameterizedType.init(Schemecto.MapOf, %{type: type})
end

@doc """
Defines a validation for maps of arbitrary keys to nested values.

## Parameters

* `fields` - List of field definitions for each nested value
* `opts` - Keyword list of options:
* `:with` - A 1-arity function that receives a changeset
with the parameters already cast into them (if any) (required)

## Examples

def validate_endpoint(changeset) do
changeset
|> Ecto.Changeset.validate_required([:host])
|> Ecto.Changeset.validate_number(:port, greater_than: 0, less_than: 65_536)
end

fields = [
%{
name: :endpoints,
type: Schemecto.map_of(
[
%{name: :host, type: :string},
%{name: :port, type: :integer, default: 443}
],
with: &validate_endpoint/1
)
}
]

changeset = Schemecto.new(fields, params)

"""
def map_of(fields, opts) when is_list(fields) and is_list(opts) do
function = Keyword.fetch!(opts, :with)

if not is_function(function, 1) do
raise ArgumentError,
"expected :with option to be a 1-arity function, got: #{inspect(function)}"
end

Ecto.ParameterizedType.init(Schemecto.MapOf, %{
changeset: build_changeset(fields),
with: function
})
end

# Builds a changeset from field definitions
defp build_changeset(fields) do
{types, defaults, metadata_validations} = extract_field_info(fields)
Expand Down Expand Up @@ -265,6 +338,22 @@ defmodule Schemecto do
}
end

defp type_to_json_schema(
{:parameterized, {Schemecto.MapOf, %{changeset: changeset, with: fun}}}
) do
%{
"type" => "object",
"additionalProperties" => changeset |> fun.() |> to_json_schema()
}
end

defp type_to_json_schema({:parameterized, {Schemecto.MapOf, %{type: type}}}) do
%{
"type" => "object",
"additionalProperties" => type_to_json_schema(type)
}
end

# For all other types, get the underlying type using Ecto.Type.type/1
defp type_to_json_schema(type) do
try do
Expand Down
80 changes: 80 additions & 0 deletions lib/schemecto/map_of.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
defmodule Schemecto.MapOf do
@moduledoc false
use Ecto.ParameterizedType

@impl true
def type(_params), do: :map

@impl true
def init(state) do
state
end

@impl true
def cast(nil, _params), do: {:ok, nil}

def cast(value, %{changeset: changeset, with: fun}) when is_map(value) do
keys = Map.keys(changeset.types)

{valid_values, all_errors} =
Enum.reduce(value, {%{}, []}, fn {key, inner}, {valid_acc, error_acc} ->
if is_map(inner) do
changeset =
if inner == %{} do
changeset
else
Ecto.Changeset.cast(changeset, inner, keys)
end

case fun.(changeset) do
%Ecto.Changeset{valid?: true} = cs ->
{Map.put(valid_acc, key, Ecto.Changeset.apply_changes(cs)), error_acc}

%Ecto.Changeset{valid?: false, errors: errors} ->
{valid_acc, [{key, errors} | error_acc]}
end
else
{valid_acc, [{key, []} | error_acc]}
end
end)

if all_errors == [] do
{:ok, valid_values}
else
{:error, [errors: Enum.sort(all_errors)]}
end
end

def cast(value, %{type: type}) when is_map(value) do
{valid_values, all_errors} =
Enum.reduce(value, {%{}, []}, fn {key, inner}, {valid_acc, error_acc} ->
case Ecto.Type.cast(type, inner) do
{:ok, cast_value} -> {Map.put(valid_acc, key, cast_value), error_acc}
_ -> {valid_acc, [{key, []} | error_acc]}
end
end)

if all_errors == [] do
{:ok, valid_values}
else
{:error, [errors: Enum.sort(all_errors)]}
end
end

def cast(_value, _params), do: :error

@impl true
def load(nil, _loader, _params), do: {:ok, nil}
def load(value, _loader, _params) when is_map(value), do: {:ok, value}
def load(_value, _loader, _params), do: :error

@impl true
def dump(nil, _dumper, _params), do: {:ok, nil}
def dump(value, _dumper, _params) when is_map(value), do: {:ok, value}
def dump(_value, _dumper, _params), do: :error

@impl true
def equal?(nil, nil, _params), do: true
def equal?(a, b, _params) when is_map(a) and is_map(b), do: a == b
def equal?(_a, _b, _params), do: false
end
Loading