ActiveRecordの便利メソッドと落とし穴

この記事について

本記事は、2023年5月に社内で実施した勉強会の資料を基に、社外向けに再編集したものです。
記載の情報は執筆当時のものであり、最新の仕様やベストプラクティスとは異なる場合があります。
実装にあたっては、必ず最新の公式ドキュメントをご確認ください。

はじめに

普段の業務でRailsのActiveRecordを利用していると、便利なメソッドに助けられる一方で、思わぬ落とし穴に出会うこともあります。

本記事では、業務を通じて得た知見を整理し、共有いたします。

目次

基本動作の確認

基本的に、ActiveRecordのSQL発行はインスタンス変数の中身を参照したタイミングで行われます。

それまではオブジェクトとして保持されているため、定義後に.whereを追加したり、ransackといった検索用gemで.resultを呼び出すことも可能です。

詳細は公式サイトをご参照ください。 

参考:Railsガイド「Active Record の基礎」
https://railsguides.jp/active_record_basics.html

便利なインスタンスメソッド

ActiveRecord::Base.instance_methods をコンソール上で実行すると多数表示されますが、その中から利用頻度の高いものを抜粋します。

changed? オブジェクトが作成されてから、いずれかの属性が更新されているかどうか
xxx_changed? オブジェクトが作成されてから、xxxという属性が更新されているかどうか
xxx_was オブジェクト作成時のxxxという属性に格納されていた値。

なお、これらは現在非推奨となっており、現在は以下のメソッドの利用を推奨されてます。

出典:Qiita – @htz – Rails 5.1 以降で attribute_was, attribute_change, attribute_changed?, changed?, changed 等が DEPRECATION WARNING #Rails
https://qiita.com/htz/items/56798d53ec5988733fc6

 

代替メソッドは以下の通りです。

changed?has_changes_to_save?
xxx_changed?will_save_change_to_xxx?
xxx_wasxxx_in_database

これらを整理した情報は以下のサイトにまとめられています。

出典:Zenn – megeton – ActiveRecord の change 系メソッドまとめ
https://zenn.dev/megeton/articles/425184479e6364

上記で触れた非推奨のxxx_was 系メソッドがすべて置き換え後の形で記載されているため、初めからこちらを参照するのが望ましいでしょう。

 

また、こちらのサイトには、
create(x: "a"), update(x: "b"), update({})実行時、before_saveafter_saveなどのコールバック内で各種インスタンスメソッドを呼び出した際の戻り値についても整理されています。

 

実際の業務開発で利用されることが多いメソッドを以下に示します。

will_save_change_to_attribute?(:x) 登録や更新がされるかどうか
attribute_before_last_save 処理実行前のDB登録値
attributes_in_database DBに登録されている値

例えば、関連先IDを保持するカラムxxx_idが UPDATE された際に関連先レコードへの処理が必要な場合、after_save内でattribute_before_last_save(:xxx_id)を利用することで、更新前のレコードIDを取得できます。

便利なクラスメソッド

.includes

関連オブジェクトをあらかじめ取得し、マッピングを行います。
一覧表示の際に1行ごとにSQLが発行される問題(N+1問題)を防ぎ、処理の遅延を回避するため、業務システムでは頻繁に利用されています。

.where

引数にはさまざまなバリエーションがあります。

文字列を第一引数に渡す場合、同じ数値を繰り返し利用する際には?ではなく:nameを使用し、引数にHashを渡す方法が推奨されます。

また、Rangeを渡すことで 'xxx ≥ :start_val and xxx < end_val' と毎回記述する必要がなくなります。
さらに、FromやToのいずれかが空である場合でも、自動的に適切なSQLに変換されます。

参考:TechRacho – hachi8833 – ActiveRecordで日付・時刻の範囲検索をシンプルに書く方法
https://techracho.bpsinc.jp/hachi8833/2023_04_12/24876

日付の範囲指定を簡潔に記述できるメソッド群
参考:Qiita – @terufumi1122 -【知らずに損した】ActiveSupportの期間指定メソッドall_day, all_week, all_month, all_year

https://qiita.com/terufumi1122/items/3aa21c20eeacbce33b93 

 

注意すべきポイント

.pluck()

関連先レコードのカラム値は、データベースに登録されていない場合、pluckでは取得できません。
.mapを用いて取得してください。

irb(main):010:0> shop = Shop.new
=> #<Shop id: nil, name: nil, memo: nil, rent: nil, created_at: nil, updated_at: nil>

irb(main):011:0> shop.cats = [Cat.new(name: 'new_cat')]
=> [#<Cat id: nil, name: "new_cat", color: nil, memo: nil, fee: nil, created_at: nil, updated_at: nil, shop_id: nil>]

irb(main):012:0> shop.cats << Cat.new(name: 'new_cat2')
=> #<ActiveRecord::Associations::CollectionProxy [#<Cat id: nil, name: "new_cat", color: nil, memo: nil, fee: nil, created_at: nil, updated_at: nil, shop_id: nil>, #<Cat id: nil, name: "new_cat2", color: nil, memo: nil, fee: nil, created_at: nil, updated_at: nil, shop_id: nil>]>

irb(main):013:0> shop.cats << Cat.new(name: 'new_cat3')
=> #<ActiveRecord::Associations::CollectionProxy [#<Cat id: nil, name: "new_cat", color: nil, memo: nil, fee: nil, created_at: nil, updated_at: nil, shop_id: nil>, #<Cat id: nil, name: "new_cat2", color: nil, memo: nil, fee: nil, created_at: nil, updated_at: nil, shop_id: nil>, #<Cat id: nil, name: "new_cat3", color: nil, memo: nil, fee: nil, created_at: nil, updated_at: nil, shop_id: nil>]>

irb(main):014:0> shop.cats.pluck(&:name)
=> []

irb(main):015:0> shop.cats.map(&:name)
=> ["new_cat", "new_cat2", "new_cat3"]

 

参考:ActiveRecord::Associations::CollectionProxy
https://edgeapi.rubyonrails.org/classes/ActiveRecord/Associations/CollectionProxy.html

(データベースに登録されているレコードを取得する場合は、pluckを使用できます。)

 

.or()

複数の.or()を繋げる際に、想定とは異なる動作が確認されました。

irb(main):022:0> puts Shop.where('rent < 500000').to_sql
SELECT "shops".* FROM "shops" WHERE (rent < 500000)
=> nil

irb(main):023:0> puts Shop.where('rent < 50000').or(Shop.where('rent > 80000').where('exists (select * from cats where cats.shop_id = shop.id and cats.color="white")')).to_sql
SELECT "shops".* FROM "shops" WHERE ((rent < 50000) OR (rent > 80000) AND (exists (select * from cats where cats.shop_id = shop.id and cats.color="white")))
=> nil

# 全部orで繋げた例
irb(main):001:0> puts Shop.where('rent < 50000').or(Shop.where('rent > 80000')).or(Shop.where('exists (select * from cats where cats.shop_id = shop.id and cats.color="white")')).to_sql
SELECT "shops".* FROM "shops" WHERE (((rent < 50000) OR (rent > 80000)) OR (exists (select * from cats where cats.shop_id = shop.id and cats.color="white")))
=> nil

 

2番目の例では、
家賃が5万未満 または (家賃8万より上 かつ 白い猫がいるお店)
を検索した際に、
(家賃が5万未満 または 家賃が8万より上) または 白い猫がいるお店
になってしまうのです。

Rails 7.0 でも同様の挙動が確認されましたので、注意が必要です。

回避方法は以下の通りです。

# 1個のwhereに詰め込めば回避できます
irb(main):006:0> puts Shop.where('rent < 50000').or(Shop.where('rent > 80000 and exists (select * from cats where cats.shop_id = shop.id and cats.color="white")')).to_sql
SELECT "shops".* FROM "shops" WHERE ((rent < 50000) OR (rent > 80000 and exists (select * from cats where cats.shop_id = shop.id and cats.color="white")))
=> nil

 

.where.not(複数)

Railsのバージョンによって挙動が異なる場合があります。

[3] pry(main)> puts Member.where.not(status: 2, deleted_at: nil).to_sql
SELECT "users".* FROM "users" WHERE "users"."type" = 'Member' AND "users"."status" != 2 AND "users"."deleted_at" IS NOT NULL
=> nil
[4] pry(main)> Rails.version
=> "6.0.3.3"

 

status ≠ 2 AND deleted_at IS NOT NULL

この結果は、AでもBでもない、つまりNORの集合を示しています。

ベン図で表すと次の通りです。

 

[1] pry(main)> puts Member.where.not(status: 2, deleted_at: nil).to_sql
SELECT "users".* FROM "users" WHERE "users"."type" = 'Member' AND NOT ("users"."status" = 2 AND "users"."deleted_at" IS NULL)
=> nil
[2] pry(main)> Rails.version
=> "7.0.4.3"

 

NOT( status = 2 AND deleted_at is null )

となりました。AかつB ではない、といったNANDの集合を指します。ベン図は以下。

結果が異なっている気がしますね。
同じ動作にしたい場合は、where('status != 2 AND deleted_at IS NOT NULL')と記述する必要がありそうです。

引用:Wikipedia – ベン図
https://ja.wikipedia.org/wiki/ベン図#論理演算とベン図

 

その他、ActiveRecordはRailsのアップデートにより挙動が変わる場合があります。
詳細は以下のリンクをご参照ください。

出典:Rails ガイド – Ruby on Rails 6.1 リリースノート
https://railsguides.jp/6_1_release_notes.html#active-record

 特に、
「名前付きスコープをチェーンしたときのスコープが、クラスレベルのクエリメソッドにリークしないようになった」
の部分については注意が必要そうです。

includes().where()

関連先を一緒に保持してくれる上に検索までしてくれる夢のような組み合わせだと考えていましたが、
whereに文字列を渡す場合、includesではJOINされません。
whereに引数をハッシュで渡すか、whereをどうしても文字列で書かざるを得ない場合はjoinsを利用しましょう。

# できてない(エラーになる)
irb(main):001:0> puts Shop.includes(:cats).where('cats.color = "white"').to_sql
SELECT "shops".* FROM "shops" WHERE (cats.color = "white")
=> nil

# whereにHashを渡す
irb(main):002:0> puts Shop.includes(:cats).where(cats: {color: 'white'}).to_sql
SELECT "shops"."id" AS t0_r0, "shops"."name" AS t0_r1, "shops"."memo" AS t0_r2, "shops"."rent" AS t0_r3, "shops"."created_at" AS t0_r4, "shops"."updated_at" AS t0_r5, "cats"."id" AS t1_r0, "cats"."name" AS t1_r1, "cats"."color" AS t1_r2, "cats"."memo" AS t1_r3, "cats"."fee" AS t1_r4, "cats"."created_at" AS t1_r5, "cats"."updated_at" AS t1_r6, "cats"."shop_id" AS t1_r7 FROM "shops" LEFT OUTER JOIN "cats" ON "cats"."shop_id" = "shops"."id" WHERE "cats"."color" = 'white'
=> nil

# includesをjoinsに変更
irb(main):004:0> puts Shop.joins(:cats).where('cats.color = "white"').to_sql
SELECT "shops".* FROM "shops" INNER JOIN "cats" ON "cats"."shop_id" = "shops"."id" WHERE (cats.color = "white")
=> nil

# ちなみに両方かくとこうなる
irb(main):003:0> puts Shop.includes(:cats).joins(:cats).where('cats.color = "white"').to_sql
SELECT "shops"."id" AS t0_r0, "shops"."name" AS t0_r1, "shops"."memo" AS t0_r2, "shops"."rent" AS t0_r3, "shops"."created_at" AS t0_r4, "shops"."updated_at" AS t0_r5, "cats"."id" AS t1_r0, "cats"."name" AS t1_r1, "cats"."color" AS t1_r2, "cats"."memo" AS t1_r3, "cats"."fee" AS t1_r4, "cats"."created_at" AS t1_r5, "cats"."updated_at" AS t1_r6, "cats"."shop_id" AS t1_r7 FROM "shops" INNER JOIN "cats" ON "cats"."shop_id" = "shops"."id" WHERE (cats.color = "white")
=> nil

  

ActiveRecord::Base.transaction do

中でエラーが発生すると、ロールバックしてくれるので一般的によく使用されているかと思います。

ただし、ネストして使いたい場合は、内部でraiseされるエラーの種類によって挙動が変わってくるので注意が必要です。

具体的には、親子関係にあるトランザクションがあるとして、子トランザクションの中で発生したエラーによって、親もロールバックする場合と、子だけロールバックする場合とに別れます。

  • 子トランザクションの中でActiveRecord::Rollbackがraiseされた場合
    • 子トランザクションのみがロールバックする
    • 親トランザクションは処理が進む
  • 子トランザクションの中でそれ以外のエラー(StandardErrorとか)がraiseされた場合
    • 子トランザクションがロールバック
    • 親トランザクションもロールバック

この違いを利用して、「この処理はロールバックしても良いが、本処理はロールバックしてはいけない」といった業務上のケースに対応することが可能な場合もあります。

ただし、特殊なケースを除き、トランザクションが二重にならないように設計するのが無難です。

.concat()

関連レコードに対して実行すると、has_oneではなくhas_manyの関係を持つ場合にレコードが増加します。
pushと同じ挙動を示します。

過去の事例として、外部キーがUPDATEされるという現象が発生しました。
ドキュメントを確認しても原因は明確ではありませんが、そのような現象が発生する場合があるようです。

aaa.concat(key: xxx)
#=> UPDATE table SET key=XXX where (id=aaaのもの)

単に文字列を連結して表示したいだけであっても、表示のたびにレコードが増加したり更新されたりすると、大きな問題となります。
実際にそのような事象が発生し、対応が困難でした。

参考までに、公式のRDocを示します(concat<<のエイリアスのため、<<のページへのリンクです)。

参考:ActiveRecord::Associations::CollectionProxy
 https://edgeapi.rubyonrails.org/classes/ActiveRecord/Associations/CollectionProxy.html#method-i-3C-3C

所感

本記事で紹介した各メソッドは、実際の業務でActiveRecordを扱う際に頻繁に利用されるものです。

これらの特性や注意点を理解して適切に活用することで、より安全で効率的な開発が実現できると考えます。

役に立ったらシェアしていただけると嬉しいです
  • URLをコピーしました!
  • URLをコピーしました!
目次