Many to many associations in Elixir and Phoenix
Tags: elixir, phoenix, many, association
Published: 2019-07-18 13:01:46

I was creating the website of a construction company, so I had 2 schemas: `properties` and `amenities`. One property can have many amenities (like pool, barbecue grill, playground, etc), one amenity can belong to many properties.

Another popular example similar to this one is `posts` and `tags`.

So my idea was: I can create a lot of amenities, and in the property form I list these amenities in checkboxes to the user check which one the property has.

I had some bad times trying to do that, so that's why I created this post, so I may help someone...

OK, so I created this schema called `properties_amenities`:

```ex
defmodule MyApp.PropertiesAmenities do
  use Ecto.Schema
  
  import Ecto.Changeset
  
  @primary_key false
  schema "properties_amenities" do
    belongs_to :property, MyApp.Properties.Property
    belongs_to :amenity, MyApp.Amenities.Amenity
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:property_id, :amenity_id])
    |> validate_required([:property_id, :amenity_id])
  end
end
```

And then I added it to the `properties` schema:

```ex
schema "properties" do
  # ...
  
  many_to_many(:amenities, MyApp.Amenities.Amenity, join_through: MyApp.PropertiesAmenities)
  
  # ...
end
```

And the `amenities` schema:

```ex
schema "amenities" do
  # ...
  
  many_to_many(:properties, MyApp.Properties.Property, join_through: MyApp.PropertiesAmenities)

  # ...
end
```

OK, now I created a helper to list all the amenities on the property form, so in the file `lib/myapp_web/helpers/checkbox_helper.ex`:

```ex
defmodule ConstrutoraLcHiertWeb.Helpers.CheckboxHelper do
  use Phoenix.HTML
  
  @doc """
  Renders multiple checkboxes.
  
  ## Example

      iex> multiselect_checkboxes(
             f,
             :amenities,
             Enum.map(@amenities, fn c -> { c.name, c.id } end),
             selected: Enum.map(@changeset.data.amenities,&(&1.id))
           )
      <div class="checkbox">
        <label>
          <input name="property[amenities][]" id="property_amenities_1" type="checkbox" value="1" checked>
          <input name="property[amenities][]" id="property_amenities_2" type="checkbox" value="2">
        </label>
      </div
  """
  def multiselect_checkboxes(form, field, options, opts \\ []) do
    {selected, _} = get_selected_values(form, field, opts)
    selected_as_strings = Enum.map(selected, &"#{&1}")

    for {value, key} <- options, into: [] do
      content_tag(:label, class: "checkbox-inline") do
        [
          tag(:input,
            name: input_name(form, field) <> "[]",
            id: input_id(form, field, key),
            type: "checkbox",
            value: key,
            checked: Enum.member?(selected_as_strings, "#{key}")
          ),
          value
        ]
      end
    end
  end

  defp get_selected_values(form, field, opts) do
    {selected, opts} = Keyword.pop(opts, :selected)
    param = field_to_string(field)

    case form do
      %{params: %{^param => sent}} ->
        {sent, opts}

      _ ->
        {selected || input_value(form, field), opts}
    end
  end

  defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
  defp field_to_string(field) when is_binary(field), do: field
end
```

And added it to `lib/myapp_web.ex`:

```ex
def view do
  quote do
    # ...

    import MyAppWeb.Helpers.CheckboxHelper

    # ...
  end
end
```

Now I returned the `amenities` to the form of properties, so, in the `properties_controller#new` I added:

```ex
def new(conn, _params) do
  # ...

  amenities = Amenities.list_amenities()

  render("new.html", changeset: changeset, amenities: amenities)
end
```

And used the new function to render multi-select checkboxes in the `new.html.eex` file:

```html
<div class="form-group">
  <%=
    multiselect_checkboxes(
      f,
      :amenities,
      Enum.map(@amenities, fn a -> { a.name, a.id } end),
      selected: Enum.map(@changeset.data.amenities,&(&1.id))
    )
  %>
</div>
```

Now the view is rendering the checkboxes :)

OK, now we have to create a new function to create the association between properties and amenities, to do this, I created this function in the file containing all the function related to properties:

```ex
defp maybe_put_amenities(changeset, []), do: changeset

defp maybe_put_amenities(changeset, attrs) do
  amenities = Amenities.get_amenities(attrs["amenities"])

  Ecto.Changeset.put_assoc(changeset, :amenities, amenities)
end
```

And this function in the file related to amenities:

```ex
def get_amenities(nil), do: []

def get_amenities(ids) do
  Repo.all(from a in MyApp.Amenities.Amenity, where: a.id in ^ids)
end
```

And added this new method `maybe_put_amenities` in the function that creates a new property, like this:

```ex
def create_property(attrs) do
  %Property{}
  |> Property.changeset(attrs)
  |> maybe_put_amenities(attrs)
  |> Repo.insert()
end
```

So now, when the access the `properties/new` page, it will render the form and all the amenities. When we select the amenities and click on the Submit button, it will go the `properties_controller#create` and execute the code like:

```ex
Properties.create_property(params)
```

And it will execute our function `maybe_put_amenities` that will list all the amenities using the function `Amenities.get_amenities/1` and `put_assoc` in every amenity found. And then save it. And it's done. It works.

But we still may have a problem when trying to submit the form but it got an error. For example, if the user selects some amenities, click on submit but it got an error, and we render again the `new` page, the previously selected amenities will not be selected anymore, so to fix that, we can do this in the `properties_controller.ex`:

```ex
def create(conn, %{"property" => params}) do
  case Properties.create_property(params) do
    {:ok, property} ->
      conn
      |> put_flash(:info, "Success!")
      |> redirect(to: "/")

    {:error, changeset} ->
      data = Properties.load_amenities(changeset.data)
      amenities = Amenities.list_amenities()

      conn
      |> put_flash(:error, "Error!"))
      |> render("new.html", changeset: %{changeset | data: data}, amenities: amenities)
  end
end
```

And create this new method in the file responsible for properties:


```ex
def load_amenities(property), do: Repo.preload(property, :amenities)
```

And that's all. It looks like a lot of steps but I hope I can help someone :)