More Changesets, Less Problems

by Vikram Ramakrishnan

Introduction

Changesets allow filtering, casting, validation and definition of constraints when manipulating structs.
Source: https://hexdocs.pm/ecto/Ecto.Changeset.html

Changesets allow us to manipulate a struct before inserting that
struct into a database. Since they are not tied to the database directly
(in ActiveRecord, on the other hand, attributes and validations are tied to an object and often mirror the database table), we can create as many changesets as we’d like for the different types of manipulations we’d like to do an a struct before sending it off to the database.

In this post, we intend to show that multiple changesets in a single model help us separate concerns and domains of that model easily. For example, we’d use a create_changeset or an update_changeset depending on whether we were creating or updating a database row.

Anatomy of a changeset

Here’s a fairly standard changeset that we might come across:

def changeset(user, params \\ %{}) do
user
|> cast(params, [:email, :password])
|> validate_required([:email, :password])
|> unique_constraint(:email)
end

So what’s going on here? The user struct gets passed to changeset/2.
cast/3 here applies attributes or changes in params on user:email and
:password are allowed parameters to this changeset, and they are stored as
changes in the changeset’s :changes field. validate_required/2 next
validates that fields in a list are in the changeset (in this case, that list
contains :email and :password). unique_constraint/3 then determines
whether or not :email is unique to user by querying the database.

Something to note:

unique_constraint/3 looks at a database index in order to determine
uniqueness.

The unique constraint works by relying on the database to check if the unique constraint has been violated or not and, if so, Ecto converts it into a changeset error.
Source: https://hexdocs.pm/ecto/Ecto.Changeset.html#unique_constraint/3

We have to pass the index name to unique_constraint/3 or it will default to the generated index name:

def unique_constraint(changeset, field, opts \\ []) do
constraint = opts[:name] || “#{get_source(changeset)}_#{field}_index”
message = message(opts, “has already been taken”)
match_type = Keyword.get(opts, :match, :exact)
add_constraint(changeset, :unique, to_string(constraint), match_type, field, {message, []})
end
Source:
https://github.com/elixir-ecto/ecto/blob/v2.1.3/lib/ecto/changeset.ex#L1893

If there is no uniqueness index for a given field, unique_constraint/3 on that field will simply pass, so we need to make sure to add such indices to fields we want to validate uniqueness on.

Multiple changesets

There are a few use cases for multiple changesets in an Ecto model: (1)
different actions (i.e, creating and updating) on the same object; and (2) validating different domains on the same action (two very different means to create a user).

In the first case, we could have a create_changeset and an update_changeset. Let’s look at a small example (handling orders):

@valid_create_statuses [:submitted, :pending]
def create_changeset(order, params \\ %{}) do
order
|> cast(params, @permitted_fields)
|> validate_required(@required_fields)
|> validate_inclusion(:status, @valid_create_statuses)
|> put_change(:display_id, generate_display_id())
end
defp generate_display_id() do
# generates a unique string for the order
end

put_change/3 applies generate_display_id() to the changeset, which inserts%{display_id: “some-unique-string”} into the changeset’s changes. The display_id is unique and should never change, so we don’t want to call the same changeset when updating an order. Instead, we can control manipulation of the order struct with a separate changeset for updating:

@valid_update_statuses [:processing, :shipped]
def update_changeset(order, params \\ %{})
order
|> cast(params, @permitted_fields)
|> validate_required(params, @required_fields)
|> validate_inclusion(:status, @valid_update_statuses)
end

This is a really clear way of separating concerns for actions. It’s a good idea
to separate concerns when we can. It allows us to write better code and test
unique, atomic transactions more easily.

In the second case, where we want to validate different domains on the same action, multiple changesets give us a great deal of power. We recently worked on a project where users were able to create users within a web app or by simply sending an email to our server. The former is done as we would expect through the server API directly, while the latter is accomplished using an Amazon Lambda which receives the email, parses it and sends that response to the API.

Case (1) User registers for an account through the web app at www.my-app.com/register and is required to enter an email and password.

Case (2) User registers by sending an email to register@my-app.com, which
creates a temporary account for them until they set their password in a
follow-up workflow.

A difference between the two is that we need to capture a password field when a user registers via the web app, but not when the user begins the registration process by email. It’s trivial to simply separate the two domains on the same action:

def create_by_email_changeset(user, params \\ %{}) do
user
|> cast(params, [:email, :password])
|> validate_required([:email, :password])
|> validate_length(:password, min: 8)
|> unique_constraint(:email)
|> generate_encrypted_password()
end
def create_by_lambda_changeset(user, params \\ %{}) do
user
|> cast(params, [:email])
|> validate_required([:email])
|> unique_constraint(:email)
end

In create_by_email_changeset/2, we accept both :email and :password fields and then encrypt the password before storing it in the database, whereas in create_by_lambda_changeset/2 we only accept :email (we don’t want a user sending a password in an email).

In Rails and ActiveRecord, we could do this a number of ways, but two common approaches are as follows: (1) generate a secure random password if the user came in via lambda (which would require flow control in the controller or a model callback); or (2) add a boolean field in the model which denotes whether the user came by lambda. Neither of these approaches are satisfactory. What if we want to add Facebook login next? Then Twitter login? Keeping track of all these types of authentication become a headache to manage.

Phoenix and Ecto allow us to be explicit with our changesets for actions and
domains so we gain clarity around our codebase and separate concerns, allowing us to build future features on our app safely and comfortably.

Interested in discussing custom software needs more broadly? Drop me a line at vikram@quantlayer.com — I would love to chat with you. Follow us on Twitter at https://twitter.com/@QuantLayer