Nested Resources in Ruby on Rails. Let's Build a Dictionary!
In Ruby on Rails Nov 20, 2021
Updated on May 13, 2023
In this tutorial, we will deal with nested resources in Ruby on Rails. This is useful when there is a has_many association corresponding to a belongs_to association between two models. We want to emphasize this logical relationship using nested routes. We will build a dictionary with letters, and each letter will be associated with words beginning with that letter. We’ll use Rails 6.1.4.1 and Ruby 3.0.2. We will use the friendly_id gem for pretty URLs.
In Ruby on Rails, the routes of our application are kept in the file config/routes.rb
. The Rails router
will match a request in the browser to a controller action. The Rails default for routing is the resource routing. A
resource route will map HTTP
verbs like GET
, POST
, PATCH
,
PUT
and DELETE
to a controller action and also to a certain CRUD
operation in
the database. More precisely, a resource route will create seven routes in our application corresponding to the seven
standard controller actions: index
, show
, new
, edit
,
create
, update
and destroy
. In Rails, when between two models there is a
logical association, we can reflect that association in the browser, nesting the child resource within the parent
resource.
Let’s get started with our application!
Set Up the Application
First of all, we’ll have to create our application. From the terminal, we’ll type the well-known command to create a new Rails app:
rails new dictionary
After the app is created, we can go to that folder:
cd dictionary
Now we can start the server:
bin/rails s
Since we didn't choose a root
route yet, we’ll see the default Rails information page.
Generate Scaffold for Letters
We will generate a scaffold for the Letter
resource:
bin/rails g scaffold Letter letter:string
The Letter
model has only one attribute: the letter. Next, we’ll run the migration that was generated for
us:
bin/rails db:migrate
Generate a Controller and a Model for Words
For the other resource, we’ll not generate a scaffold. Instead, we’ll generate a controller and a model.
First, let’s generate the controller:
bin/rails g controller Words
This controller is empty, so we need to add some actions to it. We’ll add only the show
,
edit
, create
, update
and destroy
actions. We do not want an
index
action. Instead, we’ll list the words beginning with a letter on the letter show
view.
And the form for creating the word will be on the letter show
view too because this makes the most sense.
So, let’s open the app/controllers/words_controller.rb
file and add these actions. After adding these
actions our controller looks like this:
class WordsController < ApplicationController
def show
end
def edit
end
def create
end
def update
end
def destroy
end
end
The actions are empty for now. We'll edit them later, one by one.
We need view files also, corresponding to the show
and edit
actions. Hence, we go to the
app/views/words/
folder and create two files: show.html.erb
and edit.html.erb
.
Next, we'll generate the model:
bin/rails g model Word word:string meaning:text
The Word
model has two attributes: the word itself and its meaning.
After that, we'll run the migration:
bin/rails db:migrate
Now, let's associate the two models. In the letter.rb
file in the
app/models/
folder, add this line:
has_many :words, dependent: :destroy
And in the word.rb
file, add this line:
belongs_to :letter
We also need to add a letter_id
to the words table. For that, we’ll generate a migration:
bin/rails g migration AddLetterRefToWords letter:references
And then we run:
bin/rails db:migrate
In the words_controller.rb
file, we’ll add the set_word
and the words_params
methods:
private
def set_word
@word = Word.find(params[:id])
end
def word_params
params.require(:word).permit(:word, :meaning, :letter_id)
end
We’ll call the set_word
method before every action, except create
of course:
before_action :set_word, except: [:create]
The Words
controller looks like this now:
class WordsController < ApplicationController
before_action :set_word, except: [:create]
def show
end
def edit
end
def create
end
def update
end
def destroy
end
private
def set_word
@word = Word.find(params[:id])
end
def word_params
params.require(:word).permit(:word, :meaning, :letter_id)
end
end
Customize the Routes and Set a Home Page
Now let’s open the file config/routes.rb
. We’ll generate the routes for the word resource, except for the
index
and new
actions :
resources :words, except: [:index, :new]
Next, we’ll change the path to letters:
resources :letters, except: [:index], path: 'letter'
This way, the user will see the word “letter” in the browser, which makes the most sense in this situation. I except
the index
action because our application doesn’t have a home page and I want to set the root
route to the letters index
action.
We put the root
route at the top of the config/routes.rb
file:
root 'letters#index'
The routes look like this now. We’ll nest them later:
Rails.application.routes.draw do
root 'letters#index'
resources :letters, except: [:index], path: 'letter'
resources :words, except: [:index, :new]
end
In the letters_controller.rb
file, we need to replace the letters_url
with the
root_url
since we no longer have a letters_url
. Specifically, now we
redirect to the letters_url
after deleting a letter, but we need to redirect to the
root_url
. So we only change that.
In the views also, we have a few occurrences of the letters_path
in the new.html.erb
,
edit.html.erb
and show.html.erb
files. That was generated when we generate the scaffold. We
replace them with the root_path
.
We can now return to the browser to see our home page. Of course, we can edit the default view. We can change the
title, and add some text. Since editing HTML is beyond the scope of this article, I’ll do only the minimum. I’ll
change the title and get rid of the table. Instead, I’ll use a list. This is the edited index.html.erb
file in the app/views/letters/
folder:
<p id="notice"><%= notice %></p>
<h1>Dictionary</h1>
<ul>
<% @letters.each do |letter| %>
<li>
<%= link_to letter.letter, letter_path(letter) %>
<%= link_to 'Edit', edit_letter_path(letter) %>
<%= link_to 'Delete', letter, method: :delete, data: { confirm: 'Are you sure?' } %>
</li>
<% end %>
</ul>
<%= link_to 'New Letter', new_letter_path %>
We display a list of letters and add a link to the new
letter form.
Install and Configure the FriendlyId Gem
We’ll now install and configure the friendly_id gem. FriendlyId
works with active record models and allows you to use strings like they are ids from the database.
Add this gem to your Gemfile
:
gem 'friendly_id'
And then run:
bundle install
We will now generate a few migrations:
bin/rails g migration AddSlugToLetters slug:uniq
bin/rails g migration AddSlugToWords slug:uniq
bin/rails g friendly_id
Now we can run:
bin/rails db:migrate
We can now edit our models to use friendly ids.
In our letter.rb
file in the app/models/
folder, we add these lines:
extend FriendlyId
friendly_id :letter, use: :slugged
And in our word.rb
file in the same folder, we add these lines:
extend FriendlyId
friendly_id :word, use: :slugged
In the Letters
controller, inside the set_letter
method, we’ll replace
Letter.find
with
Letter.friendly.find
:
def set_letter
@letter = Letter.friendly.find(params[:id])
end
And in the Words
controller, inside the set_word
method, we'll replace
Word.find
with Word.friendly.find
:
@word = Word.friendly.find(params[:id])
We need to restart our server.
Now we can start creating letters. We’ll see in the browser URLs like:
localhost:3000/letter/a
localhost:3000/letter/b
…and so on.
We’ll get rid of the bullets in the unordered list by adding this rule to the letters.scss
file in the
app/assets/stylesheets
folder:
ul,
li {
list-style-type: none;
}
Nest the Word Resource within the Letter Resource
It's time to nest our two resources. We’ll take the word
resource in the routes.rb
file and
nest it within the letter
resource like this:
resources :letters, except: [:index], path: 'letter' do
resources :words, except: [:index, :new]
end
We do not need the word “words” displayed in the browser, so we’ll change the path to an empty string:
resources :letters, except: [:index], path: 'letter' do
resources :words, except: [:index, :new], path: ''
end
But now we have a problem. Because in Rails the child routes come first, the /letter/:id/edit
URL will
map to the action show
in the Words
controller. Rails will find the word “edit” and not the
edit
action of the Letters controller. Because the routes are now identical. And that will raise an
error. But we can add the following line above the nested resources:
resources :letters, path: 'letter', only: [:edit], path_names: {edit: 'edit-letter'}
And next we will except the action edit
since the route to it was already defined. That way, the route to
the edit
letter action will be first, and there will be no problem getting the URL. And I changed the
path name because later, when we'll want to add the word "edit" to our dictionary we won't be allowed to because Rails
will try to find the edit_letter_path
instead.
Our routes look like this:
Rails.application.routes.draw do
root 'letters#index'
resources :letters, path: 'letter', only: [:edit], path_names: {edit: 'edit-letter'}
resources :letters, path: 'letter', except: [:index, :edit] do
resources :words, except: [:index, :new], path: ''
end
end
The only issue we have now is with FriendlyId gem which had reserved the words “new” and “edit” for Rails. But we can
exclude this and in config/initializers/friendly_id.rb
file we’ll set these reserved
words to an empty array. At the moment, we have this in our file:
config.reserved_words = %w(new edit index session login logout users admin stylesheets assets javascripts images)
We'll change that too:
config.reserved_words = %w()
That’s it. Back to our routes, we obtained 5 nested routes for the word resource. We’ll discuss further in detail all of them.
Edit the Controllers and Views
Now we will edit the Words
controller and views. Let’s take them one by one. First, we’ll put the
new
word form on the letter
show
view. This makes the most sense.
Editing the show.html.erb
file in the app/views/letters/
folder, I will get
rid of the auto-generated code, and put only the letter name in a <h1>
tag. The page looks like
this:
<p id="notice"><%= notice %></p>
<h1><%= @letter.letter %></h1>
<%= link_to 'Edit', edit_letter_path(@letter) %> |
<%= link_to 'Back', root_path %>
Now, let’s add the form. We’ll create a partial, since it will be used by the edit
action, too. We need
to pass both models to this form. So, in the app/views/words/
folder, we’ll create the
_form.html.erb
file. We’ll use the form_with
helper to generate it. The first line looks
like this:
<%= form_with(model: [@word.letter, @word], local: true) do |form| %>
The form will also contain a hidden field for the letter_id
.
The final form looks like this:
<%= form_with(model: [@word.letter, @word], local: true) do |form| %>
<p>
<%= form.label :word %>
<%= form.text_field :word %>
</p>
<p>
<%= form.label :meaning %>
<%= form.text_area :meaning %>
</p>
<%= form.hidden_field :letter_id %>
<p>
<%= form.submit %>
</p>
<% end %>
In the show.html.erb
file in the app/views/letters/
directory, we call this partial right
after the <h1>
tag:
<%= render partial: 'words/form' %>
The Letters
controller doesn’t know what this @word
means. So in the
letter_controller.rb
we add this line inside the show
method:
@word = @letter.words.build
The build
method will create a new word in memory.
After we create the word, we want to redirect the user to the newly created word. We need to go to the
Words
controller and edit our create
action. We’ll use the helper generated for us by Rails
when we nested our resources: letter_word_path
, and pass the
@word.letter
and the @word
to it:
def create
@word = Word.new(word_params)
if @word.save
redirect_to letter_word_path(@word.letter, @word), notice: 'Word was successfully created.'
else
redirect_back fallback_location: root_path , notice: 'Something went wrong.'
end
end
We’ll need to display the word's information on the word
show
view. So let’s edit the
show.html.erb
file in the app/views/words
folder:
<p id="notice"><%= notice %></p>
<h1><%= @word.word %></h1>
<p> <%= @word.meaning.html_safe %> </p>
<%= link_to 'Edit', edit_letter_word_path(@word.letter, @word) %>
At the bottom, we link to the word
edit
path. Notice the helper that was generated when we
nested the resources. We must pass both the letter and the word to the helper.
Going to edit.html.erb
file, we’ll add the form, so we can edit the word if we want to.
<h1>Editing <%= @word.word %>: </h1>
<%= render 'form' %>
Of course, in order for that form to do anything, we need to edit the update
action in the controller:
def update
if @word.update(word_params)
redirect_to letter_word_path(@word.letter, @word), notice: 'Word was successfully updated.'
else
render :edit
end
end
The last action to edit is the destroy
action:
def destroy
@word.destroy
redirect_to letter_path(@word.letter), notice: 'Word was successfully deleted.'
end
We’ll put a link to this action on the word
show
view:
<%= link_to 'Delete', letter_word_path(@word.letter, @word), method: :delete, data: { confirm: 'Are you sure?' } %>
The final page looks like this:
<p id="notice"><%= notice %></p>
<h1><%= @word.word %></h1>
<p> <%= @word.meaning.html_safe %> </p>
<%= link_to 'Edit', edit_letter_word_path(@word.letter, @word) %>
<%= link_to 'Delete', letter_word_path(@word.letter, @word), method: :delete, data: { confirm: 'Are you sure?' } %>
Finally, we’ll want to list all the words beginning with a letter in the letter
show
view.
In the Letters
controller, we’ll add this inside the show
method:
@words = @letter.words.all
In the show.html.erb
page in the app/views/letters/
folder, after the
<h1>
tag we’ll add this:
<ul>
<% @words.all.each do |word| %>
<li>
<%= link_to word.word, letter_word_path(@letter, word) %>
</li>
<% end %>
</ul>
As usual, we need to pass both the letter and the word in order to get the word.
Order Letters and Words Alphabetically
We didn’t add validations for models and other things. But it was beyond the scope of this article. But a dictionary isn’t a dictionary if we don’t sort letters and words alphabetically. So we need to do this.
So, we’ll add this method in our letter.rb
file:
def self.alphabetically
self.order(:letter)
end
The method sorts alphabetically our letters using the letter field in the database. And we call the method in the
Letters
controller inside the index
method:
def index
@letters = Letter.all.alphabetically
end
Similarly, we’ll add this method in our word.rb
file:
def self.alphabetically
self.order(:word)
end
And call it in the Letters
controller inside the show
method where we declared our
@words
variables:
def show
@word = @letter.words.build
@words = @letter.words.all.alphabetically
end
Conclusion
We built a dictionary with letters and words. Using Rails nested resources, we were able to show to the user, in the browser, the logical relationship between our two models: the letters and the words.