Ruby on Rails チュートリアル 機能拡張4(返信機能)

#Ruby on Rails チュートリアルについて
Ruby on Railsを勉強したいというと、まず紹介される有名なRailsのチュートリアル。
内容はハードですが、無料でRailsによるWebアプリケーション開発を楽しく学べます。<br>
**Ruby on Rails チュートリアル**
https://railstutorial.jp/

#Sample Appの拡張
チュートリアルの最後には、作成したSampleAppの拡張機能についていくつかのヒントが記載されています。
その中の以下の機能を順に実装していきます(途中で挫折するかも。。。)。
1. ユーザー検索
2. マイクロポスト検索
3. フォロワーの通知
4. 返信機能
5. メッセージ機能

#返信機能
今回は、4つ目の返信機能の実装を行います。
返信機能は下記の仕様で作っていきます。

- 返信先は、@ID-usernameで本文の中で指定する<br>
  例： IDが1、User名が「Example User」の場合、@1-Example-Userとなる
- 返信は、投稿者と返信先ユーザーのフィードにのみ表示される
- 自分への返信はできない
- 指定したIDが存在しない場合、マイクロポスト投稿時にエラーとする
- 指定したIDとそのIDのユーザー名が一致しない場合、マイクロポスト投稿時にエラーとする

前回(フォロワーの通知)は[こちら](https://qiita.com/YN6127yn/items/6187670f7412d387f632)

##環境と準備
今回は、特に新たにインストールする必要のあるモジュールはありません。

|モジュール   |  バージョン |  
|:----------|-----------:|
| Rails     |      5.1.2 |
| Ruby      |      2.3.1 |

##実装
まずRailsチュートリアルに記載してあるように、micropostsにin_reply_to属性を追加します。
in_reply_toには返信先のユーザーのIDを格納するためIntegerとし、デフォルトを0とします。
そのため、返信先が指定されていない場合、in_reply_toは0になります。
さらにindexを追加し、DBにマイグレートします。

そして、次もチュートリアルに記載のあるようにMicropostクラスにscope(including_replies)を追加します。
scopeを記述する前に、追加したscopeを利用した新しいfeedメソッドの処理を記載します。

```ruby:app/models/user.rb
def feed
  following_ids = "SELECT followed_id FROM relationships
                   WHERE follower_id = :user_id"
  Micropost.including_replies(id)
           .where("user_id IN (#{following_ids})
                   OR user_id = :user_id", user_id: id)
end
```

上記のように、scopeの後にwhereで今までのfeedの処理に繋げられるようにscopeを定義します。
上で設定した仕様を満たすためには、

- in_reply_toが0または自分のIDである(返信先の指定がないor返信先が自分)
- user_idが自分のIDである(自分が投稿者)

のどちらかでなけらばならないとわかります。

あとはこれを実装すればいいね！
ってなるんですが、実は[こちら](https://railsguides.jp/active_record_querying.html#%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%97)にあるように、引数を取るscopeはクラスメソッドで定義することが推奨されています。

そのため、scopeではなくクラスメソッドで定義するようにします。

```ruby:app/models/micropost.rb
def Micropost.including_replies(id)
    where(in_reply_to: [id, 0]).or(Micropost.where(user_id: id))
end
```

feedメソッドと合わせて2回もuser_idが自分のIDであることを確認していて凄く気持ち悪いですが、私の浅はかな知識ではこれ以外うまくいきませんでした。。。

さて、ユーザーのフィードに出す準備は整ったので、次に本文から返信先を取得する処理を実装します。

まずはどのタイミングで返信先を取得し、どのタイミングで返信先の内容をチェックするかを決めます。
チェックは通常の`validate`で指定するとして、返信先の取得はvalidationが走る前にしなければならないので、before_validationのコールバックで実施することにします。

```ruby:app/models/micropost.rb
before_validation :set_in_reply_to # ここ

validates :user_id, presence: true
validates :content, presence: true, length: {maximum: 140}
validates :in_reply_to, presence: false
validate :picture_size, :reply_to_user # ここ
```

名前はよくないですが、`set_in_reply_to`で本文から返信先を抜き出し、`reply_to_user`でチェックを行います。
あと、`:presence`を`false`に設定しています。

まず、`set_in_reply_to`を以下のように実装します。

```ruby:app/models/micropost.rb
def set_in_reply_to
  if @index = content.index("@")
    reply_id = []
    while is_i?(content[@index+1])
      @index += 1
      reply_id << content[@index]
    end
    self.in_reply_to = reply_id.join.to_i
  else
    self.in_reply_to = 0
  end
end
```

処理は非常に単純で、本文中の`@`を探し、その位置から数字が続くだけ文字を取得し、最後に連結&Integerにキャストしています。
indexを`@index`とインスタンス変数にしているのは、バリデーションの際にもこのindexを利用するためです。
また、数値のチェックに利用している`is_i?`の実装は以下のとおりです。

```ruby:app/models/micropost.rb
def is_i?(s)
  Integer(s) != nil rescue false
end
```

返信先のユーザーのIDの取得ができたので、次はバリデーションを実装します。

```ruby:app/models/micropost.rb
def reply_to_user
  return if self.in_reply_to == 0 # 1
  unless user = User.find_by(id: self.in_reply_to) # 2
    errors.add(:base, "User ID you specified doesn't exist.")
  else
    if user_id == self.in_reply_to # 3
      errors.add(:base, "You can't reply to yourself.")
    else
      unless reply_to_user_name_correct?(user) # 4
        errors.add(:base, "User ID doesn't match its name.")
      end
    end
  end
end
```

少し複雑ですが、以下の順番でチェックを行なっています。
1. 返信先が指定されていない場合、チェックしない
2. 指定したIDのユーザーが見つからない場合、エラーとする
3. 自分自身に返信を行なった場合、エラーとする
4. 指定したIDのユーザー名が間違っていた場合、エラーとする

エラーメッセージ追加時に指定している`:base`は、エラーメッセージを表示する際に、属性名を表示しないようにできます。
`reply_to_user_name_correct?`は、指定されたIDのユーザー名が正しいかをチェックするメソッドです。

```ruby:app/models/micropost.rb
def reply_to_user_name_correct?(user)
  user_name = user.name.gsub(" ", "-")
  content[@index+2, user_name.length] == user_name
end
```

まず指定されたIDのユーザー名を取得し、空白を「-」で埋めています。
そして、先ほど取得していた`@index`を2つ進め、実際のユーザー名の長さだけ本文を抜き出し、比較しています。
`@index`を2つ進めるのは、@1<font color="Red">2</font>-user-nameを指していたのを@<font color="Black">12</font>-<font color="Red">u</font>ser-nameのように進めるためです。

これで実装は終わりです。
短いような気がしますが、viewとcontrollerの変更が必要なかったためですね。

##テスト
最後にテストについて記載します。
今回は、

- 返信投稿時の返信先の指定
- 返信の表示範囲

をテストします。
テストは、チュートリアル内で作成した、microposts_inteface_test.rbに追記します。

まず、返信先の指定のテストです。

```ruby:test/integration/microposts_inteface_test_test.rb
~
省略
~
test "reply to other user" do
    log_in_as(@user)
    get root_path

    # invalid post(ID doesn't exist)
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: {micropost: {content: "@1000000000000000000"}}
    end
    assert_select 'div#error_explanation'
    # invalid post(Reply to yourself)
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: {micropost: {content: "@#{@user.id}-Hoge-Hoge"}}
    end
    assert_select 'div#error_explanation'
    # invalid post(ID doesn't match its user name)
    other_user = users(:fuga)
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: {micropost: {content: "@#{other_user.id}-Hogera-Hogera"}}
    end
    assert_select 'div#error_explanation'

    # valid post
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: {micropost: {content: "@#{other_user.id}-Fuga-Fuga"}}
    end
  end
~
省略
~
```

存在しないIDを指定した場合、自分自身への返信した場合、IDとユーザー名が一致しない場合、正しく返信先を指定した場合の投稿数の変化を確認しています。

次に、返信が投稿者と返信先のフィードのみに表示され、それ以外のユーザーのフィードには表示されないことをチェックします。

```ruby:test/integration/microposts_inteface_test_test.rb
~
省略
~
test "reply post visibility" do
    log_in_as(@user)
    get root_path
    reply_to_user = users(:fuga)
    content = "@#{reply_to_user.id}-Fuga-Fuga"
    post microposts_path, params: {micropost: {content: content}}
    follow_redirect!
    assert_match content, response.body

    # should be visible
    log_in_as(reply_to_user)
    get root_path
    assert_match content, response.body

    # shouldn't be visible
    other_user = users(:piyo)
    log_in_as(other_user)
    get root_path
    assert_no_match content, response.body
  end
```

投稿者、返信先、他ユーザーの順番にフィードを確認しています。

両方のテストが通ったことを確認して終了です。

#最後に
私はまだRails歴2週間ほどであり、以上の実装/テストも私の環境で動いたということに過ぎません。
修正点や指摘等ございましたら、ぜひコメントお願いいたします。

#参考記事
返信機能では、下記のページを参考にさせていただきました。

**Active Record クエリインターフェイス**
https://railsguides.jp/active_record_querying.html#%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%97

**Activeレコードのコールバック**
http://ruby.studio-kingdom.com/rails/guides/active_record_callbacks

**Validation error messages without attribute**
https://stackoverflow.com/questions/6863307/validation-error-messages-without-the-attribute