【Ruby on Rails チュートリアル】サンプルアプリケーションに返信機能を追加する
Ruby on Rails チュートリアル (Rails 5.1 第4版) を一通り完走したので、14.4.1 サンプルアプリケーションの機能を拡張するを実践していきます。
今回は返信機能を実装しました。
実装に当たってチュートリアルには以下のようなヒントが記載されています。
- micropostsテーブルにin_reply_toカラムを追加する
- including_repliesスコープをMicropostモデルに追加する
- 一意のユーザー名を表す方法を追加する
- idと名前を組み合わせる
- ユーザー登録の項目に一意のユーザー名を追加する
仕様
- ユーザのidと名前を組み合わせて一意のユーザー名を表現する。(以下リプライ名とする)
例.@1-michael-hartl
(名前の空白はハイフンで置換) - マイクロポストの
content
がリプライ名 (半角スペース)
で始まる場合、リプライとして処理する。
例.@1-michael-hartl test content
- 自分へのリプライは出来ない。
- リプライは以下のユーザーのステータスフィードにのみ表示される。
- 送信ユーザー
- 送信ユーザーのフォロワー
- 宛先ユーザー
- 宛先ユーザーに指定できるのは1リプライにつき1ユーザーのみ。
Gemの追加
リプライの表示のためにactive_decoratorを利用しているのでGemfileを修正します。
# Gemfile gem 'active_decorator', '1.1.1'
実装
テスト等は記載していません。
ソースコードの全体を見たい方はこちら→GitLab
値オブジェクト(Value Object)の作成
まずはcontent
とリプライ名
の関係を表す値オブジェクトを作成します。
# app/values/micropost_content.rb class MicropostContent REPLY_CONTENT_REGEX = /\A(@(?<user_id>\d{1,})-(?<user_name>[^\s]+)\s)?/i def initialize(micropost_content) @micropost_content = micropost_content end def reply_name m = @micropost_content.match(REPLY_CONTENT_REGEX) ReplyName.new(m[:user_id], m[:user_name]) end def content @micropost_content.sub(REPLY_CONTENT_REGEX, '') end def reply? reply_name.valid? end def blank? content.blank? end end
リプライ名
はUser
モデルでも使用したいので別クラスに切り出します。
# app/values/reply_name.rb class ReplyName include Comparable attr_reader :user_id def initialize(user_id, user_name) @user_id = user_id @user_name = user_name end def <=>(other) to_s <=> other.to_s end def to_s "@#{user_id}-#{@user_name.gsub(/\s/, '-')}" end def valid? !!user_id && !!@user_name end end
次にcomposed_of
メソッドでモデルの仮想カラムとして利用できるようにします。
# app/models/micropost.rb composed_of :content_object, class_name: "MicropostContent", mapping: %w(content micropost_content)
# app/models/user.rb composed_of :reply_name, mapping: [ %w(id user_id), %w(name user_name) ]
これで以下のような記述が可能になります。
$ micropost = Micropost.first $ micropost.content_object.class #=> MicropostContent $ micropost.content_object.reply_name.class #=> ReplyName $ user = User.first $ user.reply_name.class #=> ReplyName
Micropostsテーブルにin_reply_toカラム(宛先ユーザー)を追加
以下のようなマイグレーションを作成しました。
# db/migrate/20181110102729_add_in_reply_to_column_to_microposts.rb class AddInReplyToColumnToMicroposts < ActiveRecord::Migration[5.1] def change add_reference :microposts, :in_reply_to, foreign_key: { to_table: :users } add_index :microposts, [:in_reply_to_id, :created_at] end end
foreign_key
で外部キー制約を付与していますが、今回のようにカラム名から参照先テーブルを推測できない場合はto_table
オプションで明示する必要があります。
Micropostモデルに関連付けを追加
belongs_to
メソッドを使ってin_reply_to
カラムの関連付けを行います。
宛先ユーザーはマイクロポストの場合nil
となるため、optional: true
を付与しています。
# app/models/micripost.rb belongs_to :in_reply_to, class_name: "User", foreign_key: "in_reply_to_id", optional: true
宛先ユーザーの取得は、リプライの場合のみbefore_validation
コールバックで行います。
# app/models/micripost.rb before_validation :assign_in_reply_to private def assign_in_reply_to if content_object.reply? self.in_reply_to = User.find_by(id: content_object.reply_name.user_id) end end
Micropostモデルにバリデーションを追加
まずはcontent
カラムに対するバリデーションを修正します。
リプライの場合、content
は以下のようになるため、リプライの内容
に対して存在性(presence
)のバリデーションを追加する必要があります。
リプライ名 (半角スペース)リプライの内容
validates
メソッドのpresence
オプションは対象オブジェクトのblank?
メソッドで判定している為、MicropostContent#blank?
メソッドを実装し、content_object
カラムに対してバリデーションするよう書き換えています。
# app/models/micropost.rb # validates :content, presence: true, length: { maximum: 140 } validates :content, length: { maximum: 140 } validates :content_object, presence: true
# app/values/micropost_content.rb def blank? content.blank? end
次にリプライに対するバリデーションとして以下を追加します。
- 宛先ユーザーが存在している。
- 宛先ユーザーが有効化済みである。(
User#activated = true
) - 宛先ユーザーと
content_object
のリプライ名が一致している。 - 差出ユーザー(
user
カラム)と宛先ユーザーが異なる。
複数のカラムが登場するためValidator
を作成します。
# app/validators/reply_validator.rb class ReplyValidator < ActiveModel::Validator def validate(record) if record.in_reply_to.nil? || !record.in_reply_to.activated # 1,2 record.errors.add('content', "User ID does not exist or account is anactivated.") elsif record.in_reply_to.reply_name != record.content_object.reply_name # 3 record.errors.add('content', "Reply Name is invalid.") elsif record.in_reply_to == record.user # 4 record.errors.add('content', "can not reply to myself.") end end end
Validator
の指定はvalidates_with
メソッドで行います。
リプライの場合のみ処理したいのでif
オプションを付与しています。
# app/models/micropost.rb validates_with ReplyValidator, if: -> { content_object.reply? }
Micropostモデルにリプライを含めたスコープを追加
ユーザーのid
を引数としたクラスメソッドとして定義しています。
# app/models/micropost.rb # リプライを含めたスコープ def self.including_replies(user_id) where("user_id = :user_id OR in_reply_to_id = :user_id", user_id: user_id) end
リプライはユーザーのステータスフィードに表示したいのでUser#feed
メソッドを修正します。
# app/models/user.rb # ユーザーのステータスフィードを返す def feed following_ids = "SELECT followed_id FROM relationships WHERE follower_id = :user_id" #Micropost.where("user_id IN (#{following_ids}) # OR user_id = :user_id", user_id: id) Micropost.where("user_id IN (#{following_ids})", user_id: id) .or(Micropost.including_replies(id)) end
リプライの表示
リプライはリプライ名
を対象ユーザーページへのリンクとして表示したいのでジェネレータでdecolator
を作成します。
$ rails g decorator micropost_decorator create app/decorators/micropost_decorator.rb invoke test_unit create test/decorators/micropost_decorator_test.rb
# app/decorator/micropost_decorator.rb module MicropostDecorator def decorated_content if reply? link_to("@#{in_reply_to.name}", in_reply_to) + " " + content_object.content else content end end end
view
ファイルを修正します。
# app/views/microposts/_micropost.html.erb <span class="content"> #<%= micropost.content %> <%= micropost.decorated_content %> # ここ <%= image_tag micropost.picture.url if micropost.picture? %> </span>
おわりに
チュートリアルを完走してから、手探りで実装を進めてきましたが何とか完成までこぎつけることができました。
できるだけシンプルなコードになるように実装しています。
コードについて不備等ありましたらコメントにてご指摘いただけると助かります。
参考記事
Value Object
Rails tips: Value Objectパターンでリファクタリング(翻訳)
Rails における値オブジェクトと ActiveRecord の composed_of - Qiita
before_validaton
Rails: :before_validationコールバックの逸脱した用法を改善する(翻訳)
Rails: `before_validation`コールバックで複雑なステートを正規化する(翻訳)
Validator
【Rails】カスタムバリデータの使い方 - TASK NOTES
【Rails】まだValidatorのテストで消耗してるの? - Qiita
Decorator
Rails Viewの表示のためにDecoratorを用意してHelperとModelを助ける - Qiita
ruby on rails - Test view by rspec with draper Decorator - Stack Overflow