第13章ユーザーのマイクロポスト
ユーザーというリソースだけが、
Active Recordによってデータベース上のテーブルと紐付いています。
全ての準備が整った今、
ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していきます。
Userモデルとhas_manyおよびbelongs_toメソッドを使って関連付けを行い、さらに、結果を処理し表示するために必要なフォームとその部品を作成します
13.1 Micropostモデル
この新しいMicropostモデルもデータ検証とUserモデルの関連付けを含んでいます。
以前のモデルとは違って、
今回のマイクロポストモデルは完全にテストされ、
デフォルトの順序を持ち、また親であるユーザーが破棄された場合には自動的に破棄されるようにします。
13.1.1 基本的なモデル
Micropostモデルは、マイクロポストの内容を保存するcontent属性と、特定のユーザーとマイクロポストを関連付けるuser_id属性の2つの属性だけを持ちます。
マイクロポストの投稿にString型ではなくText型を使っている。
Text型の方が表現豊かなマイクロポストを実現できるから
Text型の方が将来における柔軟性に富んでいて
Text用のテキストエリアを使うため、
より自然な投稿フォームが実現できます。
rails generate model Micropost content:text user:references
ApplicationRecordを継承したモデルが作られます。
ただし、今回は生成されたモデルの中に、
ユーザーと1対1の関係であることを表す。
belongs_toのコードも追加されています。
user:referencesという引数も含めていたからです。
このgenerateコマンドはmicropostsテーブルを作成するための
マイグレーションファイルを生成します。
Userモデルとの最大の違いはreferences型を利用している点です。
これを利用すると、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、
UserとMicropostを関連付けする下準備をしてくれます。
インデックスが付与されたMicropostのマイグレーション
db/migrate/[timestamp]_create_microposts.rb
def change create_table :microposts do |t| t.text :content t.references :user, foreign_key: true t.timestamps end add_index :microposts, [:user_id, :created_at] end end
user_idとcreated_atカラムにインデックスが付与されていることに注目こうすることで、
user_idに関連付けられたすべてのマイクロポストを
作成時刻の逆順で取り出しやすくなります。
user_idとcreated_atの両方を1つの配列に含めている点にも注目です。
こうすることでActive Recordは、両方のキーを同時に扱う複合キーインデックス(Multiple Key Index) を作成します。
rails db:migrate
演習
1:RailsコンソールでMicropost.newを実行し、インスタンスを変数micropostに代入してください。
その後、user_idに最初のユーザーのidを、contentに
“Lorem ipsum” をそれぞれ代入してみてください。
この時点では、micropostオブジェクトのマジックカラム (created_atとupdated_at)には何が入っているでしょうか?
>> micropost = Micropost.new => #<Micropost id: nil, content: nil, user_id: nil, created_at: nil, updated_at: nil> >> micropost.user_id = User.first.id User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => 1 >> micropost.content = "Lorem ipsum" => "Lorem ipsum" >> micropost.created_at => nil >> micropost.updated_at => nil
2:先ほど作ったオブジェクトを使って、micropost.userを実行してみましょう。
どのような結果が返ってくるでしょうか? また、micropost.user.nameを実行した
場合の結果はどうなるでしょうか?
micropost.user User Load (1.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-04-07 13:46:42", updated_at: "2020-04-09 06:04:49", password_digest: "$2a$10$GANEfKFrSAstHXyU9iKuCuZCqGiIkWq8G9omIW2kfFD...", remember_digest: nil, admin: true, activation_digest: "$2a$10$PRCrEV3JLxiTFRfnbnQSquram12Njfl4cs9ieMebt3a...", activated: true, activated_at: "2020-04-07 13:46:41", reset_digest: "$2a$10$rqLaoPfGBIsYQ83ULnk3JO4ozC9kCB1tw2Z9dF55ysh...", reset_sent_at: "2020-04-09 06:04:49">
micropostにidを付与することで、userとnameがきちんと紐づいている。
3:先ほど作ったmicropostオブジェクトをデータベースに保存してみましょう。
この時点でもう一度マジックカラムの内容を調べてみましょう。
今度はどのような値が入っているでしょうか?
micropost.save (0.1ms) SAVEPOINT active_record_1 SQL (2.1ms) INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2020-04-09 13:48:05.586960"], ["updated_at", "2020-04-09 13:48:05.586960"]] (0.1ms) RELEASE SAVEPOINT active_record_1 => true >> micropost.created_at => Thu, 09 Apr 2020 13:48:05 UTC +00:00 >> micropost.updated_at => Thu, 09 Apr 2020 13:48:05 UTC +00:00
13.1.2 Micropostのバリデーション
test/models/micropost_test.rb
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
# このコードは慣習的に正しくない
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
end
user_id
に対する検証 greenapp/models/micropost.rb
class Micropost < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
end
rails test:models
test/models/micropost_test.rb
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
test "content should be present" do
@micropost.content = " "
assert_not @micropost.valid?
end
test "content should be at most 140 characters" do
@micropost.content = "a" * 141
assert_not @micropost.valid?
end
end
app/models/micropost.rb
class Micropost < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
rails test
演習
Railsコンソールを開き、user_id
とcontent
が空になっているmicropostオブジェクトを作ってみてください。このオブジェクトに対してvalid?
を実行すると、失敗することを確認してみましょう。また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?
動作確認するだけ
コンソールを開き、今度はuser_id
が空でcontent
が141文字以上のmicropostオブジェクトを作ってみてください。このオブジェクトに対してvalid?
を実行すると、失敗することを確認してみましょう。また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?
動作確認するだけ
13.1.3 User/Micropostの関連付け
belongs_to/has_many関連付けを使うことで
↓に示すようなメソッドをRailsで使えるようになります
このメソッドになっていることに注意してください。
user.microposts.create user.microposts.create! user.microposts.build
@user = users(:michael) # このコードは慣習的に正しくない @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
という書き方が、次のように書き換えられます。
@user = users(:michael) @micropost = @user.microposts.build(content: "Lorem ipsum")
buildメソッドはオブジェクトを返しますがデータベースには反映されません。
一度正しい関連付けを定義してしまえば、@micropost変数のuser_idには、関連するユーザーのidが自動的に設定されます。
メソッド 用途 micropost.user Micropostに紐付いたUserオブジェクトを返す user.microposts Userのマイクロポストの集合をかえす user.microposts.create(arg) userに紐付いたマイクロポストを作成する user.microposts.create!(arg) userに紐付いたマイクロポストを作成する (失敗時に例外を発生) user.microposts.build(arg) userに紐付いた新しいMicropostオブジェクトを返す user.microposts.find_by(id: 1) userに紐付いていて、idが1である マイクロポストを検索する user/micropost関連メソッドのまとめ
一方、Userモデルの方では、has_many :micropostsと追加する必要があります。 ここは自動的に生成されないので、手動で追加してください
マイクロポストがユーザーに所属する (belongs_to) 関連付け
app/models/micropost.rb
class Micropost < ApplicationRecord belongs_to :user validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end
ユーザーがマイクロポストを複数所有する (has_many) 関連付け green
app/models/user.rb
#追加 has_many :microposts
慣習的に正しくマイクロポストを作成する green
test/models/micropost_test.rb
require 'test_helper' class MicropostTest < ActiveSupport::TestCase def setup @user = users(:michael) @micropost = @user.microposts.build(content: "Lorem ipsum") end test "should be valid" do assert @micropost.valid? end test "user id should be present" do @micropost.user_id = nil assert_not @micropost.valid? end . . . end
演習
1:データベースにいる最初のユーザーを変数userに代入してください。そのuserオブジェクトを使ってmicropost = user.microposts.create(content: “Lorem ipsum”)を実行すると、どのような結果が得られるでしょうか?
micropost = user.microposts.create(content: "Lorem ipsum") SQL (6.6ms) INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2020-04-10 00:33:04.704864"], ["updated_at", "2020-04-10 00:33:04.704864"]] => #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2020-04-10 00:33:04", updated_at: "2020-04-10 00:33:04">
2:先ほどの演習課題で、データベース上に新しいマイクロポストが追加されたはずです。user.microposts.find(micropost.id)を実行して、
本当に追加されたのかを確かめてみましょう。また、先ほど実行した
micropost.idの部分をmicropostに変更すると、結果はどうなるでしょうか?
user.microposts.find(micropost.id) Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? AND "microposts"."id" = ? LIMIT ? [["user_id", 1], ["id", 1], ["LIMIT", 1]] => #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2020-04-10 00:33:04", updated_at: "2020-04-10 00:33:04"> ArgumentError (You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`.)
3:user == micropost.userを実行した結果はどうなるでしょうか?
また、user.microposts.first == micropost を実行した結果は
どうなるでしょうか? それぞれ確認してみてください。
user == micropost.user => true >> user.microposts.first == micropost Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ? [["user_id", 1], ["LIMIT", 1]] => true
13.1.4 マイクロポストを改良する
ユーザーのマイクロポストを特定の順序で取得できるようにしたり、
マイクロポストをユーザーに依存させて、ユーザーが削除されたらマイクロポストも自動的に削除されるようにしていきます。
デフォルトのスコープ
user.micropostsメソッドはデフォルトでは読み出しの順序に対して何も保証しませんが、
最も新しいマイクロポストを最初に表示するようにしてみましょう。
これを実装するためには、default scopeというテクニックを使います。
まずデータベース上の最初のマイクロポストが、
fixture内のマイクロポスト (most_recent) と同じであるか検証するテストを書いていきましょう。
マイクロポストの順序付けをテストする red
test/models/micropost_test.rb
require 'test_helper' class MicropostTest < ActiveSupport::TestCase . . . test "order should be most recent first" do assert_equal microposts(:most_recent), Micropost.first end end
マイクロポスト用のfixture test/fixtures/microposts.yml orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> tau_manifesto: content: "Check out the @tauday site by @mhartl: http://tauday.com" created_at: <%= 3.years.ago %> cat_video: content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk" created_at: <%= 2.hours.ago %> most_recent: content: "Writing a short test" created_at: <%= Time.zone.now %> StandardError: No fixture named 'most_recent' found for fixture set 'microposts'
埋め込みRubyを使ってcreated_atカラムに値をセットしている点に注目してください
このカラムはRailsによって自動的に更新されるため基本的には手動で更新できないのですが、fixtureファイルの中では更新可能になっています。
rails test test/models/micropost_test.rb
次に、Railsのdefault_scopeメソッドを使ってこのテストを成功させます。
このメソッドは、データベースから要素を取得したときの、
デフォルトの順序を指定するメソッドです。特定の順序にしたい場合
は、default_scopeの引数にorderを与えます。
例えば、created_atカラムの順にしたい場合は次のようになります。
default_scopeでマイクロポストを順序付ける
app/models/micropost.rb
#追加 default_scope -> { order(created_at: :desc) }
ラムダ式 (Stabby lambda) という文法を使っています。これは、Procやlambda(もしくは無名関数)と呼ばれるオブジェクトを作成する文法です。
->というラムダ式は、ブロックを引数に取り、Procオブジェクトを返します。
このオブジェクトは、callメソッドが呼ばれたとき、
ブロック内の処理を評価します。この構文をコンソールで確かめてみましょう。
-> { puts "foo" } => #<Proc:0x007fab938d0108@(irb):1 (lambda)> >> -> { puts "foo" }.call foo => nil
rails test
ユーザーが削除された場合、ユーザーのマイクロポストも同様に削除されるべきです。
この振る舞いは、has_manyメソッドにオプションを渡してあげることで実装できます
マイクロポストは、その所有者 (ユーザー) と一緒に破棄されることを保証する
app/models/user.rb
class User < ApplicationRecord has_many :microposts, dependent: :destroy
dependent: :destroyというオプションを使うと、
ユーザーが削除されたときに、そのユーザーに紐付いた
(そのユーザーが投稿した)マイクロポストも一緒に削除されるようになります
dependent: :destroyのテスト green
test/models/user_test.rb
require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "associated microposts should be destroyed" do @user.save @user.microposts.create!(content: "Lorem ipsum") assert_difference 'Micropost.count', -1 do @user.destroy end end end
演習
1:Micropost.first.created_atの実行結果と、Micropost.last.created_atの実行結果を比べてみましょう。
Micropost.first.created_at Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" ASC LIMIT ? [["LIMIT", 1]] => Tue, 29 Jan 2019 07:36:23 UTC +00:00 Micropost.last.created_at Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."id" DESC LIMIT ? [["LIMIT", 1]] => Fri, 10 Apr 2020 00:33:04 UTC +00:00
2:Micropost.firstを実行したときに発行されるSQL文はどうなっているでしょうか? 同様にして、Micropost.lastの場合はどうなっているでしょうか?
それぞれをコンソール上で実行したときに表示される文字列が、SQL文になります。
Micropost.first Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" DESC LIMIT ? [["LIMIT", 1]] => #<Micropost id: 2, content: "test", user_id: 2, created_at: "2020-04-10 01:43:31", updated_at: "2021-04-10 01:43:31"> Micropost.last Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" ASC LIMIT ? [["LIMIT", 1]] => #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2020-04-10 00:33:04", updated_at: "2021-04-10 00:33:04">
firstはDesk降順で取り出してlastはASC昇順で取り出している。
3:データベース上の最初のユーザーを変数userに代入してください。
そのuserオブジェクトが最初に投稿したマイクロポストのidは
いくつでしょうか?
次に、destroyメソッドを使ってそのuserオブジェクトを削除してみてください。削除すると、そのuserに紐付いていた
マイクロポストも削除されていることを
Micropost.findで確認してみましょう
>> user = User.first User Load (0.1ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User id: 1, name: "YUUKI", email: "yuukitetsuyanet@gmail.com", created_at: "2019-01-27 11:45:56", updated_at: "2019-01-28 10:06:18", password_digest: "$2a$10$RtF8nSd22GLENhTmlBTkPeaqyv7f3UeR6UecG...", remember_digest: nil, admin: false, ¥: nil, activation_digest: "$2a$10$f3kgl8ZWwRuixqBNDohcb.qkPwxw1Pq16gM...", activated: true, activated_at: "2019-01-27 11:46:05", reset_digest: "$2a$10$cs997A4KK0w9hg6cv82z0gw06pZOzIX3x/p...", reset_sent_at: "2019-01-28 10:05:13"> >> user.microposts.first Micropost Load (0.1ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ? [["user_id", 1], ["LIMIT", 1]] => #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-29 07:36:23", updated_at: "2019-01-29 07:36:23"> >> user_f = user.microposts.first Micropost Load (0.1ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ? [["user_id", 1], ["LIMIT", 1]] => #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-29 07:36:23", updated_at: "2019-01-29 07:36:23"> >> user_f.destroy (0.1ms) begin transaction SQL (2.2ms) DELETE FROM "microposts" WHERE "microposts"."id" = ? [["id", 1]] (6.1ms) commit transaction => #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2019-01-29 07:36:23", updated_at: "2019-01-29 07:36:23">>> Micropost.find(1) Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] ActiveRecord::RecordNotFound: Couldn't find Micropost with 'id'=1 from (irb):37