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

Building on the foundation laid in the first part of our tutorial, we're poised to explore the dynamic functionalities of our bookstore application. In this segment, we'll journey further into the realm of Active Record associations, focusing on the practical implementation of crucial operations: creating, updating, and deleting records. We'll step through the process of enabling seamless interaction within our bookstore application. From adding new authors, publishers, and genres to creating and managing books, this section will empower you to wield the power of Rails associations effectively. Let's continue our exploration and dive deeper into the heart of Ruby on Rails associations.

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

Create and Update publishers

First, we will implement the ability to create and update publishers.

Let's start by adding a method that defines the strong parameters. At the bottom of the Publishers controller, add this:

private
def publisher_params
  params.require(:publisher).permit(:name)
end

Let's add the new and create methods: 

def new
  @publisher = Publisher.new
end

def create
  @publisher = Publisher.new(publisher_params)
  if @publisher.save
    redirect_to publisher_url(@publisher)
  else
    render :new, status: :unprocessable_entity
  end
end

In the app/views/publishers/ folder, we'll add a _form.html.erb partial, since we will use the same form for updating a publisher. Let's edit this partial:

<%= form_with(model: publisher) do |form| %>
  <% if publisher.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(publisher.errors.count, "error") %> prohibited this publisher from being saved:</h2>

      <ul>
        <% publisher.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <p><%= form.label :name %></p>
  <p><%= form.text_field :name %></p>
  <p><%= form.submit %></p>
<% end %>

Next, we create a new view, and add this partial to it:

<h1>New Publisher</h1>
<%= render "form", publisher: @publisher %>

Finally, we will link to the new_publisher_path in the Publishers index view. Below the h1 tag, add this:

<p><%= link_to "Add Publisher", new_publisher_path %></p>

Let's now implement the ability to update publisher records in the database. To facilitate this, we'll first establish a method, set_publisher, responsible for retrieving the specific publisher from the database based on its ID:

def set_publisher
  @publisher = Publisher.find(params[:id])
end

Next, we'll invoke this method using a before_action filter at the top of the controller to ensure that it fetches the publisher data before executing specific actions. In our case, it will be used for all actions, excluding index, new, and create:

before_action :set_publisher, except: [:index, :new, :create]

You can now remove the line to fetch the publisher in the show action, as the before_action :set_publisher filter ensures this action already fetches the necessary data.

For the editing functionality, we'll create the edit and update methods in the controller:

def edit
end

def update
  if @publisher.update(publisher_params)
   redirect_to @publisher
  else
    render :edit, status: :unprocessable_entity
  end
end

Now, to provide a seamless editing experience, we'll introduce an edit view within the app/views/publishers/ folder, incorporating the existing form partial:

<h1>Edit <%= @publisher.name %></h1>
<%= render "form", publisher: @publisher %>

In the show view, add a link to the edit_publisher_path after the books list:

<p>
  <%= link_to 'Edit Publisher', edit_publisher_path(@publisher) %>
</p>

CREATE GENRES

Next, we'll implement the ability to add genres. First, let's define the strong parameters in the private method genre_params:

private
def genre_params
  params.require(:genre).permit(:name)
end

And let's add the new and create methods, as well:

def new
  @genre = Genre.new
end

def create
  @genre = Genre.new(genre_params)
  if @genre.save
    redirect_to root_path
  else
    render :new, status: :unprocessable_entity
  end
end

Next, we will create a new.html.erb file in the app/views/genres/ folder, and edit it like this:

<%= form_with(model: @genre) do |form| %>
  <% if @genre.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(@genre.errors.count, "error") %> prohibited this genre from being saved:</h2>

      <ul>
        <% @genre.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <p><%= form.label :name %></p>
  <p><%= form.text_field :name %></p>
  <p><%= form.submit %></p>
<% end %>

And, in the books index view, we'll add a link to the new_genre_path, before the genres list:

<%= link_to "Add Genre", new_genre_path %>

CREATE AND EDIT AUTHORS

Let's now implement the ability to add a new author. When we add or update an author, we automatically want to be able to add or edit their biography, too. For this, we will use nested attributes. Nested attributes allow you to save attributes on associated models through the parent.
To enable these nested attributes, we will use the accepts_nested_attributes_for method. Let's add this method to the app/model.author.rb file:

accepts_nested_attributes_for :biography

Enabling nested attributes simplifies the process of creating or updating associated records (like the biography) alongside the parent record (author) in a single action.

Let's validate the content attribute in the Biography model. Let's add this in the app/models/biography.rb file:

validates :content, presence: true, length: { minimum: 10 }

This ensures that these validations are enforced when creating or updating an author and their biography. If any validation fails during the save operation, the error messages propagate back to the form, allowing users to correct errors.

We now need to edit our Authors controller to add the new and the create methods. But first, let's add at the bottom, the private method author_params that defines the strong parameters:

private
def author_params
  params.require(:author).permit(:name, biography_attributes: :content)
end

Now, let's add the new and the create methods:

def new
  @author = Author.new
  @biography = @author.build_biography
end

We are initializing a new Author object and building an associated Biography object, which aligns perfectly with the nested attributes setup. This allows us to create an author and their biography in a single action.

def create
  @author = Author.new(author_params)
  if @author.save
    redirect_to author_url(@author)
  else
    render :new, status:  :unprocessable_entity
  end
end

In the app/views/authors/ folder, we'll add a _form.html.erb partial. Let's edit this partial:

<%= form_with(model: author) do |form| %>
  <% if author.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(author.errors.count, "error") %> prohibited this author from being saved:</h2>

      <ul>
        <% author.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <p><%= form.label :name %></p>
  <p><%= form.text_field :name %></p>
  <%= form.fields_for :biography do |biography_fields| %>
    <p><%= biography_fields.label :content, "Biography" %></p>
    <p><%= biography_fields.text_area :content %></p>
  <% end %>
  <p><%= form.submit %></p>
<% end %>

Next, we create a new view, and add this partial to it:

<h1>New Author</h1>
<%= render "form", author: @author %>

Finally, we will link to the new_author_path in the Authors index view. Below the h1 tag, add this:

<p><%= link_to "Add Author", new_author_path %></p>

Let's enable the modification of author records within our application by introducing an editing feature. Similar to the process with the publisher, we'll begin by creating a set_author method in the controller to retrieve an author from the database based on its ID:

def set_author
  @author = Author.find(params[:id])
end

Following our previous approach, we'll utilize a before_action filter to invoke this method for specific actions, excluding index, new, and create:

before_action :set_author, except: [:index, :new, :create]

You can now delete the line to fetch the author in the show method.

Now, to facilitate the editing functionality, we'll define the edit and update methods in the controller:

def edit
end

def update
  if @author.update(author_params)
    redirect_to @author
  else
    render :new, status: :unprocessable_entity
  end
end

To complement this functionality, let's create an edit view within the app/views/authors/ folder, utilizing the existing form partial:

<h1>Edit <%= @author.name %></h1>
<%= render "form", author: @author %>

Additionally, in the show view, we'll include, after the author book's list, a link directing users to the author's edit path, enhancing the navigational experience:

<p>
  <%= link_to 'Edit Author', edit_author_path(@author) %>
</p>

When updating an author, Rails will attempt to delete and create a new biography associated with that author. But if you want only to update it, you must use the :update_only option on accepts_nested_attributes_for class method. Let's modify it in app/models/author.rb file:

accepts_nested_attributes_for :biography, update_only: true

EDIT THE BOOK FORM

Let's now edit the books form to include fields for authors, publishers, and genres. First, we'll have to edit the book_params method that sets the strong parameters:

def book_params
  params.require(:book).permit(:title, :publisher_id, author_ids: [], genre_ids: [])
end

We've included the publisher_id,  author_ids array, and the genres_ids array.

The form that was generated when we created the book's scaffold includes only the title field. We will have to include fields for the book author (or authors), the genre, and the publisher. 

Let's start with the publisher. We will use the collection_select helper. Let's add this to our form:

<p>
  <%= form.label :publisher_id, style: "display: block" %>
  <%= form.collection_select :publisher_id, Publisher.all, :id, :name, { prompt: true } %>
</p>

The collection_select helper in Rails generates a dropdown select element within a form. It's particularly useful when you want users to choose a single item from a collection. In the context of our book creation form, collection_select allows us to select a single publisher for a book. Here's a breakdown of its parameters:

:publisher_id specifies the attribute in the Book model that will store the selected publisher's ID.

Publisher.all represents the collection of all publishers available in the database.

:id is the method called on each Publisher object to retrieve its ID.

:name is the method called on each Publisher object to retrieve its name (displayed in the dropdown).

{ prompt: true } adds a default prompt (e.g., "Select a publisher") as the first option in the dropdown.

For the authors, we'll use checkboxes, since a book can have multiple authors. collection_check_boxes generates a set of checkboxes within a form, allowing users to select multiple items from a collection. It's ideal when dealing with associations that allow multiple selections, like books having multiple authors or belonging to multiple genres.

Let's add this to the form:

<fieldset>
  <legend>Authors</legend>
  <%= form.collection_check_boxes :author_ids, Author.all, :id, :name %>
</fieldset>

Here's an explanation of its usage:

:author_ids specifies the attribute in the Book model that will store the selected authors' IDs. It's an array because a book can have multiple authors.

Author.all represents the collection of all authors available in the database.

:id retrieves the ID of each Author object.

:name retrieves the name of each Author object, which is displayed alongside the checkboxes.

We will use the collection_select helper to generate fields for adding the book's genres as well:

<fieldset>
  <legend>Genres</legend>
  <%= form.collection_check_boxes :genre_ids, Genre.all, :id, :name %>
</fieldset>

Similarly, the collection_check_boxes for genres functions in the same way, allowing the selection of multiple genres for a book.

By incorporating these helpers into our form, users can efficiently select publishers, authors, and genres for a book during creation, reflecting the relationships between books and their associated entities in the database.

DELETE BOOKS

One  last thing we need to do. When attempting to delete a book, if there are associated records in the book_authors table, direct deletion becomes problematic due to referential integrity. To enable the deletion of books along with their associated authors in the book_authors table, we use dependent: :destroy in the has_many :book_authors association within the Book model. This setup ensures that when a book is deleted, its associated book-author relationships are also removed, maintaining data consistency.

So, let's edit this association in the app/models/book.rb file:

has_many :authors, through: :book_authors, dependent: :destroy

Conversely, for associations like has_and_belongs_to_many :genres, Rails applies a default behavior that automatically handles the deletion of associated records in join tables, such as the books_genres table. Therefore, when a book is deleted, its associations with genres in the join table are automatically removed without the need for explicit dependent options.

conclusion

In this tutorial, we've explored the backbone of Ruby on Rails development: Active Record associations. We've navigated through these types of associations, understanding their roles and intricacies while constructing a robust bookstore application using the latest version of Ruby on Rails.

Post last updated on Jan 4, 2024