Meagan Waller
Posted on January 17, 2021
has_many :through
at a high-level.
A has_many :through
association is how we setup many-to-many connections with another model. When we define a has_many :through
relationship on a model, we're saying that we can potentially link the model to another model by going through a third model. I think the Rails guides do a great job of illustrating an example of how to use this relationship. Let's think of a different example. Say we have a company, a company has employees through an employment.
class Company < ApplicationRecord
has_many :employments
has_many :employees, through: :employments
end
class Employment < ApplicationRecord
belongs_to :employee
belongs_to :company
end
class Employee < ApplicationRecord
has_many :employments
has_many :companies, through: :employments
end
Our migrations would look like this:
class CreateCompanies < ActiveRecord::Migration[6.0]
def change
create_table :companies do |t|
t.string :name
t.timestamps
end
create_table :employees do |t|
t.string :name
t.timestamps
end
create_table :employments do |t|
t.belongs_to :employee
t.belongs_to :company
t.datetime :start_date
t.datetime :end_date
t.string :role
t.timestamps
end
end
end
When an employee "leaves" a company, we don't have to create the employee all over again. We make a new employment linked to their employee record and their new company.
When should I reach for has_many :through
In the example above, we touched on when you should go for this association type. We don't want an employee tied to a company. We want there to be some intermediary relationship that links the two things together. In this case, it's because we want to store some extra data on the employment table, like start date, end date, and role name. An employee will have multiple employments throughout their life. Now we have a table that keeps track of them. A relationship allows us to ask employee.companies
and shortcut through the employments
relationship to grab all the companies for which an employee has worked. My advice is to reach for has_many :through
when you need additional data on the association.
Introducing Pantry
I'm big on meal prepping, recipe saving, and cooking in general. I thought a recipe organization application would be a great example because it is interesting, fresh (not a to-do app!), and provides some potential challenges.
Pantry will be the name of the application that we'll be building to illustrate how to use has_many :through,
nested forms, nested_attributes, and dynamic fields using a third-party library. Let's get started.
First, we need to set requirements, expectations and then think through this application's business logic/relationships.
Pantry is a proof-of-concept application. There will be no extraneous styling or interactivity. We're talking bare bones to illustrate a concept only. We will create a recipe using a form, and a recipe has a name and ingredients. Ingredients have a name, an amount, and a description.
I think our relationships will likely look something like this:
There are many more attributes each of these could have, and I might expand on that in a future blog post to illustrate other concepts, but we are keeping it as simple as possible for now.
Getting started
Before we dive in, you can find the completed code here. I will break out each section into branches and link them as we get to them.
I'm using Rails version 6.1.1 and Ruby version 2.7.2.
First things first, we need to create our Rails application:
rails new pantry
cd pantry
rails db:create
rails webpacker:install
(You may not have to do the webpacker:install
step, I did because I didn't have yarn installed already.)
Now we can view our rails application at localhost:3000
.
rails server
Creating the models
First, we will create the Recipe.
rails g model Recipe title
And the migration this generation creates.
class CreateRecipes < ActiveRecord::Migration[6.1]
def change
create_table :recipes do |t|
t.string :title
t.timestamps
end
end
end
Next, let's create the Ingredient.
rails g model Ingredient name
And the migration this generation creates.
class CreateIngredients < ActiveRecord::Migration[6.1]
def change
create_table :ingredients do |t|
t.string :name
t.timestamps
end
end
end
Last, we create RecipeIngredient.
rails g model RecipeIngredient amount description recipe:belongs_to ingredient:belongs_to
And the migration this generation creates.
class CreateRecipeIngredients < ActiveRecord::Migration[6.1]
def change
create_table :recipe_ingredients do |t|
t.string :amount
t.string :description
t.belongs_to :recipe, null: false, foreign_key: true
t.belongs_to :ingredient, null: false, foreign_key: true
t.timestamps
end
end
end
We are going to add an index to the belongs_to
fields.
class CreateRecipeIngredients < ActiveRecord::Migration[6.1]
def change
create_table :recipe_ingredients do |t|
t.string :amount
t.string :description
t.belongs_to :recipe, null: false, foreign_key: true
t.belongs_to :ingredient, null: false, foreign_key: true
t.timestamps
end
end
end
Now, let's run the migrations!
rails db:migrate
Set up the associations inside of our models.
app/models/recipe.rb
class Recipe < ApplicationRecord
has_many :recipe_ingredients
has_many :ingredients, through: :recipe_ingredients
end
app/models/ingredient.rb
class Ingredient < ApplicationRecord
has_many :recipe_ingredients
has_many :recipes, through: :recipe_ingredients
end
app/models/recipe_ingredient.rb
class RecipeIngredient < ApplicationRecord
belongs_to :recipe
belongs_to :ingredient
end
Let's import the recipe for Best Ever Grilled Cheese Sandwich into our seeds file.
db/seeds.rb
grilled_cheese = Recipe.create(title: "Grilled Cheese")
butter = Ingredient.create(name: "Butter")
bread = Ingredient.create(name: "Sourdough Bread")
mayonnaise = Ingredient.create(name: "Mayonnaise")
manchego_cheese = Ingredient.create(name: "Manchego Cheese")
onion_powder = Ingredient.create(name: "Onion Powder")
white_cheddar = Ingredient.create(name: "Sharp White Cheddar Cheese")
monterey_jack = Ingredient.create(name: "Monterey Jack Cheese")
gruyere_cheese = Ingredient.create(name: "Gruyere Cheese")
brie_cheese = Ingredient.create(name: "Brie Cheese")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: butter, amount: "6 tbsp", description: "softened, divided")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: bread, amount: "8 slices")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: mayonnaise, amount: "3 tbsp")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: manchego_cheese, amount: "3 tbsp", description: "finely shredded")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: onion_powder, amount: "1/8 tsp")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: white_cheddar, amount: "1/2 cup", description: "shredded")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: gruyere_cheese, amount: "1/2 cup", description: "shredded")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: brie_cheese, amount: "4 oz", description: "rind removed and sliced")
When we run this rails task, it will execute the contents of the db/seeds.rb
file.
rails db:seed
We can check out what our data looks like in the rails console.
# In the rails console
± rails c
irb(main):005:0> Recipe.first
Recipe Load (0.1ms) SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Recipe id: 3, title: "Grilled Cheese", created_at: "2021-01-14 02:05:16.753424000 +0000", updated_at: "2021-01-14 02:05:16.753424000 +0000">
irb(main):006:0> Recipe.first.ingredients
Recipe Load (0.2ms) SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" ASC LIMIT ? [["LIMIT", 1]]
Ingredient Load (0.2ms) SELECT "ingredients".* FROM "ingredients" INNER JOIN "recipe_ingredients" ON "ingredients"."id" = "recipe_ingredients"."ingredient_id" WHERE "recipe_ingredients"."recipe_id" = ? /* loading for inspect */ LIMIT ? [["recipe_id", 3], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Ingredient id: 16, name: "Butter", created_at: "2021-01-14 02:05:16.762877000 +0000", updated_at: "2021-01-14 02:05:16.762877000 +0000">, #<Ingredient id: 17, name: "Sourdough Bread", created_at: "2021-01-14 02:05:16.766733000 +0000", updated_at: "2021-01-14 02:05:16.766733000 +0000">, #<Ingredient id: 18, name: "Mayonnaise", created_at: "2021-01-14 02:05:16.770198000 +0000", updated_at: "2021-01-14 02:05:16.770198000 +0000">, #<Ingredient id: 19, name: "Manchego Cheese", created_at: "2021-01-14 02:05:16.773554000 +0000", updated_at: "2021-01-14 02:05:16.773554000 +0000">, #<Ingredient id: 20, name: "Onion Powder", created_at: "2021-01-14 02:05:16.776771000 +0000", updated_at: "2021-01-14 02:05:16.776771000 +0000">, #<Ingredient id: 21, name: "Sharp White Cheddar Cheese", created_at: "2021-01-14 02:05:16.779594000 +0000", updated_at: "2021-01-14 02:05:16.779594000 +0000">, #<Ingredient id: 23, name: "Gruyere Cheese", created_at: "2021-01-14 02:05:16.784709000 +0000", updated_at: "2021-01-14 02:05:16.784709000 +0000">, #<Ingredient id: 24, name: "Brie Cheese", created_at: "2021-01-14 02:05:16.789495000 +0000", updated_at: "2021-01-14 02:05:16.789495000 +0000">]>
irb(main):007:0> Recipe.first.recipe_ingredients
Recipe Load (0.1ms) SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" ASC LIMIT ? [["LIMIT", 1]]
RecipeIngredient Load (0.2ms) SELECT "recipe_ingredients".* FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ? /* loading for inspect */ LIMIT ? [["recipe_id", 3], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<RecipeIngredient id: 5, amount: "6 tbsp", description: "softened, divided", recipe_id: 3, ingredient_id: 16, created_at: "2021-01-14 02:05:16.801631000 +0000", updated_at: "2021-01-14 02:05:16.801631000 +0000">, #<RecipeIngredient id: 6, amount: "8 slices", description: nil, recipe_id: 3, ingredient_id: 17, created_at: "2021-01-14 02:05:16.805122000 +0000", updated_at: "2021-01-14 02:05:16.805122000 +0000">, #<RecipeIngredient id: 7, amount: "3 tbsp", description: nil, recipe_id: 3, ingredient_id: 18, created_at: "2021-01-14 02:05:16.808517000 +0000", updated_at: "2021-01-14 02:05:16.808517000 +0000">, #<RecipeIngredient id: 8, amount: "3 tbsp", description: "finely shredded", recipe_id: 3, ingredient_id: 19, created_at: "2021-01-14 02:05:16.812203000 +0000", updated_at: "2021-01-14 02:05:16.812203000 +0000">, #<RecipeIngredient id: 9, amount: "1/8 tsp", description: nil, recipe_id: 3, ingredient_id: 20, created_at: "2021-01-14 02:05:16.815748000 +0000", updated_at: "2021-01-14 02:05:16.815748000 +0000">, #<RecipeIngredient id: 10, amount: "1/2 cup", description: "shredded", recipe_id: 3, ingredient_id: 21, created_at: "2021-01-14 02:05:16.819594000 +0000", updated_at: "2021-01-14 02:05:16.819594000 +0000">, #<RecipeIngredient id: 11, amount: "1/2 cup", description: "shredded", recipe_id: 3, ingredient_id: 23, created_at: "2021-01-14 02:05:16.825120000 +0000", updated_at: "2021-01-14 02:05:16.825120000 +0000">, #<RecipeIngredient id: 12, amount: "4 oz", description: "rind removed and sliced", recipe_id: 3, ingredient_id: 24, created_at: "2021-01-14 02:05:16.829869000 +0000", updated_at: "2021-01-14 02:05:16.829869000 +0000">]>
Now, let's create another recipe that uses some of the same ingredients. I'll be using this Gruyere and White Cheddar Mac and Cheese recipe.
db/seeds.rb
# Creating another recipe that uses some of the same ingredients, switch to find_or_create_by so I don't create duplicate records.
grilled_cheese = Recipe.find_or_create_by(title: "Grilled Cheese")
mac = Recipe.find_or_create_by(title: "Gruyere and White Cheddar Mac and Cheese")
butter = Ingredient.find_or_create_by(name: "Butter")
bread = Ingredient.find_or_create_by(name: "Sourdough Bread")
mayonnaise = Ingredient.find_or_create_by(name: "Mayonnaise")
manchego_cheese = Ingredient.find_or_create_by(name: "Manchego Cheese")
onion_powder = Ingredient.find_or_create_by(name: "Onion Powder")
white_cheddar = Ingredient.find_or_create_by(name: "Sharp White Cheddar Cheese")
monterey_jack = Ingredient.find_or_create_by(name: "Monterey Jack Cheese")
gruyere_cheese = Ingredient.find_or_create_by(name: "Gruyere Cheese")
brie_cheese = Ingredient.find_or_create_by(name: "Brie Cheese")
elbows = Ingredient.find_or_create_by(name: "Elbow Macaroni")
breadcrumbs = Ingredient.find_or_create_by(name: "Seasoned Breadcrumbs")
flour = Ingredient.find_or_create_by(name: "Flour")
milk = Ingredient.find_or_create_by(name: "Milk")
half_and_half = Ingredient.find_or_create_by(name: "Half and Half")
nutmeg = Ingredient.find_or_create_by(name: "Nutmeg")
salt = Ingredient.find_or_create_by(name: "Salt")
pepper = Ingredient.find_or_create_by(name: "Pepper")
parmigiano = Ingredient.find_or_create_by(name: "Parmigiano Reggiano")
olive_oil = Ingredient.find_or_create_by(name: "Olive Oil")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: butter, amount: "6 tbsp", description: "softened, divided")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: bread, amount: "8 slices")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: mayonnaise, amount: "3 tbsp")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: manchego_cheese, amount: "3 tbsp", description: "finely shredded")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: onion_powder, amount: "1/8 tsp")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: white_cheddar, amount: "1/2 cup", description: "shredded")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: gruyere_cheese, amount: "1/2 cup", description: "shredded")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: brie_cheese, amount: "4 oz", description: "rind removed and sliced")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: elbows, amount: "1 lb")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: gruyere_cheese, amount: "1 lb", description: "grated")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: white_cheddar, amount: "1 lb", description: "grated")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: breadcrumbs, amount: "1/3 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: butter, amount: "1/2 stick")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: flour, amount: "1/4 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: milk, amount: "3 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: half_and_half, amount: "1 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: nutmeg, amount: "pinch")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: salt, description: "to taste")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: pepper, description: "to taste")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: parmigiano, description: "to taste")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: olive_oil)
We can jump back into the rails console and see our new data.
± rails c
irb(main):001:0> Recipe.first
=> #<Recipe id: 3, title: "Grilled Cheese", created_at: "2021-01-14 02:05:16.753424000 +0000", updated_at: "2021-01-14 02:05:16.753424000 +0000">
irb(main):002:0> Recipe.last
=> #<Recipe id: 4, title: "Gruyere and White Cheddar Mac and Cheese", created_at: "2021-01-14 02:15:40.205860000 +0000", updated_at: "2021-01-14 02:15:40.205860000 +0000">
irb(main):003:0> Ingredient.count
=> 19
irb(main):004:0> RecipeIngredient.count
=> 21
Creating controllers
We're going to create our controller. We won't have a way to make standalone ingredients, so we only need a recipe controller.
rails g controller recipes
Let's setup our routes now.
config/routes.rb
Rails.application.routes.draw do
resources :recipes
end
We can see what using the resources
method in our routes provided for us.
± rails routes | grep recipes
recipes GET /recipes(.:format) recipes#index
POST /recipes(.:format) recipes#create
new_recipe GET /recipes/new(.:format) recipes#new
edit_recipe GET /recipes/:id/edit(.:format) recipes#edit
recipe GET /recipes/:id(.:format) recipes#show
PATCH /recipes/:id(.:format) recipes#update
PUT /recipes/:id(.:format) recipes#update
DELETE /recipes/:id(.:format) recipes#destroy
Let's flesh out our controller.
First, let's start up the Rails server.
rails s
Go to localhost:3000/recipes
in your browser. You should see an error. Let's fix that.
app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
def index
@recipes = Recipe.all
end
end
app/views/recipes/index.html.erb
<h1>Recipes</h1>
<% @recipes.each do |recipe| %>
<h2>
<%= recipe.title %>
</h2>
<ul>
<% recipe.recipe_ingredients.each do |recipe_ingredient| %>
<li>
<%= recipe_ingredient.amount %> <%= recipe_ingredient.ingredient.name %>, <%= recipe_ingredient.description %>
</li>
<% end %>
</ul>
<% end %>
Now refresh to see our index. We will see our listing of the recipes we created.
app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
def index
@recipes = Recipe.all
end
def show
@recipe = Recipe.find(params[:id])
end
end
app/views/recipes/show.html.erb
<h1><%= @recipe.title %></h1>
<ul>
<% @recipe.recipe_ingredients.each do |recipe_ingredient| %>
<li>
<%= recipe_ingredient.amount %> <%= recipe_ingredient.ingredient.name %>, <%= recipe_ingredient.description %>
</li>
<% end %>
</ul>
<%= link_to "Back to all recipes", recipes_path %>
Visit this page by going to localhost:3000/recipes/RECIPE_ID_HERE
, find the recipe id in your console, Recipe.first.id
, etc.
app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
def index
@recipes = Recipe.all
end
def show
@recipe = Recipe.find(params[:id])
end
def new
@recipe = Recipe.new
end
end
app/views/recipes/new.html.erb
<h1>Create a new recipe</h1>
<%= form_with model: @recipe do |form| %>
<%= form.text_field :title, placeholder: "Recipe Title" %>
<%= form.submit %>
<% end %>
Visit this page at localhost:3000/recipes/new
. If you try and create a recipe, you will get an error because our create
action isn't defined yet.
app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
def index
@recipes = Recipe.all
end
def show
@recipe = Recipe.find(params[:id])
end
def new
@recipe = Recipe.new
end
def create
@recipe = Recipe.create(recipe_params)
if @recipe.save
redirect_to @recipe
else
render action: "new"
end
end
protected
def recipe_params
params.require(:recipe).permit(:title)
end
end
Refresh the new page and create a recipe. You'll see that we're able to save a recipe without a title. Let's add a validation to ensure every recipe at least has a title.
app/models/recipe.rb
class Recipe < ApplicationRecord
has_many :recipe_ingredients
has_many :ingredients, through: :recipe_ingredients
validates_presence_of :title
end
Now we get kicked back to the new page. We're not going to worry about flash messages or anything at this time. We might find we need them later, though.
Let's add our edit action now.
app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
def index
@recipes = Recipe.all
end
def show
@recipe = Recipe.find(params[:id])
end
def new
@recipe = Recipe.new
end
def create
@recipe = Recipe.create(recipe_params)
if @recipe.save
redirect_to @recipe
else
render action: "new"
end
end
def edit
@recipe = Recipe.find(params[:id])
end
protected
def recipe_params
params.require(:recipe).permit(:title)
end
end
app/views/recipes/edit.html.erb
<h1>Edit <%= @recipe.title %></h1>
<%= form_with model: @recipe do |form| %>
<%= form.text_field :title, placeholder: "Recipe Title" %>
<%= form.submit %>
<% end %>
Visit the edit page of a recipe with localhost:3000/recipes/RECIPE_ID/edit
. Once again, we'll see that we can't edit a recipe because the update action doesn't exist yet.
app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
def index
@recipes = Recipe.all
end
def show
@recipe = Recipe.find(params[:id])
end
def new
@recipe = Recipe.new
end
def create
@recipe = Recipe.create(recipe_params)
if @recipe.save
redirect_to @recipe
else
render action: "new"
end
end
def edit
@recipe = Recipe.find(params[:id])
end
def update
@recipe = Recipe.find(params[:id])
if @recipe.update(recipe_params)
redirect_to @recipe
else
render action: "edit"
end
end
protected
def recipe_params
params.require(:recipe).permit(:title)
end
end
Now try and edit a recipe.
Lastly, let's add an action to destroy a recipe, we won't go over deleting recipes in this tutorial, but we've added it to our controller for completion's sake. At the end of this tutorial, I have ideas to further this application. Adding deletion functionality is one enhancement you could add.
class RecipesController < ApplicationController
def index
@recipes = Recipe.all
end
def show
@recipe = Recipe.find(params[:id])
end
def new
@recipe = Recipe.new
end
def create
@recipe = Recipe.create(recipe_params)
if @recipe.save
redirect_to @recipe
else
render action: "new"
end
end
def edit
@recipe = Recipe.find(params[:id])
end
def update
@recipe = Recipe.find(params[:id])
if @recipe.update(recipe_params)
redirect_to @recipe
else
render action: "edit"
end
end
def destroy
@recipe = Recipe.find(params[:id])
@recipe.destroy
redirect_to recipes_url
end
protected
def recipe_params
params.require(:recipe).permit(:title)
end
end
In our recipes form, we're only inputting the title of the recipe. In the next section, we will look into how to create a nested form to add ingredients to a recipe.
View all the code up to this point
Rendering nested forms
We want to create recipe_ingredients
when we make a recipe.
We do that by using the fields_for
that Rails form builder provides. For now, we will only include one field on the recipe_ingredient
fields to keep this focused.
app/views/recipes/new.html.erb
<h1>Create a new recipe</h1>
<%= form_with model: @recipe do |recipe_form| %>
<%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
<h2>Ingredients</h2>
<%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
<%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
<% end %>
<br />
<%= recipe_form.submit %>
<% end %>
Now go to the new recipe page and create a recipe with a recipe_ingredient
amount.
Let's go into the rails console and see what we just created.
± rails c
irb(main):001:0> Recipe.last
(2.0ms) SELECT sqlite_version(*)
Recipe Load (0.1ms) SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" DESC LIMIT ? [["LIMIT", 1]]
=> #<Recipe id: 7, title: "Cheesy Broccoli", created_at: "2021-01-14 15:26:17.638397000 +0000", updated_at: "2021-01-14 15:26:17.638397000 +0000">
irb(main):002:0> Recipe.last.recipe_ingredients
Recipe Load (0.1ms) SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" DESC LIMIT ? [["LIMIT", 1]]
RecipeIngredient Load (0.4ms) SELECT "recipe_ingredients".* FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ? /* loading for inspect */ LIMIT ? [["recipe_id", 7], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy []>
Our recipe has no recipe ingredients associated with it. Hmmm 🤔. Let's take a look at the logs when we submitted the form.
Started POST "/recipes" for 127.0.0.1 at 2021-01-14 10:26:17 -0500
Processing by RecipesController#create as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "recipe"=>{"title"=>"Cheesy Broccoli", "recipe_ingredients"=>{"amount"=>"1 cup"}}, "commit"=>"Create Recipe"}
Unpermitted parameter: :recipe_ingredients
Aha! There's the culprit. We tried to pass a parameter that our recipe_params
method doesn't know about, recipe_ingredients.
Let's remedy that.
Nested Attributes
Rails nested attributes have this naming convention. We append _attributes
to the end of the collection name. We won't go into why/how it works, be aware that even though the parameters we saw above showed recipe_ingredients,
we need to name them the way rails expect inside our recipe_params.
app/controllers/recipes_controller.rb
def recipe_params
params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount])
end
Okay, should we working now! Let's try and save a new recipe and check out the logs.
Started POST "/recipes" for 127.0.0.1 at 2021-01-14 10:31:26 -0500
Processing by RecipesController#create as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "recipe"=>{"title"=>"Spaghetti", "recipe_ingredients"=>{"amount"=>"28 oz"}}, "commit"=>"Create Recipe"}
Unpermitted parameter: :recipe_ingredients
Still unpermitted! Because we have to tell the Recipe
model to accept the nested attributes for recipe_ingredients
(this accepts_nested_attributes
is where Rails creates the #{model_name}_attributes
method that it's using inside of the recipe_params.
)
app/models/recipe.rb
class Recipe < ApplicationRecord
has_many :recipe_ingredients
has_many :ingredients, through: :recipe_ingredients
validates_presence_of :title
accepts_nested_attributes_for :recipe_ingredients
end
When we refresh the new page, we'll find that no field shows up on our form for recipe_ingredients.
That's because we've got to build our first recipe_ingredient
inside of the new
action.
app/controllers/recipes_controller.rb
def new
@recipe = Recipe.new
@recipe.recipe_ingredients.build
end
Finally! We've got the recipe ingredient field showing. We're accepting nested attributes. Let's try to create a recipe
with a recipe_ingredient
on it.
Still not creating, and now the form is re-rendering new
. At this point, we need to add flash messages so we can see errors in the form.
Adding flash notices
Inside of our controller, we're going to use the standard Rails way of creating and rendering flash messages for our create action.
app/controllers/recipes_controller.rb
def create
@recipe = Recipe.new(recipe_params)
respond_to do |format|
if @recipe.save
format.html { redirect_to @recipe, notice: "Recipe was successfully created."}
format.json { render :show, status: :created, location: @recipe }
else
format.html { render :new }
format.json { render json: @recipe.errors, status: :unproccessable_entity }
end
end
end
Below is the standard Rails way of rendering errors in a form.
app/views/recipes/new.html.erb
<h1>Create a new recipe</h1>
<%= form_with model: @recipe do |recipe_form| %>
<% if @recipe.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
<ul>
<% @recipe.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
<h2>Ingredients</h2>
<%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
<%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
<% end %>
<br />
<%= recipe_form.submit %>
<% end %>
When we try and create our recipe again, we can see the error: Recipe ingredients, ingredient must exist
. Okay, let's make an ingredient
when we create a recipe_ingredient.
app/views/recipes/new.html.erb
<h1>Create a new recipe</h1>
<%= form_with model: @recipe do |recipe_form| %>
<% if @recipe.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
<ul>
<% @recipe.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
<h2>Ingredients</h2>
<%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
<%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
<%= recipe_ingredient_form.fields_for :ingredient do |ingredient_form| %>
<%= ingredient_form.text_field :name %>
<% end %>
<% end %>
<br />
<%= recipe_form.submit %>
<% end %>
We've added a nested fields_for
inside of our recipe_ingredients fields_for,
this time for an ingredient, and it's got one field, a text field for its name. Let's create a recipe and view the logs and see what's happening.
Started POST "/recipes" for 127.0.0.1 at 2021-01-14 10:51:28 -0500
Processing by RecipesController#create as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "recipe"=>{"title"=>"Macaroni", "recipe_ingredients_attributes"=>{"0"=>{"amount"=>"1lb", "ingredient"=>{"name"=>"Elbow Noodles"}}}}, "commit"=>"Create Recipe"}
Unpermitted parameter: :ingredient
Alright, this is the same thing we saw above with recipe_ingredients.
We know exactly how to tackle this.
app/controllers/recipes_controller.rb
def recipe_params
params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, ingredient_attributes: [:name]])
end
We're still getting the same error:
1 error prohibited this recipe from being saved:
Recipe ingredients ingredient must exist
We still need to make sure that recipe_ingredients
accept the ingredients
nested attributes. Look at our recipe_params,
ingredients_attributes
is nested inside recipe_ingredients_attributes
.
app/models/recipe_ingredient.rb
class RecipeIngredient < ApplicationRecord
belongs_to :recipe
belongs_to :ingredient
accepts_nested_attributes_for :ingredient
end
Like before, when we added the accepts_nested_attributes_for :recipe_ingredients
to Recipe,
the form for the ingredient isn't showing up. We need to build the initial recipe_ingredient
in our controller.
app/controllers/recipes_controller.rb
def new
@recipe = Recipe.new
@recipe.recipe_ingredients.build.build_ingredient
end
Now we can save our recipe with recipe ingredients!
Adding more recipe ingredients to a recipe
Okay, but no recipe requires only one recipe ingredient. How do we add more to the form? You could build them all inside of the controller.
app/controllers/recipes_controller.rb
def new
@recipe = Recipe.new
10.times { @recipe.recipe_ingredients.build.build_ingredient }
end
If you view the form, you have ten inputs for recipe_ingredients
(I've added some line breaks to the form to make them easier to view)
app/views/recipes/new.html.erb
<h1>Create a new recipe</h1>
<%= form_with model: @recipe do |recipe_form| %>
<% if @recipe.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
<ul>
<% @recipe.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
<h2>Ingredients</h2>
<%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
<%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
<%= recipe_ingredient_form.fields_for :ingredient do |ingredient_form| %>
<%= ingredient_form.text_field :name %>
<% end %>
<br />
<% end %>
<br />
<%= recipe_form.submit %>
<% end %>
But, who can say that every recipe will have precisely ten ingredients? For some applications, this could be enough. Maybe we have an application where there is always an expected number of associations, and it never waivers from that. But for this application, we need this to be dynamic. We want to be able to add and remove recipe ingredients as required. So, let's get into it.
View the code up to this point
Adding and Removing Recipe Ingredients using Cocoon
To add and remove recipe ingredients, we're going to use my favorite gem for handling this. The gem is called cocoon.
Cocoon defines two helper methods, link_to_add_association
and link_to_remove_association,
these are aptly named because what they do is provide links that, when clicked, will either build a new association and create the appropriate fields or will create a link with the ability to mark an association for deletion.
Setting up cocoon
To set up cocoon, we first need to add it to our Gemfile.
Stop the rails server and add the gem to your Gemfile.
Gemfile
gem 'cocoon'
Next, run bundle install
to install the gem. Because we're using Rails 6, we also need to add the companion file for webpacker. Cocoon requires jQuery, so we need to install that as well.
yarn add jquery @nathanvda/cocoon
Next, let's add it to our application JavaScript.
app/javascripts/packs/application.js
require("jquery")
require("@nathanvda/cocoon")
Cocoon requires the use of partials. We won't get into how cocoon works, but these partials named expectedly are how cocoon knows what to render when we click 'add ingredient.'
app/views/recipes/new.html.erb
<h2>Ingredients</h2>
<div id="recipeIngredients">
<%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
<%= render "recipe_ingredient_fields", f: recipe_ingredient_form %>
<% end %>
<div class='links'>
<%= link_to_add_association 'add ingredient', recipe_form, :recipe_ingredients %>
</div>
</div>
Now when we refresh we see this error: Missing partial recipes/_recipe_ingredient_fields, application/_recipe_ingredient_fields with {:locale=>[:en], :formats=>[:html], :variants=>[], :handlers=>[:raw, :erb, :html, :builder, :ruby, :jbuilder]}. Searched in:
Let's create our partial.
app/views/recipes/_recipe_ingredient_fields.html.erb
<div class="nested-fields">
<div>
<%= f.label :amount %>
<%= f.text_field :amount %>
</div>
<div class="ingredients">
<%= f.fields_for :ingredient do |ingredient| %>
<%= render "ingredient_fields", f: ingredient %>
<% end %>
</div>
<div>
<%= f.label :description %>
<%= f.text_field :description %>
</div>
</div>
And let's add our ingredient_fields
partial as well.
app/views/recipes/_ingredient_fields.html.erb
<div>
<%= f.label :name %>
<%= f.text_field :name %>
</div>
We are also ready to remove the explicit building of recipe_ingredients
in the new
action in RecipesController.
Now, we have a form that has a link to add ingredient.
However, nothing is happening when we click it. If I open up the dev tools, I see this message in the console:
Uncaught ReferenceError: jQuery is not defined
A quick Google search leads me to this page, and we still have a bit more configuration to do before jQuery is available in our application. Copy the code below into config/webpack/environment.js
to define jQuery
config/webpack/environment.js
const { environment } = require('@rails/webpacker')
const webpack = require('webpack')
environment.plugins.prepend('Provide',
new webpack.ProvidePlugin({
$: 'jquery/src/jquery',
jQuery: 'jquery/src/jquery'
})
)
module.exports = environment
When we click add ingredient, a recipe ingredient form is available for us to fill in! How cool is that? However, the ingredient portion of the form doesn't exist, so it's not displaying. Let's figure out how to fix that.
Cocoon's link_to_add_association
takes four parameters, the parameter html_options
has some special options that we can take advantage of. We're going to look at the wrap_object
option. The wrap_object
option is a proc that wraps the object. We can use this to build our ingredient when we use the add ingredient link.
Replace the link_to_add_association
with this updated version:
<%= link_to_add_association 'add ingredient', recipe_form, :recipe_ingredients, wrap_object: Proc.new { |recipe_ingredient| recipe_ingredient.build_ingredient; recipe_ingredient } %>
When we click add ingredient, the entire form, including the ingredient name, is available to fill in. When filling out my recipe and saving it, I noticed that the description
for the recipe_ingredient
didn't seem to be saving. Fix that by adding description
to our recipe_params
in the RecipeController
def recipe_params
params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, :description, ingredient_attributes: [:name]])
end
Let's add the ability to remove a recipe ingredient now.
app/views/recipes/_recipe_ingredient_fields.html.erb
<div class="nested-fields">
<div>
<%= f.label :amount %>
<%= f.text_field :amount %>
</div>
<div class="ingredients">
<%= f.fields_for :ingredient do |ingredient| %>
<%= render "ingredient_fields", f: ingredient %>
<% end %>
</div>
<div>
<%= f.label :description %>
<%= f.text_field :description %>
</div>
<%= link_to_remove_association "remove ingredient", f %>
</div>
Now we can add and also remove our recipe ingredients! Awesome.
However, these changes only apply to the new form. We want to be able to edit recipes and have the same functionality. Let's do some refactoring and make that happen.
Refactoring our form and updating recipes
We can pretty much reuse the same form for edit and new, so let's do that now. First, let's pull out the form and put it into a partial that we can render inside of the new view.
app/views/recipes/_form.html.erb
<%= form_with model: @recipe do |recipe_form| %>
<% if @recipe.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
<ul>
<% @recipe.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= recipe_form.label :title %>
<%= recipe_form.text_field :title %>
</div>
<h2>Ingredients</h2>
<div id="recipeIngredients">
<%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
<%= render "recipe_ingredient_fields", f: recipe_ingredient_form %>
<% end %>
<div class='links'>
<%= link_to_add_association 'add ingredient', recipe_form, :recipe_ingredients, wrap_object: Proc.new { |recipe_ingredient| recipe_ingredient.build_ingredient; recipe_ingredient } %>
</div>
</div>
<br />
<%= recipe_form.submit %>
<% end %>
Now, our new view is much cleaner.
app/views/recipes/new.html.erb
<h1>Create a new recipe</h1>
<%= render "form" %>
Let's go ahead and do the same to our edit view.
app/views/recipes/edit.html.erb
<h1>Edit <%= @recipe.title %></h1>
<%= render "form" %>
Now our edit form allows us to add and remove recipe ingredients. While using the application, I noticed that we don't have a good way of viewing our recipes individually or getting to an edit page with a click. Let's add that to access an edit page to test out our functionality quickly.
Change the index page to be a listing of links to the recipes. We don't need to see the full recipe on the index page.
app/views/recipes/index.html.erb
<h1>Recipes</h1>
<% @recipes.each do |recipe| %>
<h2>
<%= link_to recipe.title, recipe %>
</h2>
<% end %>
Now, on the show page, we can add a link to edit.
app/views/recipes/show.html.erb
<h1><%= @recipe.title %></h1>
<ul>
<% @recipe.recipe_ingredients.each do |recipe_ingredient| %>
<li>
<%= recipe_ingredient.amount %> <%= recipe_ingredient.ingredient.name %>, <%= recipe_ingredient.description %>
</li>
<% end %>
</ul>
<%= link_to "Edit recipe", edit_recipe_path(@recipe) %> | <%= link_to "Back to all recipes", recipes_path %>
Now we can quickly get to our edit page. Let's ensure that things are working as expected. Try adding a new ingredient to a recipe and removing an existing ingredient. When checking to make sure that recipe ingredients get appropriately removed, I saw that they were not. Let's fix that.
Make sure recipe ingredients get removed
We need to make sure that _destroy,
and id
are permitted parameters in recipe_params.
def recipe_params
params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, :description, :_destroy, :id, ingredient_attributes: [:name, :id]])
end
We also need to add allow_destroy: true
to our accepts_nested_attributes
. Now we can add and remove recipe ingredients successfully.
app/models/recipe.rb
accepts_nested_attributes_for :recipe_ingredients, allow_destroy: true
Let's make sure that we are taking advantage of the fact that we have a has_many :through.
When it comes to ingredients, we want the name
field to be unique. If we have an ingredient with a name
of butter, every recipe named butter will use that ingredient
instead of creating multiple ingredients of the same name. Down the road, we will be able to find all the recipes that have a particular ingredient.
Create or find ingredients
To our Recipe
model, let's add a before_save
hook so we can get at our ingredients before they get saved. We can use find_or_create_by
when assigning an Ingredient
to RecipeIngredient.
app/models/recipe.rb
class Recipe < ApplicationRecord
has_many :recipe_ingredients
has_many :ingredients, through: :recipe_ingredients
validates_presence_of :title
accepts_nested_attributes_for :recipe_ingredients, allow_destroy: true
before_save :find_or_create_ingredients
def find_or_create_ingredients
self.recipe_ingredients.each do |recipe_ingredient|
recipe_ingredient.ingredient = Ingredient.find_or_create_by(name:recipe_ingredient.ingredient.name)
end
end
end
Test it out by adding multiple recipe ingredients with the same ingredient name, and then go into the rails console, and we can see that an ingredient with the same name gets created once. I made a recipe called "Butter Two Ways," both recipe_ingredients
have the name of butter. When I go into the rails console, I see that there is only one ingredient
named butter and that both of my recipe_ingredients
belong to it.
rails c
View your last Recipe
in the rails console after you create it in the application.
recipe = Recipe.last
Recipe Load (0.1ms) SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" DESC LIMIT ? [["LIMIT", 1]]
=> #<Recipe id: 18, title: "Butter Two Ways", created_at: "2021-01-17 00:47:28.243659000 +0000", updated_at: "2021-01-17 00:47:28.243659000 +0000">
recipe.recipe_ingredients.count
(0.2ms) SELECT COUNT(*) FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ? [["recipe_id", 18]]
=> 2
recipe.recipe_ingredients.pluck(:ingredient_id)
(0.1ms) SELECT "recipe_ingredients"."ingredient_id" FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ? [["recipe_id", 18]]
=> [180, 180]
Ingredient.find(180)
Ingredient Load (1.0ms) SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."id" = ? LIMIT ? [["id", 180], ["LIMIT", 1]]
=> #<Ingredient id: 180, name: "butter", created_at: "2021-01-16 23:17:33.293763000 +0000", updated_at: "2021-01-16 23:17:33.293763000 +0000">
Ta-da 🎉 Wow, we did it y'all! How does that feel?
View the code up to this point
Wrap Up
Phew, that was a doozy of a post. I hope it was informative and useful for you. Let's refresh what we did.
- We learned what a
has_many :through
relationship is. It's a relationship that links two other models together. A recipe has many ingredients through its recipe ingredients. In practice, it creates a third database table that has at least a reference to both tables to which it belongs. - We learned when we need to use a
has_many :through
relationship, we use one because we wanted ingredients on our recipes to have additional information that was specific to the recipe only. Reach for ahas_many :through
when you need other data on the association. - We defined the Pantry app and its requirements
- We built a Rails 6 application, using generators for our models, migrations, and controllers. Then we used a seed file to populate our database initially and the rails console to view our data. We also built out a CRUD controller for recipes and a form to input our recipe information into and save it.
- We created nested forms using
fields_for
andaccepts_nested_attributes_for,
learned how to build out our ingredient so the ingredient fields would render - We added what we needed when we needed it, like when we added our flash notices when we needed more information about why our recipe wasn't saving.
- We used the cocoon gem to make our nested form dynamic, allowing us to add and remove recipe ingredients
- We pulled form partials out so we could reuse our form on the new and edit page
- We used an ActiveRecord lifecycle hook (
before_save
) so that recipe_ingredients would either use an existing ingredient if it existed and, if not, create the ingredient.
Don't you feel accomplished?
I have some further challenges for you if you'd like to iterate more on this application. Add auto-completion to the ingredient name field, selecting an option from the auto-select dropdown could populate a hidden id
field in the ingredient fields. Add specs. Ideally, we would do this before/alongside writing our code, but I wanted to focus on the code, still trying to figure out this balance. Add a way to use the destroy
action from the RecipesController.
Make the index page better, add a link to create new recipes. Add a way to search for recipes that include a specific ingredient. Think about how your controller would change if your Rails application were API only. How would the request payload look? What changes would you need? There are so many cool ways to expand on this application. I hope that this provides a good base and jumping-off point if you're so inclined.
I do enjoy writing these tutorials, but they take a lot of effort! If you appreciated this post, please share it on Twitter or other social media; that would make my day. Keep in mind that there are different ways to solve this problem; this happens to be my approach using my favorite tools. If you have any questions or comments, please tweet at me, I always appreciate lovely messages and constructive feedback.
Posted on January 17, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.