【Rails】ActiveModelのsliceは賢い!…でもキーの型には要注意

に公開

こんにちは!
最近フルサイクルエンジニアになるべくフロントエンドの勉強を始めた @ourly_nobuo です!

Railsで開発していると、 Post.find('uuid') のようにデータベースから取得したActiveRecordオブジェクトを扱う場面が頻繁にあります。このオブジェクトから一部のプロパティだけを抜き出したい時、ActiveRecordがincludeしているActiveModelの slice メソッドは非常に便利です。

しかし、この slice メソッド、実は私たちが思っているよりも少し賢く、そして少しトリッキーな一面を持っています。この記事では、ActiveModelオブジェクトに対する slice の挙動と、そこに潜む「静かな罠」について共有します!

前提: ActiveModel の slice は気が利く

slice メソッドは、ActiveModelに定義されているインスタンスメソッドです。
https://apidock.com/rails/ActiveModel/Model/slice
実際に挙動を試してみると、以下のように動作します。

ActiveModelのsliceメソッド
post = Post.find_by(title: "Railsのコツ")

# :title, :view_countキーでsliceを実行
sliced_post = post.slice(:title, :view_count)

# 試してみると、StringでもSymbolでもどちらでも値にアクセスできる!
puts sliced_post['title']
#=> "Railsのコツ"
puts sliced_post[:title]
#=> "Railsのコツ"

# クラスを確認すると、その理由がわかる
puts sliced_post.class
#=> ActiveSupport::HashWithIndifferentAccess

ActiveModelオブジェクトのsliceは、気を利かせて ActiveSupport::HashWithIndifferentAccess を返してくれるのです。これにより、 slice した後もキーがStringかSymbolかを気にすることなく値にアクセスできます。非常に便利ですね。
https://api.rubyonrails.org/v7.2/classes/ActiveSupport/HashWithIndifferentAccess.html

ちなみに補足として、HashWithIndifferentAccessの内部処理としては、Symbolが渡ってきた場合に、Stringに変換するというシンプルなことをしています。
https://github.com/rails/rails/blob/9204eb520c2784ca7a1da9a4884aad21c59088fd/activesupport/lib/active_support/hash_with_indifferent_access.rb#L395-L397

本当の罠: キーの型はStringだった

これで万事解決、便利な slice をどんどん使っていこう!…と言いたいところですが、ここに静かな罠が潜んでいます。

この罠は、値にアクセスしているだけでは現れません。キーそのものを扱おうとした時に初めて姿を表します。

例えば、特定のキー(例::view_count)だったら特別な処理をしたい、というコードを見てみましょう。

slice後のkeyに着目する際には注意が必要
post = Post.find_by(title: "Railsのコツ")
sliced_post = post.slice(:title, :view_count)

TARGET_METRICS_KEYS = [:view_count]

sliced_post.each do |key, value|
  if TARGET_METRICS_KEYS.include?(key) # `[:view_count].include?("view_count")` となり、falseになる
    # この中の処理は永遠に実行されない…
    puts "【特別指標】#{key}: #{value}"
  else
    puts "#{key}: #{value}"
  end
end
#実行結果
# => title: Railsのコツ
# => view_count: 580

:view_countは特別扱いされず、他のキーと同じように出力されてしまいました。

原因は、HashWithIndifferentAccessの内部仕様にあります。このクラスは、キーをString/Symbolのどちらで受け取っても、内部ではすべて文字列 (String) に統一して保持します。

そのため、 .each.keys でキーを取り出すと、その型は常にStringです。Symbolの配列と比較しても、型が違うため一致することはなく、バグは静かに潜伏し続けます。

解決策

原因が分かれば解決は簡単です。キーの型を合わせてあげましょう。

TARGET_METRICS_KEYS = [:view_count]
sliced_post.each do |key, value|
  # 比較する際に、文字列のキーをシンボルに変換する
  if TARGET_METRICS_KEYS.include?(key.to_sym)
    puts "【特別指標】#{key}: #{value}"
  else
    puts "#{key}: #{value}"
  end
end
# 修正後の実行結果
# => title: Railsのコツ
# => 【特別指標】view_count: 580

上記かもしくは比較したいキーをあらかじめStringで定義しておくのも有効でしょう。

TARGET_METRICS_KEYS = ['view_count']

これで意図通りの動作になりました👏

まとめ

ここまでお読みいただきありがとうございました!!

ActiveModelの slice は便利です!
返り値はHashWithIndifferentAccessなので、String/Symbolどちらでも値にアクセスできます。

しかしキーの型には要注意!
キー自体を扱う( .keys で取り出す、比較する等)場合、その型はStringです。

この 「値の参照ではString/Symbolを区別しないが、キー自体の型はStringに統一される」 という特性を理解しておくことで、見つけにくい静かなバグを未然に防ぐことができます。便利なRailsの機能を、より深く理解して使いこなしていきましょう。

ourly tech blog

Discussion