Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: データベースクエリにRangeを渡してコードを明確にしよう(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

参考: Support endless ranges in where by gregnavis · Pull Request #34906 · rails/rails
参考: Add beginless range support to clusivity by bjeanes · Pull Request #45123 · rails/rails

Rails: データベースクエリにRangeを渡してコードを明確にしよう(翻訳)

Active Recordのクエリインターフェイスには、データベースから行を取得するロジックをSQLに変換するさまざまな方法が豊富に用意されています。

Active Supportコア拡張のDateTimeの拡張の素晴らしさについては過去記事でもご紹介しました。Webアプリケーションではデータベースからレコードを取得するときに時刻と時刻による期間が主要なフィルタになります。
それでは、これらの拡張を使ってデータベースにクエリをかけてみましょう。

以下のように書くよりも

文字列ベースのSQLフラグメントで日時のフィールドにクエリをかける。

User.where("created_at > ? AND created_at < ?", 2.weeks.ago, 1.week.ago).to_sql
#=> "SELECT ... WHERE (created_at > '2022-08-31 11:29:53.945995' AND created_at < '2022-09-07 11:29:53.946280')"

User.where("created_at > ? AND created_at < ?", 2.weeks.ago.beginning_of_day, 2.weeks.ago.end_of_day).to_sql
#=> "SELECT ... WHERE (created_at > '2022-08-31 00:00:00' AND created_at < '2022-08-31 23:59:59.999999')"

User.where("created_at >= ?", 2.weeks.ago.beginning_of_day).to_sql
#=> "SELECT ... WHERE \"users\".\"created_at\" >= '2022-08-31 00:00:00'"

以下のように書こう

日時のRangeを引数として渡す。

User.where(created_at: (2.weeks.ago..1.week.ago)).to_sql
#=> "SELECT ... WHERE \"users\".\"created_at\" BETWEEN '2022-08-31 11:29:33.248193' AND '2022-09-07 11:29:33.248938'"

User.where(created_at: 2.weeks.ago.all_day).to_sql
#=> "SELECT ... WHERE \"users\".\"created_at\" BETWEEN '2022-08-31 00:00:00' AND '2022-08-31 23:59:59.999999'"

User.where(created_at: (2.weeks.ago.beginning_of_day..)).to_sql
#=> "SELECT ... WHERE \"users\".\"created_at\" >= '2022-08-31 00:00:00'"

編集部注

上述のコード例の1番目と2番目のSQLクエリは、変更前が<>になっていて両端の日時を含まないのに対し、変更後のBETWEENは両端の日時を含んでいるので、クエリの意味が変わっている点に注意が必要という点がBPS社内レビューで指摘されました。

そうする理由

Rubyの構文は、こういう場合に短く簡潔に書けます。

これによって生成されるSQLステートメントは、テキストで挿入する方法に比べて値の範囲が大きく外れにくくなるので、その分ミスも減るでしょう。

さらに、Rangeを渡されたときにフレームワークが生成するSQLステートメントは、SQLの正しいBETWEEN構文を使います(少なくともPostgreSQLでは)。
これによって振る舞いを記述する精度が高まり、背後のデータベースが統計情報やインデックスを活用して結果のクエリを高速に返すようになります。

そうしない理由があるとすれば

Active RecordのスコープにRangeを渡すのは、まだ思ったほど一般的ではないようです。これは、Railsガイドのドキュメントでは文字列ベースで書くクエリ条件が最初に紹介されていて、初期のRailsではこれが主要な構文だったからではないかと私は推測しています。

この現代的な構文を使わない理由はほとんどないでしょう。Active Recordの機能を使うなら、文字列をあれこれ組み立てて書くよりもこの構文の方が読みやすくわかりやすく書けます。普段からそのように書いておけば、まれに複雑なSQLクエリを文字列で書かなければならなくなった場所も際立つようになります。

上の最後の例では、..によるエンドレスRange構文を使っています。これは2018年にRuby 2.6で導入されたもので、Railsに降りてくるまで少し時間がかかったせいか、同じことをまだ文字列の式展開でやっている人がいるかもしれません。

訳注

mainブランチにマージされたRailsガイドの更新には、文字列と?プレースホルダによるクエリサンプルを.....によるクエリに更新しているものもあります(#47054)。Rails 7.1で反映される見込みです。

# https://github.com/rails/rails/commit/7707377ddfff3348f91cf046a7b975410fe9a08e#diff-77236751c3ab97b753f641ae6e10445de7662826f0176c68b5176feec92d0a1bL47
- scope :old, -> { where('year_published < ?', 50.years.ago )}
+ scope :old, -> { where(year_published: ...50.years.ago.year) }

参考: Update guides to use ranges instead of sql literals · rails/rails@7707377

インデックス!

同じフィールドに対して恒常的にクエリを送信することがあるなら、そのデータベーステーブルでインデックスを追加することでメリットを得られるかどうかを調べる価値があるでしょう。

関連記事

Rails: Active Record関連付けのループではeachよりfind_eachを検討しよう(翻訳)

ActiveRecordで日付・時刻の範囲検索をシンプルに書く方法


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。