Psst. It would be super cool if you could try the new Phoenix Boilerplate

Try now →

Tutorial

View on Github

Setup a has_many / belongs_to in Phoenix

belongs_toectohas_manyphoenix

Something I do in EVERY project is to setup some sort of relation between resources. And even though Phoenix comes with generators for migrations and CRUD operations, you still need to modify the code to suite for the relations you want.

In this short tutorial, I will go through my usual steps regarding setting up a basic has_many / belongs_to - relation.

I will to it by adding variants to products.

STEP 1 - Generate variants

I will start with generationg variants and I do it in the same context as the products.

mix phx.gen.context Products Variant variants product_id:references:products name value

I wan’t them in the same context, so I will just go agead and press Y

Note that I use a database constraint on product_id. It means that there cant exist a variant pointing to a product that doesn’t exist. When I delete a product, I want the database to remove the references variants as well. Open up the migration file that should look like this:

defmodule Tutorial.Repo.Migrations.CreateVariants do
  use Ecto.Migration

  def change do
    create table(:variants) do
      add :name, :string
      add :value, :string
      add :product_id, references(:products, on_delete: :nothing)

      timestamps()
    end

    create index(:variants, [:product_id])
  end
end

And make these changes:

  1. Change on_delete: :nothing to n_delete: :delete_all
  2. Add null: false after name and value
  3. Add a unique index unique_index(:variants, [:name, :value, :product_id])

I want the unique index because I don’t want a product having same name and value twice.

I want to allow:

Product 1, variant: 'Size', 'Medium'
Product 1, variant: 'Size', 'Large'
Product 2, variant: 'Size', 'Medium'
Product 2, variant: 'Size', 'Large'

But I don’t want to allow this combination twice:

Product 1, variant: 'Size', 'Medium'
Product 1, variant: 'Size', 'Medium'

So, the migration ends up something like:

ddefmodule Tutorial.Repo.Migrations.CreateVariants do
  use Ecto.Migration

  def change do
    create table(:variants) do
      add :name, :string, null: false
      add :value, :string, null: false
      add :product_id, references(:products, on_delete: :delete_all)

      timestamps()
    end

    create index(:variants, [:product_id])
    create unique_index(:variants, [:name, :value, :product_id])
  end
end

And then run the migration:

mix ecto.migrate

STEP 2 - Make validations and associations work

In the Variant module, I can define the relation to the product. I also want to make sure that I have defined the unique constraint in the changeset.

open lib/tutorial/products/variant.ex and

  1. Change field :product_id, :id to belongs_to :product, Tutorial.Products.Product
  2. Add in the changeset: |> unique_constraint(:name, name: :variants_name_value_product_id_index)
defmodule Tutorial.Products.Variant do
  use Ecto.Schema
  import Ecto.Changeset

  schema "variants" do
    field :name, :string
    field :value, :string
    belongs_to :product, Tutorial.Products.Product

    timestamps()
  end

  @doc false
  def changeset(variant, attrs) do
    variant
    |> cast(attrs, [:name, :value])
    |> validate_required([:name, :value])
    |> unique_constraint(:name, name: :variants_name_value_product_id_index)
  end
end

Note: Since I added the constraint in the migration, the database has given it the name: variants_name_value_product_id_index so I will specify it here. That means that if the database raises an error on this index, Ecto will rescue it and return it as a more friendly validation error.

Since we added the relation in this file, I also need to specify it in lib/tutorial/products/product.ex and add:

has_many :variants, Tutorial.Products.Variant

These changes are not enough however.

The newly generated functions for the CRUD operations also need to be changed to I can pass in a specific product that I want the variants to belong to.

Open lib/tutorial/products.ex and go down to the newly generated code. Change:

  def list_variants do
    Repo.all(Variant)
  end

to

  def list_variants(product) do
    from(v in Variant, where: [product_id: ^product.id], order_by: [asc: :id])
    |> Repo.all()
  end

And when getting a single product:

def get_variant!(id), do: Repo.get!(Variant, id)

to:

def get_variant!(product, id), do: Repo.get_by!(Variant, product_id: product.id, id: id)

And last, when creating a new variant. Change from:

  def create_variant(attrs \\ %{}) do
    %Variant{}
    |> Variant.changeset(attrs)
    |> Repo.insert()
  end

to:

  def create_variant(product, attrs \\ %{}) do
    product
    |> Ecto.build_assoc(:variants)
    |> Variant.changeset(attrs)
    |> Repo.insert()
  end

These changes should be enough and for updating and deleting a variant, we have already used one of the other function to retreive it first.

STEP 3 - Get spec passing

If I run the specs now, they will of course not pass. So lets fix that. Open test/tutorial/products_test.exs and scroll down to where the new test are. basically, what I need to do it to make sure I have created a product before that I can pass in to the test.

Create a setup function above the first variant-spec

setup do
  product = product_fixture()
  {:ok, product: product}
end

And change the variant fixture to:

def variant_fixture(product, attrs \\ %{}) do
  attrs = Enum.into(attrs, @valid_attrs)
  {:ok, variant} = Products.create_variant(product, attrs)

  variant
end

Now I can use that in the specs below. So change:

test "list_variants/0 returns all variants" do
  variant = variant_fixture()
  assert Products.list_variants() == [variant]
end

To:

test "list_variants/0 returns all variants", %{product: product} do
  variant = variant_fixture(product)
  assert Products.list_variants(product) == [variant]
end

However, this will still not work. I need to change variant_fixture/1 to accept the product as an argument. To see all changes, view the file on Github

With that done, I will update all the specs, and pass in product until they are all green.


Related Tutorials

Published 15 Feb

Create ghost loading cards in Phoenix LiveView

ectoliveviewphoenix

Unless you already didn't know, when a LieView component is mounted on a page, it runs the mount/2 function twice. One when the page is rendered fro..

Published 11 Feb

Add Tags with Ecto has_many, through in Phoenix - Tagging part 1

belongs_toectohas_manyphoenix

I want to add tags to products. And as usual there are a situation where a product can have many tags and a tag can belong to many products. Howeve..