0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ActiveRecord・ArelでSQLを操作する

Last updated at Posted at 2023-03-16

環境

  • バージョン
    • 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を使用することで、プログラム内のオブジェクトとデータベースのテーブルを自動的にマッピングすることが可能
      • これにより、プログラム内でオブジェクトを直接操作することが出来る

SQL文を直接書く代わりにわずかなアクセスコードを書くだけで、アプリケーションにおけるオブジェクトの属性やリレーションシップをデータベースに保存することも、データベースから読み出すこともできるようになります。

  • ActiveRecordモデルを使用してリレーショナルデータベースに保存されたデータを操作する

使用例

※アソシエーションは以下の通り

member.rb
class Member < ActiveRecord::Base
  has_many :posts
end
post.rb
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アプリケーションのデバッグについては以下を参照

その他参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?