diff --git a/README.md b/README.md index b08f12d..74ca5c8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/schemecto.ex b/lib/schemecto.ex index cf82d2c..1ceb3f2 100644 --- a/lib/schemecto.ex +++ b/lib/schemecto.ex @@ -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) @@ -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 diff --git a/lib/schemecto/map_of.ex b/lib/schemecto/map_of.ex new file mode 100644 index 0000000..babce75 --- /dev/null +++ b/lib/schemecto/map_of.ex @@ -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 diff --git a/test/schemecto_test.exs b/test/schemecto_test.exs index a9ea45c..218ed7f 100644 --- a/test/schemecto_test.exs +++ b/test/schemecto_test.exs @@ -23,6 +23,11 @@ defmodule SchemectoTest do |> Ecto.Changeset.validate_required([:street, :city]) end + defp validate_override(changeset) do + changeset + |> Ecto.Changeset.validate_required([:default]) + end + describe "new/2" do test "creates a schemaless changeset with empty defaults" do fields = [ @@ -343,6 +348,154 @@ defmodule SchemectoTest do end end + describe "map_of/2" do + test "validates map values against nested fields" do + override_fields = [%{name: :default, type: :integer}] + + fields = [ + %{name: :overrides, type: Schemecto.map_of(override_fields, with: &validate_override/1)} + ] + + params = %{ + overrides: %{ + "timeout" => %{default: 30}, + "retries" => %{default: "5"} + } + } + + changeset = Schemecto.new(fields, params) + + assert changeset.valid? + + overrides = Ecto.Changeset.get_change(changeset, :overrides) + assert overrides["timeout"].default == 30 + assert overrides["retries"].default == 5 + end + + test "collects errors keyed by map key" do + override_fields = [%{name: :default, type: :integer}] + + fields = [ + %{name: :overrides, type: Schemecto.map_of(override_fields, with: &validate_override/1)} + ] + + params = %{ + overrides: %{ + "timeout" => %{default: 30}, + "retries" => %{}, + "limit" => "not a map" + } + } + + changeset = Schemecto.new(fields, params) + + refute changeset.valid? + assert {"is invalid", metadata} = changeset.errors[:overrides] + + assert metadata[:errors] == [ + {"limit", []}, + {"retries", [default: {"can't be blank", [validation: :required]}]} + ] + + assert metadata[:validation] == :cast + assert {:parameterized, {Schemecto.MapOf, _}} = metadata[:type] + end + + test "handles nil" do + override_fields = [%{name: :default, type: :integer}] + + fields = [ + %{name: :overrides, type: Schemecto.map_of(override_fields, with: &validate_override/1)} + ] + + changeset = Schemecto.new(fields, %{overrides: nil}) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :overrides) == nil + end + + test "handles empty map" do + override_fields = [%{name: :default, type: :integer}] + + fields = [ + %{name: :overrides, type: Schemecto.map_of(override_fields, with: &validate_override/1)} + ] + + changeset = Schemecto.new(fields, %{overrides: %{}}) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :overrides) == %{} + end + + test "uses defaults in nested changesets" do + override_fields = [ + %{name: :default, type: :integer}, + %{name: :enabled, type: :boolean, default: true} + ] + + fields = [ + %{name: :overrides, type: Schemecto.map_of(override_fields, with: &validate_override/1)} + ] + + changeset = Schemecto.new(fields, %{overrides: %{"timeout" => %{default: 30}}}) + + assert changeset.valid? + + overrides = Ecto.Changeset.get_change(changeset, :overrides) + assert overrides["timeout"].enabled == true + end + + test "casts map values against a bare type" do + fields = [%{name: :rate_limits, type: Schemecto.map_of(:integer)}] + + changeset = Schemecto.new(fields, %{rate_limits: %{"free" => 10, "pro" => "100"}}) + + assert changeset.valid? + + assert Ecto.Changeset.get_change(changeset, :rate_limits) == %{ + "free" => 10, + "pro" => 100 + } + end + + test "collects errors keyed by map key for a bare type" do + fields = [%{name: :rate_limits, type: Schemecto.map_of(:integer)}] + + changeset = Schemecto.new(fields, %{rate_limits: %{"free" => 10, "pro" => "lots"}}) + + refute changeset.valid? + assert {"is invalid", metadata} = changeset.errors[:rate_limits] + assert metadata[:errors] == [{"pro", []}] + end + + test "handles nil for a bare type" do + fields = [%{name: :rate_limits, type: Schemecto.map_of(:integer)}] + + changeset = Schemecto.new(fields, %{rate_limits: nil}) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :rate_limits) == nil + end + + test "raises when a field list is given without options" do + assert_raise ArgumentError, ~r/use map_of\(fields, with: fun\)/, fn -> + Schemecto.map_of([%{name: :default, type: :integer}]) + end + end + + test "raises without a :with function" do + assert_raise KeyError, fn -> + Schemecto.map_of([%{name: :default, type: :integer}], []) + end + end + + test "raises when :with is not a 1-arity function" do + assert_raise ArgumentError, ~r/expected :with option to be a 1-arity function/, fn -> + Schemecto.map_of([%{name: :default, type: :integer}], with: fn _a, _b -> :ok end) + end + end + end + describe "integration" do test "one nested inside many" do # Define fields for the nested address (one) @@ -548,6 +701,48 @@ defmodule SchemectoTest do } end + test "converts map_of with nested fields to JSON schema" do + override_fields = [%{name: :default, type: :integer}] + + fields = [ + %{name: :overrides, type: Schemecto.map_of(override_fields, with: &validate_override/1)} + ] + + changeset = Schemecto.new(fields) + result = Schemecto.to_json_schema(changeset) + + assert result == %{ + "type" => "object", + "properties" => %{ + "overrides" => %{ + "type" => "object", + "additionalProperties" => %{ + "type" => "object", + "properties" => %{"default" => %{"type" => "integer"}}, + "required" => ["default"] + } + } + } + } + end + + test "converts map_of with a bare type to JSON schema" do + fields = [%{name: :rate_limits, type: Schemecto.map_of(:integer)}] + + changeset = Schemecto.new(fields) + result = Schemecto.to_json_schema(changeset) + + assert result == %{ + "type" => "object", + "properties" => %{ + "rate_limits" => %{ + "type" => "object", + "additionalProperties" => %{"type" => "integer"} + } + } + } + end + test "converts custom Ecto types to JSON schema" do fields = [ %{name: :custom_name, type: Schemecto.Test.CustomString},