はじめに
railsを触り始めて4ヶ月が経ち、「ActiveRecordってSQLを知らなくても書けるってすごいなぁ」
と思っていた自分に変化が訪れました。その変化とは、「何でActiveRecordって全部纏めちゃうんだよ。。。」
という気持ちが芽生えたことです。
今私が扱っている案件では、「複数のテーブルから、ある条件のレコードの件数を、タイムアウトなしで取得したい」
という状態になっています。
一例ではありますが、以下のようなサブクエリを使用しないscopeを複数チェーンして、各条件の件数を取得していました。
scope :select_page_views_count, -> do
left_joins(:page_views)
.select("COUNT(DISTINCT page_views.id) AS page_views_count")
.group("#{table_name}.id")
end
scope :select_microposts_count, -> do
left_joins(:microposts)
.select("COUNT(DISTINCT microposts.id) AS microposts_count")
.group("#{table_name}.id")
end
そしてタイムアウトが発生し、SQLの闇であるチューニングの世界に入り込みました。
早く検索できるSQL(サブクエリ有)を作成し、いざActiveRecordで書いてみよう!!!と思い書いてみると、
「こいつ...groupの条件とか全部纏めるやん...サブクエリ発行してくれないやん...」
というわけで、今回はArelという、ActiveRecordの内部で使用されるSQL生成ライブラリを使用して、
複雑なサブクエリを実現したいと思います。
※ただし、ArelはRailsのプライベートAPIのため、利用しない方がいいらしいので、ご利用は計画的にお願いします。
Arelとは
gitHub: https://github.com/rails/arel を参照
Arelを使ってみる
実際にArelを使ってみましょう。
Modelは以下の通りです。
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
.
.
.
end
class Micropost < ApplicationRecord
belongs_to :user
.
.
.
end
Arelへの変換
まずは既存のModelをArelに変換します。
>> User.arel_table
=> #<Arel::Table:0x00000003bac8d8 @name="users", @type_caster=#<ActiveRecord::TypeCaster::Map:0x00000003bac928 @types=User (call 'User.connection' to establish a connection)>, @table_alias=nil>
>> Micropost.arel_table
=> #<Arel::Table:0x000000027d4388 @name="microposts", @type_caster=#<ActiveRecord::TypeCaster::Map:0x000000027d43d8 @types=Micropost(id: integer, content: text, user_id: integer, created_at: datetime, updated_at: datetime, picture: string, likes_count: integer)>, @table_alias=nil>
>> users = User.arel_table
=> #<Arel::Table:0x00000003bac8d8 @name="users", @type_caster=#<ActiveRecord::TypeCaster::Map:0x00000003bac928 @types=User(id: integer, name: string, email: string, created_at: datetime, updated_at: datetime, password_digest: string, remember_digest: string, admin: boolean, activation_digest: string, activated: boolean, activated_at: datetime, reset_digest: string, reset_sent_at: datetime)>, @table_alias=nil>
>> microposts = Micropost.arel_table
=> #<Arel::Table:0x000000027d4388 @name="microposts", @type_caster=#<ActiveRecord::TypeCaster::Map:0x000000027d43d8 @types=Micropost(id: integer, content: text, user_id: integer, created_at: datetime, updated_at: datetime, picture: string, likes_count: integer)>, @table_alias=nil>
簡単なクエリの発行
Arel、ActiveRecordで簡単なクエリを発行してみましょう。
SELECT
usersテーブルからidを取得
# ActiveRecord
>> User.select(:id).to_sql
=>
"SELECT
\"users\".\"id\"
FROM
\"users\""
# Arel
>> users.project(users[:id]).to_sql
=>
"SELECT
\"users\".\"id\"
FROM
\"users\""
WHERE
usersテーブルから、名前がmojamojaで、作成日が昨日の今の時間から今日の今の時間までの全てのカラムを取得
# ActiveRecord
>> User.where(name: "mojamoja").where(created_at: Time.zone.now-1.day..Time.zone.now).to_sql
=>
"SELECT
\"users\".*
FROM
\"users\"
WHERE
\"users\".\"name\" = 'mojamoja'
AND
(\"users\".\"created_at\" BETWEEN '2018-03-08 03:17:47.474153' AND '2018-03-09 03:17:47.474396')"
# Arel
>> users.project(users[Arel.sql('*')])
.where(users[:name].eq("mojamoja"))
.where(users[:created_at].between(Time.zone.now-1.day..Time.zone.now)).to_sql
=>
"SELECT
\"users\".*
FROM
\"users\"
WHERE
\"users\".\"name\" = 'mojamoja'
AND
\"users\".\"created_at\" BETWEEN '2018-03-08 03:22:09.787989' AND '2018-03-09 03:22:09.788328'"
JOIN
usersテーブルとmicropostsテーブルを内部結合し、usersテーブルの全てのカラムを取得
# ActiveRecord
>> User.joins(:microposts).to_sql
=>
"SELECT
\"users\".*
FROM
\"users\"
INNER JOIN
\"microposts\"
ON
\"microposts\".\"user_id\" = \"users\".\"id\""
# Arel
>> users.project(users[Arel.sql('*')])
.join(microposts)
.on(microposts[:user_id].eq(users[:id])).to_sql
=>
"SELECT
\"users\".*
FROM
\"users\"
INNER JOIN
\"microposts\"
ON
\"microposts\".\"user_id\" = \"users\".\"id\""
...この辺までのクエリは、ActiveRecordのコードの方が分かりやすいですね。
複雑なクエリ(サブクエリ)の発行
次に、Arel、ActiveRecordでサブクエリを発行してみましょう。
FROMでのサブクエリ
# ActiveRecord
>> # 実現不可能でした。。。知っている方いたらご教示ください。
# Arel
>> Arel::Table.new(nil)
.project(Arel.sql('*'))
.from(
users.project(Arel.sql('*'))
).to_sql
=>
"SELECT
*
FROM (
SELECT
*
FROM
\"users\"
)"
WHEREでのサブクエリ
# ActiveRecord
>> User.where(id: Micropost.select(:user_id).where(id: 1)).to_sql
=>
"SELECT
\"users\".*
FROM
\"users\"
WHERE
\"users\".\"id\" IN (
SELECT
\"microposts\".\"user_id\"
FROM
\"microposts\"
WHERE
\"microposts\".\"id\" = 1
)"
# Arel
>> users.project(users[Arel.sql('*')])
.where(users[:id].eq(
microposts.project(microposts[:user_id])
.where(microposts[:id].eq('1'))
)).to_sql
=>
"SELECT
\"users\".*
FROM
\"users\"
WHERE
\"users\".\"id\" = (
SELECT
\"microposts\".\"user_id\"
FROM
\"microposts\"
WHERE
\"microposts\".\"id\" = 1
)"
ActiveRecordでは発行できなかったり、サブクエリにモデルが必要だったりと、ActiveRecordの限界が見えてきましたね。
超複雑なクエリ(たくさんのサブクエリとグルーピングと件数算出)の発行
ここで本題です。実現したい内容は以下となります。
- 要望内容
- ユーザの活動状況を分析したいため、ユーザ一覧に他のユーザページを見た件数、ユーザが投稿した件数を表示したい
- ユーザ一覧で期間を選択すると、その該当期間での上記の件数を表示したい
- 実現内容
- ユーザ毎のあるページの閲覧数(page_view)、投稿数(micropost)のある期間でのレコードの件数
- 上記のレコードをscopeとしたい
Modelは以下の通りです。
class User < ApplicationRecord
has_many :page_views, dependent: :destroy
has_many :microposts, dependent: :destroy
.
.
.
scope :select_page_views_count, -> do
page_views_table = PageView.arel_table
count_page_views_table = page_views_table
.project(Arel.sql("count(*) as page_views_cnt, user_id as page_views_user_id"))
.where(page_views_table[:created_at].between(Time.zone.now-1..Time.zone.now))
.group(page_views_table[:user_id])
.as("count_page_views")
count_page_views_arel_table = arel_table.join(count_page_views_table, Arel::Nodes::OuterJoin)
.on(count_page_views_table[:page_views_user_id].eq(arel_table[:id]))
select("count_page_views.page_views_cnt as views_count").joins(count_page_views_arel_table.join_sources)
end
scope :select_microposts_count, -> do
microposts_table = Micropost.arel_table
count_microposts_table = microposts_table
.project(Arel.sql("count(*) as microposts_cnt, user_id as microposts_user_id"))
.where(microposts_table[:created_at].between(Time.zone.now-1..Time.zone.now))
.group(microposts_table[:user_id])
.as("count_microposts")
count_microposts_arel_table = arel_table.join(count_microposts_table, Arel::Nodes::OuterJoin)
.on(count_microposts_table[:microposts_user_id].eq(arel_table[:id]))
select("count_microposts.microposts_cnt as microposts_count").joins(count_microposts_arel_table.join_sources)
end
end
class PageView < ApplicationRecord
belongs_to :user
.
.
.
end
class Micropost < ApplicationRecord
belongs_to :user
.
.
.
end
これをarelで実現すると以下のようになります。
>> User.select_page_views_count.to_sql
=>
"SELECT
count_page_views.page_views_cnt as views_count
FROM
`users`
LEFT OUTER JOIN (
SELECT
count(*) as page_views_cnt,
user_id as page_views_user_id
FROM
`page_views`
WHERE
`page_views`.`created_at` BETWEEN '2018-04-13 01:51:40' AND '2018-04-13 01:51:41'
GROUP BY
`page_views`.`user_id`
) count_page_views
ON
count_page_views.`page_views_user_id` = `users`.`id`"
>> User.select_microposts_count.to_sql
=>
"SELECT
count_microposts.microposts_cnt as microposts_count
FROM
`users`
LEFT OUTER JOIN (
SELECT
count(*) as microposts_cnt,
user_id as microposts_user_id
FROM
`microposts`
WHERE
`microposts`.`created_at` BETWEEN '2018-04-13 01:49:40' AND '2018-04-13 01:49:41'
GROUP BY
`microposts`.`user_id`
) count_microposts
ON
count_microposts.`microposts_user_id` = `users`.`id`"
>> User.select_page_views_count.select_microposts_count.to_sql
=>
"SELECT
count_page_views.page_views_cnt as views_count,
count_microposts.microposts_cnt as microposts_count
FROM
`users`
LEFT OUTER JOIN (
SELECT
count(*) as page_views_cnt,
user_id as page_views_user_id
FROM
`page_views`
WHERE
`page_views`.`created_at` BETWEEN '2018-04-13 01:52:59' AND '2018-04-13 01:53:00'
GROUP BY
`page_views`.`user_id`
) count_page_views
ON
count_page_views.`page_views_user_id` = `users`.`id`"
LEFT OUTER JOIN (
SELECT
count(*) as microposts_cnt,
user_id as microposts_user_id
FROM
`microposts`
WHERE
`microposts`.`created_at` BETWEEN '2018-04-13 01:52:59' AND '2018-04-13 01:53:00'
GROUP BY
`microposts`.`user_id`
) count_microposts
ON
count_microposts.`microposts_user_id` = `users`.`id`
GROUP BYが各サブクエリの中で発行されていますね。
ActiveRecordの場合、サブクエリ内でGROUP BYを使用するにはjoin関数で生SQLを書かなくてはなりません。
これを知らずに、group関数を各scopeに追加しすると、最後にまとめてGROUP BYしてしまい、予期せぬ結果が取得される恐れがあります。
まとめ
Arelで記載したコードがとても綺麗とは思えないですが、汎用性は高い気はします。
ですが、結局メンテナンス性が落ちるので、実際に使用することはありませんでした。
これが公式のAPIになってくれたら、がっつり使って行きたいですね。
※何度も言いますが、ArelはRailsのプライベートAPIのため、利用しない方がいいらしいので、ご利用は計画的にお願いします。