Ecto changeset validation bug?

This simple module gives a counter-intuitive result:

defmodule D do
  import Ecto.Changeset
  use Ecto.Schema

  schema "ds" do
    field :title, :string
  end

  def changeset(cs, t) do
    cast(cs, %{"title" => t}, [:title])
    |> validate_required([:title])
    |> validate_length(:title,   min: 10)
  end

end

As expected, providing a short title triggers a validation error:

iex(1)> cs = D.changeset(%D{}, "foo")

If the user attempts to correct that but provides another short title, the changeset now has two validations errors on the same key:

iex(2)> cs = D.changeset(cs, "too short")
#Ecto.Changeset<
  action: nil,
  changes: %{title: "too short"},
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]},
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}
  ],
  data: #D<>,
  valid?: false
>

Where it gets counter-intuitive is that trying once again with a long-enough title, reduces the error count, but keeps one:

iex(3)> cs = D.changeset(cs, "now long enough")
#Ecto.Changeset<
  action: nil,
  changes: %{title: "now long enough"},
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}
  ],
  data: #D<>,
  valid?: false
>

After that, no matter what title is given, the error count either goes back to 2 (if the title is too short), or goes to 1 (if the title is long enough) but the changeset never becomes valid? despite being valid. What went wrong in that code?

2 Likes

Changeset doesn’t remove validation errors in an existing instance. These objects are meant to be throwaway. Just make a new one.

But it does remove errors: as mentioned above the error count goes down from 2 to 1 when giving a valid title. It just removes the errors only partially, which is the bug I’m interested in.

(Note that the above is a highly simplified example from a real case of a multi-step form, where the changeset gets updated as the user moves through the various liveview steps of an application form)

Yep, I’m just restating the observed behavior. Not sure the maintainers would fix it but it’s been there for a while, hence I always regenerate ‘Changeset`s after the input data usage is resubmitted / corrected.

Multi-stage / wizard-like scenario is not a problem, you can put the-user-input-so-far in a session.

1 Like

It doesn’t look like this should be happening. It should only add new errors or leave the changeset alone. Are you sure you are not always re-using the changeset that is produced after the first error?

Instead of doing the above, you could maybe try :

cs = D.changeset(%D{}, "foo")
cs = D.changeset(Map.merge(%D{}, cs.params), "too short")`

This way the former errors won’t show up anymore.

I appreciate the workarounds, @dimitarvp & @Kurisu , but I’m more interested in the underlying bug and how to fix it. I forgot to show something that might make the bug even clearer: the errors for a given key max out at 2, no matter how many time a too short value is given (I elided some IEx output for brevity):

iex(1)> cs = D.changeset(%D{}, "foo")
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}],

iex(2)>cs = D.changeset(cs, "bar")
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]},
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}],

iex(3)> cs = D.changeset(cs, "baz")
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]},
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}],

iex(4)> cs = D.changeset(cs, "too short")
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]},
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}],

Notice how the errors for title cap at 2. Then giving a title that passes validation does reduce the error count, down to one, but will not reduce it further:

iex(5)> cs = D.changeset(cs, "A valid title")
 errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}],

iex(6)> cs = D.changeset(cs, "Another good title")
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 10, validation: :length, kind: :min, type: :string]}],

Putting together the “max at 2” and “reduce by 1”, it looks like there is a bug in allowing it to grow to 2 in the first place. If the cap was at 1, the behavior would make perfect sense: go up to 1 and then back down to 0. Thoughts?

2 Likes

It’s not really a “max two reduce by one” rule but it looks that way because of how the changeset functions work.

When you call cast with a changeset as the first argument, it merges the new casting information into the original changeset. This merging de-duplicates errors to avoid the same cast error being set twice.

So what happens, assuming your changeset function calls cast and then validate_length is first the identical errors are de-duplicated by cast and then validate_length adds a new one if the length is not valid. Otherwise a single error remains.

If you try calling validate_length multiple times without casting first you’ll see the same error added as many times as you call it.

1 Like

Makes sense, thanks for clearing it up!