In this chapter, we will complete the Rails Tutorial sample application by adding a social layer that allows users to follow and unfollow other users, resulting in each user’s Home page displaying a status feed of the followed users’ microposts. We’ll start by learning how to model relationships between users in Section 14.1, and we’ll build the corresponding web interface in Section 14.2 (including an introduction to Hotwire). We’ll end by developing a fully functional status feed in Section 14.3.
This final chapter contains some of the most challenging material in the tutorial, including some Ruby/SQL trickery to make the status feed. Through these examples, you will see how Rails can handle even rather intricate data models, which should serve you well as you go on to develop your own applications with their own specific requirements. To help with the transition from tutorial to independent development, Section 14.4 offers some pointers to more advanced resources.
Because the material in this chapter is particularly challenging, before writing any code we’ll pause for a moment and take a tour of the interface. As in previous chapters, at this early stage we’ll represent pages using mockups.1 The full page flow runs as follows: A user (John Calvin) starts at his profile page (Figure 14.1) and navigates to the Users page (Figure 14.2) to select a user to follow. Calvin navigates to the profile of a second user, Thomas Hobbes (Figure 14.3), clicking on the “Follow” button to follow that user. This changes the “Follow” button to “Unfollow” and increments Hobbes’s “followers” count by one (Figure 14.4). Navigating to his home page, Calvin now sees an incremented “following” count and finds Hobbes’s microposts in his status feed (Figure 14.5). The rest of this chapter is dedicated to making this page flow actually work.
1. Image of child courtesy of Here/Shutterstock; image of tiger courtesy of Tiago Jorge da Silva Estima/Shutterstock.
Our first step in implementing following users is to construct a data model, which is not as straightforward as it seems. Naïvely, it seems that a has_many
relationship would do: A user has_many
followed users and has_many
followers. As we will see, there is a problem with this approach, and we’ll learn how to fix it using has_many :through
.
As usual, Git users should create a new topic branch:
$ git checkout -b following-users
As a first step toward constructing a data model for following users, let’s examine a typical case. For instance, consider a user who follows a second user: We could say that, e.g., Calvin is following Hobbes, and Hobbes is followed by Calvin, so that Calvin is the follower and Hobbes is followed. Using Rails’ default pluralization convention, the set of all users following a given user is that user’s followers, and hobbes.followers
is an array of those users. Unfortunately, the reverse doesn’t work: By default, the set of all followed users would be called the followeds, which is ungrammatical and clumsy. We’ll adopt Twitter’s convention and call them following (as in “50 following, 75 followers”), with a corresponding calvin.following
array.
This discussion suggests modeling the followed users as in Figure 14.6, with a following
table and a has_many
association. Since user.following
should be a collection of users, each row of the following
table would need to be a user, as identified by the followed_id
, together with the follower_id
to establish the association.2 In addition, since each row is a user, we would need to include the user’s other attributes, including the name, email, password, etc.
2. For simplicity, Figure 14.6 omits the following
table’s id
column.
The problem with the data model in Figure 14.6 is that it is terribly redundant: Each row contains not only each followed user’s id, but all their other information as well—all of which is already in the users
table. Even worse, to model user followers we would need a separate, similarly redundant followers
table. Finally, this data model is a maintainability nightmare: Each time a user changed (say) their name, we would need to update not just the user’s record in the users
table but also every row containing that user in both the following
and followers
tables.
The problem here is that we are missing an underlying abstraction. One way to find the proper model is to consider how we might implement the act of following in a web application. Recall from Section 7.1.2 that the REST architecture involves resources that are created and destroyed. This leads us to ask two questions: When a user follows another user, what is being created? When a user unfollows another user, what is being destroyed? Upon reflection, we see that in these cases the application should either create or destroy a relationship between two users. A user then has many relationships, and has many following
(or followers
) through these relationships.
There’s an additional detail we need to address regarding our application’s data model: Unlike symmetric Facebook-style friendships, which are always reciprocal (at least at the data-model level), Twitter-style following relationships are potentially asymmetric—Calvin can follow Hobbes without Hobbes following Calvin. To distinguish between these two cases, we’ll adopt the terminology of active and passive relationships: If Calvin is following Hobbes but not vice versa, Calvin has an active relationship with Hobbes and Hobbes has a passive relationship with Calvin.3
3. Thanks to reader Paul Fioravanti for suggesting this terminology.
We’ll focus now on using active relationships to generate a list of followed users, and consider the passive case in Section 14.1.5. Figure 14.6 suggests how to implement it: Since each followed user is uniquely identified by followed_id
, we could convert following
to an active_relationships
table, omit the user details, and use followed_id
to retrieve the followed user from the users
table. A diagram of the data model appears in Figure 14.7.
Because we’ll end up using the same database table for both active and passive relationships, we’ll use the generic term relationship for the table name, with a corresponding Relationship model. The result is the Relationship data model shown in Figure 14.8. We’ll see starting in Section 14.1.4 how to use the Relationship model to simulate both Active Relationship and Passive Relationship models.
To get started with the implementation, we first generate a migration corresponding to Figure 14.8:
$ rails generate model Relationship follower_id:integer followed_id:integer
Because we will be finding relationships by follower_id
and by followed_id
, we should add an index on each column for efficiency, as shown in Listing 14.1.
class CreateRelationships < ActiveRecord::Migration[7.0]
def change
create_table :relationships do |t|
t.integer :follower_id
t.integer :followed_id
t.timestamps
end
add_index :relationships, :follower_id
add_index :relationships, :followed_id
add_index :relationships, [:follower_id, :followed_id], unique: true
end
end
Listing 14.1 also includes a multiple-key index that enforces uniqueness on (follower_id
, followed_id
) pairs, so that a user can’t follow another user more than once. (Compare to the email uniqueness index from Listing 6.29 and the multiple-key index in Listing 13.3.) As we’ll see starting in Section 14.1.4, our user interface won’t allow this to happen, but adding a unique index arranges to raise an error if a user tries to create duplicate relationships anyway (for example, by using a command-line tool such as curl
).
To create the relationships
table, we migrate the database as usual:
$ rails db:migrate
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course (https://www.railstutorial.org/) or to the Learn Enough All Access Subscription (https://www.learnenough.com/all-access).
For the user with id equal to 1
from Figure 14.7, what would the value of user.following.map(&:id)
be? (Recall the map(&:method_name)
pattern from Section 4.3.2; user.following.map(&:id)
just returns the array of ids.)
By referring again to Figure 14.7, determine the ids of user.following
for the user with id equal to 2
. What would the value of user.following.map(&:id)
be for this user?
Before implementing user following and followers, we first need to establish the association between users and relationships. A user has_many
relationships, and—since relationships involve two users—a relationship belongs_to
both a follower and a followed user.
As with microposts in Section 13.1.3, we will create new relationships using the user association, with code such as
user.active_relationships.build(followed_id: ...)
At this point, you might expect application code as in Section 13.1.3, and it’s similar, but there are two key differences.
First, in the case of the user/micropost association, we could write
class User < ApplicationRecord
has_many :microposts
.
.
.
end
This works because by convention Rails looks for a Micropost model corresponding to the :microposts
symbol.4 In the present case, though, we want to write
4. Technically, Rails converts the argument of has_many
to a class name using the classify
method, which converts "foo_bars" to "FooBar".
has_many :active_relationships
even though the underlying model is called Relationship. We will thus have to tell Rails the model class name to look for.
Second, before we wrote
class Micropost < ApplicationRecord
belongs_to :user
.
.
.
end
in the Micropost model. This works because the microposts
table has a user_id
attribute to identify the user (Section 13.1.1). An id used in this manner to connect two database tables is known as a foreign key, and when the foreign key for a User model object is user_id
, Rails infers the association automatically: By default, Rails expects a foreign key of the form <class>_id
, where <class>
is the lowercase version of the class name.5 In the present case, although we are still dealing with users, the user following another user is now identified with the foreign key follower_id
, so we have to tell that to Rails.
5. Technically, Rails uses the underscore
method to convert the class name to an id. For example, "FooBar".underscore is "foo_bar", so the foreign key for a FooBar
object would be foo_bar_id
.
The result of the above discussion is the user/relationship association shown in Listing 14.2 and Listing 14.3. As noted in the captions, the tests are currently RED (Why?);6 we’ll fix this issue in Section 14.1.3.
6. Answer: As in Listing 6.30, the generated fixtures don’t satisfy the validations, which causes the tests to fail. (Technically, the tests are in an error state, which is even more severe than failing, but here I’m using “fail” in the colloquial sense of having at least one test that doesn’t pass.
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
has_many :active_relationships, class_name: "Relationship",
foreign_key: "follower_id",
dependent: :destroy
.
.
.
end
(Since destroying a user should also destroy that user’s relationships, we’ve added dependent: :destroy
to the association.)
class Relationship < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
end
The followed
association isn’t actually needed until Section 14.1.4, but the parallel follower/followed structure is clearer if we implement them both at the same time.
The relationships in Listing 14.2 and Listing 14.3 give rise to methods analogous to the ones we saw in Table 13.1, as shown in Table 14.1.
Table 14.1: A summary of user/active relationship association methods.
Method | Purpose |
---|---|
| returns the follower |
| returns the followed user |
| creates an active relationship associated with |
| creates an active relationship associated with |
| returns a new relationship object associated with |
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Using the create
method from Table 14.1 in the console, create an active relationship for the first user in the database where the followed id is the second user.
Confirm that the values for active_relationship.followed
and active_relationship.follower
are correct.
Before moving on, we’ll add a couple of Relationship model validations for completeness. The tests (Listing 14.4) and application code (Listing 14.5) are straightforward. As with the generated user fixture from Listing 6.30, the generated relationship fixture violates the uniqueness constraint imposed by the corresponding migration (Listing 14.1). The solution—removing the fixture contents as in Listing 6.31—is also the same, as seen in Listing 14.6.
require "test_helper"
class RelationshipTest < ActiveSupport::TestCase
def setup
@relationship = Relationship.new(follower_id: users(:michael).id,
followed_id: users(:archer).id)
end
test "should be valid" do
assert @relationship.valid?
end
test "should require a follower_id" do
@relationship.follower_id = nil
assert_not @relationship.valid?
end
test "should require a followed_id" do
@relationship.followed_id = nil
assert_not @relationship.valid?
end
end
class Relationship < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
validates :follower_id, presence: true
validates :followed_id, presence: true
end
# empty
At this point, the tests should be GREEN:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Verify by commenting out the validations in Listing 14.5 that the tests still pass. (This is a change as of Rails 5, and in previous versions of Rails the validations are required. We’ll plan to leave them in for completeness, but it’s worth bearing in mind that you may see these validations omitted in other people’s code.)
We come now to the heart of the Relationship associations: following
and followers
. Here we will use has_many :through
for the first time: A user has many following through relationships, as illustrated in Figure 14.7. By default, in a has_many :through
association Rails looks for a foreign key corresponding to the singular version of the association. In other words, with code like
has_many :followeds, through: :active_relationships
Rails would see “followeds” and use the singular “followed”, assembling a collection using the followed_id
in the relationships
table. But, as noted in Section 14.1.1, user.followeds
is rather awkward, so we’ll write user.following
instead. Naturally, Rails allows us to override the default, in this case using the source
parameter (as shown in Listing 14.8), which explicitly tells Rails that the source of the following
array is the set of followed
ids.
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
has_many :active_relationships, class_name: "Relationship",
foreign_key: "follower_id",
dependent: :destroy
has_many :following, through: :active_relationships, source: :followed
.
.
.
end
The association defined in Listing 14.8 leads to a powerful combination of Active Record and array-like behavior. For example, we can check if the followed users collection includes another user with the include?
method (Section 4.3.1), or find objects through the association:
user.following.include?(other_user)
user.following.find(other_user)
We can also add and delete elements just as with arrays:
user.following << other_user
user.following.delete(other_user)
(Recall from Section 4.3.1 that the shovel operator <<
appends to the end of an array.)
Although in many contexts we can effectively treat following
as an array, Rails is smart about how it handles things under the hood. For example, code like
following.include?(other_user)
looks like it might have to pull all the followed users out of the database to apply the include?
method, but in fact for efficiency Rails arranges for the comparison to happen directly in the database. (Compare to the code in Section 13.2.1, where we saw that
user.microposts.count
performs the count directly in the database.)
To manipulate following relationships, we’ll introduce follow
and unfollow
utility methods so that we can write, e.g., user.follow(other_user)
. We’ll also add an associated following?
boolean method to test if one user is following another.7
7. Once you have a lot of experience modeling a particular domain, you can often guess such utility methods in advance, and even when you can’t you’ll often find yourself writing them to make the tests cleaner. In this case, though, it’s OK if you wouldn’t have guessed them. Software development is usually an iterative process—you write code until it starts getting ugly, and then you refactor it—but for brevity the tutorial presentation is streamlined a bit.
This is exactly the kind of situation where I like to write some tests first. The reason is that we are quite far from writing a working web interface for following users, but it’s hard to proceed without some sort of client for the code we’re developing. In this case, it’s easy to write a short test for the User model, in which we use following?
to make sure the user isn’t following the other user, use follow
to follow another user, use following?
to verify that the operation succeeded, and finally unfollow
and verify that it worked. The result appears in Listing 14.9.8
8. Thanks to reader Sunday Uche Ezeilo for pointing out that users shouldn’t be able to follow themselves.
require "test_helper"
class UserTest < ActiveSupport::TestCase
.
.
.
test "should follow and unfollow a user" do
michael = users(:michael)
archer = users(:archer)
assert_not michael.following?(archer)
michael.follow(archer)
assert michael.following?(archer)
michael.unfollow(archer)
assert_not michael.following?(archer)
# Users can't follow themselves.
michael.follow(michael)
assert_not michael.following?(michael)
end
end
By treating the following
association as an array, we can write the follow
, unfollow
, and following?
methods as shown in Listing 14.10. (Note that we have omitted the user self
variable whenever possible.)
class User < ApplicationRecord
.
.
.
# Follows a user.
def follow(other_user)
following << other_user unless self == other_user
end
# Unfollows a user.
def unfollow(other_user)
following.delete(other_user)
end
# Returns true if the current user is following the other user.
def following?(other_user)
following.include?(other_user)
end
private
.
.
.
end
With the code in Listing 14.10, the tests should be GREEN:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
At the console, replicate the steps shown in Listing 14.9.
What is the SQL for each of the commands in the previous exercise?
The final piece of the relationships puzzle is to add a user.followers
method to go with user.following
. You may have noticed from Figure 14.7 that all the information needed to extract an array of followers is already present in the relationships
table (which we are treating as the active_relationships
table via the code in Listing 14.2). Indeed, the technique is exactly the same as for followed users, with the roles of follower_id
and followed_id
reversed, and with passive_relationships
in place of active_relationships
. The data model then appears as in Figure 14.9.
The implementation of the data model in Figure 14.9 parallels Listing 14.8 exactly, as seen in Listing 14.12.
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
has_many :active_relationships, class_name: "Relationship",
foreign_key: "follower_id",
dependent: :destroy
has_many :passive_relationships, class_name: "Relationship",
foreign_key: "followed_id",
dependent: :destroy
has_many :following, through: :active_relationships, source: :followed
has_many :followers, through: :passive_relationships, source: :follower
.
.
.
end
It’s worth noting that we could actually omit the :source
key for followers
in Listing 14.12, using simply
has_many :followers, through: :passive_relationships
This is because, in the case of a :followers
attribute, Rails will singularize “followers” and automatically look for the foreign key follower_id
in this case. Listing 14.12 keeps the :source
key to emphasize the parallel structure with the has_many :following
association.
We can conveniently test the data model above using the followers.include?
method, as shown in Listing 14.13. (Listing 14.13 might have used a followed_by?
method to complement the following?
method, but it turns out we won’t need it in our application.)
require "test_helper"
class UserTest < ActiveSupport::TestCase
.
.
.
test "should follow and unfollow a user" do
michael = users(:michael)
archer = users(:archer)
assert_not michael.following?(archer)
michael.follow(archer)
assert michael.following?(archer)
assert archer.followers.include?(michael)
michael.unfollow(archer)
assert_not michael.following?(archer)
# Users can't follow themselves.
michael.follow(michael)
assert_not michael.following?(michael)
end
end
Listing 14.13 adds only one line to the test from Listing 14.9, but so many things have to go right to get it to pass that it’s a very sensitive test of the code in Listing 14.12.
At this point, the full test suite should be GREEN:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
At the console, create several followers for the first user in the database (which you should call user
). What is the value of user.followers.map(&:id)
?
Confirm that user.followers.count
matches the number of followers you created in the previous exercise.
What is the SQL used by user.followers.count
? How is this different from user.followers.to_a.count
? Hint: Suppose that the user had a million followers.
Section 14.1 placed some rather heavy demands on our data modeling skills, and it’s fine if it takes a while to soak in. In fact, one of the best ways to understand the associations is to use them in the web interface.
In the introduction to this chapter, we saw a preview of the page flow for user following. In this section, we will implement the basic interface and following/unfollowing functionality shown in those mockups. We will also make separate pages to show the user following and followers arrays. In Section 14.3, we’ll complete our sample application by adding the user’s status feed.
As in previous chapters, we will find it convenient to use rails db:seed
to fill the database with sample relationships. This will allow us to design the look and feel of the web pages first, deferring the back-end functionality until later in this section.
Code to seed the following relationships appears in Listing 14.14. Here we somewhat arbitrarily arrange for the first user to follow users 3 through 51, and then have users 4 through 41 follow that user back. The resulting relationships will be sufficient for developing the application interface.
# Users
User.create!(name: "Example User",
email: "[email protected]",
password: "foobar",
password_confirmation: "foobar",
admin: true,
activated: true,
activated_at: Time.zone.now)
99.times do |n|
name = Faker::Name.name
email = "example-#{n+1}@railstutorial.org"
password = "password"
User.create!(name: name,
email: email,
password: password,
password_confirmation: password,
activated: true,
activated_at: Time.zone.now)
end
# Microposts
users = User.order(:created_at).take(6)
50.times do
content = Faker::Lorem.sentence(5)
users.each { |user| user.microposts.create!(content: content) }
end
# Create following relationships.
users = User.all
user = users.first
following = users[2..50]
followers = users[3..40]
following.each { |followed| user.follow(followed) }
followers.each { |follower| follower.follow(user) }
To execute the code in Listing 14.14, we reset and reseed the database as usual:
$ rails db:migrate:reset
$ rails db:seed
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Using the console, confirm that User.first.followers.count
matches the value expected from Listing 14.14.
Confirm that User.first.following.count
is correct as well.
Now that our sample users have both followed users and followers, we need to update the profile page and Home page to reflect this. We’ll start by making a partial to display the following and follower statistics on the profile and home pages. We’ll next add a follow/unfollow form, and then make dedicated pages for showing “following” (followed users) and “followers”.
As noted in Section 14.1.1, we’ll adopt Twitter’s convention of using “following” as a label for followed users, as in “50 following”. This usage is reflected in the mockup sequence starting in Figure 14.1 and shown in close-up in Figure 14.10.
The stats in Figure 14.10 consist of the number of users the current user is following and the number of followers, each of which should be a link to its respective dedicated display page. In Chapter 5, we stubbed out such links with the dummy text '#'
, but that was before we had much experience with routes. This time, although we’ll defer the actual pages to Section 14.2.3, we’ll make the routes now, as seen in Listing 14.15. This code uses the :member
method inside a resources
block, which we haven’t seen before, but see if you can guess what it does.
Rails.application.routes.draw do
root "static_pages#home"
get "/help", to: "static_pages#help"
get "/about", to: "static_pages#about"
get "/contact", to: "static_pages#contact"
get "/signup", to: "users#new"
get "/login", to: "sessions#new"
post "/login", to: "sessions#create"
delete "/logout", to: "sessions#destroy"
resources :users do
member do
get :following, :followers
end
end
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
resources :microposts, only: [:create, :destroy]
get '/microposts', to: 'static_pages#home'
end
You might suspect that the URLs for following and followers will look like /users/1/following and /users/1/followers, and that is exactly what the code in Listing 14.15 arranges. Since both pages will be showing data, the proper HTTP verb is a GET
request, so we use the get
method to arrange for the URLs to respond appropriately. Meanwhile, the member
method arranges for the routes to respond to URLs containing the user id. The other possibility, collection
, works without the id, so that
resources :users do
collection do
get :tigers
end
end
would respond to the URL /users/tigers (presumably to display all the tigers in our application).9
9. For more details on such routing options, see the Rails Guides article on “Rails Routing from the Outside In” (https://guides.rubyonrails.org/routing.html).
A table of the routes generated by Listing 14.15 appears in Table 14.2. Note the named routes for the followed user and followers pages, which we’ll put to use shortly.
Table 14.2: RESTful routes provided by the custom rules in resource in Listing 14.15.
HTTP request method | URL | Action | Named route |
---|---|---|---|
| /users/1/following |
|
|
| /users/1/followers |
|
|
With the routes defined, we are now in a position to define the stats partial, which involves a couple of links inside a div, as shown in Listing 14.16.
<% @user ||= current_user %>
<div class="stats">
<a href="<%= following_user_path(@user) %>">
<strong id="following" class="stat">
<%= @user.following.count %>
</strong>
following
</a>
<a href="<%= followers_user_path(@user) %>">
<strong id="followers" class="stat">
<%= @user.followers.count %>
</strong>
followers
</a>
</div>
Since we will be including the stats on both the user show pages and the Home page, the first line of Listing 14.16 picks the right one using
<% @user ||= current_user %>
As discussed in Box 8.1, this does nothing when @user
is not nil
(as on a profile page), but when it is (as on the Home page) it sets @user
to the current user. Note also that the following/follower counts are calculated through the associations using
@user.following.count
and
@user.followers.count
Compare these to the microposts count from Listing 13.25, where we wrote
@user.microposts.count
to count the microposts. As in that case, Rails calculates the count directly in the database for efficiency.
One final detail worth noting is the presence of CSS ids on some elements, as in
<strong id="following" class="stat">
...
</strong>
This is for the benefit of the Hotwire implementation in Section 14.2.5, which accesses elements on the page using their unique ids.
With the partial in hand, including the stats on the Home page is easy, as shown in Listing 14.17.
<% if logged_in? %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<%= render 'shared/user_info' %>
</section>
<section class="stats">
<%= render 'shared/stats' %>
</section>
<section class="micropost_form">
<%= render 'shared/micropost_form' %>
</section>
</aside>
<div class="col-md-8">
<h3>Micropost Feed</h3>
<%= render 'shared/feed' %>
</div>
</div>
<% else %>
.
.
.
<% end %>
To style the stats, we’ll add some SCSS, as shown in Listing 14.18 (which contains all the stylesheet code needed in this chapter). The resulting Home page appears in Figure 14.11.
.
.
.
/* sidebar */
.
.
.
.gravatar {
float: left;
margin-right: 10px;
}
.gravatar_edit {
margin-top: 15px;
}
.stats {
overflow: auto;
margin-top: 0;
padding: 0;
a {
float: left;
padding: 0 10px;
border-left: 1px solid $gray-lighter;
color: gray;
&:first-child {
padding-left: 0;
border: 0;
}
&:hover {
text-decoration: none;
color: blue;
}
}
strong {
display: block;
}
}
.user_avatars {
overflow: auto;
margin-top: 10px;
.gravatar {
margin: 1px 1px;
}
a {
padding: 0;
}
}
.users.follow {
padding: 0;
}
/* forms */
.
.
.
We’ll render the stats partial on the profile page in a moment, but first let’s make a partial for the follow/unfollow button, as shown in Listing 14.19.
<% unless current_user?(@user) %>
<div id="follow_form">
<% if current_user.following?(@user) %>
<%= render 'unfollow' %>
<% else %>
<%= render 'follow' %>
<% end %>
</div>
<% end %>
This does nothing but defer the real work to follow
and unfollow
partials, which need new routes for the Relationships resource, which follows the Microposts resource example (Listing 13.31), as seen in Listing 14.20.
Rails.application.routes.draw do
root "static_pages#home"
get "/help", to: "static_pages#help"
get "/about", to: "static_pages#about"
get "/contact", to: "static_pages#contact"
get "/signup", to: "users#new"
get "/login", to: "sessions#new"
post "/login", to: "sessions#create"
delete "/logout", to: "sessions#destroy"
resources :users do
member do
get :following, :followers
end
end
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
resources :microposts, only: [:create, :destroy]
resources :relationships, only: [:create, :destroy]
get '/microposts', to: 'static_pages#home'
end
The follow/unfollow partials themselves are shown in Listing 14.21 and Listing 14.22.
<%= form_with(model: current_user.active_relationships.build) do |f| %>
<div><%= hidden_field_tag :followed_id, @user.id %></div>
<%= f.submit "Follow", class: "btn btn-primary" %>
<% end %>
<%= form_with(model: current_user.active_relationships.find_by(followed: @user),
html: { method: :delete }) do |f| %>
<%= f.submit "Unfollow", class: "btn" %>
<% end %>
These two forms both use form_with
to manipulate a Relationship model object; the main difference between the two is that Listing 14.21 builds a new relationship, whereas Listing 14.22 finds the existing relationship. Naturally, the former sends a POST
request to the Relationships controller to create
a relationship, while the latter sends a DELETE
request to destroy
a relationship. (We’ll write these actions in Section 14.2.4.) Finally, you’ll note that the follow form doesn’t have any content other than the button, but it still needs to send the followed_id
to the controller. We accomplish this with the hidden_field_tag
method in Listing 14.21, which produces HTML of the form
<input id="followed_id" name="followed_id" type="hidden" value="3" />
As we saw in Section 12.3 (Listing 12.14), the hidden input
tag puts the relevant information on the page without displaying it in the browser.
We can now include the follow form and the following statistics on the user profile page simply by rendering the partials, as shown in Listing 14.23. Profiles with follow and unfollow buttons, respectively, appear in Figure 14.12 and Figure 14.13.
<% provide(:title, @user.name) %>
<div class="row">
<aside class="col-md-4">
<section>
<h1>
<%= gravatar_for @user %>
<%= @user.name %>
</h1>
</section>
<section class="stats">
<%= render 'shared/stats' %>
</section>
</aside>
<div class="col-md-8">
<%= render 'follow_form' if logged_in? %>
<% if @user.microposts.any? %>
<h3>Microposts (<%= @user.microposts.count %>)</h3>
<ol class="microposts">
<%= render @microposts %>
</ol>
<%= will_paginate @microposts %>
<% end %>
</div>
</div>
We’ll get these buttons working soon enough—in fact, we’ll do it two ways, the standard way (Section 14.2.4) and using Hotwire (Section 14.2.5)—but first we’ll finish the HTML interface by making the following and followers pages.
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Verify that /users/2 has a follow form and that /users/5 has an unfollow form. Is there a follow form on /users/1?
Confirm in the browser that the stats appear correctly on the Home page and on the profile page.
Write tests for the stats on the Home page. Hint: Add to the test in Listing 13.29. Why don’t we also have to test the stats on the profile page?
Pages to display followed users and followers will resemble a hybrid of the user profile page and the user index page (Section 10.3.1), with a sidebar of user information (including the following stats) and a list of users. In addition, we’ll include a raster of smaller user profile image links in the sidebar. Mockups matching these requirements appear in Figure 14.14 (following) and Figure 14.15 (followers).
Our first step is to get the following and followers links to work. We’ll follow Twitter’s lead and have both pages require user login. As with most previous examples of access control, we’ll write the tests first, as shown in Listing 14.24. Note that Listing 14.24 uses the named routes from Table 14.2.
require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
@other_user = users(:archer)
end
.
.
.
test "should redirect following when not logged in" do
get following_user_path(@user)
assert_redirected_to login_url
end
test "should redirect followers when not logged in" do
get followers_user_path(@user)
assert_redirected_to login_url
end
end
The only tricky part of the implementation is realizing that we need to add two new actions to the Users controller. Based on the routes defined in Listing 14.15, we need to call them following
and followers
. Each action needs to set a title, find the user, retrieve either @user.following
or @user.followers
(in paginated form), and then render the page (with status: :unprocessable_entity
for the sake of Turbo). The result appears in Listing 14.25.
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update, :destroy,
:following, :followers]
.
.
.
def following
@title = "Following"
@user = User.find(params[:id])
@users = @user.following.paginate(page: params[:page])
render 'show_follow', status: :unprocessable_entity
end
def followers
@title = "Followers"
@user = User.find(params[:id])
@users = @user.followers.paginate(page: params[:page])
render 'show_follow', status: :unprocessable_entity
end
private
.
.
.
end
As we’ve seen throughout this tutorial, the usual Rails convention is to implicitly render the template corresponding to an action, such as rendering show.html.erb
at the end of the show
action. In contrast, both actions in Listing 14.25 make an explicit call to render
, in this case rendering a view called show_follow
, which we must create. The reason for the common view is that the ERb is nearly identical for the two cases, and Listing 14.26 covers them both.
<% provide(:title, @title) %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<%= gravatar_for @user %>
<h1><%= @user.name %></h1>
<span><%= link_to "view my profile", @user %></span>
<span><strong>Microposts:</strong> <%= @user.microposts.count %></span>
</section>
<section class="stats">
<%= render 'shared/stats' %>
<% if @users.any? %>
<div class="user_avatars">
<% @users.each do |user| %>
<%= link_to gravatar_for(user, size: 30), user %>
<% end %>
</div>
<% end %>
</section>
</aside>
<div class="col-md-8">
<h3><%= @title %></h3>
<% if @users.any? %>
<ul class="users follow">
<%= render @users %>
</ul>
<%= will_paginate %>
<% end %>
</div>
</div>
The actions in Listing 14.25 render the view from Listing 14.26 in two contexts, “following” and “followers”, with the results shown in Figure 14.16 and Figure 14.17. Note that nothing in the above code uses the current user, so the same links work for other users, as shown in Figure 14.18.
At this point, the tests in Listing 14.24 should be GREEN due to the before filter in Listing 14.25:
$ rails test
To test the show_follow
rendering, we’ll write a couple of short integration tests that verify the presence of working following and followers pages. They are designed to be a reality check, not to be comprehensive; indeed, as noted in Section 5.3.4, comprehensive tests of things like HTML structure are likely to be brittle and thus counterproductive. Our plan in the case of following/followers pages is to check that the number is correctly displayed and that links with the right URLs appear on the page.
To get started, we’ll generate an integration test as usual:
$ rails generate integration_test following
invoke test_unit
create test/integration/following_test.rb
Next, we need to assemble some test data, which we can do by adding some relationships fixtures to create following/follower relationships. Recall from Section 13.2.3 that we can use code like
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
user: michael
to associate a micropost with a given user. In particular, we can write
user: michael
instead of
user_id: 1
Applying this idea to the relationships fixtures gives the associations in Listing 14.28.
one:
follower: michael
followed: lana
two:
follower: michael
followed: malory
three:
follower: lana
followed: michael
four:
follower: archer
followed: michael
The fixtures in Listing 14.28 first arrange for Michael to follow Lana and Malory, and then arrange for Michael to be followed by Lana and Archer. To test for the right count, we can use the same assert_match
method we used in Listing 13.29 to test for the display of the number of microposts on the user profile page. Adding in assertions for the right links yields the tests shown in Listing 14.29.
require "test_helper"
class Following < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
log_in_as(@user)
end
end
class FollowPagesTest < Following
test "following page" do
get following_user_path(@user)
assert_response :unprocessable_entity
assert_not @user.following.empty?
assert_match @user.following.count.to_s, response.body
@user.following.each do |user|
assert_select "a[href=?]", user_path(user)
end
end
test "followers page" do
get followers_user_path(@user)
assert_response :unprocessable_entity
assert_not @user.followers.empty?
assert_match @user.followers.count.to_s, response.body
@user.followers.each do |user|
assert_select "a[href=?]", user_path(user)
end
end
end
In Listing 14.29, note that we include the assertion
assert_not @user.following.empty?
which is included to make sure that
@user.following.each do |user|
assert_select "a[href=?]", user_path(user)
end
isn’t vacuously true (and similarly for followers
). In other words, if @user.following.empty?
were true, not a single assert_select
would execute in the loop, leading the tests to pass and thereby give us a false sense of security.
The test suite should now be GREEN:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Verify in a browser that /users/1/followers and /users/1/following work. Do the image links in the sidebar work as well?
Comment out the application code needed to turn the assert_select
tests in Listing 14.29 RED to confirm they’re testing the right thing.
Now that our views are in order, it’s time to get the follow/unfollow buttons working. Because following and unfollowing involve creating and destroying relationships, we need a Relationships controller, which we generate as usual
$ rails generate controller Relationships
As we’ll see in Listing 14.32, enforcing access control on the Relationships controller actions won’t much matter, but we’ll still follow our previous practice of enforcing the security model as early as possible. In particular, we’ll check that attempts to access actions in the Relationships controller require a logged-in user (and thus get redirected to the login page), while also not changing the Relationship count, as shown in Listing 14.31.
require "test_helper"
class RelationshipsControllerTest < ActionDispatch::IntegrationTest
test "create should require logged-in user" do
assert_no_difference 'Relationship.count' do
post relationships_path
end
assert_redirected_to login_url
end
test "destroy should require logged-in user" do
assert_no_difference 'Relationship.count' do
delete relationship_path(relationships(:one))
end
assert_redirected_to login_url
end
end
We can get the tests in Listing 14.31 to pass by adding the logged_in_user
before filter (Listing 14.32).
class RelationshipsController < ApplicationController
before_action :logged_in_user
def create
end
def destroy
end
end
To get the follow and unfollow buttons to work, all we need to do is find the user associated with the followed_id
in the corresponding form (i.e., Listing 14.21 or Listing 14.22), and then use the appropriate follow
or unfollow
method from Listing 14.10. The full implementation appears in Listing 14.33.
class RelationshipsController < ApplicationController
before_action :logged_in_user
def create
user = User.find(params[:followed_id])
current_user.follow(user)
redirect_to user
end
def destroy
user = Relationship.find(params[:id]).followed
current_user.unfollow(user)
redirect_to user, status: :see_other
end
end
We can see from Listing 14.33 why the security issue mentioned above is minor: If an unlogged-in user were to hit either action directly (e.g., using a command-line tool like curl
), current_user
would be nil
, and in both cases the action’s second line would raise an exception, resulting in an error but no harm to the application or its data. It’s best not to rely on that, though, so we’ve taken the extra step and added an additional layer of security.
With that, the core follow/unfollow functionality is complete, and any user can follow or unfollow any other user, as you can verify by clicking the corresponding buttons in your browser. (We’ll write integration tests to verify this behavior in Section 14.2.6.) The result of following user #2 is shown in Figure 14.19 and Figure 14.20.
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Follow and unfollow /users/2 through the web. Did it work?
According to the server log, which templates are rendered in each case?
Although our user following implementation is complete as it stands, we have one bit of polish left to add before starting work on the status feed. You may have noticed in Section 14.2.4 that both the create
and destroy
actions in the Relationships controller simply redirect back to the original profile. In other words, a user starts on another user’s profile page, follows the other user, and is immediately redirected back to the original page. It is reasonable to ask why, instead of reloading the entire page, we can’t replace only the parts of it that have changed.
This is exactly the problem solved by Hotwire, which works with multiple programming languages but was originally developed for use in Rails applications in the context of Basecamp and HEY.10 In this section, we’ll see how to use Turbo, described as “the heart of Hotwire”, to update only the follow/unfollow button and the follower count while leaving the rest of the page alone.
Turbo works via so-called Turbo streams to send small snippets of HTML directly to the page using WebSockets, which is a technology that allows for a persistent connection between the client (e.g., a web browser) and the web server.11 Our strategy is to handle Turbo requests and regular requests (as implemented in Section 14.2.4) in a unified way using the important respond_to
method, which follows a pattern that looks like this:12
10. Basecamp, a collaboration tool, was the original Rails web application, from which David Heinemeier Hansson extracted the first version of Rails. HEY is an email service produced by the same company.
11. For more on using WebSockets in Rails applications, see Learn Enough Action Cable to Be Dangerous (https://www.learnenough.com/action-cable).
12. If you look back at the toy app from Chapter 2, you’ll see that the generated scaffold code actually includes multiple calls to respond_to
.
respond_to do |format|
format.html { redirect_to user, status: :see_other }
format.turbo_stream
end
The syntax is potentially confusing, and it’s important to understand that in the code above only one of the lines gets executed. (In this sense, respond_to
is more like an if
-elsif
statement than a series of sequential lines.)
Adapting the Relationships controller to respond to Turbo streams involves adding respond_to
lines as above to the create
and destroy
actions from Listing 14.33. The result appears as in Listing 14.34. Note the change from the local variable user
to the instance variable @user
; in Listing 14.33, there was no need for an instance variable, but we’ll see in a moment that such a variable is necessary for the Turbo implementation.
class RelationshipsController < ApplicationController
before_action :logged_in_user
def create
@user = User.find(params[:followed_id])
current_user.follow(@user)
respond_to do |format|
format.html { redirect_to @user }
format.turbo_stream
end
end
def destroy
@user = Relationship.find(params[:id]).followed
current_user.unfollow(@user)
respond_to do |format|
format.html { redirect_to @user, status: :see_other }
format.turbo_stream
end
end
end
Rails activates Turbo automatically after it’s installed, so our app has actually been issuing Turbo-stream requests ever since we installed Turbo in Listing 8.20. But if there is nothing to respond to Turbo streams, then Rails defaults to responding as if they were regular HTML requests. This is why the code in Listing 14.33 worked just fine. But the code in Listing 14.34 adds lines specifically to respond to Turbo requests, so what happens when we add format.turbo_stream
? The answer is that Rails looks for an embedded Ruby template of the form
<action>.turbo_stream.erb
where <action>
is the name of the corresponding action. In our case, this means defining templates called
create.turbo_stream.erb
and
destroy.turbo_stream.erb
corresponding to create
and destroy
. Let’s take a look at how to make them.
When a user follows another user, two things need to happen on the page: The “follow” button needs to change to “unfollow” and the follower count needs to show the new number of followers. We can accomplish both of these using the turbo_stream.update
method, which takes in a CSS id and replaces the corresponding element with the result of evaluating a block of embedded Ruby passed to the method. For example, to replace the follow form with the unfollow form, we need only note from Listing 14.19 that the follow form has CSS id "follow_form"
and should be replaced with the contents of the unfollow form partial from Listing 14.22:
<%= turbo_stream.update "follow_form" do %>
<%= render partial: "users/unfollow" %>
<% end %>
(Because the Turbo template will be located in the relationships
directory, we have to include the users
directory in "users/unfollow"
for the unfollow
template to be found.) Likewise, to update the followers count, we just need to update the element with CSS id "followers"
in Listing 14.16 with the new count:
<%= turbo_stream.update "followers" do %>
<%= @user.followers.count %>
<% end %>
If we put the preceding code into a create.turbo_stream.erb
file, it will automatically be evaluated when the Relationships controller receives a Turbo-stream request to the create
action. The result is the Turbo template shown in Listing 14.35.
<%= turbo_stream.update "follow_form" do %>
<%= render partial: "users/unfollow" %>
<% end %>
<%= turbo_stream.update "followers" do %>
<%= @user.followers.count
%> <% end %>
Listing 14.35 shows why we needed to change to an instance variable in Listing 14.34: If we used the local variable user
instead of the instance variable @user
, there would be no way to access the user’s follower count in the template.
The template for unfollowing users is similar to the one for following them. In fact, the only difference is replacing the unfollow form with the follow form instead of vice versa; because we have access to the @user
variable directly, the code to update the count is identical. The result appears in Listing 14.36.
<%= turbo_stream.update "follow_form" do %>
<%= render partial: "users/follow" %>
<% end %>
<%= turbo_stream.update "followers" do %>
<%= @user.followers.count %>
<% end %>
With that, you should navigate to a user profile page and verify that you can follow and unfollow without a page refresh. (See Section 14.2.5 for an exercise on how to make 100% sure it’s working as expected.)
There are a lot of moving parts in this section, so let’s take a minute to review what’s going on under the hood:
Clicking on the follow or unfollow button sends a Turbo-stream request to the Rails server.
If there isn’t any code in the action to respond to a Turbo stream, the request is handled like a regular HTML request.
If there is code to respond to a Turbo stream, Rails automatically evaluates a Turbo template with the same name as the corresponding action (in our case, create
or destroy
).
Turbo templates can use the turbo_stream.update
method to replace the contents of a particular HTML element with the result of evaluating a block of embedded Ruby passed to the template.
The result is a more responsive site with the speed of a “single-page” JavaScript application and the convenience of doing the heavy lifting on the server with Rails.
As great as this is, Hotwire can actually do a lot more, and in this section we’ve barely scratched the surface. See the Hotwire documentation for more information.
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Because the only difference between the standard and Turbo methods of following and unfollowing users is an extra server request in the standard case, it can be hard to tell for sure if Turbo is working. By temporarily adding “using turbo” after the follower count in Listing 14.35 and Listing 14.36, verify that the Turbo templates are in fact being rendered upon clicking the follow/unfollow button.
Now that the follow buttons are working, we’ll write some simple tests to prevent regressions. To follow a user, we post to the relationships path and verify that the number of followed users increases by 1:
assert_difference "@user.following.count", 1 do
post relationships_path, params: { followed_id: @other.id }
end
This tests the standard implementation, but testing the Hotwire version is almost exactly the same; the only difference is the addition of the option format: :turbo_stream
:
assert_difference "@user.following.count", 1 do
post relationships_path(format: :turbo_stream,
params: { followed_id: @other.id }
end
Using the :turbo_stream
format option arranges to send a Turbo-stream request in the test, which causes the respond_to
block in Listing 14.34 to execute the Turbo-stream line.
The same parallel structure applies to deleting users, with delete
instead of post
. Here we check that the followed user count goes down by 1:
assert_difference "@user.following.count", -1 do
delete relationship_path(@relationship)
end
and
assert_difference "@user.following.count", -1 do
delete relationship_path(@relationship, format: :turbo_stream)
end
Putting the two cases together gives the tests in Listing 14.37. Note that we’ve included additional lines to confirm the proper redirects and, in the case of the destroy
action’s redirect, the proper response code.
require "test_helper"
class Following < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
@other = users(:archer)
log_in_as(@user)
end
end
.
.
.
class FollowTest < Following
test "should follow a user the standard way" do
assert_difference "@user.following.count", 1 do
post relationships_path, params: { followed_id: @other.id }
end
assert_redirected_to @other
end
test "should follow a user with Hotwire" do
assert_difference "@user.following.count", 1 do
post relationships_path(format: :turbo_stream),
params: { followed_id: @other.id }
end
end
end
class Unfollow < Following
def setup
super
@user.follow(@other)
@relationship = @user.active_relationships.find_by(followed_id: @other.id)
end
end
class UnfollowTest < Unfollow
test "should unfollow a user the standard way" do
assert_difference "@user.following.count", -1 do
delete relationship_path(@relationship)
end
assert_response :see_other
assert_redirected_to @other
end
test "should unfollow a user with Hotwire" do
assert_difference "@user.following.count", -1 do
delete relationship_path(@relationship, format: :turbo_stream)
end
end
end
At this point, the tests should be GREEN:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
By commenting and uncommenting each of the lines in the respond_to
blocks (Listing 14.34), verify that the tests are testing the right things. Which test fails in each case?
We come now to the pinnacle of our sample application: the status feed of microposts. Appropriately, this section contains some of the most advanced material in the entire tutorial. The full status feed builds on the proto-feed from Section 13.3.3 by assembling an array of the microposts from the users being followed by the current user, along with the current user’s own microposts. Throughout this section, we’ll proceed through a series of feed implementations of increasing sophistication. To accomplish this, we will need some fairly advanced Rails, Ruby, and even SQL programming techniques.
Because of the heavy lifting ahead, it’s especially important to review where we’re going. A recap of the final status feed, shown in Figure 14.5, appears again in Figure 14.21.
The basic idea behind the feed is simple. Figure 14.22 shows a sample microposts
database table and the resulting feed. The purpose of a feed is to pull out the microposts whose user ids correspond to the users being followed by the current user (and the current user itself), as indicated by the arrows in the diagram.
Although we don’t yet know how to implement the feed, the tests are relatively straightforward, so (following the guidelines in Box 3.3) we’ll write them first. The key is to check all three requirements for the feed: Microposts for both followed users and the user itself should be included in the feed, but a post from an unfollowed user should not be included.
As we’ll see in Listing 14.28, we’ll be arranging for Michael to follow Lana but not Archer; based on the fixtures in Listing 10.48 and Listing 13.57, this means that Michael should see Lana’s posts and his own posts, but not Archer’s posts. We also want to make sure that all users (both with and without followers) see their own posts. Converting these requirements to assertions and recalling that the feed
is in the User model (Listing 13.47) gives the updated User model test shown in Listing 14.39.
require "test_helper"
class UserTest < ActiveSupport::TestCase
.
.
.
test "feed should have the right posts" do
michael = users(:michael)
archer = users(:archer)
lana = users(:lana)
# Posts from followed user
lana.microposts.each do |post_following|
assert michael.feed.include?(post_following)
end
# Self-posts for user with followers
michael.microposts.each do |post_self|
assert michael.feed.include?(post_self)
end
# Posts from non-followed user
archer.microposts.each do |post_unfollowed|
assert_not michael.feed.include?(post_unfollowed)
end
end
end
Of course, the current implementation is just a proto-feed, so the new test is initially RED:
$ rails test
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Assuming the micropost’s ids are numbered sequentially, with larger numbers being more recent, what would user.feed.map(&:id)
return for the feed shown in Figure 14.22? Hint: Recall the default scope from Section 13.1.4.
With the status feed design requirements captured in the test from Listing 14.39, we’re ready to start writing the feed. Since the final feed implementation is rather intricate, we’ll build up to it by introducing one piece at a time. The first step is to think of the kind of query we’ll need. We need to select all the microposts from the microposts
table with ids corresponding to the users being followed by a given user (or the user itself). We might write this schematically as follows:
SELECT * FROM microposts
WHERE user_id IN (<list of ids>) OR user_id = <user id>
In writing this code, we’ve guessed that SQL supports an IN
keyword that allows us to test for set inclusion. (Happily, it does.)
Recall from the proto-feed in Section 13.3.3 that Active Record uses the where
method to accomplish the kind of select shown above, as illustrated in Listing 13.47. There, our select was very simple; we just picked out all the microposts with user id corresponding to the current user:
Micropost.where("user_id = ?", id)
Here, we expect it to be more complicated, something like this:
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
We see from these conditions that we’ll need an array of ids corresponding to the users being followed. One way to do this is to use Ruby’s map
method, available on any “enumerable” object, i.e., any object (such as an Array or a Hash) that consists of a collection of elements.13 We saw an example of this method in Section 4.3.2; as another example, we’ll use map
to convert an array of integers to an array of strings:
13. The main requirement is that enumerable objects must implement an each
method to iterate through the collection.
$ rails console
>> [1, 2, 3, 4].map { |i| i.to_s }
=> ["1", "2", "3", "4"]
Situations like the one illustrated above, where the same method gets called on each element in the collection, are common enough that there’s a shorthand notation for it (seen briefly in Section 4.3.2) that uses an ampersand &
and a symbol corresponding to the method:
>> [1, 2, 3, 4].map(&:to_s)
=> ["1", "2", "3", "4"]
Using the join
method (Section 4.3.1), we can create a string composed of the ids by joining them on comma-space:
>> [1, 2, 3, 4].map(&:to_s).join(', ')
=> "1, 2, 3, 4"
We can use the above method to construct the necessary array of followed user ids by calling id
on each element in user.following
. For example, for the first user in the database this array appears as follows:
>> User.first.following.map(&:id)
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51]
In fact, because this sort of construction is so useful, Active Record provides it by default:
>> User.first.following_ids
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51]
Here the following_ids
method is synthesized by Active Record based on the has_many :following
association (Listing 14.8); the result is that we need only append _ids
to the association name to get the ids corresponding to the user.following
collection. A string of followed user ids then appears as follows:
>> User.first.following_ids.join(', ')
=> "3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51"
When inserting into an SQL string, though, you don’t need to do this; the ?
interpolation takes care of it for you (and in fact eliminates some database-dependent incompatibilities). This means we can use following_ids
by itself. As a result, the initial guess of
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
actually works! The result appears in Listing 14.41.
class User < ApplicationRecord
.
.
.
# Returns true if a password reset has expired.
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
# Returns a user's status feed.
def feed
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
end
# Follows a user.
def follow(other_user)
following << other_user unless self == other_user
end
.
.
.
end
The test suite should be GREEN:
$ rails test
In some applications, this initial implementation might be good enough for most practical purposes, but Listing 14.41 isn’t the final implementation; see if you can make a guess about why not before moving on to the next section. (Hint: What if a user is following 5000 other users?)
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
In Listing 14.41, remove the part of the query that finds the user’s own posts. Which test in Listing 14.39 breaks?
In Listing 14.41, remove the part of the query that finds the followed users’ posts. Which test in Listing 14.39 breaks?
How could you change the query in Listing 14.41 to have the feed erroneously return microposts of unfollowed users, thereby breaking the third test in Listing 14.39? Hint: Returning all the microposts would do the trick.
As hinted at in the last section, the feed implementation in Section 14.3.2 doesn’t scale well when the number of microposts in the feed is large, as would likely happen if a user were following, say, 5000 other users. In this section, we’ll reimplement the status feed in a way that scales better with the number of followed users.
The first problem with the code in Section 14.3.2 is that following_ids
pulls all the followed users’ ids into memory, which creates an array with the full length of the followed users array. Since the condition in Listing 14.41 actually just checks inclusion in a set, there must be a more efficient way to do this, and indeed SQL is optimized for just such set operations. The solution involves pushing the finding of followed user ids into the database using a subselect.
We’ll start by refactoring the feed with the slightly modified code in Listing 14.43.
class User < ApplicationRecord
.
.
.
# Returns a user's status feed.
def feed
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
following_ids: following_ids, user_id: id)
end
.
.
.
end
As preparation for the next step, we have replaced
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
with the equivalent
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
following_ids: following_ids, user_id: id)
The question mark syntax is fine, but when we want the same variable inserted in more than one place, the second hash-based syntax is more convenient.
The above discussion implies that we will be adding a second occurrence of user_id
in the SQL query. In particular, we can replace the Ruby code
following_ids
with the SQL snippet
following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"
This code contains an SQL subselect, and internally the entire select for user 1 would look something like this:
SELECT * FROM microposts
WHERE user_id IN (SELECT followed_id FROM relationships
WHERE follower_id = 1)
OR user_id = 1
This subselect arranges for all the set logic to be pushed into the database, which is more efficient.
With this foundation, we are ready for a more efficient feed implementation, as seen in Listing 14.44. Note that, because it is now raw SQL, the following_ids
string is interpolated, not escaped.
class User < ApplicationRecord
.
.
.
# Returns a user's status feed.
def feed
following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"
Micropost.where("user_id IN (#{following_ids})
OR user_id = :user_id", user_id: id)
end
.
.
.
end
As usual, we can confirm that the code works by running the test suite:
$ rails test
The second problem is that even the more efficient code in Listing 14.44 pulls out only the feed’s microposts, without the associated users (or attachments). As a result, the feed partial (Listing 13.52), which calls the micropost partial (Listing 13.76), generates an extra database hit to find the user corresponding to each micropost. If there are N such microposts, it’s true that Listing 14.44 uses only 1 query to pull out the microposts, but the feed view itself generates an additional N queries to find each micropost’s user, for a total of N+1 queries. (The same argument applies to as many as N attached images, so in this case the number of queries could be as high as 2N +1.) This so-called N + 1 query problem can slowly drag down an app’s performance as the size of the database grows.
The solution to this problem involves a technique known as eager loading. As explained in more detail in the Learn Enough blog post “Eager Loading and the N+1 Query Problem” (https://news.learnenough.com/eager-loading) by Kevin Gilpin, eager loading involves including the users (and images) as part of a single micropost query, so that everything needed for the feed gets pulled out at the same time, thus requiring only one database hit. The efficiency gains from this technique can be substantial.
The way to use eager loading in Rails is via a method called includes
. In the present case, we can include both the micropost’s user and the image attachment (if any) by chaining includes
with Micropost.where
:
Micropost.where("user_id IN (#{following_ids})
OR user_id = :user_id", user_id: id)
.includes(:user, image_attachment: :blob)
Adding this to the code from Listing 14.44 yields the feed
shown in Listing 14.46.
class User < ApplicationRecord
.
.
.
# Returns a user's status feed.
def feed
following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"
Micropost.where("user_id IN (#{following_ids})
OR user_id = :user_id", user_id: id)
.includes(:user, image_attachment: :blob)
end
.
.
.
end
One final run of the test suite confirms that the feed in Listing 14.46 works as required:
$ rails test
It would also be nice to test for eager loading directly so that we could catch any regressions in our application. Doing so requires some more advanced testing techniques, but it is certainly possible; see “Eager Loading and the N+1 Query Problem” for details.
Of course, even the subselect and eager loading won’t scale forever. For bigger sites, you would need to do things like generating the feed asynchronously using a background job. The full range of such scaling subtleties is beyond the scope of this tutorial, but, with the technical sophistication you’ve developed, you’re in a great position to take the next steps on your own.
With the code in Listing 14.46, our status feed is now complete. (See the exercises in Section 14.3.3 for one further refinement.) Recall from Section 13.3.3 that the Home page already includes the feed. In Chapter 13, the result was only a proto-feed (Figure 13.14), but with the implementation in Listing 14.46 as seen in Figure 14.23 the Home page now shows the full feed.
At this point, we’re ready to merge our changes into the main branch:
$ rails test
$ git add -A
$ git commit -m "Add user following"
$ git checkout main
$ git merge following-users
We can then push the code to the remote repository and deploy the application to production:
$ git push
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed
The result is a working status feed on the live Web (Figure 14.24). (To learn how to host a Heroku site using a custom domain instead of a herokuapp.com subdomain, see the free tutorial Learn Enough Custom Domains to Be Dangerous (https://www.learnenough.com/custom-domains).)
To see other people’s answers and to record your own, subscribe to the Rails Tutorial course or to the Learn Enough All Access Subscription.
Write an integration test to verify that the first page of the feed appears on the Home page as required. A template appears in Listing 14.48.
Note that Listing 14.48 escapes the expected HTML using CGI.escapeHTML
(which is closely related to the CGI.escape
method we used in Section 11.2.3 to escape URLs). Why is escaping the HTML necessary in this case? Hint: Try removing the escaping and carefully inspect the page source for the micropost content that doesn’t match. Using the search feature of your terminal shell (Cmd-F on Ctrl-F on most systems) to find the word “sorry” may prove particularly helpful.
The code in Listing 14.44 can be expressed directly in Rails using a so-called left outer join using the left_outer_joins
method. By running the tests, show that the code in Listing 14.49 returns a passing feed.14 Unfortunately, the actual feed contains multiple copies of the user’s own microposts (Figure 14.25),15 so use the test in Listing 14.50 to catch this error (using the distinct
method, which returns the distinct elements in a collection). Then show that appending the distinct
method to the query (Listing 14.51) results in a GREEN test. By inspecting the generated SQL directly, confirm that the word DISTINCT
appears in the query itself, indicating that the distinct elements are efficiently selected in the database rather than in our application’s memory. (Hint: To get the SQL, run User.first.feed
in the Rails console.)
14. Thanks to reader Anna for suggesting this version and to Andrew Mead for spotting an error in the original SQL.
15. Thanks to Brittany Louviere for finding and solving this issue.
require "test_helper"
class Following < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
@other = users(:archer)
log_in_as(@user)
end
end
class FollowPagesTest < Following
.
.
.
test "feed on Home page" do
get root_path
@user.feed.paginate(page: 1).each do |micropost|
assert_match CGI.escapeHTML(FILL_IN), FILL_IN
end
end
end
.
.
.
class User < ApplicationRecord
.
.
.
# Returns a user's status feed.
def feed
part_of_feed = "relationships.follower_id = :id or microposts.user_id = :id"
Micropost.left_outer_joins(user: :followers)
.where(part_of_feed, { id: id })
.includes(:user, image_attachment: :blob)
end
.
.
.
end
require "test_helper"
class UserTest < ActiveSupport::TestCase
.
.
.
test "feed should have the right posts" do
michael = users(:michael)
archer = users(:archer)
lana = users(:lana)
# Posts from followed user
lana.microposts.each do |post_following|
assert michael.feed.include?(post_following)
end
# Self-posts for user with followers
michael.microposts.each do |post_self|
assert michael.feed.include?(post_self)
assert_equal michael.feed.distinct, michael.feed
end
# Self-posts for user with no followers
archer.microposts.each do |post_self|
assert archer.feed.include?(post_self)
end
# Posts from unfollowed user
archer.microposts.each do |post_unfollowed|
assert_not michael.feed.include?(post_unfollowed)
end
end
end
class User < ApplicationRecord
.
.
.
# Returns a user's status feed.
def feed
part_of_feed = "relationships.follower_id = :id or microposts.user_id = :id"
Micropost.left_outer_joins(user: :followers)
.where(part_of_feed, { id: id }).distinct
.includes(:user, image_attachment: :blob)
end
.
.
.
end
With the addition of the status feed, we’ve finished the sample application for the Ruby on Rails Tutorial. This application includes examples of all the major features of Rails, including models, views, controllers, templates, partials, filters, validations, callbacks, has_many
/belongs_to
and has_many :through
associations, security, testing, and deployment.
Despite this impressive list, there is still much to learn about web development. As a first step in this process, this section contains some suggestions for further learning.
There is a wealth of Rails resources in stores and on the web—indeed, the supply is so rich that it can be overwhelming. The good news is that, having gotten this far, you’re ready for almost anything else out there. Here are some suggestions for further learning:
Learn Enough All Access Subscription: Premium subscription service that includes a special enhanced version of the Ruby on Rails Tutorial book and 15+ hours of streaming screencast lessons filled with the kind of tips, tricks, and live demos that you can’t get from reading a book. Also includes text and videos for the other Learn Enough (https://www.learnenough.com/) tutorials. Scholarship discounts are available.
Launch School: Lots of in-person developer bootcamps have sprung up in recent years, and I recommend looking for one in your area, but Launch School is available online and so can be taken from anywhere. Launch School is an especially good choice if you want instructor feedback within the context of a structured curriculum.
The Turing School of Software & Design: A full-time, 27-week Ruby/Rails/JavaScript training program in Denver, Colorado. Most of their students start with limited programming experience but have the determination and drive needed to pick it up quickly.
Bloc: An online bootcamp with a structured curriculum, personalized mentorship, and a focus on learning through concrete projects. Use the coupon code BLOCLOVESHARTL to get $500 off the enrollment fee.
Thinkful: An online class that pairs you with a professional engineer as you work through a project-based curriculum. Subjects include Ruby on Rails, front-end development, web design, and data science.
Pragmatic Studio: Online Ruby and Rails courses from Mike and Nicole Clark.
RailsApps: Instructive sample Rails apps.
Bloom Institute of Technology: Innovative online program that you pay for only if you land a high-paying job.
Rails’ has_many :through
allows the modeling of complicated data relationships.
The has_many
method takes several optional arguments, including the object class name and the foreign key.
Using has_many
and has_many :through
with properly chosen class names and foreign keys, we can model both active (following) and passive (being followed) relationships.
Rails routing supports nested routes.
The where
method is a flexible and powerful way to create database queries.
Rails supports issuing lower-level SQL queries if needed.
We can solve the N + 1 query problem in Rails using the includes
method to implement eager loading.
By putting together everything we’ve learned in this book, we’ve successfully implemented user following with a status feed of microposts from followed users.
3.129.71.164