Rails4におけるDB検索方法あれこれ

Rails4で検索条件を付けてデータを取得しようとすると「ん?」となることが多いので、備忘録として改めてまとめておきたいと思います。

サンプルデータ

今回の記事では以下のフルーツテーブルを使います。

mysql> select * from fruits;
+----+-----------+-------+-------+---------------------+
| id | name      | price | stock | update_at           |
+----+-----------+-------+-------+---------------------+
|  1 | メロン    |  1000 |   300 | 2016-03-13 19:30:39 |
|  2 | ミカン    |   200 |   500 | 2016-03-13 19:30:55 |
|  3 | いちご    |   500 |    80 | 2016-03-13 19:31:06 |
|  4 | ぶどう    |   300 |    90 | 2016-03-13 19:31:29 |
+----+-----------+-------+-------+---------------------+

パターン1:ActiveRecord::Relationによるメソッドチェイン

Rails公式ガイドにも掲載されていて情報は豊富です。
Active Record クエリインターフェイス | Rails ガイド

ActiveRecord::Baseのメソッドを呼び出すと、ActiveRecord::Relationオブジェクトが返されます。

Fruit.where("price >= 500")
  Fruit Load (0.8ms)  SELECT `fruits`.* FROM `fruits` WHERE (price >= 500)
=> #<ActiveRecord::Relation [#<Fruit id: 1, name: "メロン", price: 1000, stock: 300, update_at: "2016-03-13 19:30:39">, #<Fruit id: 3, name: "いちご", price: 500, stock: 80, update_at: "2016-03-13 19:31:06">]>

#ワイルドカードを用いて動的なパラメータの設定も可能
Fruit.where("price >= ? and stock >= ?",500,100)
  Fruit Load (2.9ms)  SELECT `fruits`.* FROM `fruits` WHERE (price >= 500 and stock >= 100)
=> #<ActiveRecord::Relation [#<Fruit id: 1, name: "メロン", price: 1000, stock: 300, update_at: "2016-03-13 19:30:39">]>

さらにActiveRecord::Relation同士でメソッドチェインが可能です。

Fruit.where("price >= ? and stock >= ?",500,100).order(:price).limit(5)
  Fruit Load (3.0ms)  SELECT  `fruits`.* FROM `fruits` WHERE (price >= 500 and stock >= 100)  ORDER BY `fruits`.`price` ASC LIMIT 5
=> #<ActiveRecord::Relation [#<Fruit id: 1, name: "メロン", price: 1000, stock: 300, update_at: "2016-03-13 19:30:39">]>

注意点として、ActiveRecord::Baseメソッドを実行するとSQLが即座に発行されます。

#この時点でSQLが発行され、resultにはActiveRecord::Relationインスタンスが格納される。
result = Fruit.where("price > 100")

#各レコードデータを取り出して利用することができる。
result[0].name
=> "メロン"

#返されたActiveRecord::Relationに対して繰り返しメソッドチェインが可能
result = result.where("stock >= 90").group("date(update_at)")
  Fruit Load (6.3ms)  SELECT `fruits`.* FROM `fruits` WHERE (price > 100) AND (stock >= 90) GROUP BY date(update_at)

この方法はシンプルで理解しやすいのですが、

  1. or が利用できない
  2. メソッドチェインを繰り返す度に裏ではSQLが発行されオーバーヘッドが発生する

といったデメリットがあります。

パターン2:Arel::TableからArel::Nodesの条件部のみを作成し、ActiveRecordへ渡す

前述のパターン1、内部的にはArelというライブラリを利用してSQLが組み立てらており、
それらは直接利用することができます。
GitHub - rails/arel: A Relational Algebra

#ActiveRecord::Baseのarel_tableメソッドによりArel::Tableインスタンスを取得
fruits = Fruit.arel_table
=> #<Arel::Table

#Arel::Tableのメソッドを利用して条件部だけを組み立てる。
fruits[:price].gteq(500)
=> #<Arel::Nodes::GreaterThanOrEqual

#得られる条件文
fruits[:price].gteq(500).to_sql
=> "`fruits`.`price` >= 500"

#Arel::NodesインスタンスをActiveRecord::Baseメソッドの引数に渡すことで結果が得られる
Fruit.where(fruits[:price].gteq(500))
  Fruit Load (1.0ms)  SELECT `fruits`.* FROM `fruits` WHERE (`fruits`.`price` >= 500)
=> #<ActiveRecord::Relation [#<Fruit id: 1, name: "メロン", price: 1000, stock: 300, update_at: "2016-03-13 19:30:39">, #<Fruit id: 3, name: "いちご", price: 500, stock: 80, update_at: "2016-03-13 19:31:06">]>

Arel::Nodes同士は「and」「or」を用いてメソッドチェインできます。

fruits = Fruit.arel_table

#条件1
cond1 = fruits[:price].gteq(300)
=> #<Arel::Nodes::GreaterThanOrEqual

#条件2
cond2 = fruits[:stock].gteq(90)
=> #<Arel::Nodes::GreaterThanOrEqual

#条件1 and 条件2
cond1.and(cond2).to_sql
=> "`fruits`.`price` >= 300 AND `fruits`.`stock` >= 90"

#条件1 or 条件2
cond1.or(cond2).to_sql
=> "(`fruits`.`price` >= 300 OR `fruits`.`stock` >= 90)"

ただしこの方法についても引数の渡し方が違うだけで、ActiveRecord::Base.whereを呼び出す度にSQLが発行され、
オーバーヘッドが発生します。

パターン3.SelectManagerを経由してActiveRecord::Base.find_by_sqlする

前述のパターン2で触れたArel::Tableですが、「project」「where」「group」「order」「take」といったメソッドを利用することで、
Arel::SelectManagerインスタンスを得ることができます。

fruits = Fruit.arel_table

#projectは選択列を指定するために利用する
fruits.project("*")
=> #<Arel::SelectManager

#得られるSQL
fruits.project("*").to_sql
=> "SELECT * FROM `fruits`"

Arel::SelectManager同士でメソッドチェインが可能です。
また、Arel::SelectManager自体は「生成されたSQL情報を持つだけ」であり、単体ではSQLの発行はされません。
そのため、パターン1、2のようなオーバヘッド無しでメソッドチェインが行えます。

fruits = Fruit.arel_table

fruits_chain = movies.project('*')

#以下、直前のSelectManager(fruits_chain)に対してメソッドチェインを繰り返す
fruits_chain = fruits_chain.where(fruits[:price].gteq(90))
fruits_chain = fruits_chain.where(fruits[:stock].gt(1))
fruits_chain = fruits_chain.group(fruits[:name])
fruits_chain = fruits_chain.order(fruits[:id])

#最終的なSQL
fruits_chain.to_sql
=> "SELECT * FROM `fruits` WHERE `fruits`.`price` AND `fruits`.`price` >= 90 AND `fruits`.`stock` > 1 GROUP BY `fruits`.`name`  ORDER BY `fruits`.`id`"

#ActiveRecord::Base.find_by_sqlにSQL文字列を渡して実際にSQLを発行する
result = Fruit.find_by_sql(fruits_chain.to_sql)
  Fruit Load (12.0ms)  SELECT * FROM `fruits` WHERE `fruits`.`price` AND `fruits`.`price` >= 90 AND `fruits`.`stock` > 1 GROUP BY `fruits`.`name`  ORDER BY `fruits`.`id`
=> [#<Fruit id: 1, name: "メロン", price: 1000, stock: 300, update_at: "2016-03-13 19:30:39">, #<Fruit id: 2, name: "ミカン", price: 200, stock: 500, update_at: "2016-03-13 19:30:55">, #<Fruit id: 3, name: "いちご", price: 500, stock: 80, update_at: "2016-03-13 19:31:06">, #<Fruit id: 4, name: "ぶどう", price: 300, stock: 90, update_at: "2016-03-13 19:31:29">]

#各レコードを参照できる
result[0].name
=> "メロン"

パターン4 find_by_sqlでゴリゴリにSQLを書く

流れはパターン3と一緒ですが、動的パラメータを設定する場合に少し注意が必要です。
find_by_sqlで動的パラメータを設定する際は、SQL文、パラメータをひとつの配列で渡す必要があります。

#SQL直書き + 動的パラメータ
Fruit.find_by_sql(["select fruits.name from fruits where fruits.price < ? and fruits.stock < ?", 1000, 100])
  Fruit Load (1.0ms)  select fruits.name from fruits where fruits.price < 1000 and fruits.stock < 100
=> [#<Fruit id: nil, name: "いちご">, #<Fruit id: nil, name: "ぶどう">]

まとめると

色々と例を挙げましたが、SQL直書きをなるべく避けたいという場合、個人的には以下のような使い分けになるかなと思います。
(それぞれもっと上手い使い方があるかもしれませんが・・・)

  • パターン1:動的パラメータの数が固定の場合。またはメソッドチェイン時のオーバーヘッドが気にならない場合。
  • パターン2:動的パラメータの数が固定の場合、かつorによるメソッドチェインが必要な場合。またはメソッドチェイン時のオーバーヘッドが気にならない場合。
  • パターン3:動的パラメータが動的に増減し、常に同じ条件文とならない場合。
  • パターン4:動的パラメータが不要(固定のSQL文)の場合。またはメソッドチェインが煩雑もしくは対応していない(※)場合。

※自分の場合はMySQL全文検索(match()against())を行うのに、標準ライブラリでは対応していなかったのでパターン4を使いました。)

Ruby on Rails 4 アプリケーションプログラミング

Ruby on Rails 4 アプリケーションプログラミング

Railsレシピブック 183の技

Railsレシピブック 183の技