Building a Reading Log App with the Google Books API and Ruby on Rails - Part 3

Photo by Pickawood on Unsplash

Building a Reading Log App with the Google Books API and Ruby on Rails - Part 3

Using ActiveRecord associations & a join table to organize books on shelves

ยท

9 min read

In part 2 of this series, we architected our Postgresql database with our first two tables: books and shelves. We'll use our books table to store the records we'll build from our Books API responses. And we'll use our shelves table to create a set of shelves for organizing our collection of books. The focus of this article is understanding how to associate these two models together and implement a join table to allow our users to store ๐Ÿ“š on different shelves.

Just as we took a moment to map our table schemas before generating migrations in the last post, let's take a moment to map out the relationships we need for our book and shelf models.

In this app, we want shelves to have many ๐Ÿ“š on them. And we also want a book to potentially belong on many shelves. In the physical world, this might sound odd (We typically wouldn't buy multiple copies of the same book just to let others know it was a book we had read and one that became a personal favorite). In our app, however, we want our users to be able to potentially display the same book on their "Have Read" and "Favorites" shelves simultaneously. ๐Ÿ’ก The way we will achieve this in Ruby on Rails is with ActiveRecord associations.

ActiveRecord Associations

As stated in the Rails docs, "an association is a connection between two ActiveRecord models. . . [that] make common operations simpler and easier in your code." Rails provides several different types of associations for developers to use, depending on the use case. For our purposes, we are going to use 3๏ธโƒฃ associations in combination with each other: belongs_to, has_many, and has_many :through.

As mentioned before, we are also going to generate a migration for a third database table, generally known as a join (or bridge) table. This join table, which we will name Shelvings will allow both our Book and Shelf models to have relationships with zero or more instances of the other model. To see what this actually looks like, let's start writing some code! ๐Ÿ‘จโ€๐Ÿ’ป

Whether we begin by creating our join table or by adding the association declarations to our existing models doesn't matter much. Here, we're going to start by generating a migration to add our Shelvings table to our database schema.

Open your command line, navigate to your app's project directory, and generate a Rails migration by executing the following command:

rails generate migration CreateJoinTableShelvings books shelves

If successful, you should see output similar to the following:

      invoke  active_record
      create    db/migrate/20220424194538_create_join_table_shelvings.rb

Looking at the contents of our create_join_table_shelvings.rb migration file, we see a create_join_table template (notice the commented out index fields).

class CreateJoinTableShelvings < ActiveRecord::Migration[7.0]
  def change
    create_join_table :books, :shelves do |t|
      # t.index [:book_id, :shelf_id]
      # t.index [:shelf_id, :book_id]
    end
  end
end

If we were to run our migration now, we would end up with a join table called BooksShelves. ๐Ÿ˜ We can add a custom table name to our migration to reflect a more intuitive name for this joined relationship. ๐Ÿ˜… Let's do that! Additionally, let's uncomment the index fields in our migration file.

Our db/migrate/create_join_table_shelvings.rb file should now look like this:

class CreateJoinTableShelvings < ActiveRecord::Migration[7.0]
  def change
    create_join_table :books, :shelves, :table_name => :shelvings do |t|
      t.index [:book_id, :shelf_id]
      t.index [:shelf_id, :book_id]
    end
  end
end

โœ… We are now ready to run rails db:migrate.

Successful migration output should look similar to the following:

== 20220424194538 CreateJoinTableShelvings: migrating =========================
-- create_join_table(:books, :shelves, {:table_name=>:shelvings})
   -> 0.0137s
== 20220424194538 CreateJoinTableShelvings: migrated (0.0138s) ================

Next, we need to create a model for our new Shelving class. It is inside this class that we will implement our first association: belongs_to. Let's use this association to create the relationships indicating that instances of our Shelving class belong to instances of Book and instances of Shelf. While here, we should also take the time to add presence validation for both Book and Shelf to this class to ensure the integrity of our Shelving records.

class Shelving < ApplicationRecord
  belongs_to :book
  belongs_to :shelf

  validates_presence_of :book
  validates_presence_of :shelf
end

๐Ÿฅณ In my opinion, we just accomplished the most confusing part of this process. If you feel like you understand the what? and why? of our Shelving join table, it's all downhill from here. If the dots haven't yet connected for you, don't worry. This took some time and trial-and-error for me to understand too. Trust that as we move forward together, we'll develop a better understanding of what's going on here through building out our other associations for Book and Shelf.

In our models/book.rb file, let's add the has_many and has_many :through associations, like this:

class Book < ApplicationRecord
  validates_presence_of :title, :authors, :description, :page_count, :categories, :image_links

  has_many :shelvings
  has_many :shelves, :through => :shelvings
end

The Rails docs explain this best by stating that the has_many :through association, "indicates that the declaring model can be matched with zero or more instances of another model by proceeding through a third model." ๐Ÿ‘

Applying the same associations to our models/shelf.rb file is almost identical. The only thing we need to change is our reference to :books in our has_many :through association, like this:

class Shelf < ApplicationRecord
  validates_presence_of :name

  has_many :shelvings
  has_many :books, :through => :shelvings
end

๐Ÿ’ฅ We should now be able to add ๐Ÿ“š from our database to shelves in our database through a Rails console. Let's figure out how to do that!

Adding Books to Shelves through a Rails Console

After opening a new Rails console by executing the rails console command in our command line, let's query the instance of Book and the instance of Shelf we created in part 2๏ธโƒฃ of this series. To make life easier, we'll assign both records to unique variables, like so:

> @book = Book.first
  Book Load (0.3ms)  SELECT "books".* FROM "books" ORDER BY "books"."id" ASC LIMIT $1  [["LIMIT", 1]]
 =>
#<Book:0x00007fb36f8b0478

> @shelf = Shelf.first
  Shelf Load (0.3ms)  SELECT "shelves".* FROM "shelves" ORDER BY "shelves"."id" ASC LIMIT $1  [["LIMIT", 1]]
 =>
#<Shelf:0x00007fb3692b1eb0

๐Ÿ‘ Now, let's figure out how to add @book to @shelf through our newly created associations.

Recall from earlier that the Rails docs explain that ActiveRecord associations, "make common operations simpler and easier in your code". We can see what common operations are available to us as a result of our new associations by executing the .methods method on the class instances assigned to our @book and @shelf variables.

@book.methods

 =>
[:validate_associated_records_for_shelvings,
 :autosave_associated_records_for_shelves,
 :validate_associated_records_for_shelves,
 :autosave_associated_records_for_shelvings,
 :shelving_ids,
 :shelvings=,
 :shelving_ids=,
 :shelvings,
 :shelf_ids,
 :shelves=,
 :shelf_ids=,
 :shelves,
 :authors_change_to_be_saved,
 :authors_in_database,
. . .
]

and

@shelf.methods

 =>
[:validate_associated_records_for_shelvings,
 :autosave_associated_records_for_books,
 :validate_associated_records_for_books,
 :autosave_associated_records_for_shelvings,
 :book_ids=,
 :shelving_ids,
 :shelvings=,
 :shelving_ids=,
 :shelvings,
 :book_ids,
 :books,
 :books=,
 :updated_at_in_database,
 :created_at,
. . .
]

Notice that invoking the .methods method on a class instance returns a long list of common operations available to us. It's definitely worth taking some time to ๐Ÿ‘€ through these and experiment with them to become more familiar with Ruby instance methods in general. For our purpose here, we're going to use the :shelves method on our @book variable and the :books method on our @shelf variable.

Try it out, and see what happens! ๐Ÿ‘ฉโ€๐Ÿ’ป

> @book.shelves
  Shelf Load (0.7ms)  SELECT "shelves".* FROM "shelves" INNER JOIN "shelvings" ON "shelves"."id" = "shelvings"."shelf_id" WHERE "shelvings"."book_id" = $1  [["book_id", 1]]
 => []

> @shelf.books
  Book Load (0.4ms)  SELECT "books".* FROM "books" INNER JOIN "shelvings" ON "books"."id" = "shelvings"."book_id" WHERE "shelvings"."shelf_id" = $1  [["shelf_id", 1]]
 => []

From the SQL queries on both examples, we see that we're looking for records by their id through our shelvings join table. Since we've not yet added any ๐Ÿ“š to our shelf or shelves to our book, it is no surprise that the returned value is an empty array.

With what we've learned already, adding a book to a shelf is as simple as adding a new value to a regular array.

There are a handful of different methods we can use to add a new element to an existing array, including: .push( ), .insert( ), and .unshift( ). I like to use what Rubyists commonly refer to as the shovel operator, <<, to add items to the end of an array. Let's see what adding @book to @shelf.books looks like using <<.

@shelf.books << @book

> @shelf.books << @book
  TRANSACTION (0.2ms)  BEGIN
  Shelving Create (0.6ms)  INSERT INTO "shelvings" ("book_id", "shelf_id") VALUES ($1, $2)  [["book_id", 1], ["shelf_id", 1]]
  TRANSACTION (1.9ms)  COMMIT
 =>
[#<Book:0x00007fb36f8b0478
  id: 1,
  title: "Hello Book World, the Sequel",
  authors: ["Harry Butts"],
  description: "A better story than the last",
  page_count: 123,
  categories: ["Programming"],
  image_links: {"thumbnail"=>"www.notathumbnail.com"},
  created_at: Sat, 23 Apr 2022 04:59:55.634131000 UTC +00:00,
  updated_at: Sat, 23 Apr 2022 04:59:55.634131000 UTC +00:00>]

๐Ÿคฉ We can now see the book on our shelf by invoking the .books method on our @shelf instance variable.

> @shelf.books
 =>
[#<Book:0x00007fb36f8b0478
  id: 1,
  title: "Hello Book World, the Sequel",
  authors: ["Harry Butts"],
  description: "A better story than the last",
  page_count: 123,
  categories: ["Programming"],
  image_links: {"thumbnail"=>"www.notathumbnail.com"},
  created_at: Sat, 23 Apr 2022 04:59:55.634131000 UTC +00:00,
  updated_at: Sat, 23 Apr 2022 04:59:55.634131000 UTC +00:00>]

We also see that we've added a book to a shelf by creating an instance of our Shelving class. We wouldn't have been able to do this if we didn't have our join table!

> Shelving.count
  Shelving Count (0.3ms)  SELECT COUNT(*) FROM "shelvings"
 => 1

> Shelving.first
  Shelving Load (0.2ms)  SELECT "shelvings".* FROM "shelvings" LIMIT $1  [["LIMIT", 1]]
 => #<Shelving:0x00007fb36e56d730 book_id: 1, shelf_id: 1>

Let's now test out the has_many part of our association by creating another book and adding it to that same shelf.

>   @another_book = Book.create!(:title => "Hello Book World, the Prequel", :authors => ["Harry Butts"], :description => "A better story than the last", :page_count => 123,
 :categories => ["Programming"], :image_links => { :thumbnail => "www.stillnotathumbnail.com" })

  TRANSACTION (0.1ms)  BEGIN
  Book Create (0.6ms)  INSERT INTO "books" ("title", "authors", "description", "page_count", "categories", "image_links", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id"  [["title", "Hello Book World, the Prequel"], ["authors", "[\"Harry Butts\"]"], ["description", "A better story than the last"], ["page_count", 123], ["categories", "[\"Programming\"]"], ["image_links", "{\"thumbnail\":\"www.stillnotathumbnail.com\"}"], ["created_at", "2022-04-24 21:12:36.418598"], ["updated_at", "2022-04-24 21:12:36.418598"]]
  TRANSACTION (0.6ms)  COMMIT
 =>
#<Book:0x00007f967fe42b50
> @shelf.books << @another_book
  TRANSACTION (0.2ms)  BEGIN
  Shelving Create (0.4ms)  INSERT INTO "shelvings" ("book_id", "shelf_id") VALUES ($1, $2)  [["book_id", 2], ["shelf_id", 1]]
  TRANSACTION (0.6ms)  COMMIT
  Book Load (0.5ms)  SELECT "books".* FROM "books" INNER JOIN "shelvings" ON "books"."id" = "shelvings"."book_id" WHERE "shelvings"."shelf_id" = $1  [["shelf_id", 1]]
 =>
[#<Book:0x00007f967f5f61e0
  id: 1,
  title: "Hello Book World, the Sequel",
  authors: ["Harry Butts"],
  description: "A better story than the last",
  page_count: 123,
  categories: ["Programming"],
  image_links: {"thumbnail"=>"www.notathumbnail.com"},
  created_at: Sat, 23 Apr 2022 04:59:55.634131000 UTC +00:00,
  updated_at: Sat, 23 Apr 2022 04:59:55.634131000 UTC +00:00>,
 #<Book:0x00007f967fe42b50
  id: 2,
  title: "Hello Book World, the Prequel",
  authors: ["Harry Butts"],
  description: "A better story than the last",
  page_count: 123,
  categories: ["Programming"],
  image_links: {"thumbnail"=>"www.stillnotathumbnail.com"},
  created_at: Sun, 24 Apr 2022 21:12:36.418598000 UTC +00:00,
  updated_at: Sun, 24 Apr 2022 21:12:36.418598000 UTC +00:00>]

๐Ÿค“ We even see our two Book instances in the output!

Conclusion

Together, we learned how to:

  1. Map out relationships between records,

  2. Represent those relationships programmatically with ActiveRecord associations,

  3. Create a join table for our has_many :through associations, and

  4. Use those bidirectional associations between records in a Rails console.

In part 4๏ธโƒฃ, we'll dive into our views and controllers to create book records from our fetched Books API data with the click of a button and store those new records on our shelves.

ย