From d2c7a985907bf456b30feab98baf39ed3f9863a2 Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 11 Sep 2025 14:08:36 -0400 Subject: [PATCH 1/2] Make default empty values consider type --- lib/ecto/changeset.ex | 30 ++++++++++++++++++++---------- lib/ecto/type.ex | 4 ++-- test/ecto/changeset_test.exs | 10 ++++++++++ 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index ca0572c55a..0a5677e111 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -382,7 +382,7 @@ defmodule Ecto.Changeset do alias Ecto.Changeset.Relation alias Ecto.Schema.Metadata - @empty_values [&Ecto.Type.empty_trimmed_string?/1] + @empty_values [&Ecto.Type.empty_trimmed_string?/2] # If a new field is added here, def merge must be adapted defstruct valid?: false, @@ -656,7 +656,10 @@ defmodule Ecto.Changeset do ## Options - * `:empty_values` - a list of values to be considered as empty when casting. + * `:empty_values` - a list containing elements of type `t:empty_value/0`. Those are + either values, which will be considered empty if they match, or a function that must + return a boolean if the value is empty or not. 1-arity functions will receive the value + being casted and 2-arity functions will receive the value being casted and its field type. Empty values are always replaced by the default value of the respective field. If the field is an array type, any empty value inside of the array will be removed. To set this option while keeping the current default, use `empty_values/0` and add @@ -961,24 +964,31 @@ defmodule Ecto.Changeset do end end - defp filter_empty_values(_type, value, empty_values) do - filter_empty_value(empty_values, value) + defp filter_empty_values(type, value, empty_values) do + filter_empty_value(empty_values, value, type) end - defp filter_empty_value([head | tail], value) when is_function(head) do + defp filter_empty_value([head | tail], value, type) when is_function(head, 1) do case head.(value) do true -> :empty - false -> filter_empty_value(tail, value) + false -> filter_empty_value(tail, value, type) end end - defp filter_empty_value([value | _tail], value), + defp filter_empty_value([head | tail], value, type) when is_function(head, 2) do + case head.(value, type) do + true -> :empty + false -> filter_empty_value(tail, value, type) + end + end + + defp filter_empty_value([value | _tail], value, _type), do: :empty - defp filter_empty_value([_head | tail], value), - do: filter_empty_value(tail, value) + defp filter_empty_value([_head | tail], value, type), + do: filter_empty_value(tail, value, type) - defp filter_empty_value([], value), + defp filter_empty_value([], value, _type), do: {:ok, value} # We only look at the first element because traversing the whole map diff --git a/lib/ecto/type.ex b/lib/ecto/type.ex index f8561df2d7..4f5d3a7dd0 100644 --- a/lib/ecto/type.ex +++ b/lib/ecto/type.ex @@ -1001,8 +1001,8 @@ defmodule Ecto.Type do defp same_duration(_), do: :error @doc false - def empty_trimmed_string?(value) do - is_binary(value) and String.trim_leading(value) == "" + def empty_trimmed_string?(value, type) do + is_binary(value) and type != :binary and String.trim_leading(value) == "" end ## Adapter related diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs index a7b966fa86..bfda2b5c88 100644 --- a/test/ecto/changeset_test.exs +++ b/test/ecto/changeset_test.exs @@ -219,6 +219,16 @@ defmodule Ecto.ChangesetTest do assert changeset.changes == %{} end + test "cast/4: with binary empty values" do + # <<9>> is a control character which should not be empty_trimmed_string + # for a binary field + params = %{"color" => <<9>>} + struct = %Post{} + + changeset = cast(struct, params, ~w(color)a) + assert changeset.changes == %{"color": <<9>>} + end + test "cast/4: with force_changes" do params = %{"title" => "", "body" => nil} struct = %Post{title: "", body: nil} From 42e22512bf77902bd6990415c2c79300f43d031e Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 11 Sep 2025 14:24:49 -0400 Subject: [PATCH 2/2] rename function --- lib/ecto/changeset.ex | 2 +- lib/ecto/type.ex | 2 +- test/ecto/changeset_test.exs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 0a5677e111..b727ef96e3 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -382,7 +382,7 @@ defmodule Ecto.Changeset do alias Ecto.Changeset.Relation alias Ecto.Schema.Metadata - @empty_values [&Ecto.Type.empty_trimmed_string?/2] + @empty_values [&Ecto.Type.empty_trimmed?/2] # If a new field is added here, def merge must be adapted defstruct valid?: false, diff --git a/lib/ecto/type.ex b/lib/ecto/type.ex index 4f5d3a7dd0..a5ac8ad28a 100644 --- a/lib/ecto/type.ex +++ b/lib/ecto/type.ex @@ -1001,7 +1001,7 @@ defmodule Ecto.Type do defp same_duration(_), do: :error @doc false - def empty_trimmed_string?(value, type) do + def empty_trimmed?(value, type) do is_binary(value) and type != :binary and String.trim_leading(value) == "" end diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs index bfda2b5c88..734709d326 100644 --- a/test/ecto/changeset_test.exs +++ b/test/ecto/changeset_test.exs @@ -220,7 +220,7 @@ defmodule Ecto.ChangesetTest do end test "cast/4: with binary empty values" do - # <<9>> is a control character which should not be empty_trimmed_string + # <<9>> is a control character which should not be empty_trimmed # for a binary field params = %{"color" => <<9>>} struct = %Post{}