Ecto validate_required "one of"

In some, admittedly relatively rare cases when I needed to validate presence of an attribute unless another attribute is already present I used to do

validate_presence_of :the_attribute, unless:  <another_attribute.present?> 

That’s obviously in “the other” framework’s syntax. How do you guys do such things with Ecto? Do we have something remotely similar for Ecto? Or custom validations and manual checking to the rescue?

1 Like

Just have a pipe-able function e.g.

def validate_unless_other_present(changeset, required_field, the_other_field) do
  case Changeset.get_field(changeset, the_other_field) do
    nil -> Changeset.validate_required(changeset, required_field) # the other field is not present, validate away
    _value -> changeset # the other field is present, skip validation
  end
end

And put it inside your changeset function(s) that need to make use of that logic e.g.

def changeset(data, params) do
  data
  |> Changeset.change(params)
  |> validate_required(@required_fields) # the optionally-required field from above must NOT be here
  |> cast_assoc(...)
  |> cast_embed(...)
  |> validate_unless_other_present(:the_attribute, :another_attribute)
end

And you should be good to go.

6 Likes

Yeah one of the things I really like about how changesets work as a datastructure is that instead of needing to reinvent their own control flow you can just use the language level controls to compose it as desired.

I see. That’s the custom validation path I mentioned but you wrapped it up so nicely - thanks!

Consider marking a comment as a solution – it helps future readers by surfacing such questions higher in the search function. I get no “points” or “reputation” from it.

Happy to help! :023:

Yep, definitely. Also Ecto.Multi.

Elixir opened my eyes about accumulating state and/or future actions in one place and then get/set/do everything in one call at the end. I’ve been happily applying this way of work in my other work as well – mainly Rust and some Golang. And it does help there as well.

I do it gladly™ :wink: And if it gave you points or reputation I’d do it as much or even more gladly

This was my approach so far.
I changed it slightly and I’d like to put here as a future reference in case anyone needs.

@dimitarvp I’ll use your example since you gave me the initial ideia.

This is how it works

The changeset

def changeset(data, params) do
  data
  |> Changeset.change(params)
  |> validate_required(@required_fields) # the optionally-required field from above must NOT be here
  |> cast_assoc(...)
  |> cast_embed(...)
  |> validate_one_of() # the function which I only pass the changeset
end

then I have:

defp validate_one_of(changeset) do
    first_field = Ecto.Changeset.get_field(changeset, :first_field)
    second_field = Ecto.Changeset.get_field(changeset, :second_field)
    # you can add as many as you want here keeping the changeset clean and not having to change in two places if you need to add/remove fields

    case Enum.any?([first_field, second_field, #other fields if you'd like to]) do
      true -> 
      # returns the changeset with no errors if any field is populated
      changeset
      _ ->
       # returns errors on all fields with custom message
        changeset
        |> add_error(:first_field, "enter first field or second field")
        |> add_error(:second_field, "enter first field or second field")

    end
  end

You could make it even better but for the sake of explaining the idea I kept the fields apart so it’s easy to see the approach.

Thank you for the initial idea and I hope it helps someone in the future as well.

P.S. Ecto.Changeset.add_error/4 makes the changeset invalid so it will prevent persisting inconsistent data as well but it does not store the field in changeset.required like validate_required/3 does.

1 Like

Here’s another take that is explicit about the other optional fields
You can set the validation on the first of the optional fields or all of them depending on where you want to see error messages.
It provides a basic error message but should probably be used with an explicit error message.

 def validate_one_of(changeset, field, other_fields, opts \\ []) do
    fields = [field | List.wrap(other_fields)]

    if Enum.all?(fields, fn field -> field_missing?(changeset, field) end) do
      add_error(
        changeset,
        field,
        opts[:message] || "either this or #{Enum.join(other_fields, ", ")} should be present"
      )
    else
      changeset
    end
  end

with a few tests


  describe "validate_one_of/2" do
    test "invalid if none of the fields are present (first field gets error)" do
      params = %{}

      changeset =
        Ecto.Changeset.cast({%{}, %{a: :string, b: :string, c: :string}}, params, [:a, :b, :c])
        |> Helpers.validate_one_of(:a, [:b, :c],
          message: "either this or b or c should be present"
        )

      refute changeset.valid?

      assert [{:a, {"either this or b or c should be present", _}}] = changeset.errors
    end

    test "invalid if none of the fields are present (error on all fields)" do
      params = %{}

      changeset =
        Ecto.Changeset.cast({%{}, %{a: :string, b: :string, c: :string}}, params, [:a, :b, :c])
        |> Helpers.validate_one_of(:a, [:b, :c],
          message: "either this or b or c should be present"
        )
        |> Helpers.validate_one_of(:b, [:a, :c],
          message: "either this or a or c should be present"
        )
        |> Helpers.validate_one_of(:c, [:a, :b],
          message: "either this or a or b should be present"
        )

      refute changeset.valid?

      assert [
               {:c, {"either this or a or b should be present", _}},
               {:b, {"either this or a or c should be present", _}},
               {:a, {"either this or b or c should be present", _}}
             ] = changeset.errors
    end

    test "valid if one of the fields is present" do
      params = %{a: "a"}

      changeset =
        Ecto.Changeset.cast({%{}, %{a: :string, b: :string, c: :string}}, params, [:a, :b, :c])
        |> Helpers.validate_one_of(:a, [:b, :c],
          message: "either this or b or c should be present"
        )

      assert changeset.valid?

      params = %{b: "b"}

      changeset =
        Ecto.Changeset.cast({%{}, %{a: :string, b: :string, c: :string}}, params, [:a, :b, :c])
        |> Helpers.validate_one_of(:a, [:b, :c],
          message: "either this or b or c should be present"
        )

      assert changeset.valid?
    end
  end