基本的なモデル
Micropostモデルが持つ属性
Micropostモデルの基本となる属性は以下の2つです。
-
content
属性- マイクロポストの内容
-
user_id
属性- 特定のユーザーと、当該マイクロポストを関連付ける
実際には、Railsにより自動生成される属性もあるため、Micropostモデルの全体像は以下のようになります。
Text
型とは
RDBにおけるText
型は、(存在する場合)String
型とは異なる、文字列を内容として格納する型です。Railsチュートリアルでは、String
型と対比した場合のText
型の特徴として、以下の事柄が挙げられています。
- String型に対応するのはテキストフィールドであり、Test型に対応するのはテキストエリアである
- マイクロポストの投稿フィールドとして用いる場合、1行のテキストフィールドより複数行のテキストエリアのほうが自然である
- 将来における柔軟性に富む
- 言語に応じて投稿の長さを調整できる、など
以上のような理由から、今回のサンプルアプリケーションにおいて、マイクロポストの投稿・保存は、Text
型の属性を用いて行うこととします。
Micropostモデルの生成
モデルを生成するためのコマンドは、rails generate model
コマンドですね。今回は以下のコマンドを用いてMicropostモデルを生成します。
# rails generate model Micropost content:text user:references
Running via Spring preloader in process 1289
invoke active_record
create db/migrate/20191218224953_create_microposts.rb
create app/models/micropost.rb
invoke test_unit
create test/models/micropost_test.rb
create test/fixtures/microposts.yml
結果、以下のようなモデルが生成されます。例によってApplicationRecord
を継承しています。
class Micropost < ApplicationRecord
belongs_to :user
end
user:references
やbelongs_to :user
という、見慣れないコードが存在しますね。大まかには、「RDBのインデックスと外部キー参照が定義されたuser_id
カラムを自動追加し、UserとMicropostを紐付けする準備を行う」という意味合いのコードです。これらのコードが意味する事柄については、後の「User/Micropostの関連付け」の項で、より詳しく解説していきます。
Micropostモデルのマイグレーション
上述rails generate model
コマンドで生成されたMicropostモデルのマイグレーションは、初期状態では以下のようになっています。
class CreateMicroposts < ActiveRecord::Migration[5.1]
def change
create_table :microposts do |t|
t.text :content
t.references :user, foreign_key: true
t.timestamps
end
end
end
t.references :user, foreign_key: true
というのも、user:references
やbelongs_to :user
と同様、UserとMicropostの紐付けに関するコードです。
また、t.timestamps
というコードが自動で追加されています。「created_at
属性およびupdated_at
属性の定義と、その値の自動保存」を実装するためのコードです。Userモデルのマイグレーションでも、同様のコードが登場していましたね。
Micropostモデルのマイグレーションに、新たなインデックスを追加する
Micropostモデルにおいては、「user_id
に関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出す」という主要なユースケースが存在します。当該ユースケースを効率よく実行できるようにするために、必要なインデックスを追加していきましょう。すなわち、「user_id
属性とcreated_at
属性の組に対応するインデックス」ですね。
class CreateMicroposts < ActiveRecord::Migration[5.1]
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
複合キーインデックスとは
RDBにおいて、「複数の属性の組み合わせ」に対して設定されるインデックスのことを指します。「単一の属性では重複が発生する」という場合などに用いられます。なお、日本語では「複合インデックス」や「複数列インデックス」という言い回しのほうがより一般的なようです。
RailsのActive Recordに複合キーインデックスを作成させるには、対応するマイグレーションにおいて、add_index
の2番目の引数に、複合キーインデックスを構成するすべての属性を指すシンボルを配列で与える必要があります。
例えば、以下のような感じですね。
add_index :microposts, [:user_id, :created_at]
Micropostモデルの生成をRDBに反映する
例によってrails db:migrate
コマンドです。
# rails db:migrate
== [timestamp] CreateMicroposts: migrating =================================
-- create_table(:microposts)
-> 0.0213s
-- add_index(:microposts, [:user_id, :created_at])
-> 0.0058s
== [timestamp] CreateMicroposts: migrated (0.0349s) ========================
なお、現在RailsサーバーやRailsコンソールが起動されている場合は、先にexitしておく必要があります。そうでないとrails db:migrate
が失敗します。
演習 - 基本的なモデル
1.1. RailsコンソールでMicropost.new
を実行し、インスタンスを変数micropost
に代入してください。
# rails console
>> micropost = Micropost.new
=> #<Micropost id: nil, content: nil, user_id: nil, created_at: nil, updated_at: nil>
1.2. その後、user_id
に最初のユーザーのidを、content
に "Lorem ipsum" をそれぞれ代入してみてください。この時点では、 micropost
オブジェクトのマジックカラム (created_at
とupdated_at
) には何が入っているでしょうか?
>> micropost[:user_id] = 1
=> 1
>> micropost[:content] = "Lorem ipsum"
=> "Lorem ipsum"
>> pp micropost[:created_at]
nil
>> pp micropost[:updated_at]
nil
created_at
とupdated_at
の内容は、いずれもnil
になっていますね。
2.1. 先ほど作ったオブジェクトを使って、micropost.user
を実行してみましょう。どのような結果が返ってくるでしょうか?
>> micropost.user
User Load (2.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-12-02 23:07:26", updated_at: "2019-12-02 23:07:26", password_digest: "$2a$10$AXWNXZNDu9NkLx3tvgr80umb9xpbx1KwPz9ndp/7Pwr...", remember_digest: nil, admin: true, activation_digest: "$2a$10$Q.bVywQrrgEJC6Mg0IhdXONY5M/0jQYm4/ZEBwhJfxc...", activated: true, activated_at: "2019-12-02 23:07:26", reset_digest: nil, reset_sent_at: nil>
users
テーブルに対し、SQLのSELECT
文を、抽出対象カラムをid
・抽出条件をmicropost[:user_id]
として実行しています。
結果、id=1のUserオブジェクトが返ってきます。
2.2. また、micropost.user.name
を実行した場合の結果はどうなるでしょうか?
>> micropost.user.name
=> "Example User"
>> User.find(1).name
User Load (5.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> "Example User"
id=1であるUserオブジェクトのname
属性の値が返ってきます。User.find(1).name
と同じ結果ですね。
3.1. 先ほど作ったmicropost
オブジェクトをデータベースに保存してみましょう。
>> micropost.save
(0.5ms) begin transaction
SQL (19.6ms) INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2019-12-19 05:11:44.579268"], ["updated_at", "2019-12-19 05:11:44.579268"]]
(9.8ms) commit transaction
特にバリデーションが設定されていないため、何も考えずにmicropost.save
メソッドを実行しても、micropost
オブジェクトの内容が正しくRDBに保存されます。
3.2. この時点でもう一度マジックカラムの内容を調べてみましょう。今度はどのような値が入っているでしょうか?
>> pp micropost[:created_at]
Thu, 19 Dec 2019 05:11:44 UTC +00:00
>> pp micropost[:updated_at]
Thu, 19 Dec 2019 05:11:44 UTC +00:00
created_at
およびupdated_at
の内容には、先ほどのmicropost.save
のログに表示された時刻と同じ時刻が保存されています。
Micropostのバリデーション
Micropostモデルに対する最初のテスト
-
setup
メソッドで与えたMicropostオブジェクトが有効であることを確認する -
user_id
が存在しないMicropostオブジェクトが有効でないことを確認する
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:rhakurei)
#HACK: このコードは慣習的に正しくないため、要修正
@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
Micropostモデルに対する最初のテストの実行
# rails test test/models/micropost_test.rb
Running via Spring preloader in process 1340
Started with run options --seed 11643
2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.74715s
2 tests, 2 assertions, 0 failures, 0 errors, 0 skips
現時点においては、当該テストは問題なく成功します。
Micropostモデルに、テスト駆動で新たな機能を追加していく
長くなりましたので、別記事で解説します。
演習 - Micropostのバリデーション
1.1. Railsコンソールを開き、user_id
とcontent
が空になっているmicropost
オブジェクトを作ってみてください。このオブジェクトに対してvalid?
を実行すると、失敗することを確認してみましょう。
>> micropost = Micropost.new
=> #<Micropost id: nil, content: nil, user_id: nil, created_at: nil, updated_at: nil>
>> micropost.valid?
=> false
micropost.valid?
を実行すると、少々の待ち時間の後にfalse
という結果が返ってきます。
1.2. また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?
1.1. の続きとなります。
>> micropost.errors.full_messages
=> ["User must exist", "Content can't be blank"]
- User must exist
- Content can't be blank
以上のエラーメッセージが返ってきていますね。
2.1. コンソールを開き、今度はuser_id
が空でcontent
が141文字以上のmicropost
オブジェクトを作ってみてください。このオブジェクトに対してvalid?
を実行すると、失敗することを確認してみましょう。
>> micropost = Micropost.new(user_id: nil, content: "a" * 141)
=> #<Micropost id: nil, content: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...", user_id: nil, created_at: nil, updated_at: nil>
>> micropost.valid?
=> false
文字列の乗算により、141文字の文字列を生成し、その内容をcontent
の内容としています。
"a" * 141
2.2. また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?
>> micropost.errors.full_messages
=> ["User must exist", "Content is too long (maximum is 140 characters)"]
- User must exist
- Content is too long (maximum is 140 characters)
以上のエラーメッセージが返ってきていますね。
User/Micropostの関連付け
# rails generate model Micropost content:text user:references
belongs_to :user
t.references :user, foreign_key: true
Micropostモデルの生成に際して、以上のような見慣れないコードが登場しました。これらはどのような意味を持つのでしょうか。その解説です。
Micropost belongs to User
MicropostとそのUserは、belongs_to
(1対1)という関係性があります。
User has many Microposts
UserとそのMicropostには、has_many
(1対多)という関係性があります。
モデル定義に記述する、belongs_to
とhas_many
まず、Micropostモデルにbelongs_to :user
というコードが必要になります。ただ、このコードは、Micropostモデルの生成時点で自動で生成されています。
class Micropost < ApplicationRecord
belongs_to :user
# ...略
end
さらに、Userモデルにhas_many :microposts
というコードが必要になります。こちらは手動で追加する必要があります。
class User < ApplicationRecord
+ has_many :microposts
...略
end
User/Micropost関連メソッド
上述のbelongs_to
やhas_many
というモデル同士の関係性をモデルに記述することにより、以下のようなメソッドを利用することが可能になります。
メソッド名 | 用途 |
---|---|
micropost.user |
Micropostに紐付いたUserオブジェクトを返す |
user.microposts |
Userのマイクロポストの集合を返す |
user.microposts.create(arg) |
user に紐付いたマイクロポストを生成する。失敗時にはnil を返す |
user.microposts.create!(arg) |
user に紐付いたマイクロポストを生成する。失敗時には例外を発生させる |
user.microposts.build(arg) |
user に紐付いた新しいMicropostオブジェクトを返す |
user.microposts.find_by(id: 1) |
user に紐付いていて、id が1 であるマイクロポストを検索する |
Micropostモデルに対するテストのsetup
メソッドを、慣習的に正しいマイクロポストの生成方法に書き換えていく
関連するモデルにbelongs_to
とhas_many
を実装すれば、慣習的に正しいマイクロポストの生成を実装できるようになります。test/models/micropost_test.rb
のsetup
メソッドを、慣習的に正しいマイクロポストの生成方法に書き換えていきましょう。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:rhakurei)
- #HACK: このコードは慣習的に正しくないため、要修正
- @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
+ @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
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
Micropostモデルに対するテストのsetup
メソッドを、慣習的に正しいマイクロポストの生成方法をテストで実装すると、テストが成功しなくなる
この時点でtest/models/micropost_test.rb
に対するテストを実行すると、テストが成功しなくなります。
# rails test test/models/micropost_test.rb
Running via Spring preloader in process 1476
Started with run options --seed 20502
FAIL["test_user_id_should_be_present", MicropostTest, 0.7893391999969026]
test_user_id_should_be_present#MicropostTest (0.79s)
Expected true to be nil or false
test/models/micropost_test.rb:15:in `block in <class:MicropostTest>'
4/4: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.81867s
4 tests, 4 assertions, 1 failures, 0 errors, 0 skips
私の環境では、test/models/micropost_test.rb
の14行目〜15行目の内容は以下のようになっています。
@micropost.user_id = nil
assert_not @micropost.valid?
「@micropost.user_id
がnil
であるのに、@micropost.valid?
がtrue
を返してしまっている」という失敗ですね。
Micropostモデルに、ユーザーの存在性を検証するバリデーションを追加する
先ほどの問題を解消するためには、Micropostモデルに、ユーザーの存在性を検証するバリデーションを追加する必要があります。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 test/models/micropost_test.rb
Running via Spring preloader in process 1489
Started with run options --seed 47152
4/4: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.87582s
4 tests, 4 assertions, 0 failures, 0 errors, 0 skips
今度はテストが成功するようになります。
演習 - User/Micropostの関連付け
1. データベースにいる最初のユーザーを変数user
に代入してください。そのuser
オブジェクトを使ってmicropost = user.microposts.create(content: "Lorem ipsum")
を実行すると、どのような結果が得られるでしょうか?
>> user = User.first
>> micropost = user.microposts.create(content: "Lorem ipsum")
(0.1ms) begin transaction
SQL (15.5ms) INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2019-12-20 13:10:45.903917"], ["updated_at", "2019-12-20 13:10:45.903917"]]
(10.2ms) commit transaction
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2019-12-20 13:10:45", updated_at: "2019-12-20 13:10:45">
実際に生成されたマイクロポストが返ってきています。
2.1. 先ほどの演習課題で、データベース上に新しいマイクロポストが追加されたはずです。user.microposts.find(micropost.id)を実行して、本当に追加されたのかを確かめてみましょう。
>> user.microposts.find(micropost.id)
Micropost Load (6.3ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? AND "microposts"."id" = ? LIMIT ? [["user_id", 1], ["id", 2], ["LIMIT", 1]]
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2019-12-20 13:10:45", updated_at: "2019-12-20 13:10:45">
確かに 1. で追加されたマイクロポストを返してきています。
2.2. また、先ほど実行したmicropost.id
の部分をmicropost
に変更すると、結果はどうなるでしょうか?
>> user.microposts.find(micropost)
Traceback (most recent call last):
1: from (irb):5
ArgumentError (You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`.)
ArgumentError
を投げてきていますね。
「.find
にActiveRecord::Base
を継承したオブジェクトそのものを渡すとArgumentError
を投げる」というのは、Rails 5.1 以降の仕様となります。 - rails/CHANGELOG.md at v5.1.0 · rails/rails · GitHub
Raise ArgumentError when passing an ActiveRecord::Base instance to .find, > .exists? and .update. Rafael Mendonça França
3.1. user == micropost.user
を実行した結果はどうなるでしょうか?
>> user == micropost.user
=> true
3.2. また、user.microposts.first == micropost
を実行した結果はどうなるでしょうか? それぞれ確認してみてください。
>> user.microposts.first == micropost
Micropost Load (27.9ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ? [["user_id", 1], ["LIMIT", 1]]
=> false
当該ユーザーにより、今回の演習 1. 以前に一度マイクロポストを追加したことがありました。そのため、今回追加したマイクロポストとuser.microposts.first
は一致せず、user.microposts.first == micropostは
false`を返します。
>> user.microposts.first
Micropost Load (7.8ms) 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-12-19 05:11:44", updated_at: "2019-12-19 05:11:44">
マイクロポストを改良する
マイクロポストが投稿日時の降順(=新しい順)に表示されるようにする
「マイクロポストが投稿日時の降順(=新しい順)に表示されるようにする」という実装を、Micropostモデルに追加します。実装およびテストについては、別記事で解説します。
ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする
「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする」という実装を、Userモデルに追加します。実装およびテストについては、別記事で解説します。
Railsチュートリアル 第13章 ユーザーのマイクロポスト - 「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする」という実装を、Userモデルに追加する」
演習 - マイクロポストを改良する
1. Micropost.first.created_at
の実行結果と、Micropost.last.created_at
の実行結果を比べてみましょう。
>> Micropost.first.created_at
Micropost Load (14.9ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" DESC LIMIT ? [["LIMIT", 1]]
=> Fri, 20 Dec 2019 13:10:45 UTC +00:00
>> Micropost.last.created_at
Micropost Load (0.5ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" ASC LIMIT ? [["LIMIT", 1]]
=> Thu, 19 Dec 2019 05:11:44 UTC +00:00
確かにMicropost.first.created_at
の結果の日時よりも、Micropost.last.created_at
の日時のほうが過去の日時となっています。
2. Micropost.first
を実行したときに発行されるSQL文はどうなっているでしょうか? 同様にして、Micropost.last
の場合はどうなっているでしょうか?
ヒント: それぞれをコンソール上で実行したときに表示される文字列が、SQL文になります。
>> Micropost.first
Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" DESC LIMIT ? [["LIMIT", 1]]
>> Micropost.last
Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" ASC LIMIT ? [["LIMIT", 1]]
first
のほうのSQL文にはDESC
という句が含まれており、一方last
のほうのSQL文にはASC
という句が含まれています。
3.1. データベース上の最初のユーザーを変数userに代入してください。そのuser
オブジェクトが最初に投稿したマイクロポストのid
はいくつでしょうか?
>> user = User.first
User Load (1.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, ...>
>> user.microposts.last.id
Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" ASC LIMIT ? [["user_id", 1], ["LIMIT", 1]]
=> 1
現状の実装において、「ユーザーが最初に投稿したマイクロポスト」というのは、user.microposts.last
というメソッドで取得することができます。そのid
のみを取得するのであれば、user.microposts.last.id
というメソッドですね。
user.microposts.last.id
の戻り値は1
ですね。
3.2. 次に、destroy
メソッドを使ってそのuser
オブジェクトを削除してみてください。削除すると、そのuser
に紐付いていたマイクロポストも削除されていることをMicropost.find
で確認してみましょう。
>> user.destroy
(0.2ms) SAVEPOINT active_record_1
Micropost Load (0.4ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC [["user_id", 1]]
SQL (18.6ms) DELETE FROM "microposts" WHERE "microposts"."id" = ? [["id", 2]]
SQL (0.2ms) DELETE FROM "microposts" WHERE "microposts"."id" = ? [["id", 1]]
SQL (5.6ms) DELETE FROM "users" WHERE "users"."id" = ? [["id", 1]]
(0.1ms) RELEASE SAVEPOINT active_record_1
>> Micropost.find(1)
Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."id" = ? ORDER BY "microposts"."created_at" DESC LIMIT ? [["id", 1], ["LIMIT", 1]]
Traceback (most recent call last):
1: from (irb):11
ActiveRecord::RecordNotFound (Couldn't find Micropost with 'id'=1)
ポイントは以下です。
-
user
オブジェクトのdestroy
メソッドを実行した時点で、microposts
テーブルのレコードに対してもSQL文DELETE
がいくつか発行されている- 削除対象のレコードを確定するために、当該ユーザーの
id
をWHERE
句に持つSQL文SELECT
が発行されている
- 削除対象のレコードを確定するために、当該ユーザーの
- 削除されたマイクロポストのIDを引数に取って
Micropost.find
を実行すると、ActiveRecord::RecordNotFound
というエラーが投げられてくる
余談 - Userモデルのhas_many
メソッドにdependent: :destroy
オプションがない場合
>> user.destroy
(0.6ms) SAVEPOINT active_record_1
SQL (25.4ms) DELETE FROM "users" WHERE "users"."id" = ? [["id", 1]]
(0.2ms) ROLLBACK TO SAVEPOINT active_record_1
Traceback (most recent call last):
1: from (irb):20
ActiveRecord::InvalidForeignKey (SQLite3::ConstraintException: FOREIGN KEY constraint failed: DELETE FROM "users" WHERE "users"."id" = ?)
user.destroy
がActiveRecord::InvalidForeignKey
というエラーで失敗します。