diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c726e3..7cc2e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added + +* Add `BitcrowdEcto.Changeset.cast_all/3`, a introspection-based automatic cast function. +* Add `BitcrowdEcto.Assertions.assert_cast_error_on/2`. + ### Fixed * Allow any type of `id` param in `Repo.fetch/2`. Remove the (incorrect) guard restricting the `id` param to binaries, against the spec saying it would allow `any`. diff --git a/lib/bitcrowd_ecto/assertions.ex b/lib/bitcrowd_ecto/assertions.ex index 31d6499..aa22dd1 100644 --- a/lib/bitcrowd_ecto/assertions.ex +++ b/lib/bitcrowd_ecto/assertions.ex @@ -91,7 +91,7 @@ defmodule BitcrowdEcto.Assertions do changeset end - for validation <- [:required, :format, :number, :inclusion, :acceptance] do + for validation <- [:cast, :required, :format, :number, :inclusion, :acceptance] do @doc """ Asserts that a changeset contains a failed "#{validation}" validation on a given field. diff --git a/lib/bitcrowd_ecto/changeset.ex b/lib/bitcrowd_ecto/changeset.ex index 001b737..13aca7c 100644 --- a/lib/bitcrowd_ecto/changeset.ex +++ b/lib/bitcrowd_ecto/changeset.ex @@ -425,4 +425,120 @@ defmodule BitcrowdEcto.Changeset do end end end + + @type cast_all_option :: {:action, atom} + + @doc """ + Introspects a schema and casts all defined fields from a params map. + + - Accepts a schema module or structs or changesets. + - Can deal with embeds. + + ## Options + + - `required` list of required field names + - `optional` list of optional field names (= inverse set is required) + + `required` and `optional` options must not be present at the same time. + + ## Examples + + iex> changeset = cast_all(TestEmbeddedSchema, %{some_field: 4}) + iex> changeset.valid? + true + + iex> changeset = cast_all(%TestEmbeddedSchema{}, %{some_field: 4}) + iex> changeset.valid? + true + + iex> changeset = cast_all(change(%TestEmbeddedSchema{}), %{some_field: 4}) + iex> changeset.valid? + true + + iex> changeset = cast_all(TestEmbeddedSchema, %{}, required: [:some_field]) + iex> changeset.errors + [some_field: {"can't be blank", [validation: :required]}] + + iex> changeset = cast_all(TestEmbeddedSchema, %{}, optional: [:some_other_field]) + iex> changeset.errors + [some_field: {"can't be blank", [validation: :required]}] + + """ + @doc since: "0.17.0" + @spec cast_all(module | struct, map) :: Ecto.Changeset.t() + @spec cast_all(module | struct, map, [cast_all_option]) :: Ecto.Changeset.t() + def cast_all(schema_or_struct_or_changeset, params, opts \\ []) + + def cast_all(%Ecto.Changeset{} = changeset, params, opts) do + do_cast_all(changeset.data.__struct__, changeset, params, opts) + end + + def cast_all(schema, params, opts) when is_atom(schema) do + do_cast_all(schema, struct!(schema), params, opts) + end + + def cast_all(struct, params, opts) when is_struct(struct) do + do_cast_all(struct.__struct__, struct, params, opts) + end + + defp do_cast_all(schema, struct_or_changeset, params, opts) do + required = required_fields(schema, opts) + %{scalars: scalars, embeds: embeds} = grouped_fields(schema) + + struct_or_changeset + |> cast_scalars(params, scalars, required) + |> cast_embeds(embeds, required) + end + + defp required_fields(schema, opts) do + required = Keyword.get(opts, :required) + optional = Keyword.get(opts, :optional) + + cond do + required && optional -> + raise ArgumentError, ":required and :optional options are mutually exclusive" + + required -> + required + + optional -> + schema.__schema__(:fields) -- optional + + true -> + [] + end + end + + defp grouped_fields(schema) do + :fields + |> schema.__schema__() + |> Enum.group_by(fn field -> + case schema.__schema__(:type, field) do + {:parameterized, Ecto.Embedded, _} -> + :embeds + + # Simplification, can be extended as needed. + _other -> + :scalars + end + end) + |> Map.put_new(:embeds, []) + |> Map.put_new(:scalars, []) + end + + defp cast_scalars(schema_struct, params, scalars, required) do + # Don't be confused, `--` is right-associative. + # https://hexdocs.pm/elixir/1.15.7/operators.html#operator-precedence-and-associativity + required = scalars -- scalars -- required + + schema_struct + |> Ecto.Changeset.cast(params, scalars) + |> Ecto.Changeset.validate_required(required) + end + + defp cast_embeds(changeset, embeds, required) do + Enum.reduce(embeds, changeset, fn embed, cs -> + Ecto.Changeset.cast_embed(cs, embed, required: embed in required) + end) + end end diff --git a/test/bitcrowd_ecto/assertions_test.exs b/test/bitcrowd_ecto/assertions_test.exs index a2cfefe..a41c8af 100644 --- a/test/bitcrowd_ecto/assertions_test.exs +++ b/test/bitcrowd_ecto/assertions_test.exs @@ -59,6 +59,18 @@ defmodule BitcrowdEcto.AssertionsTest do end end + describe "assert_cast_error_on/2" do + test "asserts on the :cast error on a field" do + cs = changeset(%{some_string: 12, some_integer: 1}) + + assert assert_cast_error_on(cs, :some_string) == cs + + assert_raise ExUnit.AssertionError, fn -> + assert_cast_error_on(cs, :some_integer) + end + end + end + describe "assert_required_error_on/2" do test "asserts on the :required error on a field" do cs = changeset() |> validate_required(:some_string) diff --git a/test/bitcrowd_ecto/changeset_test.exs b/test/bitcrowd_ecto/changeset_test.exs index d01a51b..3ef9052 100644 --- a/test/bitcrowd_ecto/changeset_test.exs +++ b/test/bitcrowd_ecto/changeset_test.exs @@ -5,6 +5,36 @@ defmodule BitcrowdEcto.ChangesetTest do import BitcrowdEcto.Assertions import BitcrowdEcto.Changeset + defmodule TestEmbeddedSchema do + use Ecto.Schema + + @primary_key false + + embedded_schema do + field(:some_field, :integer) + end + + def changeset(struct \\ %__MODULE__{}, params) do + Ecto.Changeset.cast(struct, params, [:some_field]) + end + end + + defmodule TestVarietySchema do + use Ecto.Schema + + @primary_key false + + schema "not_an_actual_table" do + field(:some_scalar, :integer) + field(:some_enum, Ecto.Enum, values: [:foo, :bar]) + field(:some_more_complex_scalar, Money.Ecto.Composite.Type) + embeds_one(:one_embed, TestEmbeddedSchema) + embeds_many(:many_embeds, TestEmbeddedSchema) + end + end + + doctest BitcrowdEcto.Changeset + describe "validate_transition/3" do defp transition_changeset(from, to, transitions) do %TestSchema{some_string: from} @@ -668,4 +698,91 @@ defmodule BitcrowdEcto.ChangesetTest do end end end + + describe "cast_all/3" do + defp params do + embedded = %{"some_field" => 12} + + %{ + "some_scalar" => 5, + "some_enum" => "foo", + "some_more_complex_scalar" => "USD 120", + "one_embed" => embedded, + "many_embeds" => [embedded, embedded] + } + end + + test "allows to automatically cast all fields of a schema" do + %Ecto.Changeset{} = cs = cast_all(TestVarietySchema, params()) + assert cs.valid? + assert_changes(cs, :some_scalar, 5) + assert_changes(cs, :some_enum, :foo) + assert_changes(cs, :some_more_complex_scalar, Money.new(:USD, "120")) + + %Ecto.Changeset{} = embedded_cs = cs.changes.one_embed + assert embedded_cs.valid? + assert_changes(embedded_cs, :some_field, 12) + + assert [%Ecto.Changeset{valid?: true}, %Ecto.Changeset{valid?: true}] = + cs.changes.many_embeds + end + + test "accepts structs as input" do + %Ecto.Changeset{} = cs = cast_all(%TestVarietySchema{}, params()) + assert cs.valid? + end + + test "accepts changesets as input" do + %Ecto.Changeset{} = cs = cast_all(change(%TestVarietySchema{}), params()) + assert cs.valid? + end + + test "accepts params maps with atom keys" do + cs = cast_all(TestVarietySchema, %{some_scalar: 5}) + assert cs.valid? + assert_changes(cs, :some_scalar, 5) + end + + test "returns invalid changeset on cast errors" do + cs = cast_all(TestVarietySchema, %{some_scalar: "foo"}) + refute cs.valid? + assert_cast_error_on(cs, :some_scalar) + end + + test "accepts a list of required fields and validates them" do + cs = cast_all(TestVarietySchema, %{}, required: [:some_scalar]) + refute cs.valid? + assert_required_error_on(cs, :some_scalar) + refute_errors_on(cs, :some_enum) + end + + test "accepts a list of optional fields and validates them" do + cs = cast_all(TestVarietySchema, %{}, optional: [:some_scalar]) + refute cs.valid? + assert_required_error_on(cs, :some_enum) + refute_errors_on(cs, :some_scalar) + end + + test "required validations work for embeds, too" do + cs = + cast_all( + TestVarietySchema, + %{ + one_embed: nil, + many_embeds: [] + }, + required: [:one_embed, :many_embeds] + ) + + refute cs.valid? + assert_required_error_on(cs, :one_embed) + assert_required_error_on(cs, :many_embeds) + end + + test ":required and :optional are mutually exclusive" do + assert_raise ArgumentError, ~r/options are mutually exclusive/, fn -> + cast_all(TestVarietySchema, %{}, required: [], optional: []) + end + end + end end