DEV Community

loading...

Many to many associations in Elixir and Phoenix

Ricardo Ruwer
・4 min read

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:

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
Enter fullscreen mode Exit fullscreen mode

And then I added it to the properties schema:

schema "properties" do
  # ...

  many_to_many(:amenities, MyApp.Amenities.Amenity, join_through: MyApp.PropertiesAmenities)

  # ...
end
Enter fullscreen mode Exit fullscreen mode

And the amenities schema:

schema "amenities" do
  # ...

  many_to_many(:properties, MyApp.Properties.Property, join_through: MyApp.PropertiesAmenities)

  # ...
end
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

And added it to lib/myapp_web.ex:

def view do
  quote do
    # ...

    import MyAppWeb.Helpers.CheckboxHelper

    # ...
  end
end
Enter fullscreen mode Exit fullscreen mode

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

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

  amenities = Amenities.list_amenities()

  render("new.html", changeset: changeset, amenities: amenities)
end
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

And this function in the file related to amenities:

def get_amenities(nil), do: []

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

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

def create_property(attrs) do
  %Property{}
  |> Property.changeset(attrs)
  |> maybe_put_amenities(attrs)
  |> Repo.insert()
end
Enter fullscreen mode Exit fullscreen mode

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:

Properties.create_property(params)
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

def load_amenities(property), do: Repo.preload(property, :amenities)
Enter fullscreen mode Exit fullscreen mode

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

Discussion (2)

Collapse
douglaslutz profile image
Douglas Lutz

This helped a lot! Thanks!! 🚀

Collapse
blackbart420 profile image
blackbart420

Thank you so much!