ActiveRecord / Arel
両方ともRailsでDBを操作する際に重要な要素だと思う。
今回めちゃくちゃ実践的に、わざわざこの記事用にモデルを別途用意したりして書いてみました。
モデル取得・検索についてのヒントになれば幸いです。
-
対象者
: いまいちRailsでの検索条件や結合条件をうまく書けない人 -
執筆時の環境
- rails : 4.2.0
- activerecord : 4.2.0
- arel : 6.0
使用するModel
コンテンツに紐づくチャプターを取ってこよう
多分コントローラーにこんな感じで書きますよね。
Content.joins(:chapters).where(id: 2)
ここで一旦シンキングタイム、はたしてこれはクールか?
.
..
...
全くもってクールじゃないです。
なぜなら
- 再利用不可
- コントローラーにこんなロジックが一杯あっていいの?
モデルにスコープを導入しましょう
class Content < ActiveRecord::Base
has_one :image, as: :imageable
has_many :chapters
has_many :user_contents, inverse_of: :content, dependent: :destroy
has_many :users, through: :user_contents
accepts_nested_attributes_for :image, allow_destroy: true
scope :with_chapter, -> { joins(:chapters) }
scope :search_with_id, ->(content_id) { where(id: content_id) }
scope :search_with_title, ->(title) { where(title: title) }
}
# ...省略
end
スコープはモデルに定義するとクラスメソッドのように呼び出せます。
それならクラスメソッドでもいいのでは?という疑問は置いておいて次の利用例をみてください。
Content.with_chapter.search_with_id(2)
直感的に扱えますね。
先の疑問に対する答えのひとつはメソッドチェーンできる点で利があります。
つまり扱いやすい単位でスコープとして定義しておけば、それを組み合わせて使えます。
ユーザーが特定のコンテンツを3話(チャプター)まで視聴したか確認する
class Chapter < ActiveRecord::Base
belongs_to :content
has_many :chapter_channels
has_many :user_chapters
accepts_nested_attributes_for :chapter_channels, allow_destroy: true
validates :content, presence: true
validates :chapter, uniqueness: { scope: [:content_id] }
scope :with_user_chapters, -> { joins(:user_chapters) }
scope :with_content, -> { joins(:content) }
scope :less_than_chapter, lambda { |chapter|
where(arel_table[:chapter].lteq chapter)
}
end
親を取得する場合はcontentsではなくcontentのように単数形となるので注意しましょう。
〜以上などの条件はArelの出番です。この例では引数のchpater以下を取得します。
Chapter.with_user_chapters.with_content.less_than_chapter(3)
.merge(Content.search_with_title('艦隊これくしょん'))
使うときはこんな感じですね。
他のモデルに定義されているwhere文を使うときはmergeします。
発行されるSQLは以下のようになります。(sqlite3)
SELECT chapters.*
FROM chapters
INNER JOIN user_chapters
ON user_chapters.chapter_id = chapters.id
INNER JOIN contents
ON contents.id = chapters.content_id
WHERE (chapters.chapter <= 3)
AND contents.title = '艦隊これくしょん';
これでどのユーザーが艦これを3話まで視聴しているか確認できるはずです。
コンテンツの最新話を取得する
class Chapter < ActiveRecord::Base
# ...省略
scope :new_chapter, -> { maximum(:chapter) }
scope :group_by_content, -> { group(:content_id) }
# ...省略
end
グルーピングや集計関数なんかもサポートしています。
Chapter.with_content.new_chapter.group_by_content
コンテンツを結合する必要はないですが、実質使うときはコンテンツのタイトルも含めて表示することを想定して一緒に持ってきます。
ユーザーがまだ見ていないチャプターを取ってくる
class UserChapter < ActiveRecord::Base
belongs_to :user
belongs_to :chapter
validates :user, presence: true
validates :chapter, presence: true
validates :chapter_id, uniqueness: { scope: [:user_id] }
scope :where_chapter_not_exists, lambda {
where(chapter_id: Chapter.arel_table[:id]).exists.not
}
scope :search_with_user_id, lambda { |user|
where(user_id: user.id)
}
end
existsなんかも簡単に扱えます。
Chapter.with_user_chapters
.where(UserChapter.where_chapter_not_exists)
.merge(UserChapter.search_with_user_id(User.find(2)))
求められるSQLを考えると分かるかと思いますが使うときはwhereの中に入れてしまいます。
無駄な括弧がありますが、綺麗にsqlが組み上がっています。
SELECT chapters.*
FROM chapters
INNER JOIN user_chapters
ON user_chapters.chapter_id = chapters.id
WHERE (
NOT (
EXISTS (
SELECT user_chapters.*
FROM user_chapters
WHERE user_chapters.chapter_id = chapters.id
)
)
)
AND user_chapters.user_id = 2
チャンネルとコンテンツを設定済みの特定のユーザーが未視聴の最速放送データを取ってくる。
私が今回の記事を書くにあたって到達したかった終着点です。
モデルのスコープを使わない最初の状態はこれでした。
class UserChaptersController < ApplicationController
def index
@records = Image.find_by_sql(generate_query) if current_user
end
def generate_table
[
Chapter.arel_table,
ChapterChannel.arel_table,
UserContent.arel_table,
UserChannel.arel_table,
Content.arel_table,
Channel.arel_table,
Image.arel_table
]
end
def generate_condition
cha, cch, uco, uch, co, ch, img = generate_table
cha.project(
cha[:sub_title].as('sub_title'), cha[:chapter].as('chapter'),
cha[:id].as('chapter_id'), co[:title].as('title'),
ch[:name].as('name'), cch[:start_at].minimum.as('start_at'),
img[:id].as('id'), img[:image].as('image')
).from(cha)
.join(cch).on(cha[:id].eq(cch[:chapter_id]))
.join(uco).on(Arel::Nodes::And.new(uco[:user_id].eq(current_user.id),
co[:id].eq(uco[:content_id])))
.join(uch).on(ch[:id].eq(uch[:channel_id]))
.join(co).on(cha[:content_id].eq(co[:id]))
.join(ch).on(cch[:channel_id].eq(ch[:id]))
.join(img, Arel::Nodes::OuterJoin)
.on(Arel::Nodes::And.new(img[:imageable_type].eq('Content'),
co[:id].eq(img[:imageable_id])))
.where(UserChapter.where(chapter_id: cha[:id]).exists.not)
.where(cch[:start_at].lt(Date.today + 3).and(cha[:chapter].lteq(5000))
.or(cch[:end_at].gt(Time.now).and(cha[:chapter].gt(5000))))
.group(cha[:sub_title]).group(cha[:chapter]).group(cha[:id])
.group(co[:id]).group(co[:title]).group(img[:id]).group(img[:image])
.order(cch[:start_at].desc)
end
end
ちょっと理解不能ですよね。
まずは一番重要なchapterからモデルに移していきました。
ちなみに今までの記事内で使っていたモデルは説明用でした。
class Chapter < ActiveRecord::Base
belongs_to :content
has_many :chapter_channels
has_many :user_chapters
accepts_nested_attributes_for :chapter_channels, allow_destroy: true
validates :content, presence: true
validates :chapter, uniqueness: { scope: [:content_id] }
scope :with_chapter_channels, -> { joins(:chapter_channels) }
scope :with_content, -> { joins(:content) }
end
画像をポリモーフィックで持たせて、ジューシーになったコンテンツ
class Content < ActiveRecord::Base
has_one :image, as: :imageable
has_many :chapters
has_many :user_contents, inverse_of: :content, dependent: :destroy
has_many :users, through: :user_contents
accepts_nested_attributes_for :image, allow_destroy: true
scope :with_user_contents, lambda { |user|
user_content = UserContent.arel_table
arel_table.join(user_content).on(
Arel::Nodes::And.new([
user_content[:user_id].eq(user.id),
arel_table[:id].eq(user_content[:content_id])
])
)
}
scope :with_image, lambda {
image = Image.arel_table
arel_table.join(image, Arel::Nodes::OuterJoin).on(
Arel::Nodes::And.new([
image[:imageable_type].eq('Content'),
arel_table[:id].eq(image[:imageable_id])
])
)
}
end
JOIN句で結合条件をつなぐ場合はArel::Nodes::And.newを使います。
見た目通り、括弧内の配列の条件はandで結合されます。
そして検索のメイン
内部的にchapterが5000を越えているものは一挙放送など話数がないものとしています。
サザエさんが放送回数2000回を越えているんだよね...
class ChapterChannel < ActiveRecord::Base
belongs_to :chapter
belongs_to :channel
validates :channel, presence: true
validates :chapter, presence: true
validates :chapter_id, uniqueness: { scope: [:channel_id] }
validates :pid, presence: true, uniqueness: true
scope :with_user_channels, lambda { |user|
user_channel = UserChannel.arel_table
arel_table.join(user_channel).on(
Arel::Nodes::And.new([
arel_table[:channel_id].eq(user_channel[:channel_id]),
user_channel[:user_id].eq(user.id)
])
)
}
# ATTENTION: Chapterと結合後のみ使える
scope :scheduled_or_binge_watching, lambda { |day|
where_scheduled(day).or(where_binge_watching)
}
scope :where_scheduled, lambda { |day|
chapter = Chapter.arel_table
arel_table[:start_at].lt(Date.today + day)
.and(chapter[:chapter].lteq(5_000))
}
scope :where_binge_watching, lambda {
chapter = Chapter.arel_table
arel_table[:end_at].gt(Time.now).and(chapter[:chapter].gt(5_000))
}
scope :with_channel, -> { joins(:channel) }
end
めっちゃ簡単なチャンネル
class Channel < ActiveRecord::Base
has_many :chapter_channels
has_many :user_channels, inverse_of: :channel, dependent: :destroy
has_many :users, through: :user_channels
enum region: {
# ...省略
}
validates :name, presence: true
scope :with_user_channels, -> { joins(:user_channels) }
end
最終的なコントローラーはこうなりました。
class UserChaptersController < ApplicationController
before_action :authenticate_user!, only: %w(create destroy)
def index
@records = Image.find_by_sql(generate_query) if current_user
end
private
def generate_table
[
Chapter.arel_table,
ChapterChannel.arel_table,
Channel.arel_table,
Image.arel_table
]
end
def generate_query
chapter, chapter_channel, channel, image = generate_table
active_record = joins_ar
active_record = where_ar(active_record)
active_record = select_ar(active_record)
active_record = group_ar(active_record)
active_record.arel
.project(channel[:name].as('name'), chapter[:id].as('chapter_id'),
chapter_channel[:start_at].minimum.as('start_at'),
image[:id].as('id'), image[:image].as('image'))
.group(image[:id]).group(image[:image])
.order(chapter_channel[:start_at].desc)
end
def joins_ar
Chapter.with_chapter_channels.with_content
.joins(ChapterChannel.with_channel.join_sources)
.joins(Channel.with_user_channels.join_sources)
.joins(Content.with_user_contents(current_user).join_sources)
.joins(Content.with_image.join_sources)
end
def where_ar(active_record)
active_record.where(UserChapter.where_chapter_not_exists)
.where(ChapterChannel.scheduled_or_binge_watching(3))
end
def select_ar(active_record)
active_record.select('chapter').select('sub_title').select('title')
end
def group_ar(active_record)
active_record.group('chapter').group('sub_title').group('title')
.group('chapter_id')
end
end
圧倒的に分かりやすくなりました。ありがとうございます。
いくつかポイントを書くと、
- Chapterとassociation(関連)がないものをJOINするときはjoin_sourcesをメソッドチェーンする。
- 関連がないものはselectやgroupができないのでarelオブジェクトに変換したのちにprojectでselectする。arelに変換後はarel_tableのオブジェクトを使うのでChannel.arel_table[:name]のように指定する。
- scheduled_or_binge_watchingのようにarelに変換後のオブジェクトはwhereやfromなどに入れ子にできるので、サブクエリ的な使い方をする場合はこんな感じで使う。
発行されるSQLはこんな感じです。
苦労の割にはSQL文は短いです。
SELECT chapters.chapter
, chapters.sub_title
, title
, channels.name AS name
, chapters.id AS chapter_id
, MIN(chapter_channels.start_at) AS start_at
, images.id AS id, images.image AS image
FROM chapters
INNER JOIN chapter_channels
ON chapter_channels.chapter_id = chapters.id
INNER JOIN contents
ON contents.id = chapters.content_id
INNER JOIN channels
ON channels.id = chapter_channels.channel_id
INNER JOIN user_channels
ON user_channels.channel_id = channels.id
INNER JOIN user_contents
ON user_contents.user_id = 1
AND contents.id = user_contents.content_id
LEFT OUTER JOIN images
ON images.imageable_type = 'Content'
AND contents.id = images.imageable_id
WHERE (NOT (EXISTS
(SELECT user_chapters.* FROM user_chapters
WHERE user_chapters.chapter_id = chapters.id)
))
AND ((chapter_channels.start_at < '2015-02-24'
AND chapters.chapter <= 5000
OR chapter_channels.end_at > '2015-02-21 10:58:50.713360'
AND chapters.chapter > 5000
))
GROUP BY chapter, sub_title, title, chapter_id, images.id, images.image
ORDER BY chapter_channels.start_at DESC
まとめ
- 複雑なことはコントローラーに書かず、なるべくmodelへscope定義して使い回せるようにする。
- 複雑な条件を扱うのはArelが得意。できることはrails/arelここを参照してくれ。
- join_sourcesを使えば直接関係の無いモデルをjoinできる
- Arelを使って入れ子にすればサブクエリも扱える。
- 本編でふれなかったがfind_by_sqlは最強、生SQLを記述して使うこともできる。こいつはモデルに定義していない属性まで勝手に使えるようにしてくれる。今回の例だと最終的に画像を容易に扱えるImageモデルを作成しているが、本来ImageモデルにないはずのChapterのsub_titleであったりContentのtitleを属性として持っていて、
@records
のActiveRecordから直接扱える。
以上、結構実践的に説明したつもりです。
突っ込みや質問があれば答えられる範囲で対応しますので、バシバシコメントください。
P.S.
続編できました
Railsで自由自在なSQLが組み上がるまで