Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BitcrowdEcto.Changeset.auto_cast/3 #55

Merged
merged 7 commits into from
Dec 20, 2023
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion lib/bitcrowd_ecto/assertions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
116 changes: 116 additions & 0 deletions lib/bitcrowd_ecto/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
ammancilla marked this conversation as resolved.
Show resolved Hide resolved

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
12 changes: 12 additions & 0 deletions test/bitcrowd_ecto/assertions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
117 changes: 117 additions & 0 deletions test/bitcrowd_ecto/changeset_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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