Nested Associations with Phoenix Forms

2 minute read

Elixir/Phoenix is still a new territory for most developers, as is for us, and it can be hard to do the most trivial things when starting off with a new framework. Sharing one of those experiences and the solution.

This blog post would share a simple example of storing nested associations (mainly a has_many association), with Phoenix forms. A more detailed version of this is available at this blogpost. Our example would work with a Company, and it has many People.

Company

defmodule Myapp.Company do
  use Myapp.Web, :model

  schema "companies" do
    field :name, :string

    has_many :people, Myapp.Person

    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:name])
    |> cast_assoc(:people)
    |> validate_required([:name])
  end
end

Note that we are using cast_assoc/2 here. cast_assoc/2 is used when you want to manage associations based on external parameters, like Phoenix forms. Ecto compares the data existing in the struct with the data sent through the form and generates the proper operation.

Person

defmodule Myapp.Person do
  use Myapp.Web, :model

  schema "people" do
    field :first_name, :string
    field :last_name, :string
    field :email, :string

    belongs_to :company, Myapp.Company

    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:first_name, :last_name, :email, :company_id])
    |> validate_required([:first_name, :last_name, :email])
    |> validate_length(:email, min: 1, max: 255)
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:email)
  end
end

Template

We use Dynamic Forms and Slim-Lang for templates. Follow this amazing blogpost from José Valim to learn how to build Dynamic Forms. For the sake of simplicity, we’ll only save one person at the moment.

= form_for @changeset, @action, fn c ->
  = input c, :name, label: "Company Name"
  = inputs_for c, :people, fn f ->
    = input f, :first_name
    = input f, :last_name
    = input f, :email
  = submit "Continue"

CompanyController

The create method was the trickiest to get right. I actually had to dig into the code for Ecto Tests to find a way to make this work. Again, for the sake of simplicity we are just storing one person.

defmodule Myapp.CompanyController do
  use Myapp.Web, :controller

  alias Myapp.Company
  alias Myapp.Person

  plug :scrub_params, "company" when action in [:create]

  def new(conn, _params) do
    person = Person.changeset(%Person{})
    changeset = Company.changeset(%Company{people: [person]})

    render conn, "new.html", changeset: changeset
  end

  def create(conn, %{"company" => company_params}) do
    person_changeset = Person.changeset(%Person{}, company_params["people"]["0"])
    changeset =
      Company.changeset(%Company{}, %{name: company_params["name"]})
      |> Ecto.Changeset.put_assoc(:people, [person_changeset])

    case Repo.insert(changeset) do
      {:ok, company} ->
        person = Enum.at(company.people, 0) |> Repo.preload(:company)

        conn
        |> put_flash(:info, "Welcome to Myapp. #{person.first_name}!")
        |> redirect(to: page_path(conn, :index))
      {:error, changeset} ->
        conn
        |> render "new.html", changeset: changeset
    end
  end
end

put_assoc/2 is typically used when we already have the associations as structs and changesets, and we tell Ecto to take those entries as is.

And that’s it. Please do share your feedback in the comments below, or if there are better ways to approach this.

Updated:

Leave a Comment