【Ruby on Rails チュートリアル】サンプルアプリケーションに返信機能を追加する

Ruby on Rails チュートリアル (Rails 5.1 第4版) を一通り完走したので、14.4.1 サンプルアプリケーションの機能を拡張するを実践していきます。
今回は返信機能を実装しました。

実装に当たってチュートリアルには以下のようなヒントが記載されています。

  • micropostsテーブルにin_reply_toカラムを追加する
  • including_repliesスコープをMicropostモデルに追加する
  • 一意のユーザー名を表す方法を追加する
    • idと名前を組み合わせる
    • ユーザー登録の項目に一意のユーザー名を追加する

仕様

  1. ユーザのidと名前を組み合わせて一意のユーザー名を表現する。(以下リプライ名とする)
    例. @1-michael-hartl (名前の空白はハイフンで置換)
  2. マイクロポストのcontentリプライ名 (半角スペース)で始まる場合、リプライとして処理する。
    例. @1-michael-hartl test content
  3. 自分へのリプライは出来ない。
  4. リプライは以下のユーザーのステータスフィードにのみ表示される。
    • 送信ユーザー
    • 送信ユーザーのフォロワー
    • 宛先ユーザー
  5. 宛先ユーザーに指定できるのは1リプライにつき1ユーザーのみ。

f:id:iberiko665:20181206030624p:plain
完成イメージ

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

次にリプライに対するバリデーションとして以下を追加します。

  1. 宛先ユーザーが存在している。
  2. 宛先ユーザーが有効化済みである。(User#activated = true)
  3. 宛先ユーザーとcontent_objectのリプライ名が一致している。
  4. 差出ユーザー(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