プログラミング 美徳の不幸

Ruby, Rails, JavaScriptなどのプログラミングまとめ、解説、備忘録。

【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 }