ブログのような何か

Rails関連の備忘録だったり、個人的なメモだったり・・

Railsのアソシエーションと複数登録 ~バリデーション~

前回の続き

基本的なバリデーションを追加していく。
追加する内容は下記の通り。

項目名 必須入力(選択) ユニーク制約 形式制約 文字制限
1 メールアドレス -
2 都道府県 - - -
3 住所 - -
4 電話番号 - - -
5 ゲーム経験 -
6 所有ゲーム機 - -

基本的に必須入力(選択)とし、複数登録項目については最低1件以上とする。
また、メールアドレスや電話番号等の型が決まったものには形式チェックを、
自由入力項目には念のため文字数制限を加える。

1~4をさくっと実装

# app/model/profile.rb
class Profile < ApplicationRecord
  -省略-
  validates :email, uniqueness: true, presence: true, format: { with: MAIL_REGEX, message: I18n.t('errors.messages.illegal_format')}
  validates :prefecture_id, presence: true
  validates :city, presence: true, length: { maximum: 50 }
  validates :tell, format: { with: TELL_REGEX, message: I18n.t('errors.messages.illegal_format')}

形式チェックについては、デフォルトで表示される「○○の値が不正です」がちょっとわかりにくいので「○○の形式が不正です」に変更しておく。
また正規表現は直接書き込むと長くなって見にくいので、別で定義。

  MAIL_REGEX = /\A[a-zA-Z0-9_\#!$%&`'*+\-{|}~^\/=?\.]+@[a-zA-Z0-9][a-zA-Z0-9\.-]+\z/
  TELL_REGEX = /0\d{1,4}-\d{1,4}-\d{4}/

Nested_fieldのバリデーションについて

まず必須チェックを追加

# app/model/profile.rb
  -省略-
  validates :game_careers, length: { minimum: 1, message: I18n.t('errors.messages.required') }

これでゲーム経験が0項目のときにエラーが出るようになったが、このままだとTextBox未入力でも登録できてしまうので、GameCareer側でnameのバリデーションを設定する。

# app/model/game_career.rb
class GameCareer < ApplicationRecord
  validates :name, uniqueness: { scope: [:profile_id] }, presence: true, length: { maximum: 50 }
end

これで、ゲーム経験0件の時と未入力のときにエラーが出るようになったが・・・

  • 0件の場合

f:id:akubotera:20180509183038p:plain

  • Name未入力の場合

f:id:akubotera:20180509191349p:plain

① 同時であれば同じものがいくつでも登録できてしまう。
→ uniqueness: true ではDB保存されたデータとしか比較できない。
f:id:akubotera:20180509195333p:plain
※ゲーム経験に「test」を4つ登録しようとすると、validationが働かずに登録できてしまう。

② 既存項目をフォームから削除し、同じものを追加すると、エラーになってしまう。
f:id:akubotera:20180509200145p:plain
※ゲーム経験に「test1」と「test2」を登録後、edit画面で「test1」をdeleteボタンで消した後に「test1」を追加すると、もともとあった「test1」と新しく追加した「test1」がvalidationに引っかかる

そこでnested_fieldのバリデートはCustomValidatorを作成する。

CustomValidator の作成

①,②どちらも重複がチェックできていないことが問題なので、データ仮セット段階で追加されたもの、消されたものを取得して正しくチェック出来るようなvalidatorを作成する。

# app/validators/nested_unique_validator.rb

class NestedUniqueValidator < ActiveModel::EachValidator
 def validate_each(record, attribute, value)
 # 処理
 end
end

ActiveModel::EachValidatorを使うと、record,attribute,valueが使えるが、
valueに入っているはずの「削除したかどうか(_destroy)」を取得することができない。

pry(#<NestedUniqueValidator>)> value   
=> [
 #<GameCareer:0x00007ffd98654e18 id: 26, profile_id: 18, name: "test1">, # deleteで削除したもの
 #<GameCareer:0x00007ffd98654c38 id: 27, profile_id: 18, name: "test2">, # deleteで削除したもの
 #<GameCareer:0x00007ffd9864f508 id: nil, profile_id: 18, name: "test1">, # 新規追加したもの
 #<GameCareer:0x00007ffd9864cd80 id: nil, profile_id: 18, name: "test2"> # 新規追加したもの
] 

・・と困っていたが、 marked_for_destruction?というメソッドがあるとのこと。

value[0].marked_for_destruction?  # 削除したもの
=> true
value[3].marked_for_destruction?  # 削除してないもの
=> false

これを使ってValidatorを作る

# app/validators/nested_unique_validator.rb

class NestedUniqueValidator < ActiveModel::EachValidator
 def validate_each(record, attribute, value)
    # valueから削除されたものを排除
    value = value.reject(&:marked_for_destruction?).map{|i| i.name }
    # 残った数とUniqueの数を比較
    return if value.size == value.uniq.size
    record.errors[attribute] << (options[:message] || I18n.t('errors.messages.duplicated')
 end
end

# app/models/profile.rb
validates :game_careers, presence: true, nested_unique:true

# app/models/game_career.rb
validates :name, presence: true, length: { maximum: 50 } #重複はprofile側で見るので、 uniqueness を削除


このままだとカラム名が「Name」の場合にしか使えないので、カラム名をパラメータで渡して汎用的に使えるようにする。

# app/models/profile.rb

validates :game_careers, presence: true, nested_unique: { column: :name }
validates :possess_games, presence: true, nested_unique: { column: :game_console_id }

# app/validators/nested_unique_validator.rb

def validate_each(record, attribute, value)
   -省略-
    column = options[:column]  # 追加
    value = value.reject(&:marked_for_destruction?).map{|i| i.send(column) }  # i.nameを i.send(column)に

これでバリデーションは一通り実装完了。

細かい部分はまだ手を加える余地はあるものの、nested_fieldsを使った登録画面が完成。