記事一覧表示

Rails チュートリアル(機能拡張編)

問題文から予想した必要な作業

  • Usersテーブルのnameに一意性を確保
  • Usersテーブルのnameにスペース禁止を設定
  • Micropostsテーブルにin_reply_toカラム(返信先のUser.id)を追加
  • Micropostモデルにincluding_repliesスコープ(特定のUserへ返信しているMicropostを検索する機能)を追加
  • Micropostのcontent内の”@ユーザ名 ”からUser.idをin_reply_toに加える機能を追加
  • Micropost_feedに返信用Micropostを追加
  • 自分に返信してきているMicropostのみのページを作成


作業履歴

- Usersテーブルのnameに一意性を確保

 Userモデル(app/models/user.rb)のnameにバリデーションuniquenessを追加。

class User < ApplicationRecord
    ︙
  validates :name, ... uniqueness: true
    ︙
end

 User.nameにindexを追加( % rails generate migration add_index_to_users_name を実行)。

class AddIndexToUsersName < ActiveRecord::Migration[5.2]
  def change
    add_index :users, :name, unique: true
  end
end

 nameの一意性のテスト(test/models/user_test.rb)を書く。

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
    ︙
  test "name should be unique" do
    @user.name = users(:michael).name
    assert_not @user.valid?
  end
    ︙
end

 michael fixture(test/fixtures/users.yml)は既に作成されている為、michael fixtureと同じnameを含んだUserオブジェクトは有効では無い事をテストしている。
 また、アプリ内に作成したサンプルデータ(db/seeds.rb)は faker gemを使用していたので、作成するnameに一意性を持たせるために Faker::Name.nameFaker::Name.unique.name に書き換える1


- Usersテーブルのnameにスペース禁止を設定

 スペース禁止のvalidateを見つけきれなかったので、Userモデルに自作。

class User < ApplicationRecord
    ︙
  validate :blank_validate
    ︙
  private

    def blank_validate
      if name.include?(" ")
        errors.add(:name, "shouldn't include a blank")
      end
    end
    ︙
end

 nameにスペースが禁止になったかどうかのテストを書く。

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "ExampleUser", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
    ︙
  test "name shouldn't include a blank" do
    @user.name = "Example User"
    assert_not @user.valid?
  end
    ︙
end

 またfixture、サンプルデータ、テスト等で使用したUser.nameをスペースの無い文字列に書き換えた。
 
 問題文では「マイクロポスト入力中に@記号に続けてユーザーのログイン名を入力するとそのユーザーに返信できる機能」とあったので、Micropost中の “@からスペースの間の文字列” をユーザのログイン名と断定するために、User.nameからスペースを禁止にすることにした。
 しかし、本音を言うと、nameに文字制限を掛けたくなかったので、当初としては、

select * from users where name || “%” like “@以降の文字列”

 のように、@以降の文字列の最後に制限を掛けず(スペースを入れなくて済むよう)に “@以降の文字列” に対するnameの前方一致検索(?)を掛けて実装しようとしたが、上記の書き方だとnameに結合する “%” がワイルドカードではなく、ただの文字列として扱われてしまい、断念してしまった。何か良い方法があったら教えてほしい…


- Micropostsテーブルにin_reply_toカラム(返信先のUser.id)を追加

 % rails generate migration add_in_reply_to_to_microposts in_reply_to:integer : migrationファイルを作っただけ。


- Micropostモデルにincluding_repliesスコープ(特定のUserへ返信しているMicropostを検索する機能)を追加

 Micropostモデル(app/models/micropost.rb)にincluding_repliesスコープを記述。

class Micropost < ApplicationRecord
    ︙
  scope :including_replies, ->(user_id) { where("in_reply_to = ?", user_id) }
    ︙
end

 Userが自分への返信が来ているかを確かめるためにUserへの返信用Micropostだけを表示するページが欲しいと思ったので、その為のスコープを作った(解釈に一番自信が無い…、自分の中でincluding_repliesスコープが何の機能を担っているのかが良く分かっていないかも)。


- Micropostのcontent内の”@ユーザ名 ”からUser.idをin_reply_toに加える機能を追加

 content内の”@ユーザ名 ”からUserレコードを検索し、in_reply_toにUser.idを加える機能(reply_to_user関数)をMicropost内に以下のように書いた。

class Micropost < ApplicationRecord
    ︙
  before_save :reply_to_user
    ︙
  private

    def reply_to_user
      reply_to_users_name = content.scan(/@\S+[\s\S*$]/).map { |text| text.gsub(/\s+$|^@/, "") }
      reply_to_users = User.where("name in (?)", reply_to_users_name.first)
      self.in_reply_to = reply_to_users.first.id if reply_to_users.exists?
    end
    ︙
end

 新規Micropostがsaveされる直前に、reply_to_user関数が動いて、contentにユーザが含まれているかを調べている。
 実際に動くかどうかのコントローラーテスト(test/controllers/microposts_controller_test.rb)を書く。

class MicropostsControllerTest < ActionDispatch::IntegrationTest
    ︙
  test "content including \"@User.name\" should reply to \"User\"" do
    log_in_as(users(:michael))
    reply_to_user = users(:lana)
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: "@#{reply_to_user.name} good morning!" } }
    end
    assert_equal Micropost.first.in_reply_to, reply_to_user.id
    #ユーザ名の後ろにスペースを忘れた場合は、返信不可。                                          
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: "@#{reply_to_user.name}good morning!" } }
    end
    assert_nil Micropost.first.in_reply_to
    #文末にユーザが来ても返信可能。                                                              
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: "good morning!@#{reply_to_user.name}" } }
    end
    assert_equal Micropost.first.in_reply_to, reply_to_user.id
    #返信先のユーザが複数の時は、最初のユーザのみ返信。                                          
    reply_to_user_sec = users(:malory)
    content = "@#{reply_to_user_sec.name} @#{reply_to_user.name} good morning!"
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: content } }
    end
    assert_equal Micropost.first.in_reply_to, reply_to_user_sec.id
  end
end

 文頭、ユーザ名の後ろのスペース忘れ、文末、複数ユーザ名があった場合のテストを書いた。


- Micropost_feedに返信用Micropostを追加

 Userモデルのfeed一覧を持ってくる関数(feed関数)のwhere文に OR in_reply_to = User.id を付けただけ。

class User < ApplicationRecord
    ︙
  def feed
    ︙
    Micropost.where("user_id IN (#{following_ids})                                               
                     OR user_id = :user_id OR in_reply_to = :user_id", user_id: id)
  end
    ︙
end

 ちゃんとfeed一覧 root_path に表示されているかを確認するテストを書く(test/integration/microposts_interface_test.rb)。

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest
  def setup
    @user = users(:michael)
  end
    ︙
  test "micropost interface" do
    log_in_as(@user)
    ︙
    # 違うユーザへの返信。                                                                       
    receiver_in_reply = users(:user_20)
    content = "@#{receiver_in_reply.name} reply."
    assert_difference 'Micropost.including_replies(receiver_in_reply.id).count', 1 do
      post microposts_path, params: { micropost: { content: content } }
    end
    # 自分への返信がページ内に表示されているかを確認。                                           
    log_in_as(receiver_in_reply)
    get root_path
    assert_match content, response.body
  end
    ︙
end

 michaelでログイン→user_20への返信用Micropost作成→user_20でログイン→user_20のfeed一覧に先ほどmichaelが作成した返信用Micropostが表示されてるかを確認。
 因みにmichaelとuser_20は、互いにFF外(FollowerでもFollowedでも無い)の関係である。


- 自分に返信してきているMicropostのみのページを作成

 アクションのルート reply_feed をusersリソースに設定(config/routes.rb)。

Rails.application.routes.draw do
    ︙
  resources :users do
    member do
      get ..., :reply_feed
    end
  end
    ︙
end

 コントローラ app/controllers/users_controller.rbreply_feed を書く。

class UsersController < ApplicationController
  before_action :correct_user,   only: [ ..., :reply_feed]
    ︙
  def reply_feed
    @micropost  = current_user.microposts.build
    @feed_items = Micropost.including_replies(params[:id]).paginate(page: params[:page])
    render 'static_pages/home'
  end
    ︙
end

 reply_feed アクションが動く前にリクエストに乗っているidと現在ログイン中のidが一致するかをcorrect_user関数で調べて、異なった場合はroot_pathにリダイレクトさせている。
 返信用Micropostのみを表示するviewを作る(app/views/shared/_feed.html.erb)。

<section>
  <a href="<%= root_path %>" class="variable">
    <strong class="micropost_feed">
      <h3>Micropost_feed</h3>
    </strong>
  </a>
  <a href="<%= reply_feed_user_path(current_user) %>" class="variable">
    <strong class="reply">
      <h3>@reply</h3>
    </strong>
  </a>
</section>
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

 正直、feedへのリンク root_path と返信用Micropostのみ表示へのリンク reply_feed_user_path のリンクを貼っただけ。
 テストを書く前に返信用Micropostのfixturesを書く(test/fixtures/microposts.yml)

    ︙
<% 10.times do |n| %>
micropost_reply<%= n %>:
  content: <%= Faker::Lorem.sentence(5) + "@MichaelExample" %>
  created_at: <%= 42.days.ago %>
  in_reply_to: <%= ActiveRecord::FixtureSet.identify(:michael) %>
  user: lana
<% end %>

 lanaからmichaelへの返信用Micropostを10件作成した。
 返信用Micropostのみが表示されているかのテストを書く(test/integration/microposts_interface_test.rb)。

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest
  def setup
    @user = users(:michael)
  end
    ︙
  test "micropost interface" do
    log_in_as(@user)
    # reply_feedに含まれているmicropostは全て返信用Micropostかを確認。                           
    get reply_feed_user_path(@user)
    assert_template 'static_pages/home'
    assert_not_empty assigns(:feed_items)
    assigns(:feed_items).each do |micropost|
      assert_match "@#{@user.name}", micropost.content
    end
    receiver_in_reply = users(:user_20)
    ︙
    # 自分(michael)以外に自分への返信が見られない事を確認。                                      
    get reply_feed_user_path(@user)
    assert_redirected_to root_path
    follow_redirect!
    assert_not_empty assigns(:feed_items)
    assigns(:feed_items).each do |micropost|
      assert_no_match "@#{@user.name}", micropost.content
    end
  end
    ︙
end

 michaelでログイン→reply_feedのページのmicropostが全部自分への返信用Micropostかどうか確認→user_20でログイン→michaelのreply_feedにアクセス→root_pathにリダイレクトされるのを確認→root_pathのfeedにmichaelへの返信用Micropostが無い事を確認。
 user_20はmichaelへの返信用Micropostを作成していないので、これが通ればテスト終了。


分かった知識一覧

  • ^最初の文字最後の文字$ : 正規表現
  • ActiveRecord::FixtureSet.identify(:fixture名) : fixtureのidを返す2
  • ’’ == nil
  • <オブジェクト []> == empty
  • <オブジェクト [#<id: “1”, name: ... >, ... ]> == exists


改善したい所

  • micropost.contentのフォーム内に@を書いたら、それ以降の文字列を使ったUser.nameの随時検索を表示したい。
     twittertweetに@マークを入れた後に文字列を入れると、フォームの下にポップが出て、文字列に合致したユーザの候補を出してくれるんだけど、それやりたいですって話。

  • reply_feedとfeedの切り替えを非同期処理させたい。
     ページを遷移させないなら、ページ全体の再描画要らないよな~と。

  • micoropost一つで返信できるUser数を複数人にしたい。
     in_reply_toカラムを配列にする方法があるが、あまり良くないらしいのでやめる。素直にReceiversテーブルを作って、micropost_idカラムとreceiverカラムを用意した方が良さそう。リレーションは Micropost: has_many ReceiversReceiver: belongs_to User かな?

  • micropostに対して返信したい。
     in_reply_toカラムに入れる値をUser.idにするんじゃなくて、Micropost.idにすれば良いんかな?それで返信しているUser.idは、Receiversテーブルに保存する感じかな〜


  1. 公式ドキュメントを参照。

  2. fixtureにidを指定する方法もあるらしいけど、それやると意味分からない位エラーが出たのでもうやらない方が良さそう。