Railsチュートリアル第14章
はじめに
Ruby on Railsチュートリアル(第6版)のメモ、演習の解答例を記述した記事です。
解答は個人のものなので、誤りがあればご指摘ください。
開発環境 Ruby: 2.7.2 , Rails: 6.1.4
メモ
14章
14章ではフォロー機能を実装する。
フォロー機能を実装するには誰が誰をフォローしているのかという情報を保管して置く必要がある。
そこでRelationshipsモデルを生成する。
Relationshipsモデルはfollower_id(誰が)とfollowed_id(誰を)を持つテーブル
誰が誰をフォーローしているかが分かれば誰が誰にフォローされているのかが分かる。といったことを念頭に実装していく。
Relationshipsモデル
フォローしたユーザー、フォローされたユーザーが関係性を持ったデータモデルを作成していく。
ここでは次のようなモデルを作成していく
テーブル名:Relationship
フォローした人 :follower_id
フォローされた人:followed_id
例えば、
user_idが1のユーザーがuser_idが2のユーザーをフォローした場合
follwer_id = 1、followed_id = 2となる
検索の高速化のためのインデックスを追加、フォロー、フォロワーの組み合わせが一意であることを保証するために複合インデックスも追加する。
User/Relationshipの関連付け
今回作成したRelationshipモデルをUserモデルと関連付ける方法が以前と異なる
1. RelationshipモデルをActiveRelationshipモデルとして扱いたい
2.UserモデルからActiveRelationshipモデルのカラムをfollower_idをキーとして検索できるようにしたい
以上の2つが以前のモデルの関連付けのときと違うのかと思う。
1のRelationshipモデルをActiveRelationshipモデルとして扱うには
:class_nameオプションを用いる
実際のモデルを:class_nameオプションに指定することで実現する
2のUserモデルからActiveRelationshipモデルのカラムを検索するには
:foreign_keyオプションを用いる
Userモデルに外部キーを設定していないため:foreign_keyオプションで設定することで実現する
class User < ApplicationRecord has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy . . . end
Userモデルと関連付ける
belongs_toでは1対1のつながりを設定する際にモデル名のシンボルを渡す
以前は以下の書き方でUserモデルとMicropostモデルを関連付けた。
そしてmicropost.user_idで関連付けられた値を取得できた。
class Micropost < ApplicationRecord belongs_to :user . . . end
しかし今回はactive_relationship.follower、active_relationship.followedを使いたい、User_idをfollower_id,follwed_idとして関連付けたいので以下のようなコードになる。
:class_nameオプションで実際のモデル名を指定する。
class Relationship < ApplicationRecord belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" end
関連付けを行った結果以下のメソッドが利用できるようになる
active_relationship.follower active_relationship.followed user.active_relationships.create(followed_id: other_user.id) user.active_relationships.create!(followed_id: other_user.id) user.active_relationships.build(followed_id: other_user.id)
多対多の関係を持ったモデル
多対多の関係を持たせるには、has_many :through関連付けを行う。
この関連付けをするには2つのモデルの間に第3のモデルが必要になる。
今回UserモデルとUserモデル同士の関連付けを行い、その仲介役としてActiveRelationshipモデルを設ける。
:throughオプションには仲介役のモデルをシンボルで渡す。
今回はactive_relationshipsを渡す。
参考:Active Record の関連付け - Railsガイド
次のコードでは
UserモデルがRelationshipテーブルからfollower_idを検索してフォローしているユーザーを取得できる
has_many :followeds, through: :active_relationships
例えばユーザーID1のユーザーがユーザーID2,3のユーザーをフォローしているして、user.followedでユーザー2,3のオブジェクトを(配列で)取得できる。
英語圏の問題でuser.followedsという名前は不適切なので
user.followingとしてユーザーがフォローしているユーザーを取得できるようにする。上記のコードを変更したコードが下記になる。
has_many :following, through: :active_relationships, source: :followed
:sourceでは関連付けのもとの名前を指定している。
メンバールーティング
resourcesで生成されたRESTfulな7つのルーティングに対して、必要であればルーティングを追加することができる。
resources :users で生成されたRESTfulなルーティングにメンバールーティングを追加。
resources :users do member do get :following, :followers end end
Ajax
Ajax...AsynchronousJavaScript And XML、非同期処理のこと
チュートリアルではremote: trueとして設定するように書かれていますがそのとおりに実装するとうまくいきませんでした。
Railsガイドを見るとform_withはAjaxをデフォルトで使えることを前提としているようです。
form_withは:localオプションを指定しない場合Ajaxを使うことになります。
なので対処としては、local: falseとするか、local: true自体を削除するかでAjaxを使えるようになります。
local: trueを消して動かしてみたらAjaxが機能しませんでした。Railsのバージョンが関係してくるのでしょうか。。。
Ajaxのテスト
Ajaxでのテストをするときはxhr :trueオプションを使う
assert_difference '@user.following.count', 1 do post relationships_path, params: { followed_id: @other.id }, xhr: true end
xhr(XmlHttpRequest)オプションをtrueにすることでAjaxでリクエストを発行するようにできる。
mapメソッドの短縮表記
mapメソッド
irb(main):001:0> [1,2,3,4].map{ |i| i.to_s } => ["1", "2", "3", "4"]
上記を短縮表記で記述
irb(main):002:0> [1,2,3,4].map(&:to_s) => ["1", "2", "3", "4"]
フォローしているユーザーのIDを配列で取り出す
irb(main):003:0> 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]
上記のコードは便利なのでActiveRecordeでメソッドが用意されている。
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]
このメソッドはhas_many: followingの関連付けをしたときにActiveRecordが自動生成したもの。
取得したIDを文字列として連結
irb(main):005:0> 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"
whereメソッド
ActiveRecordのwhereメソッドでデータベースから条件に合うデータを取得する。
マイクロソフトからフォローしているユーザーの投稿と自分の投稿を取得
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
SQLのサブセレクト
データベースの問い合わせを減らして高速化を図る
following_idsメソッドを呼ぶとSQLがデータベースに発行される。
よって以下のコードではfollowing_idsでデータベースに問い合わせたあともう一度Micropost.whereで問い合わせており合計2回の問い合わせをしている。
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
そこで
following_idsをSQL文に置き換え、これをサブセレクトとして使う。
following_ids = "SELECT followed_id FROM relationships WHERE follower_id = :user_id"
そしてこのfollowing_idsを利用してデータベースに問い合わせる
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)
このように書くことでデータベースへの問い合わせが1回になり高速化、効率化につながる。
演習
14.1.1
演習1
図 14.7のid=1のユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか? 想像してみてください。ヒント: 4.3.2で紹介したmap(&:method_name)のパターンを思い出してください。例えばuser.following.map(&:id)の場合、idの配列を返します。
id=1のユーザーはidが2、7、8、10のユーザーをフォローしている。
よって、user.following.map(&:id)を実行するとid = 2, 7, 8, 10 の配列を返す。
演習2
図 14.7を参考にして、id=2のユーザーに対してuser.followingを実行すると、結果はどのようになるでしょうか? また、同じユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか? 想像してみてください。
どのユーザーがid=2のユーザーに対してuser.followingを実行したのかが書かれていないのでid=1のユーザーがid=2のユーザーをフォローしたとする。
id=1のユーザーはid=2のユーザーをすでにフォローしているのでフォローできない。
user.following.map(&:id)を実行するとid=1がフォローしているユーザーid = 2,7,8,10 の配列を取得する。
14.1.2
演習1
コンソールを開き、表 14.1のcreateメソッドを使ってActiveRelationshipを作ってみましょう。データベース上に2人以上のユーザーを用意し、最初のユーザーが2人目のユーザーをフォローしている状態を作ってみてください
rb(main):001:0> user1 = User.first (0.8ms) SELECT sqlite_version(*) TRANSACTION (0.1ms) begin transaction User Load (0.6ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User id: 1, name: "Example User", email: "example@railstutorial.or... irb(main):002:0> user2 = User.second User Load (0.4ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ? [["LIMIT", 1], ["OFFSET", 1]] => #<User id: 2, name: "Robin Treutel", email: "example-1@railstutorial... irb(main):003:0> active_relationship = user1.active_relationships.create!(fo llowed_id: user2.id) TRANSACTION (0.1ms) SAVEPOINT active_record_1 User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] Relationship Create (1.1ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 1], ["followed_id", 2], ["created_at", "2021-07-20 16:32:13.643703"], ["updated_at", "2021-07-20 16:32:13.643703"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 => #<Relationship id: 1, follower_id: 1, followed_id: 2, created_at: "2... irb(main):004:0> Relationship.first Relationship Load (0.7ms) SELECT "relationships".* FROM "relationships" ORDER BY "relationships"."id" ASC LIMIT ? [["LIMIT", 1]] => #<Relationship id: 1, follower_id: 1, followed_id: 2, created_at: "2021-07-20 16:32:13.643703000 +0000", updated_at: "2021-07-20 16:32:13.643703000 +0000">
演習2
先ほどの演習を終えたら、active_relationship.followedの値とactive_relationship.followerの値を確認し、それぞれの値が正しいことを確認してみましょう。
irb(main):005:0> active_relationship.followed => #<User id: 2, name: "Robin Treutel", email: "example-1@railstutorial.org", created_at: "2021-07-16 10:22:37.201055000 +0000", updated_at: "2021-07-16 10:22:37.201055000 +0000", password_digest: [FILTERED], remember_digest: nil, admin: false, activation_digest: "$2a$12$nlGp6dNoTuBZTTqfYFVKb.VG/meRRlIX8VfYVg2P7FR...", activated: true, activated_at: "2021-07-16 10:22:36.947268000 +0000", reset_digest: nil, reset_sent_at: nil> irb(main):006:0> active_relationship.follower => #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2021-07-16 10:22:36.621010000 +0000", updated_at: "2021-07-16 10:22:36.621010000 +0000", password_digest: [FILTERED], remember_digest: nil, admin: true, activation_digest: "$2a$12$3UocKEklYfAfEG9ow377meLe8YO9E7pdrWIuscmrghY...", activated: true, activated_at: "2021-07-16 10:22:36.362398000 +0000", reset_digest: nil, reset_sent_at: nil>
14.1.5
演習1
irb(main):001:0> user = User.first (0.8ms) SELECT sqlite_version(*) TRANSACTION (0.0ms) begin transaction User Load (0.4ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User id: 1, name: "Example User", email: "example@railstutorial.or... irb(main):002:1* (2..5).each do |n| irb(main):003:1* User.find(n).follow(user) irb(main):004:0> end User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] TRANSACTION (0.1ms) SAVEPOINT active_record_1 User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] Relationship Create (0.5ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 2], ["followed_id", 1], ["created_at", "2021-07-21 15:02:26.930956"], ["updated_at", "2021-07-21 15:02:26.930956"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]] TRANSACTION (0.0ms) SAVEPOINT active_record_1 User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]] Relationship Create (0.8ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 3], ["followed_id", 1], ["created_at", "2021-07-21 15:02:26.935993"], ["updated_at", "2021-07-21 15:02:26.935993"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]] TRANSACTION (0.0ms) SAVEPOINT active_record_1 User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]] Relationship Create (0.1ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 4], ["followed_id", 1], ["created_at", "2021-07-21 15:02:26.939977"], ["updated_at", "2021-07-21 15:02:26.939977"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]]MIT ? [["id", 5], ["LIMIT", 1]] Relationship Create (0.1ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 5], ["followed_id", 1], ["created_at", "2021-07-21 15:02:26.944312"], ["updated_at", "2021-07-21 15:02:26.944312"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 => 2..5 irb(main):005:0> user.followers.map(&:id) User Load (0.4ms) SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]] => [2, 3, 4, 5]
演習2
上の演習が終わったら、user.followers.countの実行結果が、先ほどフォローさせたユーザー数と一致していることを確認してみましょう。
irb(main):006:0> user.followers.count (0.7ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]] => 4
演習3
user.followers.countを実行した結果、出力されるSQL文はどのような内容になっているでしょうか? また、user.followers.to_a.countの実行結果と違っている箇所はありますか? ヒント: もしuserに100万人のフォロワーがいた場合、どのような違いがあるでしょうか? 考えてみてください。
結果は演習2と同じ4となっている。
user.followers.to_a.countとするとuserをフォローしているユーザーの情報を一度配列にしてメモリ上に保存する。なのでフォローワーがたくさんいると配列を生成する無駄な処理が増え、また無駄なメモリ資源も出てしまう。
irb(main):007:0> user.followers.to_a.count => 4
14.2.2
演習3
Homeページに表示されている統計情報に対してテストを書いてみましょう。ヒント: リスト 13.28で示したテストに追加してみてください。同様にして、プロフィールページにもテストを追加してみましょう。
リンクがあるかテスト
test "layout links when 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_select "a[href=?]", login_path, count: 0 #演習 assert_select "a[href=?]", following_user_path(@user) assert_select "a[href=?]", followers_user_path(@user) 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_select 'div.stats' 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 end end
14.2.6
演習1
リスト 14.36のrespond_toブロック内の各行を順にコメントアウトしていき、テストが正しくエラーを検知できるかどうか確認してみましょう。実際、どのテストケースが落ちたでしょうか?
format.htmlの行をコメントアウトして行ったテストでエラーが出た。
エラーになったテストケース
test_should_follow_a_user_the_standard_way
test_should_unfollow_a_user_the_standard_way
format.jsの行をコメントアウトして行ったテストではエラーが出なかった。
演習2
リスト 14.40のxhr: trueがある行のうち、片方のみを削除するとどういった結果になるでしょうか? このとき発生する問題の原因と、なぜ先ほどの演習で確認したテストがこの問題を検知できたのか考えてみてください。
※演習文が理解しづらかったので、自分なりの解釈での解答を記述します。
test_should_follow_a_user_the_standard_wayのxhr: trueがある行を削除してテストを行った。結果はRED(failures:1)だった。
問題について
・演習1でformat.htmlをコメントアウトしてエラーが出た原因
ActionController::UnknownFormatエラーが出ていた
つまり原因としてはアクションに対応するviewファイルがないとのこと
・xhr: trueのある行をを削除したらfailsが出た原因
ブロック内の処理が行われていないから
この2つの問題から起因することを考えてみましたが思いつきませんでした。
14.3.1
演習1
マイクロポストのidが正しく並んでいると仮定して(すなわち若いidの投稿ほど古くなる前提で)、図 14.22のデータセットでuser.feed.map(&:id)を実行すると、どのような結果が表示されるでしょうか? 考えてみてください。ヒント: 13.1.4で実装したdefault_scopeを思い出してください。
user.feedで取り出した投稿のIDを格納した配列が表示される。
またdefault_scopeで投稿日時を降順に取得するようにしているので結果は次のようになる。
[ 10, 9, 7, 5, 4, 2, 1 ]
14.3.2
演習1
リスト 14.44において、現在のユーザー自身の投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.42のどのテストが失敗するでしょうか?
フォローしているユーザーの投稿を取得
Micropost.where("user_id IN (?)", following_ids )
結果がREDになったテスト
FAIL["test_feed_should_have_the_right_posts", #<Minitest::Reporters::Suite:0x000000010e2c5970 @name="UserTest">, 0.465544999926351] test_feed_should_have_the_right_posts#UserTest (0.47s) Expected false to be truthy. test/models/user_test.rb:99:in `block (2 levels) in <class:UserTest>' test/models/user_test.rb:98:in `block in <class:UserTest>' FAIL["test_micropost_interface", #<Minitest::Reporters::Suite:0x000000010bd23398 @name="MicropostsInterfaceTest">, 1.2411800000118092] test_micropost_interface#MicropostsInterfaceTest (1.24s) Expected exactly 1 element matching "div.pagination", found 0.. Expected: 1 Actual: 0 test/integration/microposts_interface_test.rb:12:in `block in <class:MicropostsInterfaceTest>'
演習2
リスト 14.44において、フォローしているユーザーの投稿を含めないようにするにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.42のどのテストが失敗するでしょうか?
フォローしているユーザー以外の投稿を取得
Micropost.where("user_id IN (?)", id)
結果がREDになったテスト
FAIL["test_feed_should_have_the_right_posts", #<Minitest::Reporters::Suite:0x000000010d851ca0 @name="UserTest">, 0.41781200002878904] test_feed_should_have_the_right_posts#UserTest (0.42s) Expected false to be truthy. test/models/user_test.rb:99:in `block (2 levels) in <class:UserTest>' test/models/user_test.rb:98:in `block in <class:UserTest>' FAIL["test_micropost_interface", #<Minitest::Reporters::Suite:0x000000010d017f08 @name="MicropostsInterfaceTest">, 1.1911460000555962] test_micropost_interface#MicropostsInterfaceTest (1.19s) Expected exactly 1 element matching "div.pagination", found 0.. Expected: 1 Actual: 0 test/integration/microposts_interface_test.rb:12:in `block in <class:MicropostsInterfaceTest>'
演習3
リスト 14.44において、フォローしていないユーザーの投稿を含めるためにはどうすれば良いでしょうか? また、そのような変更を加えると、リスト 14.42のどのテストが失敗するでしょうか? ヒント: 自分自身とフォローしているユーザー、そしてそれ以外という集合は、いったいどういった集合を表すのか考えてみてください。
すべてのユーザーの投稿を取得する。
Micropost.all
結果がREDになったテスト
FAIL["test_feed_should_have_the_right_posts", #<Minitest::Reporters::Suite:0x000000010805ca40 @name="UserTest">, 1.4157729999860749] test_feed_should_have_the_right_posts#UserTest (1.42s) Expected true to be nil or false test/models/user_test.rb:103:in `block (2 levels) in <class:UserTest>' test/models/user_test.rb:102:in `block in <class:UserTest>'
14.3.3
演習1
Homeページで表示される1ページ目のフィードに対して、統合テストを書いてみましょう。リスト 14.49はそのテンプレートです。
micropost.contentをエスケープして、マイクロソフトが表示されることを確認
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
演習2
リスト 14.49のコードでは、期待されるHTMLをCGI.escapeHTMLメソッドでエスケープしています(このメソッドは11.2.3で扱ったCGI.escapeと同じ用途です)。このコードでは、なぜHTMLをエスケープさせる必要があったのでしょうか? 考えてみてください。ヒント: 試しにエスケープ処理を外して、得られるHTMLの内容を注意深く調べてください。マイクロポストの内容が何かおかしいはずです。また、ターミナルの検索機能(Cmd-FもしくはCtrl-F)を使って「sorry」を探すと原因の究明に役立つはずです。
エスケープしないと改行文字や記号が特殊文字で出力されるため。
I'm sorry. Your words made sense, but your sarcastic tone did not.\n