LoginSignup
650
607

More than 5 years have passed since last update.

Railsの複雑な検索はスコープを使おう

Last updated at Posted at 2015-02-21

ActiveRecord / Arel

両方ともRailsでDBを操作する際に重要な要素だと思う。
今回めちゃくちゃ実践的に、わざわざこの記事用にモデルを別途用意したりして書いてみました。
モデル取得・検索についてのヒントになれば幸いです。

  • 対象者 : いまいちRailsでの検索条件や結合条件をうまく書けない人
  • 執筆時の環境
    • rails : 4.2.0
    • activerecord : 4.2.0
    • arel : 6.0

使用するModel

erd.png

コンテンツに紐づくチャプターを取ってこよう

多分コントローラーにこんな感じで書きますよね。

Content.joins(:chapters).where(id: 2)

ここで一旦シンキングタイム、はたしてこれはクールか?
.
..
...
全くもってクールじゃないです。
なぜなら

  • 再利用不可
  • コントローラーにこんなロジックが一杯あっていいの?

モデルにスコープを導入しましょう

app/models/content.rb
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話(チャプター)まで視聴したか確認する

app/models/chapter.rb
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話まで視聴しているか確認できるはずです。

コンテンツの最新話を取得する

app/models/chapter.rb
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

コンテンツを結合する必要はないですが、実質使うときはコンテンツのタイトルも含めて表示することを想定して一緒に持ってきます。

ユーザーがまだ見ていないチャプターを取ってくる

app/models/user_chapter.rb
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からモデルに移していきました。
ちなみに今までの記事内で使っていたモデルは説明用でした。

app/models/chapter.rb
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

画像をポリモーフィックで持たせて、ジューシーになったコンテンツ

app/models/content.rb
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回を越えているんだよね...

app/models/chapter_channel.rb
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

めっちゃ簡単なチャンネル

app/models/channel.rb
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

最終的なコントローラーはこうなりました。

app/controllers/user_chapters_controller.rb
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が組み上がるまで

650
607
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
650
607