Rails Tutorial 拡張機能のメッセージ機能を作ってみた(その1)：モデルを作成

Rails Tutorialの第14章にある、拡張機能を作る件の続きです。

前回までで返信機能ができました。機能追加の2つ目、メッセージ機能を作ります。

##機能の要件を調査
チュートリアルには

>Twitterでは、ダイレクトメッセージを行える機能がサポートされています。この機能をサンプルアプリケーションに実装してみましょう 

とあるので、Twitterの機能を確認します。

>(ヒント: Messageモデルと、新規マイクロポストにマッチする正規表現が必要になるでしょう)。

MessageモデルとはRailsの機能なのか？ネットで調べることにします。

まずはTwitterの機能確認です。

DMを送信できる条件で、相手が自分をフォローしている必要があります。
この機能は後で作ることにします。

既読を表示する機能もありますが、この機能は諦めます。

DMを受け取った際にプッシュ通知／SMS通知（ショートメール通知）／メール通知のいずれかが届くように設定できます。
この機能も諦めます。

また、特定のアカウントからのDMをミュートすることで、通知を受け取らないようにすることも可能です。
この機能も諦めます。

特定のアカウントからのDMを拒否する機能もあります。相手をブロックできます。この機能も諦めます。

受け取ったDMを削除することはできます。この機能は作ることにします。
送信者が送信後のDMを削除することはできません。


DMとpostは違う画面が作られています。

文字数の最大数は違うのか、調べたところ2015年7月までは140文字それ以降は1万文字でした。
今回は140文字にします。

送信先に複数を使えるか調べたところ、グループを作成して複数のアカウントと会話する機能がありました。2015年1月に出来たとのことなので、この機能は諦めます。

Twitterの機能が分かり、今回作る機能もイメージできました。

MessageモデルとはRailsの機能なのか調べましたが、それらしい記事は見つかりませんでした。チャット機能を自分で作る記事や、リアルタイムチャットを作る記事は見つかりましたが関係なさそうです。

##機能のまとめ

1.メッセージを送ることができる。
2.送信先は複数ではなく1人だけ。
3.文字数は140文字まで。
4.送信できるか制限はせず、誰にでも送信できる。この制限機能は後で作ることとする。
5.既読の表示機能は作らない。
6.DMを受信時の通知機能は作らない。
7.受信したDMを削除することができる。
8.送信者は送信したDMを削除することができない

###モデルの設計
モデルの仕様を作ります。
tutorialのMicropostを作るところを読み直します。13章では最初に13.1でモデルを作っています。
同様にDMのモデルを作ることにします。

|列名|属性|
|:-:|:-:|
|id   |  integer |
|content| text  |
|sender_id|integer|
|receiver_id|integer|
|created_at| datetime  |
|updated_at| datetime  |
図 DMモデル

ここでreceiverもUserモデルと関係があります。この関係はfollowのmodelを作ったときと同じではと考え、tutorialを読み直します。

###user削除時の仕様を検討
relationでは、userを削除するとrelationも削除されます。DMでは削除しない仕様に決めました。この点は違います。

relationでは、unfollowするとrelationも削除されます。DMではDMを受信者が削除しても、そのDMが消えるわけではなく、送信者の画面からは消えません。受信者の画面に表示されなくなるだけです。これはどういうことか考えてみます。DMの削除とは、なかったことにするのではない、送った事実は残るわけです。メールと同じで、受信者が受信済みのメールを削除したときに、送信者の送信済みのメールは削除されないです。Twitterで試してみましたが、確かでした。

メールのように、DMが入ったバケツを考えます。送信者と受信者が別々のバケツを持つモデルです。DMを送ると、送信者のバケツに1通、受信者のバケツに1通を入れます。バケツ2個に全く同じメッセージを入れるというのが重複感があると感じました。

なのでバケツは1個にして、受信者が削除したことが分かるように、削除フラグを追加することにします。

trueかfalseを入れる属性をネットで調べ、booleanがあることと、必ずデフォルト値を入れるべきと分かりました。理由はrubyではnilとfalseが同じ扱いになるためです。
https://qiita.com/jnchito/items/a342b64cd998e5c4ef3d

変更後のモデルです。

|列名|属性|
|:-:|:-:|
|id   |  integer |
|content| text  |
|sender_id|integer|
|receiver_id|integer|
|deleted|boolean|
|created_at| datetime  |
|updated_at| datetime  |
図 DMモデル

###DMモデルの開発
トピックブランチを作ります。

```
ubuntu:~/environment/sample_app (master) $ git checkout -b create-dm
```

dmモデルを生成します。

```
ubuntu:~/environment/sample_app (create-dm) $ rails generate model dm content:text user:references
      create    db/migrate/20201102003220_create_dms.rb
      create    app/models/dm.rb
      create      test/models/dm_test.rb
      create      test/fixtures/dms.ymlrails generate mode dm content:text user:references
```

マイグレーションを変更します。
インデックスは、senderを指定して時系列で取り出す場合と、receiverを指定して時系列で取り出す場合の2通りが考えられたので作りました。
deletedフラグのnullの扱いは先ほどのネットの記事を参考にしました。

```ruby:db/migrate/20201102003220_create_dms.rb
class CreateDms < ActiveRecord::Migration[5.1]
  def change
    create_table :dms do |t|
      t.text :content
      t.integer :sender_id
      t.integer :reciever_id
      t.boolean :deleted, default: false, null: false

      t.timestamps
    end
    add_index :dms, [:sender_id, :created_at]
    add_index :dms, [:receiver_id, :created_at]
  end
end
```

データベースを更新します。

```
ubuntu:~/environment/sample_app (create-dm) $ rails db:migrate
```

UserとDMの関連づけをします。
tutorialの14.1.2　「User/Relationshipの関連付け」を読みます。

```ruby:app/models/user.rb
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
  has_many      :sent_dms,class_name: "Dm",
                          foreign_key: "sender_id"
  has_many      :received_dms,class_name: "Dm",
                              foreign_key: "receiver_id"
```


```ruby:app/models/dm.rb
class Dm < ApplicationRecord
  belongs_to :sender,   class_name: "User"
  belongs_to :receiver, class_name: "User"
end
```

関連を整理するために、図を書きます。

| id | name |
|:-:|:-:|
| 1| Michael|
| 2| Archer |
userモデル

has_many

| sender_id|receiver_id|  content |
|:-:|:-:|:-:|
|1|2|...  |
|1|3|...  |
dmモデル

has_many

|id |name|
|:-:|:-:|
| 2 |Archer|
| 3 |...   |
userモデル
図 UserとDMの関連

###使えるようになるメソッド
使えるようになるメソッドは以下のとおりです。

|メソッド| 用途 |
|:-:|:-:|
|user.sent_dms|Userが送ったDMの集合を返す|
|sent_dms.sender|senderを返す|
|sent_dms.receiver|receiverを返す|
|user.sent_dms.create(receiver_id: other_user.id)|userと紐づけてDMを作|
|user.sent_dms.create!(receiver_id: other_user.id)| userと紐づけてDMを作る（失敗時にエラーを出力）|
|user.sent_dms.build(receiver_id: other_user.id)|userと紐づた新しいDMオブジェクトを返す|
|user.sent_dms.find_by(id:1)|userと紐づいていて、idが1のDMを返す|

コンソールで試してみます。dm1を作ります。

```
>> user1 = User.first
>> user2 = User.second
>> dm1 = user1.sent_dms.create(receiver_id: user2.id, content: "hoge dm1")
```

sender,receiverのUserオブジェクトが返されました。

```
>> dm1.sender
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-10-26 01:37:04", updated_at: "2020-10-26 01:37:04", password_digest: "$2a$10$2TZtcwmSTCfl9Bigz2nYGO8U1YA8ksfNXUr2O/fSGOY...", remember_digest: nil, admin: true, activation_digest: "$2a$10$EaQUKa6hfGEHosjnICR4VuYMxfOxunTOsPGQYUimNLn...", activated: true, activated_at: "2020-10-26 01:37:03", reset_digest: nil, reset_sent_at: nil, unique_name: "Example">
>> dm1.receiver
=> #<User id: 2, name: "Van Zemlak", email: "example-1@railstutorial.org", created_at: "2020-10-26 01:37:04", updated_at: "2020-10-26 01:37:04", password_digest: "$2a$10$H22BJeNVA3hYdEw/a5RArekRy73q/0AtvidwRiVpoUK...", remember_digest: nil, admin: false, activation_digest: "$2a$10$xm7AJE4Q3fzq3gi5tmVnyeld8wahxMHN/dE2Sn2jSUW...", activated: true, activated_at: "2020-10-26 01:37:04", reset_digest: nil, reset_sent_at: nil, unique_name: "Craig1">
```

userのDMのリストを検索、dmをidで検索します。

```
>> user1.sent_dms
  Dm Load (0.2ms)  SELECT  "dms".* FROM "dms" WHERE "dms"."sender_id" = ? LIMIT ?  [["sender_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>

>> user1.sent_dms.find_by(receiver_id: 2)
  Dm Load (0.4ms)  SELECT  "dms".* FROM "dms" WHERE "dms"."sender_id" = ? AND "dms"."receiver_id" = ? LIMIT ?  [["sender_id", 1], ["receiver_id", 2], ["LIMIT", 1]]
=> #<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">
```

自分がrecieverのDMのリストが必要です。followedが同じ構造なので、tutorialを読みます。メソッドはありそうなのでコンソールで試します。

```
>> user2.received_dms
  Dm Load (0.1ms)  SELECT  "dms".* FROM "dms" WHERE "dms"."receiver_id" = ? LIMIT ?  [["receiver_id", 2], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>
```

モデルはできましたが、要件に必要なメソッドが揃ったのかがあいまいと感じました。


Twitterの画面をもう一度見直します。DMの画面は親子の構造になっており、親の画面は過去にやり取りしたことがあるユーザーのリストです。ユーザーを選ぶとDMのやり取りが一覧化された画面です。

必要なメソッドとして、過去にやり取りしたことがあるユーザーのリストを返すメソッドです。
ネットでfindを検索したところ、whereでOR条件が使えると分かりました。
https://qiita.com/nakayuu07/items/3d5e2f8784b6f18186f2
コンソールで試します。

```
>> Dm.where(sender_id: 1).or(Dm.where(receiver_id: 1))                                                                                         
  Dm Load (0.1ms)  SELECT  "dms".* FROM "dms" WHERE ("dms"."sender_id" = ? OR "dms"."receiver_id" = ?) LIMIT ?  [["sender_id", 1], ["receiver_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>
>> Dm.where(sender_id: 2).or(Dm.where(receiver_id: 2))                                                                                        
  Dm Load (0.1ms)  SELECT  "dms".* FROM "dms" WHERE ("dms"."sender_id" = ? OR "dms"."receiver_id" = ?) LIMIT ?  [["sender_id", 2], ["receiver_id", 2], ["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>
```


この検索では親子の画面の機能まではできません。どの相手のDMも全て混ざっています。
ここで、仕様を深堀りし切れていないことが分かりました。
DMの相手ごとに画面を作る機能は、後で作れれば作ることとして進めることにします。

###モデルのテスト
モデルのテストを作ります。
DMのバリデーションをMicropostを参考に作ります。tutorialの13.1.2　「Micropostのバリデーション」を読みます。
fixtureのDMのファイルの中はサンプルなので削除します。

```ruby:test/models/dm_test.rb  
class DmTest < ActiveSupport::TestCase

  def setup
    @sender   = users(:michael)
    @receiver = users(:archer)
    @dm = Dm.new(content: "hogehoge1", sender_id: @sender.id, receiver_id: @receiver.id)
  end

  test "should be valid" do
    assert @dm.valid?
  end
  
  test "sender should be present" do
    @dm.sender_id = nil
    assert_not @dm.valid?
  end

  test "receiver should be present" do
    @dm.receiver_id = nil
    assert_not @dm.valid?
  end

  test "contentr should be present" do
    @dm.content = nil
    assert_not @dm.valid?
  end

  test "contentr should be at most 140 characters" do
    @dm.content = "a" * 141
    assert_not @dm.valid?
  end

end
```


micropostと同じ用にバリデーションを追加します。テストがGREENになりました。

```ruby:app/models/dm.rb
class Dm < ApplicationRecord
  belongs_to :sender,   class_name: "User"
  belongs_to :receiver, class_name: "User"
  validates :content, presence: true, length: { maximum: 140 }
end
```
DMを作るときのメソッドを慣習的に正しいやりかたに変更します。

```ruby:test/models/dm_test.rb
  def setup

    @dm = @sender.sent_dms.build(content: "hogehoge1", receiver_id: @receiver.id)
  end
```

DMを新しい順に返すようにします。13.1.4 「マイクロポストを改良する」を読みます。
先にテストを書きます。

```ruby:test/models/dm_test.rb
class DmTest < ActiveSupport::TestCase
...
  test "order should be most recent first" do
    assert_equal sent_dms(:most_recent), Dm.first
  end
end
```

fixtureに親子関係のデータを作ります。
https://qiita.com/seimiyajun/items/ffefdfc74b9fce76a538
を参考にしました。

```ruby:test/fixtures/dms.yml
morning:
  content: "Good morning!"
  sender:   michael
  receiver: archer
  created_at:  <%= 10.minutes.ago %>
```

created_atの順に並ぶように設定します。テストがGREENになりました。


```ruby:app/models/dm.rb
class Dm < ApplicationRecord
  belongs_to :sender,   class_name: "User"
  belongs_to :receiver, class_name: "User"
  default_scope -> { order(created_at: :desc) }
  validates :content, presence: true, length: { maximum: 140 }
end
```

:destroyを追加するか考えます。
ユーザーが削除されたときには過去のDMは残る仕様に決めていました。
そのテストを追加します。リスト13.20を参考にします。

```ruby:test/models/user_test.rb　GREEN
test "associated dms should not be destroyed" do
    @user.save
    @user.sent_dms.create!(content: "Lorem ipsum", receiver_id: users(:archer).id)
    assert_no_difference 'Dm.count' do
      @user.destroy
    end
  end
end
```

ここまででモデルはできました。

##所要時間
10/31から11/6までの8.5時間です。