Responsibilities of an Ecto changeset function

Hi!

I have some doubts regarding the responsibilities of the “changeset” functions. The examples in the Ecto documentation are clear to me, but things get a bit more complicated once you start working with real life examples.

I would like to draw a line between things that should be validated outside and inside the changeset function. I hope you could help me here :slight_smile:

Here are a few examples:

Example A: email address verification

This is a common example in any web application: once the user registers, the app sends an email to verify it exists. For example, here is how the Hex.pm project handles it.

From my point of view, there are two valid ways of writing this changeset (using the example from Hex.pm):

# Option A1: verification token is checked inside the changeset.
#            The controller calls the changeset directly.

def verify(email, key) do
  change(email, %{
    verified: true,
    verification_key: nil,
    verification_expiry: nil
  })
  |> validate_verification_token(key)
  |> unique_constraint(:email, name: "emails_email_key", message: "already in use")
end

# Option A2: the verification token is checked outside the changeset.
#            The controller first calls verify?/2 and if true it calls the changeset function.

def verify?(nil, _key), do: false

def verify?(email, key) do
  email_key = email.verification_key
  valid_key? = !!(email_key && Hexpm.Utils.secure_check(email_key, key))
  within_time? = Hexpm.Utils.within_last_day?(email.verification_expiry)
  valid_key? and within_time?
end

def verify(email) do
  change(email, %{
    verified: true,
    verification_key: nil,
    verification_expiry: nil
  })
  |> unique_constraint(:email, name: "emails_email_key", message: "already in use")
end

Example B: reset password token verification

Another common example. The code is also available on the Hex.pm project.

The code in the Hex.pm project checks the reset password token outside the changeset. However, that reset password token could be validated inside a changeset function.

Example C: (email address) domain validation

Another common example: admin users must register with an email address using a specific domain.

Imagine that the User schema in the Hex.pm project would have a :type field with possible values: :admin or :user. The app needs to ensure that admin users only have @hex.pm emails. If that’s the case, where would that check be done, inside or outside the changeset?

# Option C1: outside the changeset

def valid_domain?(%User{} = user, email_address) do
  validate_domain(user.type, email_address)
end

def changeset(:create, %User{} = user, email_address) do
  %__MODULE__{user_id: user.id}
  |> cast(%{email_address: email_address}, [:email_address])
  |> validate_required([:email_address])
  # Other validations
end

# Option C2: inside the changeset

def changeset(:create, %User{} = user, email_address) do
  %__MODULE__{user_id: user.id}
  |> cast(%{email_address: email_address}, [:email_address])
  |> validate_required([:email_address])
  |> validate_domain(user.type)
  # Other validations
end

Example D: currency consistency

An accounting system might optionally support multi-currency. However, the user must opt-in for this feature, adding or removing currencies by hand. When submitting invoices, the system must check that the invoice is on the list of the supported currencies for that organization.

# Option D1: outside the changeset
# Probably in the controller, you would have something like:

with {:ok, currency_codes} <- get_currencies_for_organization(organization),
     :ok <- verify_currency(currency_codes, params["currency_code"]),
  changeset = Invoice.changeset(:record, params)
  # ...
end

# Option D2: inside the changeset

def changeset(:record, %{} = params, supported_currencies) do
  # ...
end

# or:

def changeset(:record, %Organization{} = organization, %{} = params) do
  # ...
end

Thanks for your help!

2 Likes

The changeset is a data structure, like a map or a list. It doesn’t really have any responsibilities per se. Sometimes a map is the best data structure to solve a problem, sometimes it isn’t. Sometimes the changeset is the best one, sometimes it isn’t. A changeset is required really on Repo.update, since you need to track changes to perform updates.

For simple validation, maybe it is more straight-forward to bypass the changeset altogether and compare a token directly, but using it or not should not matter as long as the token is validated and displayed to the user.

1 Like

Treat the changeset as a closure. Put everything in there which is needed by the next step. If the validation is only needed by the current executing function then validate outside the changeset. If the validation needs to be carried to the next step do it inside the changeset.

2 Likes

I don’t know about that. I am kind of worried after noticing several new validation libraries sprawling up in the last few months. I am all for choice and such but IMO this shows that some sort of unified validation interface for Erlang / Elixir is in order.

To me that’s ecto 3.x. Even if it has “too much” for a certain goal you lose nothing by using it – and you have a lot to gain: like much less surprise when onboarding new team members, and general readability. I’d also argue using Changesets counts as idiomatic Elixir these days. :slight_smile:

2 Likes

@tty is correct: for your use case (and worries) do use Changesets mostly if you want to pass them around and work with them in more than one function.

I for one prefer to use Changesets for practically any validation, including in hobby projects without a database (ecto 3.0 allows it since it’s split in two libraries: a general, and a SQL-specific one).