読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでActiveRecord/Arelを使って複雑なSELECT文を実行する方法

Rails中級 Rails Model SQL

f:id:nipe880324:20150428020246j:plain

Rails(ActiveRecord/Arel)で複雑なSELECT文を実行する方法をまとめました。
メリット、デメリットや使いどころなどまとめ途中なのであしからずお願いします。

動作確認

  • Ruby 2.2.0
  • Rails 4.2.0
  • Arel 6.0.0

目次

  1. まとめ
  2. ER図
  3. Active Record
  4. Arel
  5. 生SQL

1. まとめ

まず、個人的な意見として、それぞれの方法のメリット・デメリットをまとめました。

メリットデメリット
ActiveRecord
 ・1, 2テーブル内での検索なら簡単に処理をかけれる
 ・返り値がActiveRecord::Relationやモデルのインスタンスなので扱いやすい

 ・複数のテーブルにまたがる検索で、検索条件、他テーブルのカラム値の扱いがしずらい
 ・CASE WHENやカラムなど行いづらい
Arel 文字列でなくメソッドで検索文を記載できる(個人的にあまりメリットが見えていない) RailsのプライベートAPIなので使うべきではない。参考
生SQL 複雑なSQLをかける 返り値がHashなのでモデルのメソッドが使いずらい


メインでよく行う処理では比較的簡単なSQL(ActiveRecordで十分足りる)で処理をおこなえ、レポート集計などは複雑なSQL(生のSQL)でもしょうがないと思います。
こういう状態が、保守性、パフォーマンス、メンテナンス性などに優れた良いDB設計ができているということだと個人的に思います。
つまり、メイン処理のSQLが複雑になってしまうということは、どこかでDB設計のミスを犯していたり、パフォーマンスのために正規化を崩しすぎてしまったのではと思います。



2. ER図

ER図

f:id:nipe880324:20150428015849j:plain

DBスキーマとモデルの作成

bin/rails g model User username email password
bin/rails g model Order order_no:integer user_id:integer
bin/rails g model LineItem quantity:integer book_id:integer
bin/rails g model Book isbn name name_kana price:integer
bin/rails g model Category
bin/rake db:migrate

モデルの定義

class User < ActiveRecord::Base
  has_many :orders
end

class Order < ActiveRecord::Base
  belongs_to :user
  has_many :line_items

  def total_price
    line_items.map(&:total_price).sum
  end
end

class LineItem < ActiveRecord::Base
  belongs_to :order
  belongs_to :book

  def total_price
    book.price * quantity
  end
end

class Book < ActiveRecord::Base
  has_many :line_items
  belongs_to :category
end

class Category < ActiveRecord::Base
  has_many :books
end

3. Active Record

簡単にテーブルからデータを取得でき、また、オブジェクトとして取得できるので、データを扱いやすいです。
しかし、複雑なSQLを実行する場合は、ActiveRecordにより作成されるSQLを確認しないと、パフォーマンスが悪いSQLになっている可能性もあるので注意が必要です。

joinsメソッドを使うことで次のようにINNER JOINをして、複数のテーブルに対してWHERE句を実施することができます。

users = User.joins(orders: { line_items: { book: :category }}).where(users: { username: 'test' }, categories: { name: 'IT' })

SELECT "users".* FROM "users"
INNER JOIN "orders" ON "orders"."user_id" = "users"."id"
INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
INNER JOIN "books" ON "books"."id" = "line_items"."book_id"
INNER JOIN "categories" ON "categories"."id" = "books"."category_id"
WHERE "users"."username" = 'test' AND "categories"."name" = 'IT'

includesメソッドを使うことで次のようにLEFT OUTTER JOINをして、複数のテーブルに対してWHERE句を実施することができます。

users = User.includes(orders: { line_items: { book: :category }}).where(users: { username: 'test' }, categories: { name: 'IT' })

SELECT "users"."id" AS t0_r0, "users"."username" AS t0_r1, "users"."email" AS t0_r2, "users"."password" AS t0_r3, "users"."created_at" AS t0_r4, "users"."updated_at" AS t0_r5, "orders"."id" AS t1_r0, "orders"."order_no" AS t1_r1, "orders"."user_id" AS t1_r2, "orders"."created_at" AS t1_r3, "orders"."updated_at" AS t1_r4, "line_items"."id" AS t2_r0, "line_items"."quantity" AS t2_r1, "line_items"."book_id" AS t2_r2, "line_items"."order_id" AS t2_r3, "line_items"."created_at" AS t2_r4, "line_items"."updated_at" AS t2_r5, "books"."id" AS t3_r0, "books"."isbn" AS t3_r1, "books"."name" AS t3_r2, "books"."name_kana" AS t3_r3, "books"."price" AS t3_r4, "books"."category_id" AS t3_r5, "books"."created_at" AS t3_r6, "books"."updated_at" AS t3_r7, "categories"."id" AS t4_r0, "categories"."name" AS t4_r1, "categories"."created_at" AS t4_r2, "categories"."updated_at" AS t4_r3
FROM "users"
LEFT OUTER JOIN "orders" ON "orders"."user_id" = "users"."id"
LEFT OUTER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
LEFT OUTER JOIN "books" ON "books"."id" = "line_items"."book_id"
LEFT OUTER JOIN "categories" ON "categories"."id" = "books"."category_id"
WHERE "users"."username" = 'test' AND "categories"."name" = 'IT'

また、オブジェクトとして帰ってくるので、その後に処理を行いやすいです。

user = User.includes(orders: { line_items: { book: :category }}).where(users: { username: 'test' }).first
user.orders.count

扱いづらいが、selectメソッドにより他テーブルの値を取得し、アクセスできます。

user = User.select('users.*, orders.order_no AS order_no').joins(:orders).find(1)
#=> SELECT  users.*, orders.order_no AS order_no FROM "users" INNER JOIN "orders" ON "orders"."user_id" = "users"."id" WHERE "users"."id" = ? LIMIT 1  [["id", 1]]

# selectで指定した値を取得できます
user.attributes
#=> {"id"=>1, "username"=>"test", "email"=>"hoge@example.com", "password"=>nil, "created_at"=>Mon, 27 Apr 2015 15:49:16 UTC +00:00, "updated_at"=>Mon, 27 Apr 2015 15:49:16 UTC +00:00, "order_no"=>1}

# もちろんアクセスもできます
user.order_no #=> 1

4. Arel

Arel - GitHub公式によると、複雑なSQLクエリの生成を簡単にするらしいです。(個人的にあまりメリットが見えていません)
また、一説によると、RailsのプライベートAPIなので使うべきではないらしいです。参考

users  = User.arel_table
orders = Order.arel_table
sql    = users.join(orders, Arel::Nodes::OuterJoin).on(users[:id].eq(orders[:user_id])).where(orders[:order_no].eq('1')).to_sql
#=> "SELECT FROM \"users\" LEFT OUTER JOIN \"orders\" ON \"users\".\"id\" = \"orders\".\"user_id\" WHERE \"orders\".\"order_no\" = 1"

5. 生SQL

副問い合わせ、CASE WHENなど複雑なSQLを記載でき、基本的にはSQLの知識はあると思うので学習コストは少ないと思います。
しかし、返り値がHashなのでモデルのメソッドが使いずらく、また、SQLサーバーに依存したコードになります。

# 実行したいSQLをヒアドキュメトで記載する
query = <<-SQL
  SELECT users.*
  FROM "users"
  LEFT OUTER JOIN "orders" ON "orders"."user_id" = "users"."id"
  LEFT OUTER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
  LEFT OUTER JOIN "books" ON "books"."id" = "line_items"."book_id"
  LEFT OUTER JOIN "categories" ON "categories"."id" = "books"."category_id"
  WHERE "users"."username" = 'test'
SQL

# SQLを実行する
hash = ActiveRecord::Base.connection.select_all(query)
#=> #<ActiveRecord::Result:0x007f9ab9869c90 @columns=["id", "username", "email", "password", "created_at", "updated_at"], @rows=[[1, "test", "hoge@example.com", nil, "2015-04-27 15:49:16.971518", "2015-04-27 15:49:16.971518"]], @hash_rows=nil, @column_types={}>

# 表形式で取得できる
hash.columns
#=> ["id", "username", "email", "password", "created_at", "updated_at"]
hash.rows
#=> [[1, "test", "hoge@example.com", nil, "2015-04-27 15:49:16.971518", "2015-04-27 15:49:16.971518"]]


以上です。