【Rails】一つのフォームで2つのモデルを更新
◯要件
1つのフォームで2つ以上のモデルを更新したい
パターン1 関連づいている場合
fields_forを使うのがベスト。
form_for @blog do |f| = f.text_field :title = f.fields_for :author do |af| = af.text_field :name
こうすればcontrollerでは@blogをsaveするだけでいいのでカンタン。ただしaccepts_nested_attributes_forをBlogモデルに書いてやる必要がある。
パターン2 関連づいていない場合
これも基本的にはfields_forを使うけど、controllerがちょっとむずかしい。
form_for @purchase do |f| = f.text_field :title = fields_for current_user.credit_card do |cf| = cf.text_field :code
まぁなんらかの事情で商品購入情報とクレジットカード情報を一緒に編集したくなった場合。
そもそもひとつのフォーム・リクエストで2つ以上のリソースの更新があることがいいか悪いかは議論がありそうだが、現場レベルで現実的に必ず2つ以上のモデルをいじりたい場合が確実に発生する。
そういう場合、こういうことをcontrollerでやっているケースが多い。
def update if @purchase.save if current_user.credit_card.update_attributes(params[:credit_card]) 省略 end end end
これは少し考えれば@purchaseはsaveできたがcredit_cardが更新できなかった場合などがケアされていないので良くないことが分かる。
def update if @purchase.valid? && @credit_card.valid? @purchase.save @credit_card.save end end
このコードは惜しいけどこれもよくない。まず第一に@purchase.saveができたあとにMySQLのプロセスが死んだりすることもあるので、複数のテーブルに更新を書ける場合はトランザクションをかけなくてはいけない。
次に、こちらのほうが重要だけど@purchase.valid?が失敗すると、@credit_card.valid?は評価されないので、editとかのテンプレートをrenderしなおしてエラーメッセージを出す場合に、片方のvalidation結果しか表示されない。
で、いろいろ考えた結果とりあえずこれがベストな形かなと思った。
def update if [@purchase.valid?, @credit_card.valid?].all? ActiveRecord::Base.transaction do @purchase.save @credit_card.save end else render :edit end end
これなら@purchase.valid?と@credit_card.valid?が両方評価される。そのためrender :editをしても両方のバリデーション結果が表示されてくれる。配列のインスタンス化の過程では各要素が即時評価される。
[1] pry(main)> require 'benchmark' => true [2] pry(main)> Benchmark.measure { [sleep(3), sleep(3)].any? } => 0.000000 0.000000 0.000000 ( 6.002052)
sleep(3)は3秒プロセスが停止して、戻り値は3が返る。だからもし配列の要素が遅延評価なら[sleep(3), sleep(3)].any?の1個目でブロックの中身の評価はtrueがかえるのだけど、実際は6秒待ってからtrueがかえる。
とか&&とかで両方評価したいときにこれは便利。 |
余談だけどarrayで存在しないindexにアクセスしてもnilを返すことを利用すればこんなこともできる。
1.upto(40) {|n| p [:aho][n%3] || (n.to_s =~ /3/) && :aho || n }