Ecto fields validations

Hello, guys!
I have coded a validation and I am here to ask if there is another way to do it.

My question is about “must_be_sup_or_eql” in changeset
I am checking if date_end is < then date_start.

Seems it is working but my question is: Do ecto have a default way to do it?

Thanks!

 defmodule Matthews.Schemas.Events do
        use Ecto.Schema

        import Ecto.Changeset

        schema "events" do
            field :name,        :string, null: false
            field :location,    :string, null: false        
            field :description, :string
            field :date_start, Ecto.DateTime, null: false
            field :date_end,   Ecto.DateTime, null: false

            timestamps()
        end

        @required_fields ~w(name location date_start date_end)a
        @optional_fields ~w(description)a

        def changeset(event, params \\ %{}) do
            event
            |> cast(params, @required_fields ++ @optional_fields)              
            |> validate_required(@required_fields) 
            |> validate_change(:date_start, &must_be_future/2)   
            |> must_be_sup_or_eql        
        end   

        def must_be_future(_, date_start) do          
            Ecto.DateTime.compare(date_start, Ecto.DateTime.utc)        
            |> get_error
        end    

        defp get_error(comparison) when comparison == :lt, do: [ date: "Event start date can't be in the past. #{Ecto.DateTime.utc}" ]
        defp get_error(_), do: []

        def must_be_sup_or_eql(changeset) do                 
            case (changeset.valid?) do

                true -> date_end = changeset.changes.date_end
                        date_start = changeset.changes.date_start    
                        Ecto.DateTime.compare(date_end, date_start) 
                        |> get_error1(changeset)  
                false -> changeset 
            end
        end

        defp get_error1(comparison, changeset) when comparison == :lt, do: %{changeset | errors: "Event end date can't be < than event start date", valid?: false} 
        defp get_error1(_, _), do: []

    end

I don’t think so because it doesn’t seem like a job for Ecto. It is a custom validation tailored to your apps needs.

If you don’t mind few comments:

  • In the function must_be_sup_or_eql/1 you check if the changeset is valid and then you do your work accordingly. What if the changeset is invalid because of a missing field but your date is actually valid?

  • Maybe it would be better to use Ecto.Changeset.add_error/4 for adding errors in the changeset. It takes care setting the valid? value for you.

Here is how I did something similar. It is old code and I will probably return back to it so maybe can be improved. Also I use Timex for it but it can be adopted to use Ecto.Date.

  def registration_changeset(struct,  params \\ %{}) do
    struct
    |> cast(params, [:first_name, :last_name, :birth_date, :area_of_residence])
    |> validate_required([:birth_date, :country, :area_of_residence])
    |> validate_over_18()
   # other things...
  end


  def validate_over_18(changeset, _opts \\ []) do
    validate_change(changeset, :birth_date, fn :birth_date, birth_date ->
      today = Timex.local
      with result when result == -1 <- Timex.compare(birth_date, today),
           age when age >= 18 <- Timex.diff(today, birth_date, :years)
      do
        []
      else
        1 ->
          [birth_date: "cannot-be-in-the-future"]
        0 ->
          [birth_date: "cannot-be-today"]
        _age ->
          [birth_date: "must-be-18-years-or-older"]
      end
    end)
  end

Timex uses -1, 0, 1 instead of :lt, :eq, :gt. Also in the with clause the happy path returns no errors, otherwise sets the appropriate error. validate_change takes care adding the errors in the changeset.

Personally I typically do this in the database with a constraint on the appropriate column and then let the database provide an error. This is usually faster, and ensures that no matter what happens in the application, the database will never allow bad data in.

The downside is that it always tries to insert/update in the database, rather than short-circuit in the application itself. But hopefully your database connectivity is blazing fast so it’s not a big issue :slight_smile: The other annoyance is that you then need to check the error returned from the database; naming the constraint (e.g. end_data_after_start_date) at least allows you to very easily match on the constraint error and return something pretty to the user.

IMHO the upside outweighs the downsides, and for constraints like this I don’t try to implement it in the application.

You could create a validation layer in your application. Simple field-level validations can be executed client-side also (when using input forms), this saves roundtrips to the server. You don’t want to maintain the same validation in multiple places (f.e. javascript on the client + elixir on the server) so you could use a DSL to generate the same validation in different languages (or you can write DSL interpreters for the languages you use). https://www.cas.mcmaster.ca/~carette/publications/FedorenkoThesis.pdf is interesting on the subject. Maybe examples for validation DSL’s can be found with google.
Previous discussion on this forum: Code.eval_something .
Of course validations can be hard-coded in the db also. That means at least a vendor-lockin. What if you want another kind of db?

1 Like

The last reply to this topic was over 5 years ago, yet it ranks substantially high in searches and some things have changed. Adding a perhaps more up-to-date approach, which also draws some inspiration from @LostKobrakai’s custom validation example, in case others find it useful:

@doc """
Validates that the duration is positive, that `start_at` is before `end_at`.
"""
def validate_duration(changeset, start_at_key, end_at_key)
    when is_atom(start_at_key) and is_atom(end_at_key) do
  with {_, start_at} when is_struct(start_at, DateTime) <- fetch_field(changeset, start_at_key),
        {_, end_at} when is_struct(end_at, DateTime) <- fetch_field(changeset, end_at_key) do
    if DateTime.compare(start_at, end_at) == :lt do
      changeset
    else
      add_error(changeset, :end_at, "cannot be before the start date/time")
    end
  else
    _error ->
      changeset
      |> add_error(:start_at, "needs to be a valid date/time")
      |> add_error(:end_at, "needs to be a valid date/time")
  end
end
2 Likes

Some changes I’d personally make:

  • Leave the requirement that the fields are set to another validation, e.g. validate_required(changeset, [:start_at, :end_at])
  • Clean up some of the pattern matching
@doc """
Validates that the value of `start_at_key` is before `end_at_key`, if present.
"""
def validate_duration(changeset, start_at_key, end_at_key)
    when is_atom(start_at_key) and is_atom(end_at_key) do
  with {_, {_, %DateTime{} = start_at}} <- {start_at_key, fetch_field(changeset, start_at_key)},
       {_, {_, %DateTime{} = end_at}} <- {end_at_key, fetch_field(changeset, end_at_key)},
       :lt <- DateTime.compare(start_at, end_at) do
    changeset
  else
    {_, :error} ->
      changeset

    {key, {_, other}} ->
      changeset
      |> add_error(key, "must be a valid datetime")

    _error ->
      changeset
      |> add_error(end_at_key, "must be after the start time")
  end
end
2 Likes