Tutorial
Setup a has_many / belongs_to in Phoenix
This post was updated 01 May - 2020
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 hasmany / belongsto - 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:
- Change
ondelete: :nothing
toondelete: :delete_all
- Add
null: false
after name and value - Add a unique index
create uniqueindex(:variants, [:name, :value, :productid])
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:
defmodule 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
- Change
field :productid, :id
tobelongsto :product, Tutorial.Products.Product
- Add in the changeset:
|> uniqueconstraint(:name, name: :variantsnamevalueproductidindex)
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: variantsnamevalueproductid_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.