Nested Resources in Ruby on Rails. Let's Build a Dictionary!

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.

Post last updated on May 13, 2023