見出し画像

【Rails】複雑なバリデーションはメソッドにしてModelに書いておく

自作アプリでちょっと複雑なバリデーションを書いたので書き残しておきます。
プログラミング初学者のアウトプットメモになります。情報に誤りがあればご指摘ください。


やりたいこと

  1. ほしいものリストアプリをつくる

  2. ユーザーは自分の登録したアイテムを比較できる

  3. 比較は保存できて、以前に比較した内容も見れる

  4. ユーザーは自分の比較一覧を見ることができる

必要な制限

  1. ユーザーがアクセスできるアイテムは「自分のアイテム」のみ

  2. ユーザーが比較できるアイテムは「自分のアイテム」のみ

  3. ユーザーがアクセスできる比較は「自分の比較」のみ

関係性

  1. ユーザーは、複数のアイテムを持ち(has_many :items)、アイテムは必ずユーザーに属す(belong_to :user)

  2. ユーザーは、複数の比較を持ち(has_many :comparisons)、比較は必ずユーザーに属す(belong_to :user)

  3. 比較は複数のアイテムを持つが、アイテムは複数の比較に属することができる、多対多のアソシエーション
    (これって中間テーブルっぽい振る舞いをしながらも単体でしっかり役割を持っているんですが、アンチパターンなんですかね?)

比較(comparison)に関するバリデーション

  1. primary_item_id として、itemsテーブルのitem_idを持ってくる

  2. secondary_item_idとして、itemsテーブルのitem_idを持ってくる

  3. ユーザーに属する

  4. primary_item_idとsecondary_item_idには、同じIDが登録できない

  5. すでにある組み合わせは新規登録できない

  6. 他のユーザーが持っているアイテムのIDは比較に登録できない

難しいところ

上記の4~6は、ちょっと複雑なバリデーションです。
(「100文字以内でないといけない」とか、「空欄は禁止」とかそういうのと比較したら、複雑)

やったこと

  1. model内のprivateメソッドで条件式を記述する

  2. メソッドには「こうなってたらNG!」という状況を式で記述する

  3. エラーメッセージを記述しておく

実際のコード

class Comparison < ApplicationRecord
  belongs_to :primary_item, class_name: 'Item', foreign_key: 'primary_item_id'
  belongs_to :secondary_item, class_name: 'Item', foreign_key: 'secondary_item_id'
  belongs_to :user
  has_many :notes, dependent: :destroy

  validate :different_items
  validate :unique_combination
  validate :items_belong_to_user

  private

  def different_items
    if primary_item_id == secondary_item_id
      errors.add(:secondary_item_id, "はアイテム1と異なるものを選択してください")
    end
  end

  def unique_combination
    if Comparison.exists?(primary_item_id: primary_item_id, secondary_item_id: secondary_item_id) ||
       Comparison.exists?(primary_item_id: secondary_item_id, secondary_item_id: primary_item_id)
      errors.add(:base, "このアイテムの組み合わせはすでに存在します")
    end
  end

  def items_belong_to_user
    if primary_item_id.present? && secondary_item_id.present?
      if primary_item.user.id != user.id || secondary_item.user.id != user.id
        errors.add(:base, "比較するアイテムはユーザーが所有しているものでなければなりません")
      end
    end
  end
end

different_itemsメソッド

もし1個目のアイテムと2個目のアイテムが同じだったらエラーを出すメソッド。

unique_combinationメソッド

すでにある組み合わせが作成された場合、新規作成しないでエラーを出すメソッド。かっちょいいことに、組み合わせが前後逆になっても対応できる。

このバリデートとは別に、コントローラー側で「すでに存在する組み合わせの比較を作成しようとしたら、すでに作成済みの比較の画面に飛ばす」という挙動をセットしています。実際にユーザーがこのメッセージを見るわけではありません。(下記抜粋)

class ComparisonsController < ApplicationController
  def create
    existing_comparison = find_existing_comparison
    if existing_comparison
      redirect_to comparison_path(existing_comparison)
    else
      @comparison = Comparison.new(comparison_params)
      if @comparison.save
        redirect_to @comparison
      else
        user_items_not_purchased
        render :new, status: :unprocessable_entity
      end
    end
  end

private

  def find_existing_comparison
    Comparison.find_by(primary_item_id: comparison_params[:primary_item_id], secondary_item_id: comparison_params[:secondary_item_id]) ||
    Comparison.find_by(primary_item_id: comparison_params[:secondary_item_id], secondary_item_id: comparison_params[:primary_item_id])
  end


items_belong_to_userメソッド

ユーザーが持っていない、他人のアイテムを勝手に比較登録できないようにするメソッド。

これも普通に使っていると発生しないエラー。
もともと、UI上ではユーザーが持っているアイテムしか選択・選択できないので、普通のユーザーがこのバリデーションに引っかかることはありません。
コンソールから直接item_idを指定した場合などに誤って他人のアイテムを比較しないようにするためのバリデーションです。

バリデーションは画面操作上の制限とは別で入れとこう

上記の通り、ふつうに使っていると「自分のアイテムしか見られないユーザーが比較を作成する」のだから、「他人のアイテムを比較に突っ込む」ことはないはずです。
しかし、まぁ画面の操作だけが全てではありませんし、何があるかわかりません。フォームからなんか変な操作をすれば、なんかの隙をついて他人のデータを書き換えたりできてしまう可能性もあるかもしれません。

また、万が一フロントの実装がミスっていて他人の情報がちらっと見えかけてしまっても、他人の情報は操作できないようにする制限をかけておけば助かるデータやプライバシーがあるかもしれません。URL直打ちで…とかね。

ということで、「UI上ではあり得ない挙動でも、一応制限をかけておく」のが良さそうです。たぶん。

今日は以上です。最後までお読みいただきありがとうございました。

この記事が気に入ったらサポートをしてみませんか?