データモデルの問題 (および解決策)
別記事で解説します。
演習 - データモデルの問題 (および解決策)
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
と定義していること」がポイントですね。この場合、Rails2自身が持つ機能により、followers
の単数形からfollower_id
という名前のカラムが暗黙的に検索されます。
それでもあえて: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があります。 ↩ -
より正確には、RailsのActive Recordライブラリです。 ↩