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 4 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.auto_cast/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
114 changes: 114 additions & 0 deletions lib/bitcrowd_ecto/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -425,4 +425,118 @@
end
end
end

@type auto_cast_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 = auto_cast(TestEmbeddedSchema, %{some_field: 4})
iex> changeset.valid?
true

iex> changeset = auto_cast(%TestEmbeddedSchema{}, %{some_field: 4})
iex> changeset.valid?
true

iex> changeset = auto_cast(change(%TestEmbeddedSchema{}), %{some_field: 4})
iex> changeset.valid?
true

iex> changeset = auto_cast(TestEmbeddedSchema, %{}, required: [:some_field])
iex> changeset.errors
[some_field: {"can't be blank", [validation: :required]}]

iex> changeset = auto_cast(TestEmbeddedSchema, %{}, optional: [:some_other_field])
iex> changeset.errors
[some_field: {"can't be blank", [validation: :required]}]

"""
@doc since: "0.17.0"
@spec auto_cast(module | struct, map) :: Ecto.Changeset.t()
@spec auto_cast(module | struct, map, [auto_cast_option]) :: Ecto.Changeset.t()
def auto_cast(schema_or_struct_or_changeset, params, opts \\ [])

def auto_cast(%Ecto.Changeset{} = changeset, params, opts) do
do_auto_cast(changeset.data.__struct__, changeset, params, opts)
end

def auto_cast(schema, params, opts) when is_atom(schema) do
do_auto_cast(schema, struct!(schema), params, opts)
end

def auto_cast(struct, params, opts) when is_struct(struct) do
do_auto_cast(struct.__struct__, struct, params, opts)
end

defp do_auto_cast(schema, struct_or_changeset, params, opts) do
required = required_fields(schema, opts)
%{scalars: scalars, embeds: embeds} = grouped_fields(schema)

Check warning on line 486 in lib/bitcrowd_ecto/changeset.ex

View workflow job for this annotation

GitHub Actions / Build and test (26.2, 1.15.7)

variable "embeds" is unused (if the variable is not meant to be used, prefix it with an underscore)

Check warning on line 486 in lib/bitcrowd_ecto/changeset.ex

View workflow job for this annotation

GitHub Actions / Build and test (26.2, 1.15.7)

variable "scalars" is unused (if the variable is not meant to be used, prefix it with an underscore)

struct_or_changeset
|> cast_scalars(params, grouped.scalars, required)
|> cast_embeds(grouped.embeds, required)
maltoe marked this conversation as resolved.
Show resolved Hide resolved
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
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 "auto_cast/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 = auto_cast(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 = auto_cast(%TestVarietySchema{}, params())
assert cs.valid?
end

test "accepts changesets as input" do
%Ecto.Changeset{} = cs = auto_cast(change(%TestVarietySchema{}), params())
assert cs.valid?
end

test "accepts params maps with atom keys" do
cs = auto_cast(TestVarietySchema, %{some_scalar: 5})
assert cs.valid?
assert_changes(cs, :some_scalar, 5)
end

test "returns invalid changeset on cast errors" do
cs = auto_cast(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 = auto_cast(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 = auto_cast(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 =
auto_cast(
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 ->
auto_cast(TestVarietySchema, %{}, required: [], optional: [])
end
end
end
end
Loading