Rails 4.2 で Arel を使って OR クエリを構築する

  • 44
    いいね
  • 3
    コメント
この記事は最終更新日から1年以上が経過しています。

概要

Rails 4.1 を 4.2 にバージョンアップしている時のことでした。

# Rails 4.1.10
scope1 = User.where(sex: 'male', age: 18)
scope2 = User.where(sex: 'female', age: 16)
# 条件ごとに括弧をつけるために Arel::FactoryMethods#grouping を使う。
condition1 = User.arel_table.grouping(scope1.where_values.reduce(:and))
condition2 = User.arel_table.grouping(scope2.where_values.reduce(:and))

User.where(condition1.or(condition2)).to_sql
#=> "SELECT `users`.* FROM `users` WHERE ((`users`.`sex` = 'male' AND `users`.`age` = 18) OR (`users`.`sex` = 'female' AND `users`.`age` = 16))"

上記のように Arel::Nodes::Node#or を使って OR クエリを構築している箇所があるのですが、
それが Rails のバージョンアップに伴って不正なクエリを発行するようになり、動かなくなってしまいました。

原因を調査していると @joker1007 さんの Rails4.2でArelを元にORクエリを作ろうとすると死ぬ問題の解決策 という記事に原因とその解決方法を発見しました。

# Rails 4.2.1
scope1 = User.where(sex: 'male', age: 18)
scope2 = User.where(sex: 'female', age: 16)
condition1 = User.arel_table.grouping(scope1.where_values.reduce(:and))
condition2 = User.arel_table.grouping(scope2.where_values.reduce(:and))

# そのままでは WHEERE 句の条件値が欠落した SQL が発行されてしまう。
User.where(condition1.or(condition2))
#=> "SELECT `users`.* FROM `users` WHERE ((`users`.`sex` =  AND `users`.`age` = ) OR (`users`.`sex` =  AND `users`.`age` = ))"

# 上記の記事を参考に bind_values を設定すると正しい SQL が発行される。
User.where(condition1.or(condition2))
  .tap { |scope| scope.bind_values = scope1.bind_values + scope2.bind_values }
  .to_sql
#=> "SELECT `users`.* FROM `users` WHERE ((`users`.`sex` = 'male' AND `users`.`age` = 18) OR (`users`.`sex` = 'female' AND `users`.`age` = 16))"

ただ OR クエリを構築している箇所で毎回このように記述するのは辛いので、
ActiveRecord を拡張して、例えば where_or(*scopes) のようなメソッドを追加できないかと思い、試してみました。

OR クエリ構築するマン

結果は以下のようになりました。

ActiveRecord::QueryMethods::WhereChain.include(Module.new do
  def or(*scopes)
    bind_values  = []
    where_values = []

    scopes.each do |scope|
      temp_scope = scope.is_a?(Hash) ? @scope.model.where(scope) : scope
      where_values << temp_scope.arel_table.grouping(temp_scope.arel.constraints.inject(:and))
      bind_values  += temp_scope.bind_values
    end

    @scope.where_values += [where_values.inject(:or)]
    @scope.bind_values  += bind_values
    @scope
  end
end)

scope1 = User.where(sex: 'male', age: 18)
scope2 = User.where(sex: 'female', age: 16)
User.where.or(scope1, scope2).to_sql
#=> "SELECT `users`.* FROM `users` WHERE ((`users`.`sex` = 'male' AND `users`.`age` = 18) OR (`users`.`sex` = 'female' AND `users`.`age` = 16))"

# ハッシュでもいいのよ。
User.where.or({ sex: 'male', age: 18 }, { sex: 'female', age: 16 }).to_sql
#=> "SELECT `users`.* FROM `users` WHERE ((`users`.`sex` = 'male' AND `users`.`age` = 18) OR (`users`.`sex` = 'female' AND `users`.`age` = 16))"

ActiveRecord::QueryMethods::WhereChain を拡張して where.not のように where.or と書けるようにしました。

これは StackOverflow の OR operator in WHERE clause with Arel in Rails 4.2 という記事にあった投稿を参考にし、
さらに各条件ごとに括弧を付けて

WHERE ((A1 AND A2) OR (B1 AND B2) OR (C1 AND C2)) AND the other conditions

のような WHERE 句の SQL を発行できるように手を加えました。

参考