Ruby on Rails Models Association Basics - Let's Build a Bookstore App - First Part

Welcome to my tutorial focusing on Active Record associations in Ruby on Rails. Rails offers a powerful toolkit comprising six primary types of associations: belongs_to, has_one, has_many, has_many :through, has_one :through, and has_and_belongs_to_many. In this guide, we'll delve into these associations while constructing a bookstore application using Ruby on Rails 7.1.2 and Ruby 3.2.2.

tutorial parts:

  1. Ruby on Rails Models Association Basics - Let's Build a Bookstore App - First Part - Set Up the Application
  2. Ruby on Rails Models Association Basics - Let's Build a Bookstore App - Second Part

SET UP THE APPLICATION

Let's start by creating a new application. In the terminal, we type:

rails new bookstore

Let's change directories into this folder:

cd bookstore

To generate data for the database, we will use the Faker gem.  Faker generates fake data, which can be useful for populating a database with placeholder information for testing and development purposes. Add this gem to the Gemfile in the development and test group:

gem 'faker'

Then run:

bin/bundle install

Generate and Edit the Book Resource

We will generate a scaffold for the Book resource. Let's type in the terminal:

bin/rails g scaffold Books title:string

Let's run this migration:

bin/rails db:migrate

Let's add some validations in the book.rb file in the app/models/ folder:

validates_presence_of :title
validates_uniqueness_of :title

Let's add a method to the db/seeds.rb file to create some book records in the database:

20.times do
  book = Book.create(
    title: Faker::Book.title,
  )
end

Now let's run:

bin/rails db:seed

Let's set the root route to the books index action. In config/routes.rb at this line at the top:

root "books#index"

Let's edit the _book.html.erb partial in the app/views/books/ folder. Replace the content with this:

<div id="<%= dom_id book %>">
  <%=link_to book.title, book %>
</div>

And we will edit the index view as well:

<h1>Books</h1>
<p><%= link_to "New Book", new_book_path %></p>
<ul id="books">
<% @books.each do |book| %>
<li style="margin-bottom: 1rem"><%= render book %></li>
<% end %>
</ul>

On the home page of our application, we have a list of links to our books' details.

Building the Author Resource

Books have authors, so let's continue developing our application by generating an Author model:

bin/rails g model Author name:string

We run the migration:

bin/rails db:migrate

Let's add validations for this model in the app/models/author.rb file:

validates_presence_of :name
validates_uniqueness_of :name

Now, let's add some authors to our database. In the db/seeds.rb file, above the method for generating book records, add this:

authors = 10.times.map { Author.create(name: Faker::Book.author) }

Now, we can run in the terminal:

bin/rails db:reset

This command refreshes the database to match the recent changes we've made. It creates new book and author records. Avoid using this in production as it erases all existing data.

Let's generate an Authors controller:

bin/rails g controller Authors

To this controller, we'll add two methods: index and show:

def index
  @authors = Author.all
end
def show
  @author = Author.find(params[:id])
end

In config/routes.rb, we'll add routes for the Authors resource:

resources :authors

And we'll create the index and show views in the app/views/authors/ folder. 

In the index.html.erb file, let's add a list of authors:

<h1>Authors</h1>
<ul>
  <% @authors.each do |author| %>
    <li style="margin-top: 1rem"><%= link_to author.name, author %></li>
  <% end %>
</ul>

Then, in the show.html.erb file, we add this:

<h1><%= @author.name %></h1>
<p><%= link_to "Back to authors", authors_path %></p>

We can now visit http://localhost:3000/authors and see the authors in our database.

Associate Authors with their Biographies

Authors have biographies, so let's add a Biography model to the database. In the terminal, type:

bin/rails g model Biography content:text

Then run the migration:

bin/rails db:migrate

It's now time to create our first association. In the biography.rb file in the app/models/ folder, we'll declare a belongs_to association like this:

belongs_to :author

This association establishes an association between the Biography and Author models, indicating that a biography record is associated with a specific author.

Given that an author typically has one associated biography, we will establish a bi-directional association between the Author and Biography models using the has_one :biography declaration in the author.rb file within the app/models/ folder. This association enables an author to be linked to at most one biography, facilitating easy access to an author's specific biography within the application. We'll add like this:

has_one :biography

The next step we will take is to create a foreign key constraint in the database. To do this, we'll generate a migration:

bin/rails g migration AddAuthorRefToBiography author:references

This migration will create an author_id column in the biographies table. Let's run this migration:

bin/rails db:migrate

In the seeds.rb file, let's add a method to create the biographies in the database for each of the authors. Below the method that generates author records, add this:

authors.each do |author|
  Biography.create(
    content: Faker::Lorem.paragraphs(number: rand(3..6)).join("\n\n"),
    author: author
  )
end

We will reset the database to reflect the changes we made:

bin/rails db:reset

We are now able to access the author's biography. In the Authors show view, let's add this after the author name:

<h2>Biography</h2>
<%= @author.biography.content %>

We can now visit the author's pages and see their biographies.

Building the PUBLISHER Resource

Every book has a publisher. So, we will need a Publisher model in our database. Let's generate this model:

bin/rails g model Publisher name:string

Next, let's run the migration:

bin/rails db:migrate

Let's add validations in the publisher.rb file within the app/models/ folder:

validates_presence_of :name
validates_uniqueness_of :name

Let's add a method in our seeds.rb file to generate some publisher records in the database:

6.times do
  Publisher.create(
    name: Faker::Book.publisher,
  )
end

Now, we will run:

bin/rails db:reset

Next, we will generate a Publishers controller:

bin/rails g controller Publishers

To this controller, we'll add two methods:

def index
  @publishers = Publisher.all
end
def show
  @publisher = Publisher.find(params[:id])
end

In config/routes.rb, we'll add routes for the Publishers resource:

resources :publishers

Let's now create the index and the show views. 

Let's open the index.html.erb file, and add this:

<h1>Publishers</h1>
<ul>
  <% @publishers.each do |publisher| %>
    <li style="margin-bottom: 1rem"><%= link_to publisher.name, publisher %></li>
  <% end %>
</ul>

We show a list of publishers.

And in the show.html.erb file, we'll add the publisher's name for now:

<h1><%= @publisher.name %></h1>
<p><%= link_to "Back to publishers", publishers_path %></p>

Create an Association Between The Book and the Publisher Model

It's the time to create an association between the Book and the Publisher model. The book belongs to a publisher. So, we'll use the belongs_to association. Let's edit the book.rb file to include this association:

belongs_to :publisher

This association signifies that each book record is associated with a single publisher, allowing us to retrieve a book's publisher or link a book to its corresponding publisher.

Also, we need to set the association in the other direction. The publisher has many books. That's why here we will use the has_many association. Let's edit the publisher.rb file:

has_many :books

As we did before, we must generate a migration to add a publisher_id column in the books table:

bin/rails g migration AddPublisherRefToBooks publisher:references

There are already books in the database without publishers. Since it's a development environment, we can drop the database:

bin/rails db:drop
bin/rails db:create
bin/rails db:migrate

Now, let's edit the seeds.rb file, to add a publisher to every book:

20.times do
  book = Book.create(
    title: Faker::Book.title,
    publisher: Publisher.order("RANDOM()").first
  )
end

Let's run:

bin/rails db:seed

With these changes, we can easily access the book's publisher. Let's edit the app/views/books/show.html.erb file. After the book partial add this:

<p><strong>Publisher: </strong> <%= link_to @book.publisher.name, @book.publisher %></p>

And also, we can access the publisher's books in the views. Let's edit the Publishers show view and add a list of publisher's books. Let's add the list after the publisher's name:

<h2>Books published by <%= @publisher.name %></h2>
<ul>
  <% @publisher.books.each do |book| %>
    <li><%= link_to book.title, book %></li>
  <% end %>
</ul>

Create Associations Between the Book and the Author Models

Books can have more than one author. We need to reflect that in our database schema. That's why we can't use the belongs_to and has_many associations between these two models. We will use the has_many :through association instead.

This association works through a third model, as the name says.  Let's see how this works for our models. Let's first create a migration to generate the join model:

bin/rails g model BookAuthor book:references author:references

This command also adds the belongs_to :book and belongs_to :author to this model. And it creates a join table (book_authors) to link book and authors. 

Let's run this migration:

bin/rails db:migrate

And we will set the corresponding associations for the Book and Author models. In the book.rb file, add these lines:

 has_many :book_authors
 has_many :authors, through: :book_authors

And in the author.rb file, add these lines:

has_many :book_authors
has_many :books, through: :book_authors

Let's add a validation to the Book model, to ensure there is at least one author associated with the book:

validates :authors, presence: true

In the seeds.rb file, let's edit the method for generating books to add authors to these books:

20.times do
  book = Book.new(
    title: Faker::Book.title,
    publisher: Publisher.order("RANDOM()").first
  )
  authors_for_book = authors.sample(rand(1..3))
  authors_for_book.each { |author| book.authors << author }
  book.save if book.authors.present?
end

Let's reset the database:

bin/rails db:reset

Now we can easily access @book.authors. Let's edit the Books show view to show the book's authors. Before the publisher name add this:

<p>
  <span>by</span>
    <% @book.book_authors.each_with_index do |author, index| %>
      <% if index > 0 %>, <% end %>
      <%= link_to author.author.name, author.author %>
    <% end %>
</p>

Also, we can easily access directly the @author.books. Let's edit the Authors show view to show the list of author's books. After the author biography, add this:

<h2>Books</h2>
<ul>
  <% @author.books.each do |book| %>
    <li><%= render partial: 'books/book', locals: {book: book} %></li>
  <% end %>
</ul>

Building a Genre Resource

A book must have at least one genre. Some books belong to more than one genre. So, a Genre model must exist in our database. Let's generate one:

bin/rails g model Genre name:string

Let's run the migration:

bin/rails db:migrate

Let's add some validations in the genre.rb file:

validates_presence_of :name
validates_uniqueness_of :name

Let's also write a method to add some genres to the database. In db/seeds.rb, add these lines before the method generates book records:

8.times do
  Genre.create(
    name: Faker::Book.genre,
  )
end
genres = Genre.all

Then run:

bin/rails db:reset

Next, we will generate a Genres controller:

bin/rails g controller Genres

For now, we'll add only one method to this controller, the show method:

def show
  @genre = Genre.find(params[:id])
end

 Let's add routes for the Genres resources:

resources :genres

Let's create the view. In the app/views/genres/ folder, let's add the show.html.erb file and put this at the top:

<h1><%= @genre.name %></h1>

We will add a list of genres in the Books index view because it makes the most sense. Let's make a @genres variable available in the controller. Please edit the index method like this:

def index
  @books = Book.all
  @genres = Genre.all
end

And, in the Books index view, after the books list, add this:

<h2>Genres</h2>
<ul>
  <% @genres.each do |genre| %>
    <li><%= link_to genre.name, genre %></li>
  <% end %>
</ul>

Create the Association Between the Book and Genre Models

For these two models, we will use the has_and_belongs_to_many association. A book can have multiple genres, and a genre has multiple books. Let's edit our models to include this association. In the book.rb file,  add this:

has_and_belongs_to_many :genres

And in the genre.rb file add this:

has_and_belongs_to_many :books

We need to create a join table. For this, we run the migration:

bin/rails generate migration CreateJoinTableBooksGenres books genres

This commmnad will generate a migration file to create the join table necessary for the many-to-many association between books and genres. Once you've generated the migration file, it will create a table that acts as the intermediary between books and genres, allowing the association to function. You can uncomment the methods to add indexes to the database, if you want. Then run:

bin/rails db:migrate

We will edit once again the seeds.rb file to add genres to books. Edit the method as follows:

20.times do
  book = Book.create(
    title: Faker::Book.title,
    publisher: Publisher.order("RANDOM()").first
  )
  authors_for_book = authors.sample(rand(1..3))
  authors_for_book.each { |author| book.authors << author }
  book.save if book.authors.present?
  rand(1..3).times do
    book.genres << genres.sample
  end
end

We run:

bin/rails db:reset

Now, we can access the @book.genres. In the Books show view, add this after the publisher name:

<p>
  <span>Genres </span>
    <% @book.genres.each_with_index do |genre, index| %>
      <% if index > 0 %>, <% end %>
      <%= link_to genre.name, genre %>
    <% end %>
</p>

In the Genres show view, after the h1 tag, add this:

<h2>Books</h2>
<ul>
  <% @genre.books.each do |book| %>
    <li><%= render partial: 'books/book', locals: {book: book} %></li>
  <% end %>
</ul>

In the second part of this tutorial, we'll focus on creating, updating, and deleting records.

Post last updated on Jan 4, 2024