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_id
とfollowed_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]
ボタン用のパーシャルでは、follow
とunfollow
のパーシャルに作業を振り分けている。
[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_ids
はhas_many :following
関連付けを行うとActive Recordによって自動生成されるメソッドであり、following.map(&:id)
を意味しており、user.followingにある各要素のidを呼び出し、フォローしているユーザーのidを配列として利用可能となる。
SQLのIN
句は、複数の値に合致するデータを抽出する動作なので、following_ids
で得られた配列に対しIN
句を利用することで、フォローしているユーザーのマイクロポストのデータを取得可能。
②の部分
これは元々のuser自身のマイクロポストを全て返す動作。
これによって、①と②の組み合わせで①フォローしているユーザーと②ユーザー自身のマイクロポストが取得できる。
また、?よりも同じ変数を複数の場所に挿入する場合はハッシュ形式でも便利。
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id", following_ids: following_ids, user_id: id)
サブセレクト
フィードの件数が膨大になるとスケール(処理量の調節)されないためる問題をSQLのサブセレクトで解決可能。
ここは以下の質問を参考にしました。サブセレクトを活用することでデータベースへの問い合わせがより少ない手法で記述することが可能といったところですかね。
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_match
でfollowing.count.to_s
とfollowers.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'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チュートリアルはこれにて終わり🐻