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.name
を Faker::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.rb
に reply_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の随時検索を表示したい。
twitterでtweetに@マークを入れた後に文字列を入れると、フォームの下にポップが出て、文字列に合致したユーザの候補を出してくれるんだけど、それやりたいですって話。reply_feedとfeedの切り替えを非同期処理させたい。
ページを遷移させないなら、ページ全体の再描画要らないよな~と。reply_feedとfeedのどちらのページを見ているかを分かりやすくしたい。
root_pathとreply_feed_user_pathで表示されるページがfeedの内容以外、全く同じなので、差異を付けて分かりやすくしたい。見ている方のページリンクを光らせるとか?やるとしたら、アクションに配列持たせて、view内のタグのclassにくっ付けて、scss内でデザイン定義させるか、もしくはjavascriptで書くか?micoropost一つで返信できるUser数を複数人にしたい。
in_reply_toカラムを配列にする方法があるが、あまり良くないらしいのでやめる。素直にReceiversテーブルを作って、micropost_idカラムとreceiverカラムを用意した方が良さそう。リレーションはMicropost: has_many Receivers
とReceiver: belongs_to User
かな?micropostに対して返信したい。
in_reply_toカラムに入れる値をUser.idにするんじゃなくて、Micropost.idにすれば良いんかな?それで返信しているUser.idは、Receiversテーブルに保存する感じかな〜