Rails はString/Fixnum の違いで結構はまる

昨日、はっまった ^^); ・・・ ActiveRecod.find(id) の id は文字列でも良いが、全てのAPIがそうなわけではない! - yuum3のお仕事日記と全く同じハマり方をしました。

form のselect はハマる

例として、商品を作成するフォーム。商品にはジャンルが指定できて、「Products belongs to Genre」の関係になっているとする。
その時のフォームのコードはこんな感じ。

  • new.html.erb
<% form_for(@product) do |f| %>
  <p>
    <%= f.select :genre_id, Genre.find(:all).map{|g| [g.name, g.id]} %>
  </p>
<% end %>


特に問題なく動く。一方、次の例は商品を検索するフォームと、対応するアクション。

  • search.html.erb(バグあり)
<% form_tag({:action => 'search'}, {:method => :get}) %>
  <p>
    <%= select_tag :genre_id, options_for_select(Genre.find(:all).map{|g| [g.name, g.id]}, params[:genre_id]) %>
  </p>
  ....
<% end %>
  • products_controller.rb
class ProductsController < ActionController::Base
  def search
    if params[:do_search]
      @products = Product.find(:all, :conditions => params.slice(:name, :genre_id))
    end
  end
end


このコードにはバグがあります。これだと、「http://yourapp.example/products/search?genre_id=2&do_search=1」と検索しても、検索後に選択リストの値が「genre_id が2のジャンル」にフォーカスしてくれません。
正しくは、「search.html.erb」を以下のようにしてやる必要があります。

  • search.html.erb(バグ除外後)
<% form_tag({:action => 'search'}, {:method => :get}) %>
  <p>
    <%= select_tag :genre_id, options_for_select(Genre.find(:all).map{|g| [g.name, g.id]}, params[:genre_id].to_i) %>
  </p>
<% end %>


params[:genre_id]をto_i してやっています。params[:genre_id]はFixnum ではなくString なので、Genre.find(:all).map{|g| [g.name, g.id]}で生成されるFixnum のid と一致できないんですよね。

機能テスト時でもはまる

このハマりは有名ですが、以下の様なassertion が失敗するというやつです。

test:
   post :update, :id => 1
   assert_redirected_to :action => 'show', :id => 1
controller:
   redirect_to :action => 'show', :id => params[:id]


これも、params[:id] が文字列になってしまうからですね。以下のように書くとテストをパスできます。

test:
   post :update, :id => '1'
   assert_redirected_to :action => 'show', :id => '1'
controller:
   redirect_to :action => 'show', :id => params[:id]

checkbox でもハマる

String/Fixnum の話ではありませんが、checkbox がON の時はtrue、OFF の時はfalse を送信するようにしてハマるっていうのもあるかなと思います。結局送信される時はどちらも「"true"」, 「"false"」と文字列になってしまうので、どちらも真になってしまうんですよね。

  <%= f.check_box :receive_mailmagazine, {}, true, false %>

なんでこんなハマりが起こるか

「link_to ... :id => 1」としても、params[:id] の値が文字列「"1"」になるのは、自然だと思います。HTTP の世界に出て行ったのに型情報を保持している方が不自然。
なのに、何故このようなハマりが起こるかは、id:yuum3 もご指摘している通り、Rails のfind メソッドのせいです。find メソッドは引数にid をとることができますが、このid はString/Fixnum のどちらを指定してもいいんです。

Product.find(1) == Product.find('1')  #=> true


本来「params[:id]」を受けてfind を実行する場合は、以下のようにto_i した方がいいんですよね。

Product.find(params[:id].to_i)


でも、上のようなコードはController に頻繁に出現するので、find は文字列のid も許容するようになっているんだと思います。
トータルで見ると文字列も許容する方がコードが綺麗に保てるんだと思いますが、このようなハマりが発生しますよね。Ruby では型を意識することが他の言語に比べて少ないので余計に気がつきにくい・・・(ノ∀`)