データモデルの問題 (および解決策)
別記事で解説します。
演習 - データモデルの問題 (および解決策)
1. 図 14.7のid=1のユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか? 想像してみてください。
ヒント: 4.3.2で紹介した
map(&:method_name)のパターンを思い出してください。例えばuser.following.map(&:id)の場合、idの配列を返します。
>> user = User.find(1)
>> user.following.map(&:id)
=> [2, 7, 10, 8]
このようになるのではないでしょうか。
2.1. 図 14.7を参考にして、id=2のユーザーに対してuser.followingを実行すると、結果はどのようになるでしょうか?
>> user = User.find(2)
>> user.following
=> #<ActiveRecord::Relation [#<User id: 1, name: "Michael Hartl", email: "mhartl@example.com">]
このようになるのではないでしょうか。
2.2. また、同じユーザーに対してuser.following.map(&:id)を実行すると、結果はどのようになるでしょうか?
>> user = User.find(2)
>> user.following.map(&:id)
=> [1]
このようになるのではないでしょうか。
User/Relationshipの関連付け
別記事で解説します。
演習 - User/Relationshipの関連付け
1. コンソールを開き、表 14.1のcreateメソッドを使ってActiveRelationshipを作ってみましょう。データベース上に2人以上のユーザーを用意し、最初のユーザーが2人目のユーザーをフォローしている状態を作ってみてください。
>> active_relationship = User.first.active_relationships.create(followed_id: 2)
  User Load (0.3ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
   (0.2ms)  SAVEPOINT active_record_1
  User Load (0.8ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  SQL (24.2ms)  INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["follower_id", 1], ["followed_id", 2], ["created_at", "2020-01-24 10:14:20.701055"], ["updated_at", "2020-01-24 10:14:20.701055"]]
   (0.3ms)  RELEASE SAVEPOINT active_record_1
=> #<Relationship id: 1, follower_id: 1, followed_id: 2, created_at: "2020-01-24 10:14:20", updated_at: "2020-01-24 10:14:20">
active_relationships.createメソッドにより、「id=1のユーザーがid=2のユーザーをフォローしている」という状態を作ったところです。以下のような処理が実行されているのがわかります。
- 
usersテーブルに対してSQLのSELECT文を発行し、フォローするユーザーとフォローされるユーザー双方の実在を確認する
- 
relationships文に対してSQLのINSERT文を発行し、実際にリレーションシップを作成する
- 
active_relationships.createメソッドの戻り値は、作成されたリレーションシップとなる
2. 先ほどの演習を終えたら、active_relationship.followedの値とactive_relationship.followerの値を確認し、それぞれの値が正しいことを確認してみましょう。
>> active_relationship.followed
=> #<User id: 2, name: "Dewitt Kub", email: "example-1@railstutorial.org", ...略>
>> active_relationship.follower
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", ...略>
先ほど作成したリレーションシップは「id=1のユーザーがid=2のユーザーをフォローする」というものだったので、「active_relationship.followedがid=2のユーザー」「active_relationship.followerがid=1のユーザー」となるのは、確かに正しいです。
Relationshipのバリデーション
別記事で解説します。
演習 - Relationshipのバリデーション
リスト 14.5のバリデーションをコメントアウトしても、テストが成功したままになっていることを確認してみましょう。
(以前のRailsのバージョンでは、このバリデーションが必須でしたが、Rails 5から必須ではなくなりました。今回はフォロー機能の実装を優先しますが、この手のバリデーションが省略されている可能性があることを頭の片隅で覚えておくと良いでしょう。)
app/models/relationship.rbの内容が以下のように変更された場合、app/models/relationship.rbに対するテストの結果はどうなるでしょうか。
  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
実際にテストを行っていきます。
# rails test test/models/relationship_test.rb
Running via Spring preloader in process 142
Started with run options --seed 9230
  3/3: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.83259s
3 tests, 3 assertions, 0 failures, 0 errors, 0 skips
テストは成功しましたね。
フォローしているユーザー
別記事で解説します。
演習 - フォローしているユーザー
1.コンソールを開き、リスト 14.9のコードを順々に実行してみましょう。
>> user = User.first
=> #<User id: 1, ...略>
>> other_user = User.second
=> #<User id: 2, ...略>
>> user.following?(other_user)
=> false
>> user.follow(other_user)
=> #<ActiveRecord::Associations::CollectionProxy [#<User id: 2, ...略>]>
>> user.following?(other_user)
=> true
>> user.unfollow(other_user)
=> #<Relationship id: 1, follower_id: 1, followed_id: 2, ...略>
>> user.following?(other_user)
=> false
なお、followメソッドの戻り値の型であるActiveRecord::Associations::CollectionProxyについては、以下のような言及があります。
2. 先ほどの演習の各コマンド実行時の結果を見返してみて、実際にはどんなSQLが出力されたのか確認してみましょう。
※以下、SQL文の内容がわかりやすいようにするために、適宜改行や字下げを挿入しています。
user.following?(other_user)
以下のようなSQL文が発行されました。SQL文の前にはUser Existsとついています。
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", 1],
    ["id", 2],
    ["LIMIT", 1]
  ]
なお、User ExistsなSQL文に使われる「SELECT 1 AS one」というのは、「レコードそのものは必要としないので捨て、常に1という定数を返す1」ということを意味するそうです。
user.follow(other_user)
user.follow(other_user)というメソッドを実行した際、「relationshipsテーブルにレコードを追加する」という処理に対して発行されるSQL文は以下のようになります。
INSERT INTO
  "relationships"
    ("follower_id", "followed_id", "created_at", "updated_at")
    VALUES (?, ?, ?, ?)[
      ["follower_id", 1],
      ["followed_id", 2],
      ["created_at", "2020-01-27 22:43:05.533738"],
      ["updated_at", "2020-01-27 22:43:05.533738"]
    ]
followメソッドの戻り値を返すために、以下のようなSQL文も発行されています。
SELECT "users".*
  FROM "users"
    INNER JOIN "relationships"
      ON "users"."id" = "relationships"."followed_id"
  WHERE "relationships"."follower_id" = ? LIMIT ?  [
    ["follower_id", 1],
    ["LIMIT", 11]
  ]
前述の通り、followメソッドの戻り値はActiveRecord::Associations::CollectionProxy型のオブジェクトとなります。
user.unfollow(other_user)
user.unfollow(other_user)というメソッドを実行した際、「relationshipsテーブルからレコードを削除する」という処理に対して発行されるSQL文は以下のようになります。
DELETE
  FROM "relationships"
  WHERE "relationships"."id" = ?  [
    ["id", 1]
  ]
実際にDELETE文が発行する得る前に、以下のSQL文が発行されています。unfollowメソッドの戻り値は、以下のSQL文により返ってくるrelationship型のレコードとなります。その型はRelationship型です。
SELECT "relationships".*
  FROM "relationships"
  WHERE "relationships"."follower_id" = ?
    AND "relationships"."followed_id" = ?
    LIMIT ?
    [
      ["follower_id", 1],
      ["followed_id", 2],
      ["LIMIT", 1]
    ]
フォロワー
user.followingメソッドに続いては、user.followersメソッドの実装も必要となります。
受動的関係の実際のユースケース
受動的関係の実際の実装は、能動的関係の実装と非常に類似しています。というか、使用する交差テーブルは能動的関係と同じものになります。
受動的関係の実際のユースケースは、以下のような図で模式化することができます。
上図では交差テーブルの名前がpassive_relationshipsとありますが、実際のアプリケーションでは能動的関係と交差テーブルを共用するので、正式なテーブル名はrelationshipsとなります。ポイントは以下です。
- 能動的関係と同じくrelationshipsテーブルを用いる
- 1つのfollowed_idに対し、relationshipsテーブルを通じて複数のfollower_idが紐付けられている
Userモデルにおける受動的関係の実装
関係そのものの名前はpassive_relationshipsとします。実際の実装は以下のようになります。
has_many :passive_relationships, class_name: "Relationship",
                                foreign_key: "followed_id",
                                  dependent: :destroy
has_many :followers, through: :passive_relationships, source: :follower
現時点のUserモデルに追加するコードは以下のようになります。
  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
source: followerは、省略可能だがあえて明記している
has_many :followers, through: :passive_relationships, source: :follower
上記のコードにおける:sourceキーは、実は省略可能です。「関係性の名前を:followersと定義していること」がポイントですね。この場合、Rails[^Active Record]自身が持つ機能により、followersの単数形からfollower_idという名前のカラムが暗黙的に検索されます。
[^Active Record]: より正確には、RailsのActive Recordライブラリです。
それでもあえて:sourceキーを明記しているのは、Railsチュートリアル本文における記載によれば、「has_many: :followingとの類似性を、見た目上強調する意味がある」ということです。「一目見てコードの意味がわかることは重要」というのは、プログラミングの大原則であり、理にかなったものです。
followersに対するテスト
followers関係性の動作は、followers.include?メソッドによってテストできます。
  require 'test_helper'
  class UserTest < ActiveSupport::TestCase
    ...略
    test "should follow and unfollow a user" do
      rhakurei  = users(:rhakurei)
      mkirisame = users(:mkirisame)
      assert_not rhakurei.following?(mkirisame)
      rhakurei.follow(mkirisame)
      assert rhakurei.following?(mkirisame)
+     assert mkirisame.followers.include?(rhakurei)
      rhakurei.unfollow(mkirisame)
      assert_not rhakurei.following?(mkirisame)
    end
  end
ここまでの実装が問題なくなされていれば、上記のテストは成功するはずです。
# rails test test/models/user_test.rb
Running via Spring preloader in process 292
Started with run options --seed 64495
  14/14: [=================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.14936s
14 tests, 26 assertions, 0 failures, 0 errors, 0 skips
また、テストスイート全体も成功するはずです。
# rails test
Running via Spring preloader in process 305
Started with run options --seed 592
  65/65: [=================================] 100% Time: 00:00:11, Time: 00:00:11
Finished in 11.40050s
65 tests, 338 assertions, 0 failures, 0 errors, 0 skips
演習 - フォロワー
1. コンソールを開き、何人かのユーザーが最初のユーザーをフォローしている状況を作ってみてください。最初のユーザーをuserとすると、user.followers.map(&:id)の値はどのようになっているでしょうか?
>> User.second.id
=> 2
>> User.third.id
=> 3
>> User.forty_two.id
=> 42
>> user = User.first
>> User.second.follow(user)
>> User.third.follow(user)
>> User.forty_two.follow(user)
>> user.followers.map(&:id)
=> [2, 3, 42]
2. 上の演習が終わったら、user.followers.countの実行結果が、先ほどフォローさせたユーザー数と一致していることを確認してみましょう。
>> user.followers.count
=> 3
>> user.followers.count == (user.followers.map(&:id)).count
=> true
確かに一致していますね。
3. user.followers.countを実行した結果、出力されるSQL文はどのような内容になっているでしょうか? また、user.followers.to_a.countの実行結果と違っている箇所はありますか?
ヒント: もし
userに100万人のフォロワーがいた場合、どのような違いがあるでしょうか? 考えてみてください。
user.followers.countを実行した際には、毎回以下のようなSQL文が発行されます。
SELECT COUNT(*)
  FROM "users"
    INNER JOIN "relationships"
      ON "users"."id" = "relationships"."follower_id"
  WHERE "relationships"."followed_id" = ?
  [
    ["followed_id", 1]
  ]
「user.followers.countの結果はキャッシュされず、SQL文が毎回発行される」というのは大きなポイントですね。
user.followers.to_a.countを実行した際には、初回に限り以下のようなSQL文が発行されます。レコードが既に読み込まれている場合、読み込まれたレコードを使うため、SQL文は発行されません。
SELECT "users".*
  FROM "users"
    INNER JOIN "relationships"
      ON "users"."id" = "relationships"."follower_id"
  WHERE "relationships"."followed_id" = ?
  [
    ["followed_id", 1]
  ]
user.followers.to_a.countの場合、usersテーブルのレコードの全カラムをアプリケーション側に読み込んでいます。しかしながら、結果がキャッシュされるため、SQL文の発行は1回で済みます。
レコード件数が多くなってくると、RDBへの問い合わせによるオーバーヘッドもどんどん大きくなっていきます。レコードの件数のデータが複数回必要になる場合、「アプリケーション側に結果を一時保存するような呼び出し方」を使うことを心がけたいものです。
余談 - user.followers.size
>> user.followers.size
   (0.3ms)  SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ?  [["followed_id", 1]]
=> 3
>> user.followers.size
   (0.3ms)  SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ?  [["followed_id", 1]]
=> 3
>> user.followers.to_a.count
  User Load (0.5ms)  SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ?  [["followed_id", 1]]
=> 3
>> user.followers.to_a.count
=> 3
>> user.followers.size
=> 3
>> user.followers.count
   (0.3ms)  SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ?  [["followed_id", 1]]
=> 3
user.followers.sizeの動作は以下のようになります。
- 
userの中身がキャッシュされていない場合、SQL文を発行してuser.followersの件数を出力する- この場合、user.followers.countと同じ動作となる
 
- この場合、
- 
userの中身がキャッシュされている場合、ローカルのキャッシュからuser.followersの件数を出力する- RDBにはアクセスしない
- この場合、毎回SQL文を発行するuser.followers.countとは異なる動作となる
 
user.followers.sizeについては、Railsアプリケーションを遅くする、ActiveRecordの3つの間違い:Count,Where,Present | Scout APM Blogの記述を参考にさせていただきました。
- 
「レコードの存在をチェックする目的の SELECT文で、何を返すのがよいか」というテーマでの問答として、SQL - EXISTS内のSELECTの列指定|teratailがあります。 ↩
