Elixir – Nested Changesets With Phoenix

Say we have a user registration form where the user enters their name, email etc (User schema) as well as their password (Authorization schema). Both have to be validated, both require extra actions and belong together, so if anything fails the whole thing has to roll back and give meaningful feedback.

This is doable using Ecto.Multi (or nested case with transactions in controllers, but that would be ugly). Here is an alternative to Ecto.Multi that keeps it as minimalistic as possible while still allowing for much flexibility when dealing with complex user inputs: nested forms with custom changesets (note: Ecto 2 is needed).

In the “new” controller action use changeset with nested schema:

changeset = User.form_registration_changeset(%User{authorizations: [%Authorization{}]})

It then renders the template containing nested fields:

<%= form_for @changeset, registration_path(@conn, :create), fn u -> %>

  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
  <% end %>

  <div class='col-md-6'>

    <div class="form-group">
      <%= label u, :email, class: "control-label" %>
      <%= text_input u, :email, class: "form-control" %>
      <%= error_tag u, :email %>

    <div class="form-group">
      <%= label u, :name, class: "control-label" %>
      <%= text_input u, :name, class: "form-control" %>
      <%= error_tag u, :name %>


  <div class='col-md-6'>

    <%= inputs_for u, :authorizations, fn a -> %>

      <%= error_tag a, :provider_uid %>

      <div class="form-group">
        <%= label a, "New password", class: "control-label" %>
        <%= password_input a, :password, class: "form-control" %>
        <%= error_tag a, :password %>

      <div class="form-group">
        <%= label a, :password_confirmation, class: "control-label" %>
        <%= password_input a, :password_confirmation, class: "form-control" %>
        <%= error_tag a, :password_confirmation %>

    <% end %>


  <div class='col-md-12'>

    <div class="form-group">
      <%= submit "Register", class: "btn btn-primary" %>


<% end %>

That’s a lot of code but it’s mostly self-explanatory, one thing to note is that we use inputs_for u, :authorizations to render authorization fields from the nested changeset, we refer to user as “u” and authorization as “a” when rendering markup.

Here is the form registration changeset in the User schema:

  def form_registration_changeset(model, params \\ %{}) do
    |> cast(params, [:name, :email])
    |> cast_assoc(:authorizations, required: true, with: &Authorization.create_pw_changeset/2)
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$/)
    |> unique_constraint(:email)
    |> put_identity_data()

Notice this line |> cast_assoc(:authorizations, required: true, with: &Authorization.create_pw_changeset/2) – here we state that the nested authorization uses custom changeset Authorization.create_pw_changeset that we happen to already have and can reuse, without “with” option it would use Authorization.changeset.

Here is the Authorization.create_pw_changeset:

def create_pw_changeset(model, params \\ %{}) do
    |> cast(params, [:password, :password_confirmation])
    |> validate_required([:password, :password_confirmation])
    |> validate_length(:password, [min: 8, max: 90])
    |> validate_confirmation(:password)
    |> unique_constraint(:provider_uid, [message: "You already have a password"])
    |> put_password_in_token()

this is an existing changeset that will validate inputs as well as check that the user has no existing password authorizations (which is not possible for a new user) as well as hash the password and put it into password_hash field if there are no errors.

Now the changeset above is pretty specialized and doesn’t fill all required authorization fields – we also need to have users email address and authorization provider set, but this input doesn’t come from the user and has to be hardcoded.

This is done in this step in the “top level changeset” in the User schema: |> put_identity_data(), here is the function:

defp put_identity_data(changeset) do
    if changeset.valid? do
      extras = %{provider: "identity", uid: get_field(changeset, :email)}

      identity_auth =
        |> get_field(:authorizations)
        |> Enum.map(&Map.merge(&1, extras))

      put_assoc(changeset, :authorizations, identity_auth)

this way any custom data can be added to the user input as part of the changeset (we use ueberauth to manage different authorizations and text password provider is called “identity”, hence the naming).

In the end we have all the required Logic contained in changesets, controller does almost nothing which is a good thing:

def create(conn, %{"user" => user_params})
result =
      |> User.form_registration_changeset(user_params)
      |> Repo.insert()

# process result

Repo.insert() will pack both changesets in transaction and rollback if anything goes wrong, we don’t have to do that ourselves.

The “result” variable can then be used to determine the next action, if there are errors, they will work fine with plain “changeset” variable, no matter which of the changesets triggers them:

case result do
      {:ok, _user} ->
        |> put_flash(:info, "Registration completed")
        |> redirect(to: page_path(conn, :index))
      {:error, changeset} ->
        render(conn, "register.html", changeset: changeset)

Further steps can be added as extra functions in the changesets, down below after all validations are done we can check if changeset is valid and do something else.

One flaw of this approach is when you want to do something on completed transaction only (e.g. send confirmation emails) you’ll have to go “outside” the changeset because inside the function it is only possible to check if changeset is valid before DB hit and some rules might only be triggered afterwards (all good but email address is taken) so this type of actions have to be taken separately.

Leave a Reply

Your email address will not be published. Required fields are marked *