kuma0319のブログ

webエンジニアになるためのアウトプットブログ

Ruby on Railsチュートリアル第14章

Railsチュートリアルの第14章を進めていきます。最終章です🐻

第14章(ユーザーをフォローする)

リレーションシップモデル

ユーザーのテーブル、フォロー(following)のテーブル、フォロワー(followers)のテーブルをそれぞれ作成しようとすると、重複した行が多く取り扱いが非常にやっかいとなるため、お互いの関係を考慮したリレーションシップモデルが有効。
リレーションシップという1つのモデルを用いて、フォローしている人から見てもフォロワーから見ても矛盾の無いように抽象化する。

カラム名 データ型
id integer
follower_id integer
followed_id integer
created_at datetime
updated_at datetime

マイグレーションファイルでは、それぞれのインデックスと一意性を担保した複合キーインデックスの3種。これによって、follower_idfollowed_idの組み合わせは重複しないようになり、各要素に対するクエリも高速化される。

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
User/Relationshipの関連付け

13章のマイクロポストにおけるUser/Micropostモデルの関連付けではhas_many :micropostsとしたが、これに対応するMicropostモデルを使用するからこれでよかった。
ただ今回も同じようにhas_many :active_relationshipsとしてしまうと、期待するモデルでは無いため、モデル名を明示的に渡す必要がある

更に、マイクロポストモデルにおいてbelongs_to :userとするのは、Userモデルに属しておりそれを繋げる外部キーとしてuser_idを生成するという機能であった。
Railsは自動的にアンダースコア化したクラス名を元にclass_idという外部キーを生成(Userクラスであればuser_id、FooBarクラスであればfoo_bar_id)する。
belongs_to :followerのように書くと外部キーはfollower_idでいいが、関連付けるモデルがfollowerモデルとおかしなことになるため、関連付けるモデルを明示的に示す必要がある。

クラス名と外部キーを指定したhas_many(削除依存もおまけ)

  has_many :active_relationships, class_name:  "Relationship",
                                  foreign_key: "follower_id",
                                  dependent:   :destroy

期待するクラス名を指定したbelongs_to

  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
has_many through

has_many throughではthroughオプションで指定した関係を経由した関連付けが形成される。

[app/models/user.rb]
  has_many :active_relationships, class_name:  "Relationship",
                                  foreign_key: "follower_id",
                                  dependent:   :destroy
  has_many :following, through: :active_relationships, source: :followed

ここでは、has_many :active_relationships命名したactive_relationshipsレコードを経由して、followedコレクションを取得できる。ただ、followedの複数形followedsは不適なので、source: :followedとした上でfollowingでオーバーライドしている。

passive_relationshipsも併せたユーザーモデル。

[app/models/user.rb]
  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

[Follow]のWebインターフェイス

memberメソッドを使用したルーティング

memberメソッドは、ユーザーidを含むURLを扱う。この場合だと、/users/1/following/users/1/followersとなる。(collectionメソッドというのもあり、これはidを指定しないすべてのリスト)

  resources :users do
    member do
      get :following, :followers
    end
  end

ルーティングに対応するアクションも必要となる。ここで、通常ビューはコントローラのアクションに対応する名前で呼び出す(following.html.erbなど)が、アクション内でrenderで明示的に呼び出すビューを指定しておくことも可能。

[app/controllers/users_controller.rb]

  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
follow/unfollow用のパーシャル

follow/unfollowの実際の動作としては
followは新しいリレーションシップを作成する ⇒ POSTリクエストによってリレーションシップをcreateする。
unfollowは既存のリレーションシップを探索して削除する ⇒ DELETEリクエストによってリレーションシップをdestroyする。

つまり、ルーティングが必要。followとunfollowに対応するルーティングのみをresourcesで作成しておく。

 resources :relationships,       only: [:create, :destroy]

ボタン用のパーシャルでは、followunfollowのパーシャルに作業を振り分けている。

[app/views/users/_follow_form.html.erb]
<% unless current_user?(@user) %>
  <div id="follow_form">
  <% if current_user.following?(@user) %>
    <%= render 'unfollow' %>
  <% else %>
    <%= render 'follow' %>
  <% end %>
  </div>
<% end %>

followパーシャルではfollowed_idをコントローラに送信しないといけないため、hidden_field_tagで隠し属性としてデータを埋め込んでいる。

[app/views/users/_follow.html.erb]
<%= 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 %>
follow/unfollowページの統合テスト

ここのテストは、フォロー/フォロワーの統計情報があるかassert_matchで調べており、またフォロー/フォロワーそれぞれについてそのユーザーへのパスがあるかをassert_select "a[href=?]"で調べている。
重要な部分としてassert_not @user.following.empty?の記述。これは後述するassert_select "a[href=?]"「空虚な真」で無いことを保証している。∵@user.followingがemptyで誰もフォローしていないならば、そのあとのブロックにおけるテストが実行されないままスルーされ、実際は何も検証できていないのにテストがパスしてしまう。

[test/integration/following_test.rb]
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
Followボタン

フォローはリレーションシップの作成(create)、フォロー解除はリレーションシップの削除(destroy)となる。すこしここの動作の理解は難しかったです。
createアクションではUser.find(params[:followed_id])フォローするユーザーを探しているが、ここのfollowed_id]_follow.html.erbで隠しフィールドとして配置したhidden_field_tag :followed_id, @user.idによってサポートされている。これによって、followボタンを押した時の動作としてそのユーザーのidをfollowed_idとして受け取れる。
destroyアクションは、Relationshipモデルの特定のidにおけるfollowedを示す。ここでのfollowedはbelongs_toでfollowedを紐づけているため、指定したRelationshipモデルの中のfollowed_idを有するユーザーを返す動作。
更に、current_userを主体とすることで、ログインしていないユーザーが直接この動作にアクセスしようとした場合にエラーを発生させることが出来る。

[app/controllers/relationships_controller.rb]
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
respond_toメソッド

通常のリクエストとTurboによるリクエストを統一的に扱い、どちらかを実行するようにrespond_toで制御できる。if-else的な処理。

respond_to do |format|
  format.html { redirect_to user, status: :see_other }
  format.turbo_stream
end

ステータスフィード

フィードの実装要件としては、フォローしているユーザーとユーザー自身のマイクロポストのみを含めて、フォローしていないユーザーのものは含めない。

ステータスフィードを返す記述

現在のユーザーに対応するユーザーのマイクロポストを抽出する場合は、Micropost.where("user_id = ?", id)のみでokだった。
今回の実装に使用する記述は少し複雑。

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

中身としては、①Micropost.where("user_id IN (?), following_ids)と、②Micropost.where("user_id = ?", id)の2つに分けられる。

①の部分

following_idshas_many :following関連付けを行うとActive Recordによって自動生成されるメソッドであり、following.map(&:id)を意味しており、user.followingにある各要素のidを呼び出し、フォローしているユーザーのidを配列として利用可能となる。
SQLIN句は、複数の値に合致するデータを抽出する動作なので、following_idsで得られた配列に対しIN句を利用することで、フォローしているユーザーのマイクロポストのデータを取得可能。

②の部分

これは元々のuser自身のマイクロポストを全て返す動作。

これによって、①と②の組み合わせで①フォローしているユーザーと②ユーザー自身のマイクロポストが取得できる。

また、?よりも同じ変数を複数の場所に挿入する場合はハッシュ形式でも便利。

Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
    following_ids: following_ids, user_id: id)
サブセレクト

フィードの件数が膨大になるとスケール(処理量の調節)されないためる問題をSQLのサブセレクトで解決可能。
ここは以下の質問を参考にしました。サブセレクトを活用することでデータベースへの問い合わせがより少ない手法で記述することが可能といったところですかね。

teratail.com

N+1クエリ問題、eagerloading

Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",following_ids: following_ids, user_id: id)の記述だと、マイクロポストのみをクエリしているからマイクロポストの取り出し自体はクエリ1件で済むが、その結果を元にユーザーや添付画像をクエリする必要があるため、追加でN~2N件のクエリが必要となる。これがN+1クエリ問題

1件のクエリの中にその他の必要なデータを取り出すクエリも含める手法がeager loading。これによって、トータル1件のクエリのみで済むようになる。

Micropost.where("user_id IN (#{following_ids})
                 OR user_id = :user_id", user_id: id)
         .includes(:user, image_attachment: :blob)

第14章ー演習ー

14.1.1

①図 14.7のid=1のユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか? 想像してみてください。(ヒント: 4.3.2で紹介したmap(&:method_name)のパターンを思い出してください。例えばuser.following.map(&:id)の場合、idの配列を返します。)
(解答) ["2", "7", "8", "10"]の配列が返される。

②図 14.7を参考にして、id=2のユーザーに対してuser.followingを実行すると、結果はどのようになるでしょうか? また、同じユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか? 想像してみてください。
(解答)id=1のユーザーの情報が返される。["1"]の配列が返される。

14.1.2

①コンソールを開き、表 14.1のcreateメソッドを使ってActiveRelationshipを作ってみましょう。データベース上に2人以上のユーザーを用意し、最初のユーザーが2人目のユーザーをフォローしている状態を作ってみてください。
(解答)

irb(main):002:0> user = User.first
  User Load (2.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=>                                                               
#<User:0x00007f7de5289f00                                        
...                                                              
irb(main):003:0> other_user = User.second
  User Load (0.6ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 1], ["OFFSET", 1]]
=>                                                                           
#<User:0x00007f7de6c51e88                                                    
...                                                                          
irb(main):004:0> user.active_relationships.create(followed_id: other_user.id)
  TRANSACTION (0.1ms)  begin transaction
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]                   
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]                   
  Relationship Create (8.6ms)  INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["follower_id", 1], ["followed_id", 2], ["created_at", "2023-04-21 14:59:00.445063"], ["updated_at", "2023-04-21 14:59:00.445063"]]                                                         
  TRANSACTION (13.2ms)  commit transaction                                                                                     
=> #<Relationship:0x00007f7de512cec8 id: 1, follower_id: 1, followed_id: 2, created_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00, updated_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00>

②先ほどの演習を終えたら、active_relationship.followedの値とactive_relationship.followerの値を確認し、それぞれの値が正しいことを確認してみましょう。
(解答)正しい

irb(main):009:0> user.active_relationships
  Relationship Load (0.4ms)  SELECT "relationships".* FROM "relationships" WHERE "relationships"."follower_id" = ?  [["follower_id", 1]]
=> [#<Relationship:0x00007f7de512cec8 id: 1, follower_id: 1, followed_id: 2, created_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00, updated_at: Fri, 21 Apr 2023 14:59:00.445063000 UTC +00:00>]

14.1.3

①リスト 14.5のバリデーションをコメントアウトしても、テストが成功したままになっていることを確認してみましょう。(以前のRailsのバージョンでは、このバリデーションが必須でしたが、Rails 5以降は必須ではなくなりました。ここでは念のためこのバリデーションを省略していませんが、このバリデーションが省略されているのを見かけるかもしれないので、覚えておくと良いでしょう。)
(解答) 飛ばします。

14.1.4

①コンソールを開き、リスト 14.9のコードを順々に実行してみましょう。
(解答)①②合わせて解答

②先ほどの演習の各コマンド実行時の結果を見返してみて、実際にはどんなSQLが出力されたのか確認してみましょう。
(解答)

irb(main):001:0> user = User.second
  User Load (2.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 1], ["OFFSET", 1]]
=>                                                                         
#<User:0x00007f445f438f00                                                  
...                                                                        
irb(main):002:0> other_user = User.third
  User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 1], ["OFFSET", 2]]
=>                                                                           
#<User:0x00007f445f32ad98                                                    
...                 
irb(main):003:0> user.following?(other_user)
  User Exists? (1.2ms)  SELECT 1 AS one FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? AND "users"."id" = ? LIMIT ?  [["follower_id", 2], ["id", 3], ["LIMIT", 1]]
=> false                                                                     
irb(main):004:0> user.follow(other_user)
  TRANSACTION (0.6ms)  begin transaction
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  Relationship Create (53.3ms)  INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["follower_id", 2], ["followed_id", 3], ["created_at", "2023-04-22 02:03:25.103881"], ["updated_at", "2023-04-22 02:03:25.103881"]]                                                            
  TRANSACTION (14.5ms)  commit transaction                                   
  User Load (0.5ms)  SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ?  [["follower_id", 2]]
=>                                                                           
[#<User:0x00007f445f32ad98                                                 
  id: 3,                                                                   
  name: "Walker Lindgren",                                                 
  email: "example-2@railstutorial.org",                                    
  created_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00,              
  updated_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00,              
  password_digest: "[FILTERED]",                                           
  remember_digest: nil,                                                    
  admin: false,
  activation_digest: "$2a$12$UAinDn/X6ZmPb/RsLTPQpuGyKsXOrSy4rO6IHCk/ddD06WF2ohmgq",
  activated: true,
  activated_at: Thu, 20 Apr 2023 11:37:02.138438000 UTC +00:00,
  reset_digest: nil,
  reset_sent_at: nil>]
irb(main):005:0> user.following?(other_user)
=> true
irb(main):006:0> user.unfollow(other_user)
  TRANSACTION (4.0ms)  begin transaction
  Relationship Delete All (12.5ms)  DELETE FROM "relationships" WHERE "relationships"."follower_id" = ? AND "relationships"."followed_id" = ?  [["follower_id", 2], ["followed_id", 3]]
  TRANSACTION (13.4ms)  commit transaction                                 
=>                                                                         
[#<User:0x00007f445f32ad98                                                 
  id: 3,                                                                   
  name: "Walker Lindgren",                                                 
  email: "example-2@railstutorial.org",                                    
  created_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00,              
  updated_at: Thu, 20 Apr 2023 11:37:02.392632000 UTC +00:00,              
  password_digest: "[FILTERED]",                                           
  remember_digest: nil,                                                    
  admin: false,                                                            
  activation_digest: "$2a$12$UAinDn/X6ZmPb/RsLTPQpuGyKsXOrSy4rO6IHCk/ddD06WF2ohmgq",
  activated: true,                                                         
  activated_at: Thu, 20 Apr 2023 11:37:02.138438000 UTC +00:00,
  reset_digest: nil,
  reset_sent_at: nil>]
irb(main):007:0> user.following?(other_user)
=> false
irb(main):008:0> user.follow(user)
=> nil
irb(main):009:0> user.following?(user)
=> false

14.1.5

①コンソールを開き、何人かのユーザーが最初のユーザーをフォローしている状況を作ってみてください。最初のユーザーをuserとすると、user.followers.map(&:id)の値はどのようになっているでしょうか?
(解答)

irb(main):004:0> user_2.follow(user)
  TRANSACTION (0.1ms)  begin transaction
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  Relationship Create (23.9ms)  INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["follower_id", 2], ["followed_id", 1], ["created_at", "2023-04-22 02:13:50.728684"], ["updated_at", "2023-04-22 02:13:50.728684"]]                                                             
  TRANSACTION (23.9ms)  commit transaction                                    
  User Load (0.4ms)  SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ?  [["follower_id", 2]]
=>                                                                            
[#<User:0x00007f9046bedd30                                                    
  id: 1,                                                                      
  name: "Example User",                                                       
  email: "example@railstutorial.org",                                         
  created_at: Thu, 20 Apr 2023 11:37:00.512405000 UTC +00:00,                 
  updated_at: Thu, 20 Apr 2023 13:05:51.230122000 UTC +00:00,                 
  password_digest: "[FILTERED]",                                              
  remember_digest: "$2a$12$kGwCwjO5pzAkENniN.5WM.JYJySA36du6FdeLif1aUu.CfZ7D2zgy",
  admin: true,
  activated: true,
  activated_at: Thu, 20 Apr 2023 11:37:00.235450000 UTC +00:00,
  reset_digest: nil,
  reset_sent_at: nil>]
irb(main):005:0> user_3.follow(user)
  TRANSACTION (0.2ms)  begin transaction
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  Relationship Create (2.0ms)  INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["follower_id", 3], ["followed_id", 1], ["created_at", "2023-04-22 02:14:01.600044"], ["updated_at", "2023-04-22 02:14:01.600044"]]
  TRANSACTION (14.1ms)  commit transaction
  User Load (0.4ms)  SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ?  [["follower_id", 3]]
=> 
[#<User:0x00007f9046bedd30
  id: 1,
  name: "Example User",
  email: "example@railstutorial.org",
  created_at: Thu, 20 Apr 2023 11:37:00.512405000 UTC +00:00,
  updated_at: Thu, 20 Apr 2023 13:05:51.230122000 UTC +00:00,
  password_digest: "[FILTERED]",
  remember_digest: "$2a$12$kGwCwjO5pzAkENniN.5WM.JYJySA36du6FdeLif1aUu.CfZ7D2zgy",
  admin: true,
  activation_digest: "$2a$12$YzmHowrQGG3J3BEaIPkU8uTQaelLF5edsPAHId6kYykM1fPSO8EQW",
  activated: true,
  activated_at: Thu, 20 Apr 2023 11:37:00.235450000 UTC +00:00,
  reset_digest: nil,
  reset_sent_at: nil>]
irb(main):006:0> user.followers.map(&:id)
  User Load (0.5ms)  SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ?  [["followed_id", 1]]
=> [2, 3]

②上の演習が終わったら、user.followers.countの実行結果が、先ほどフォローさせたユーザー数と一致していることを確認してみましょう。
(解答) 一致している。

irb(main):008:0> user.followers.count
  User Count (0.5ms)  SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ?  [["followed_id", 1]]
=> 2  

③user.followers.countを実行した結果、出力されるSQL文はどのような内容になっているでしょうか? また、user.followers.to_a.countの実行結果と違っている箇所はありますか?(ヒント: もしuserに100万人のフォロワーがいた場合、どのような違いがあるでしょうか? 考えてみてください。)
(解答) SQL文は②に記載。"users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id"の部分はusersテーブルのidとrelationshipsテーブルのfollower_idが共通しており、その部分の情報からwhereメソッドでfollower_idの行のみを 抽出してcountを返すため高速。user.followers.to_a.countは全フォロワーを取得しそれを配列に変換して、そのカウントを取るから莫大なデータ量になると当然遅くなるはず。

14.2.1

①コンソールを開いて、User.first.followers.countの結果がリスト 14.14で期待される結果と一致していることを確認してみましょう。
(解答)

irb(main):001:0> User.first.followers.count
  User Load (1.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
  User Count (1.0ms)  SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ?  [["followed_id", 1]]
=> 38

②上の演習と同様に、User.first.following.countの結果も一致していることを確認してみましょう。
(解答)

irb(main):002:0> User.first.following.count
  User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
  User Count (2.1ms)  SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ?  [["follower_id", 1]]
=> 49

14.2.2

①ブラウザから/users/2にアクセスし、フォローボタンが表示されていることを確認してみましょう。同様に、/users/5では[Unfollow]ボタンが表示されているはずです。さて、/users/1にアクセスすると、どのような結果が表示されるでしょうか?
(解答)それぞれフォローボタン、アンフォローボタンが表示される。自分自身には何も表示されない。

②ブラウザからHomeページとプロフィールページを表示してみて、統計情報が正しく表示されているか確認してみましょう。
(解答) どちらも正しく表示されている。

③Homeページに表示されている統計情報に対してテストを書いてみましょう。同様にして、プロフィールページにもテストを追加してみましょう。(ヒント: リスト 13.29で示したテストに追加してみてください。)
(解答)assert_matchfollowing.count.to_sfollowers.count.to_sがあることを確認。

Homeページのレイアウトテスト

[test/integration/site_layout_test.rb]
  test "root path layout links with logged in user" do
    log_in_as(@user)
    get root_path
    assert_template 'static_pages/home'
    assert_select "a[href=?]", root_path, count: 2
    assert_select "a[href=?]", help_path
    assert_select "a[href=?]", about_path
    assert_select "a[href=?]", contact_path
    assert_select "a[href=?]", users_path
    assert_select "a[href=?]", user_path(@user)
    assert_select "a[href=?]", edit_user_path(@user)
    assert_select "a[href=?]", logout_path
    assert_match @user.following.count.to_s, response.body
    assert_match @user.followers.count.to_s, response.body

  end

プロフィールページのテスト

  test "profile display" do
    get user_path(@user)
    assert_template 'users/show'
    assert_select 'title', full_title(@user.name)
    assert_select 'h1', text: @user.name
    assert_select 'h1>img.gravatar'
    assert_match @user.microposts.count.to_s, response.body
    assert_select 'div.pagination',    count: 1
    @user.microposts.paginate(page: 1).each do |micropost|
      assert_match micropost.content, response.body
    end
    assert_match @user.following.count.to_s, response.body
    assert_match @user.followers.count.to_s, response.body
  end

14.2.3

①ブラウザで/users/1/followersと/users/1/followingを開き、それぞれが適切に表示されていることを確認してみましょう。サイドバーにある画像のリンクが正常に機能していることも確認してみましょう。
(解答) 画像リンクも含めて正しく動作している。

②リスト 14.29のassert_selectのテストが正しく動作することを、関連するアプリケーションのコードをコメントアウトして確認してみましょう。
(解答) この演習は少し手間取りました。実際のfollwingなどのページを見ると分かりやすいのですが、assert_select "a[href=?]", user_path(user)にマッチする部分は、gravatarで表示される画像部分(link_to gravatar)とユーザー一覧が表示される部分( render @users)と2つあるので、該当する箇所を2箇所コメントアウトする必要があります。

<% 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>

これでREDになる。

 FAIL FollowPagesTest#test_followers_page (8.32s)
        Expected at least 1 element matching "a[href="/users/409608538"]", found 0..
        Expected 0 to be >= 1.
        test/integration/following_test.rb:29:in `block (2 levels) in <class:FollowPagesTest>'
        test/integration/following_test.rb:28:in `block in <class:FollowPagesTest>'

 FAIL FollowPagesTest#test_following_page (8.48s)
        Expected at least 1 element matching "a[href="/users/409608538"]", found 0..
        Expected 0 to be >= 1.
        test/integration/following_test.rb:19:in `block (2 levels) in <class:FollowPagesTest>'
        test/integration/following_test.rb:18:in `block in <class:FollowPagesTest>'

14.2.4

①ブラウザ上から/users/2を開き、[Follow]と[Unfollow]を実行してみましょう。うまく機能しているでしょうか?
(解答)機能している。

②先ほどの演習を終えたら、Railsサーバーのログを見てみましょう。フォロー/フォロー解除が実行されると、それぞれどのテンプレートが描画されているでしょうか?
(解答)まず、どちらも共通でshow.html.erbでその中のfollow_formパーシャルのif-else分岐がおこなわれる。フォローすると、if current_user.following?(@user)がtrueなので、render 'unfollow'。フォロー解除すると、if current_user.following?(@user)がelseなので、render 'follow'となる。

14.2.5

①フォロー機能やフォロー解除機能を「標準の方法」または「Turboによる方法」で実現する方法の違いは、Turboは標準の方法よりもサーバーリクエストが1回多いという点しかありません。このため、Turboが確実に動作しているかどうかを判定するのが難しくなることがあります。リスト 14.35およびリスト 14.36にあるフォロワー数の後ろに一時的に「Turbo利用中」という文字を追加し、次に[Follow]ボタンや[Unfollow]ボタンをクリックして、Turboのテンプレートが確実にレンダリングすることを確認してみてください。確認が終わったら元に戻しておきましょう。
(解答) 確かにTurbo利用中と表示された。

14.3.1

①マイクロポストのidが数字の小さい順に並び、数字が大きいほど新しいと仮定すると、図 14.22のデータセットでuser.feed.map(&:id)を実行すると、どのような結果が表示されるでしょうか? 考えてみてください。(ヒント: 13.1.4の実装で使ったdefault_scopeを思い出してください。)
(解答) default_scope -> { order(created_at: :desc) }としているため、マイクロポストの作成日時が新しい順(この例だとマイクロポストのidが大きい方から)に並ぶ。

14.3.2

①リスト 14.41において、現在のユーザー自身の投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.39のどのテストが失敗するでしょうか?
(解答) Micropost.where("user_id IN (?)", following_ids)のみを記述。ユーザー自身の投稿が入らないため以下の部分がRED。

    michael.microposts.each do |post_self|
      assert michael.feed.include?(post_self)
    end

②リスト 14.41において、フォローしているユーザーの投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.39のどのテストが失敗するでしょうか?
(解答)Micropost.where("user_id = ?", id)のみを記述。フォローしているユーザーの投稿が入らないため以下の部分がRED。

    lana.microposts.each do |post_following|
      assert michael.feed.include?(post_following)

③リスト 14.41において、フォローしていないユーザーの投稿を含めるためにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.39のどのテストが失敗するでしょうか?(ヒント: 自分自身とフォローしているユーザー、そしてそれ以外という集合は、いったいどういった集合を表すのか考えてみてください。)
(解答)Micropost.allでマイクロポストの全ての集合となる。フォローしていないユーザーも入るため以下の部分がRED。

    archer.microposts.each do |post_unfollowed|
      assert_not michael.feed.include?(post_unfollowed)

14.3.3

①Homeページで表示される1ページ目のフィードに対して、統合テストを書いてみましょう。リスト 14.49はそのテンプレートです。
(解答)

  test "feed on Home page" do
    get root_path
    @user.feed.paginate(page: 1).each do |micropost|
      assert_match CGI.escapeHTML(micropost.content), response.body
    end
  end

②リスト 14.49のコードでは、期待されるHTMLをCGI.escapeHTMLメソッドでエスケープしています(このメソッドは11.2.3で扱ったCGI.escapeと密接に関連します)。このコードでHTMLをエスケープする必要がある理由を考えてみてください。(ヒント: 試しにエスケープ処理を削除して、得られるHTMLのソースコードと一致しないマイクロポストのコンテンツがないかどうか、注意深く調べてみてください。また、ターミナルの検索機能Cmd-FもしくはCtrl-Fで「sorry」を検索すると、原因の究明に役立つでしょう。)
(解答)CGIエスケープしないとI&#39;m sorryのようなエスケープされていない文字列が引っかかっる。

③リスト 14.44のコードは、実はRailsのleft_outer_joinsメソッドを使うと、いわゆるLEFT OUTER JOINで直接表現できます。リスト 14.50のコード23 を適用してテストを実行し、このコードが返すフィードがテストでパスすることを確かめてみましょう。 残念ながら、テストがパスするにもかかわらず、実際のフィードにはユーザー自身のマイクロポストがいくつも重複表示されている(図 14.25)24 ので、次はリスト 14.51のテストを使ってこのエラーをキャッチしてください。このテストで使っているdistinctは、コレクション内の要素を重複抜きで返します。エラーをキャッチできたら、クエリにdistinctメソッドを追加したコード(リスト 14.52)に置き換えるとテストが green になることを確かめてください。次は、生成されたSQLを直接調べて、DISTINCTという語がクエリ自身に含まれていることを確認してください。これは、DISTINCTを指定した要素がアプリケーションのメモリ上ではなく、データベース上で効率よくSELECTされていることを示しています。(ヒント: SQLを直接調べるには、RailsコンソールでUser.first.feedを実行します。)
(解答)SELECT DISTINCTというSQLが含まれていた。

irb(main):002:0> User.first.feed.paginate(page: 1)
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
  Micropost Load (2.5ms)  SELECT DISTINCT "microposts".* FROM "microposts" LEFT OUTER JOIN "users" ON "users"."id" = "microposts"."user_id" LEFT OUTER JOIN "relationships" ON "relationships"."followed_id" = "users"."id" LEFT OUTER JOIN "users" "followers_users" ON "followers_users"."id" = "relationships"."follower_id" WHERE (relationships.follower_id = 1 or microposts.user_id = 1) ORDER BY "microposts"."created_at" DESC LIMIT ? OFFSET ?  [["LIMIT", 30], ["OFFSET", 0]] 

第14章ーまとめー

  • has_manyでは指定した名称からモデルとの関連付けをおこなう(:micropostであればMicropostモデル)。belongs_toでは自動的に外部キーを生成する(:userであればuser_id)。これらが実際に紐づけたいものが異なる場合は、明示的に指定する必要がある。
  • has_many throughで中間のモデルを経由した関連付けが可能となる。
  • ユーザーのフォロー/フォローの解除はリレーションシップ(関係性)の作成/削除に該当する。
  • ルーティングはmemberメソッドやcollectionメソッドでネストさせることが出来る。
  • サブセレクトを活用することでSQLクエリの高速化を実現できる。
  • eager loadingによって1件のSQLクエリに複数のクエリを含めることによって、N+1クエリ問題を回避できる。

ちゃんと理解を深めた完走を達成出来ました。このブログでは、特に理解が難しかったところは紐解いておくようにしたので、今後同じようなロジックの部分で止まったときも自分なりに分かるように書いてあるリファレンスとして活用出来ると思います。

第14章、Ruby on Railsチュートリアルはこれにて終わり🐻