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件の場合
- Name未入力の場合
① 同時であれば同じものがいくつでも登録できてしまう。
→ uniqueness: true ではDB保存されたデータとしか比較できない。
※ゲーム経験に「test」を4つ登録しようとすると、validationが働かずに登録できてしまう。
② 既存項目をフォームから削除し、同じものを追加すると、エラーになってしまう。
※ゲーム経験に「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を使った登録画面が完成。
Railsのアソシエーションと複数登録 ~修正画面~
前回の続き
残りの修正画面を作成する
Editの作成
before_action :set_profile, only: [:edit, :confirm, :update] def edit end def set_profile @profile = Profile.find(current_user.profile.id) if current_user.profile.present? end
editを追加する。
@profileのセットは edit,confirm,updateの3箇所で必要なので、まとめてbefore_actionに設定。
ただしconfirmはnewから経由した場合は不要なので、current_userのprofileがある場合のみ。
Confirmの修正
NewとEditの両方から遷移するので、if分で分岐させる。
def confirm if @profile @profile.assign_attributes(profile_params) render :edit if @profile.invalid? else @profile = current_user.build_profile(profile_params) render :new if @profile.invalid? end end
newの場合はまだレコードができていないので、current_user.build_で良いが、Editの場合既にレコードが存在するので使えない。
この場合は.assign_attributes
を使いparamsを仮セット出来る。
ただし、このままだと一点があり、deleteしたはずのデータがconfirm画面に表示されてしまう。
(勝手に消されても困るが・・)
# game_careersに4レコード登録した状態でEdit画面でレコードid14と15を # deleteボタンで削除し、確認画面へ遷移する GameCareer Load (0.5ms) SELECT "game_careers".* FROM "game_careers" WHERE "game_careers"."profile_id" = $1 [["profile_id", 12]] => [#<GameCareer:0x00007f8df1ca5a68 id: 12, profile_id: 12, name: "test1">, #<GameCareer:0x00007f8df1ca5298 id: 13, profile_id: 12, name: "test2">, #<GameCareer:0x00007f8df1ca4870 id: 14, profile_id: 12, name: "test3">, #<GameCareer:0x00007f8df1ca4050 id: 15, profile_id: 12, name: "test4">] # Confirm画面のprams profile_params[:game_careers_attributes] => <ActionController::Parameters{ "0"=><ActionController::Parameters {"id"=>"12", "name"=>"test1"} permitted: true>, "1"=><ActionController::Parameters {"id"=>"13", "name"=>"test2"} permitted: true>, "2"=><ActionController::Parameters {"id"=>"14", "name"=>"test3", "_destroy"=>"1"} permitted: true>, "3"=><ActionController::Parameters {"id"=>"15", "name"=>"test4", "_destroy"=>"1"} permitted: true> } permitted: true>
そこでView側を修正。_destroy
がtrueの場合は確認画面で表示しないようにする。
<% @profile.game_careers.each do |c| %> # _destoryがtrueの場合は表示しない <%= c.name unless c._destroy %> <% end %>
確認画面には戻るボタンが必要なので、それも追加する。
link_to :back
(ブラウザのBack)だと、validationが絡んだ場合にエラーを起こすので、ちゃんとsubmitする。
方法は色々あるが、今回はsubmitボタンの名前を使って分岐する仕組みにする。
クリックしたsubmitの名前が[戻る]かどうかチェックするメソッドを作成。
def submit_back? params[:commit] == I18n.t('helpers.submit.back') end
それをcreateとupdateに組み込む。
def create @profile = current_user.build_profile(profile_params) if !submit_back? && @profile.valid? @profile.save! redirect_to root_path else render :new end end def update @profile.assign_attributes(profile_params) if !submit_back? && @profile.valid? @profile.save! redirect_to root_path else render :edit end end
こうすることで、ちゃんと値を渡しつつ確認画面から戻る事ができる。
基本的な登録画面が完成したので、次は必要なバリデーションを実装する。
Railsのアソシエーションと複数登録 ~新規登録~
前回の続き
登録画面と確認画面を作成し、新規登録まで出来るようにする。
ルーティングの作成
newとedit関連をまとめて設定
resource :profile, only: [:new, :create, :edit, :update] do post :confirm, on: :collection end
postでconfirmへ遷移するよう設定する。
プロフィールは一つしかないので、IDを指定する必要が無い。
resource :profileとし、confirmにもon: :collectionを指定
コントローラーの作成
def new @profile = current_user.build_profile @profile.game_careers.build @profile.possess_games.build end def confirm @profile = current_user.build_profile(profile_params) end def create @profile = current_user.build_profile(profile_params) if @profile.valid? @profile.save! redirect_to root_path end end
current_user.build_profile で ProfileにUserIDを入れることが出来る。
同時に○○.buildをしておくと、初期状態で入力欄が表示される。
(今回は動的に入力欄を追加出来るようにするのでなくてもOK。)
ビューの作成
今回は確認画面を挟むので、フォームでPostした場合にConfirmへ飛ぶように指定する。
<%= form_for profile, url: confirm_profile_path, method: :post do |f| %> -省略- <%= f.submit '確認画面へ', class: 'btn btn-primary' %> <% end %>
Confirm画面は入力した内容を表示する。
hiddenをセットし忘れると値を渡すことができないので注意。
項目が多い場合は↓のように書くとスッキリする。
-省略- <%= form_for @profile, url: profile_path, as: :profile do |f| %> <% [:email, :tell, :prefecture_id, :city ].each do |attr| %> <%= f.hidden_field attr %> <% end %> <%= f.submit '登録する', class: 'btn btn-primary' %> <% end %>
複数登録の実装
ゲーム経験と所有ゲーム機を複数登録出来るようにする。
Association先のモデル保存には accepts_nested_attributes_for
を使う。
※ accepts_nested_attributes_for はそのうち消える運命らしい。。
FormObjectを使うと良いらしいので、今度試してみる。
model/profile.rb
# 追加 accepts_nested_attributes_for :game_careers, :possess_games, allow_destroy: true
allow_destroy: true
を追加することで"_destory"がtrueのときに削除できるようになる。
controller/profiles_controller.rb
# ストロングパラメータに○○_attributesを追加する。 # _destroyも受け取れるようにする。 def profile_params params.require(:profile).permit( :email, :tell, :prefecture_id, :city, game_careers_attributes: [:id, :profile_id, :name, :_destroy], possess_games_attributes: [:id, :profile_id, :game_console_id, :_destroy] ) end
入力フォームにはnested_form_fields
を使用する。
Gemfile
# 追加してbundle install gem 'nested_form_fields'
app/assets/javascript
# 追加 //= require jquery //= require jquery_ujs //= require nested_form_fields
jqueryとjquery_ujsを使用するので追加。
逆だと動かないので順番にも注意。
views/profile/_form.html.erb
<%= form_for profile, url: confirm_profile_path, method: :post do |f| %> -省略- # テキストボックスに入力 <%= f.nested_fields_for :game_careers, wrapper_tag: :div do |career| %> <%= career.text_field :name, class:'form-control', id:'careers', placeholder: 'careers'%> <%= career.remove_nested_fields_link 'Delete', class: 'btn btn-danger', role: 'button' %> <% end %> <%= f.add_nested_fields_link :game_careers, 'Add new', class: 'btn btn-primary', role: 'button' %> # セレクトボックスから選択。選択肢はGameConsoleから取得 <%= f.nested_fields_for :possess_games, wrapper_tag: :div do |games| %> <%= games.collection_select :game_console_id,GameConsole.all, :id, :name, class:'form-control', placeholder: 'games'%> <%= games.remove_nested_fields_link 'Delete', class: 'btn btn-danger', role: 'button' %> <% end %> <%= f.add_nested_fields_link :possess_games, 'Add new', class: 'btn btn-primary', role: 'button' %> <% end %>
nested_fields_for内のremove_nested_fields_linkが削除ボタン、add_nested_fields_linkがフォーム追加ボタン。
追加したフォームをdivで囲むnested_fields_forにwrapper_tag: :div
を追加すればOK。
views/profile/confirm.html.erb
# 項目表示 ~ 省略 ~ <% @profile.game_careers.each do |c| %> <%= c.name %> <% end %> <% @profile.possess_games.each do |g| %> <%= g.game_console.name %> <% end %> # hidden追加 ~ 省略 ~ <%= f.fields_for :game_careers do |career| %> <%= career.hidden_field :name %> <%= career.hidden_field :_destroy %> <% end %> <%= f.fields_for :possess_games do |games| %> <%= games.hidden_field :game_console_id %> <%= games.hidden_field :_destroy %> <% end %>
表示とhiddenを追加。hiddenには'_destroy'も忘れず追加すること。
登録だけならこれで問題無いが、修正時に問題があるのでそのときに修正。
これで入力から登録までが完了。
次は編集画面を実装する。
Railsのアソシエーションと複数登録 ~モデルの準備~
Facebook等からユーザー情報を持ってくる場合は、個人的には別テーブルに詳細情報をもたせたい。
詳細情報が各項目必ず一つであれば詳細テーブル内に入れてしまえば良いが、
例えば経歴だったり資格だったり、複数持つ情報の場合は詳細テーブルだけでは事足りない。
そんな時のアソシエーションの備忘録 (・ω・)
はじめに
OAuth認証でログインしてきた人に追加で必要な情報を入力させる画面を想定してみる。
今回はそこに持っているゲーム機とゲーム経験を登録させる。
このあたりのデータはなんでも良いが、特定のリストから選択と自由入力項目を複数同時に登録出来るような入力画面を作成する。
要件定義
ざっくりと大体↓こんな感じ。
- Userは一つのProfileを持つ
- Profileは複数のゲーム機を持つ
- ゲーム機名称は別テーブルで持たせて、選択させる
- Profileは複数のゲーム経験を持つ こちらは自由入力
モデルを作成する
まずは必要なモデルを作る。
class User < ApplicationRecord # OAuth認証を想定して最低限の情報で end class Profile < ApplicationRecord # 入力してほしい情報はすべてここに集約 end class GameCareer < ApplicationRecord # ゲーム経験 end class GameConsole < ApplicationRecord # 所持しているゲーム機 end class PossessGame < ApplicationRecord # 所持してるゲーム機とプロフィールをつなげる中間テーブル # 今回は繋げるだけなのでIDしか用意し無いが、例えば購入日等あればここに乗せても良い。 end
アソシエーションを組む
ユーザーとプロフィール
class User < ApplicationRecord has_one :profile, dependent: :destroy end class Profile < ApplicationRecord belongs_to :user end
ユーザーは必ず1つ(2以上はありえない)のプロフィールを持つのでhas_one
で繋げる。
belogns_to
側にIDを入れる必要があるので、Userをhas_one,Profileをbelongs_toとする。
プロフィールとゲーム経験
class Profile < ApplicationRecord has_many :game_careers, dependent: :destroy end class GameCareer < ApplicationRecord belongs_to :profile end
プロフィールは複数のゲーム経験を持つので、has_many
で繋げる。
プロフィールと持っているゲーム機
class Profile < ApplicationRecord has_many :possess_games, dependent: :destroy end class PossessGame < ApplicationRecord belongs_to :profile belongs_to :game_console end GameConsole < ApplicationRecord has_many :possess_games end
中間テーブルが出て来るが基本的に同じ。やってることは名称のマスタ化。(いわゆる第二正規化)
これでProfile - PossessGame と PossessGame - GameConsoleは繋がったが、
このままだとプロフィールから直接持っているゲーム機を取得するのが面倒なので
ProfileとGameConsoleを繋げる。
class Profile < ApplicationRecord -省略- has_many :games, through: :possess_games, source: :game_consoles end GameConsole < ApplicationRecord -省略- has_many :profiles, through: :possess_games end
through: :possess_games
とすると、中間テーブルを介してデータを取得出来る。
また、has_manyの後にaliasを定義し、sourceでモデル(テーブル名)を指定すると@profile.○○
と別名で呼べるので、モデル名が無駄に長いときやわかりにくい時に便利。
一応直接繋げない場合でも↓のように呼び出すことが可能。
@profile.possess_games.map{|game|game.game_console}
ここまでで、アソシエーションが完了。
seedで適当なデータを登録してConsoleを叩くと、データが取得できる。
アソシエーションの詳細はreflect_on_all_associations
メソッドで確認できるので、モデルから何のデータが呼べるのか確認する場合は、nameを取得すると一発。
pry(main)> Profile.reflect_on_all_associations.map(&:name) => [:user, :game_careers, :possess_games, :games] >>> Profileから以下の4つが呼び出せる。 @profile.user @profile.game_careers @profile.possess_games @profile.games
次は登録画面を作成していく。