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
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:
Map out relationships between records,
Represent those relationships programmatically with ActiveRecord associations,
Create a join table for our
has_many :through
associations, andUse 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.