環境
- バージョン
Rails 4.2
Ruby 2.4.7
Arelとは
- Ruby on Railsのクエリビルダーであり、Rubyオブジェクトを使用してSQLクエリを構築するためのライブラリ
- Rubyのオブジェクト指向プログラミングの概念を取り入れ、クエリの構造をオブジェクトとして表現することが出来る
- ActiveRecordと密接に統合されており、ActiveRecordのクエリインターフェイスと同様に使用することができる。これにより、ActiveRecordを使用してデータベースとのやり取りを簡単に行うことが可能
- Arelは、Railsの公式ドキュメントで推奨されていない
- 将来的に削除される可能性があるため、保守性の問題がある
- RailsのバージョンによってはArelのバージョンと互換性がない可能性がある
- よって、Railsの公式ドキュメントではActiveRecordのクエリビルダーを使用することが推奨されている
ActiveRecordとは(一部抜粋)
- ActiveRecordとは、MVCで言うところのモデルに相当する
- ORM(Object-Relational Mapping)システムに記述されている「ActiveRecordパターン」を実装したもの
- ORMとは
- プログラムとデータベースの間の橋渡しを行うためのフレームワーク
- 本来、プログラム内のオブジェクトとDB内のテーブルとの間には、プロパティの名称・データ型などの相違があり、プログラム内でデータベースにアクセスする場合には、手動でデータの変換やマッピングを行う必要がある
- ORMを使用することで、プログラム内のオブジェクトとデータベースのテーブルを自動的にマッピングすることが可能
- これにより、プログラム内でオブジェクトを直接操作することが出来る
- ORMとは
SQL文を直接書く代わりにわずかなアクセスコードを書くだけで、アプリケーションにおけるオブジェクトの属性やリレーションシップをデータベースに保存することも、データベースから読み出すこともできるようになります。
- ActiveRecordモデルを使用してリレーショナルデータベースに保存されたデータを操作する
使用例
※アソシエーションは以下の通り
class Member < ActiveRecord::Base
has_many :posts
end
class Post < ActiveRecord::Base
belongs_to :member
end
内部結合
ActiveRecordのみ
members = Member.joins(:post)
.where(posts: { type: 1, genre: 2 })
# puts members.to_sql
条件のみArel
# ActiveRecord_Relation
query = Member.joins(:post)
.where(post_table[:type].eq(1))
.where(post_table[:genre].eq(2))
Arelのみ
member_table = Member.arel_table
post_table = Post.arel_table
# Arel::SelectManager
query = member_table.join(post_table).on(post_table[:id].eq(member_table[:post_id]))
.where(post_table[:type].eq(1))
.where(post_table[:genre].eq(2))
# => SELECT FROM members INNER JOIN posts ON posts.id = members.post_id WHERE posts.type = 1 AND posts.genre = 2
外部結合
ActiveRecordのみ
Member.joins('LEFT OUTER JOIN posts ON posts.id = members.post_id')
# => SELECT members.* FROM members LEFT OUTER JOIN posts ON posts.id = members.post_id
※Rails 5.0以降ではleft_joins
(エイリアスはleft_outer_joins
)メソッドを使用可能
条件のみArel
member_table = Member.arel_table
post_table = Post.arel_table
join_condition = member_table.outer_join(post_table).on(post_table[:member_id].eq(member_table[:id])).join_sources
# ActiveRecord_Relation
members = Member.joins(join_condition)
.where(post_table[:type].eq(1))
.where(post_table[:genre].eq(2))
# => SELECT members.* FROM members LEFT OUTER JOIN posts ON posts.member_id = members.id WHERE posts.type = 1 AND posts.genre = 2
Arelのみ
member_table = Member.arel_table
post_table = Post.arel_table
# Arel::SelectManager
members =
member_table
.project(member_table[Arel.star])
.outer_join(post_table).on(member_table[:id].eq(post_table[:member_id]))
# => SELECT members.* FROM members LEFT OUTER JOIN posts ON members.id = posts.member_id
もしくは
member_table.join(post_table, Arel::Nodes::OuterJoin).on(member_table[:id].eq(post_table[:member_id]))
Arelで外部結合をした後に、ActiveRecordを返す
# Arelのテーブルオブジェクトを作成する
member_table = Member.arel_table
post_table = Post.arel_table
# Arelで外部結合を作成する
join = member_table.outer_join(post_table).on(member_table[:id].eq(post_table[:member_id])) # Arel::SelectManager
# ActiveRecordでSQLを実行する
Member.joins(join.to_sql) # ActiveRecord_Relation
EXISTS句
member_table = Member.arel_table
post_table = Post.arel_table
condition = Member
.where(member_table[:id].eq(post_table[:member_id]))
.where(member_table[:id].eq(1))
.exists
@posts = Post.where(condition)
# => SELECT posts.* FROM posts WHERE (EXISTS (SELECT members.* FROM members WHERE members.id = posts.member_id AND members.id = 1))
WITH句
自分でテーブルを作り、そいつをいつもと同じ方法で扱うイメージ
# 会員テーブル(DBに存在する)
member_table = Member.arel_table
# WITH句で自作するテーブル(DBに存在しない)
original_table = Arel::Table.new(:originals)
with_query = member_table.project(member_table[:birthday].maximum.as('youngest_birthday'))
query = member_table
.project(member_table[Arel.star])
.from([member_table, original_table])
.where(member_table[:birthday].eq(original_table[:youngest_birthday]))
.with(Arel::Nodes::As.new(original_table, with_query))
# => WITH originals AS (SELECT MAX(members.birthday) AS youngest_birthday FROM members) SELECT members.* FROM members, originals WHERE members.birthday = originals.youngest_birthday
実装時に気をつけること
N+1問題を回避するために、以下のメソッドでの実装も検討する
N+1問題とは、データベースへのアクセス回数が余計に多くなってしまう現象のこと
eager_loadメソッド
- 外部結合を使用し、1度のクエリで関連するモデルのデータを取得しキャッシュする
- 結合しているので
preload
とは違い、associationの値での絞り込みが可能
# 指定したテーブルをLEFT OUTER JOINで引いてキャッシュする
Member.eager_load(:posts).where(posts: { id: 1 })
preloadメソッド
- 指定したassociationを複数のクエリに分けて引いてキャッシュする
- データ量が増えるほど、
eager_load
よりもpreload
の方がSQLを分割して取得するため、レスポンスタイムは早くなる - 結合をしていない為、assosiationの値で絞り込むことは出来ない
Post.all.preload(:member)
includesメソッド
- includesしたテーブルでwhereによる絞り込みを行う
- includesしたassociationに対して、joinsまたはreferencesを呼んでいる
- 条件(where)などでアソシエーション先のデータを
- 参照している場合 :
eager_load
- 参照していない場合 :
preload
- よしなに対応してくれるが、意図しないパフォーマンスにならないかどうか注意が必要
- 参照している場合 :
# preloadの挙動
Member.includes(:posts)
# eager_loadの挙動
Member.includes(:posts).where(posts: { id: 1 })
デバッグ
ActiveRecordのログを標準出力に書き出す
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Baseは、Railsのデータベース接続を行うためのモデルクラス。
このクラスを継承することで、データベースのテーブルに対応するモデルを作成することができる。
loggerは、Railsのログ出力を管理するためのオブジェクト。
Logger.new(STDOUT) は、ログを標準出力に出力するように設定している。
この設定により、データベースに対するクエリやトランザクションの実行結果などがログに出力されるようになる。
開発やデバッグの際に、実行されたSQLクエリを確認することができる。
例
N+1問題が発生する場合、同じSQLクエリが複数回実行されることが分かる。
ActiveRecord::Base.logger = Logger.new(STDOUT)
# 投稿日降順、10件
posts = Post.order(posted_date: :desc).limit(10)
# 取得してきたPostに紐づくMemberが持つpoint_codeを表示する処理
posts.each { |post| p post.member.point_code }
D, [2023-03-16T18:46:46.678392 #73414] DEBUG -- : Post Load (0.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."posted_date" DESC LIMIT 10
D, [2023-03-16T18:46:46.679827 #73414] DEBUG -- : Member Load (0.2ms) SELECT "members".* FROM "members" WHERE "members"."id" = $1 LIMIT 1 [["id", 8]]
"0000008"
D, [2023-03-16T18:46:46.680974 #73414] DEBUG -- : Member Load (0.1ms) SELECT "members".* FROM "members" WHERE "members"."id" = $1 LIMIT 1 [["id", 1]]
"0000001"
D, [2023-03-16T18:46:46.682042 #73414] DEBUG -- : Member Load (0.2ms) SELECT "members".* FROM "members" WHERE "members"."id" = $1 LIMIT 1 [["id", 2]]
"0000002"
D, [2023-03-16T18:46:46.682901 #73414] DEBUG -- : Member Load (0.2ms) SELECT "members".* FROM "members" WHERE "members"."id" = $1 LIMIT 1 [["id", 3]]
"0000003"
D, [2023-03-16T18:46:46.683772 #73414] DEBUG -- : Member Load (0.2ms) SELECT "members".* FROM "members" WHERE "members"."id" = $1 LIMIT 1 [["id", 4]]
"0000004"
この場合、Postを取得するために、Memberのクエリが5回発行されている。これがN+1問題。
N+1問題を解決するためには・・・
preload
, eager_load
, includes
といったメソッドを使用し、関連するモデルをまとめて取得するなどの方法がある。
例えば、次のように書くことで、Memberのレコードを1度だけで取得することが可能。
eager_load
の場合
ActiveRecord::Base.logger = Logger.new(STDOUT)
# 外部結合で一括取得しキャッシュ
posts = Post.eager_load(:member).order(posted_date: :desc).limit(10)
posts.each { |post| p post.member.point_code }
D, [2023-03-16T20:17:57.470509 #81978] DEBUG -- : SQL (1.5ms) SELECT "posts"."id" AS t0_r0, "posts"."member_id" AS t0_r1, "posts"."posted_date" AS t0_r2, "posts"."body" AS t0_r3, "posts"."genre" AS t0_r4, "posts"."post_status", "posts"."created_system" AS t0_r5, "posts"."updated_system" AS t0_r6, "posts"."created_user" AS t0_r7, "posts"."updated_user" AS t0_r8, "posts"."created_at" AS t0_r9, "posts"."updated_at" AS t0_r10, "members"."id" AS t1_r0, "members"."mail_address" AS t1_r1, "members"."mail_address_for_index" AS t1_r2, "members"."member_code" AS t1_r3, "members"."point_code" AS t1_r4, "members"."first_name" AS t1_r5, "members"."first_name_kana" AS t1_r6, "members"."last_name" AS t1_r7, "members"."last_name_kana" AS t1_r8, "members"."sex_division" AS t1_r9, "members"."birthday" AS t1_r10, "members"."created_system" AS t1_r11, "members"."updated_system" AS t1_r12, "members"."created_user" AS t1_r13, "members"."updated_user" AS t1_r14, "members"."created_at" AS t1_r15, "members"."updated_at" AS t1_r16, "members"."test_account_flag" AS t1_r17 FROM "posts" LEFT OUTER JOIN "members" ON "members"."id" = "posts"."member_id" ORDER BY "posts"."posted_date" DESC LIMIT 10
"0000008"
"0000001"
"0000002"
"0000003"
"0000004"
includes
の場合
ActiveRecord::Base.logger = Logger.new(STDOUT)
# includesしたテーブルは参照していないので、preloadの挙動になる
posts = Post.includes(:member).order(posted_date: :desc).limit(10)
posts.each { |post| p post.member.point_code }
D, [2023-03-16T18:51:09.793843 #73945] DEBUG -- : Post Load (0.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."posted_date" DESC LIMIT 10
D, [2023-03-16T18:51:09.799642 #73945] DEBUG -- : Member Load (4.1ms) SELECT "members".* FROM "members" WHERE "members"."id" IN (8, 1, 2, 3, 4)
"0000008"
"0000001"
"0000002"
"0000003"
"0000004"
その他Railsアプリケーションのデバッグについては以下を参照
その他参考
- Arelの使い方(条件式)
- 【Rails】N+1を回避するメソッド(includes, eagar_load, preload)の使い分けについて
- 【Rails】N+1問題をアラート表示してくれるgem「bullet」を初心者向けにまとめてみた
- ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い
- ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由