Conditional Validation in Ecto for OR - 1 of 2 fields is required
Asked Answered
S

4

16

How can I do conditional validation for OR logic, where we check to see if 1 of the 2 values is present or both values are present.

So, for example, if I want to check to make sure that the email or the mobile fields are filled... I want to be able to pass a list into fields of validate_required_inclusion to validate that at least 1 of the fields in the list is not null.

def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :first_name, :last_name, :password_hash, :role, :birthdate, :address1, :address2, :city, :state, :zip, :status, :mobile, :card, :sms_code, :status])
    |> validate_required_inclusion([:email , :mobile])
end


def validate_required_inclusion(changeset, fields,  options \\ []) do

end

How can I do this conditional OR validation?

Sandhurst answered 13/2, 2017 at 19:54 Comment(0)
C
29

Here's a simple way. You can customize it to support better error messages:

def validate_required_inclusion(changeset, fields) do
  if Enum.any?(fields, &present?(changeset, &1)) do
    changeset
  else
    # Add the error to the first field only since Ecto requires a field name for each error.
    add_error(changeset, hd(fields), "One of these fields must be present: #{inspect fields}")
  end
end

def present?(changeset, field) do
  value = get_field(changeset, field)
  value && value != ""
end

Test with a Post model and |> validate_required_inclusion([:title , :content]):

iex(1)> Post.changeset(%Post{}, %{})
#Ecto.Changeset<action: nil, changes: %{},
 errors: [title: {"One of these fields must be present: [:title, :content]",
   []}], data: #MyApp.Post<>, valid?: false>
iex(2)> Post.changeset(%Post{}, %{title: ""})
#Ecto.Changeset<action: nil, changes: %{},
 errors: [title: {"One of these fields must be present: [:title, :content]",
   []}], data: #MyApp.Post<>, valid?: false>
iex(3)> Post.changeset(%Post{}, %{title: "foo"})
#Ecto.Changeset<action: nil, changes: %{title: "foo"}, errors: [],
 data: #MyApp.Post<>, valid?: true>
iex(4)> Post.changeset(%Post{}, %{content: ""})
#Ecto.Changeset<action: nil, changes: %{},
 errors: [title: {"One of these fields must be present: [:title, :content]",
   []}], data: #MyApp.Post<>, valid?: false>
iex(5)> Post.changeset(%Post{}, %{content: "foo"})
#Ecto.Changeset<action: nil, changes: %{content: "foo"}, errors: [],
 data: #MyApp.Post<>, valid?: true>
Clow answered 13/2, 2017 at 20:5 Comment(2)
Readers note your present? logic may differ. For example, the current implementation would consider an empty list as present.Comprador
I'd add String.trim_leading(str) != "" instead, as default option for required validation is to trim string values.Creaky
M
4

How about:

  def validate_required_inclusion(changeset, fields,  options \\ []) do
    if Enum.any?(fields, fn(field) -> get_field(changeset, field) end), 
      do: changeset,
      else: add_error(changeset, hd(fields), "One of these fields must be present: #{inspect fields}")
  end

get_field gives you fields accepted by the change set, both changed (cast) and non-changed, and Enum.any? will ensure that at least one of the field is in there.

Milky answered 13/2, 2017 at 20:2 Comment(3)
I like this as it was shorter than the other although it did not work with dropping it in... I'll have to play with it. Thank you!Sandhurst
This won't work if a field is already in the model and not in changes, e.g. Post.changeset(%Post{content: "foo"}, %{}) will fail because changes is empty, even though content is present.Clow
Thanks @Dogbert, good catch. I've updated the answer, though it still doesn't check for empty fields, as yours does.Milky
M
4

You could also create the constraint in the database, e.g. by writing a migration:

create(
  constraint(
    :users,
    :email_or_mobile,
    check: "(email IS NOT NULL) OR (mobile IS NOT NULL)"
  )
)

And use check_constraint to validate the changeset:

def changeset(struct, params \\ %{}) do
  struct
  |> cast(params, [:email, :first_name, :last_name, :password_hash, :role, :birthdate, :address1, :address2, :city, :state, :zip, :status, :mobile, :card, :sms_code, :status])
  |> check_constraint(
    :users_table,
    name: :email_or_mobile,
    message: dgettext("errors", "can't be blank")
  )
end
Maya answered 6/8, 2020 at 13:42 Comment(0)
A
1

Ecto.Changeset.field_missing?/2 was added to cover this exact use case. From the docs:

Determines whether a field is missing in a changeset.

The field passed into this function will have its presence evaluated according to the same rules as validate_required/3.

This is useful when performing complex validations that are not possible with validate_required/3. For example, evaluating whether at least one field from a list is present or evaluating that exactly one field from a list is present.

Examples

changeset = cast(%Post{}, %{color: "Red"}, [:color])
missing_fields = Enum.filter([:title, :body], &field_missing?(changeset, &1))

changeset =
  case missing_fields do
    [_, _] -> add_error(changeset, :title, "at least one of `:title` or `:body` must be present")
    _ -> changeset
  end

changeset.errors
[title: {"at least one of `:title` or `:body` must be present", []}]
Alikee answered 17/7 at 4:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.