Rails4.2でArelを元にORクエリを作ろうとすると死ぬ問題の解決策

  • 129
    いいね
  • 4
    コメント

2014/11/12時点での話で、これからどうなるか分からないのですが、現時点のrails-4.2.0.beta4では、Arelを使ったORクエリの構築を今までと同じ感覚で実行するとNoMethodErrorが発生してしまいます。

今まではこういう感じでORクエリが書けた。

a = User.where(name: "joker1007").where_values.reduce(:and)
b = User.where(name: "Tomohiro").where_values.reduce(:and)
User.where(a.or b)

しかし4.2だと例外が出て死にます。
発生箇所はactiverecordのlib/active_record/relation/query_methods.rbです。

    def build_arel
      arel = Arel::SelectManager.new(table.engine, table)

      build_joins(arel, joins_values.flatten) unless joins_values.empty?

      collapse_wheres(arel, (where_values - [''])) #TODO: Add uniq with real value comparison / ignore uniqs that have binds

      arel.having(*having_values.uniq.reject(&:blank?)) unless having_values.empty?

      arel.take(connection.sanitize_limit(limit_value)) if limit_value
      arel.skip(offset_value.to_i) if offset_value

      arel.group(*group_values.uniq.reject(&:blank?)) unless group_values.empty?

      build_order(arel)

      build_select(arel, select_values.uniq)

      byebug

      arel.distinct(distinct_value)
      arel.from(build_from) if from_value
      arel.lock(lock_value) if lock_value

      # Reorder bind indexes if joins produced bind values
      bvs = arel.bind_values + bind_values
      reorder_bind_params(arel.ast, bvs)
      arel
    end

    def reorder_bind_params(ast, bvs)
      ast.grep(Arel::Nodes::BindParam).each_with_index do |bp, i|
        column = bvs[i].first
        bp.replace connection.substitute_at(column, i)
      end
    end

このreorder_bind_params内でbvs[i]がnilになって死にます。

これは何故起こるかというとrails-4.2からARのクエリを高速化のための変更が入っており、whereにHash形式でパラメーターを渡してクエリを構築すると、内部でバインドパラメーターに変換されるようになったためです。
クエリのパラメーター部分を?(バインドパラメーター)に置き換えてクエリをノーマライズし、ハッシュで渡したパラメーターは別途bind_valuesとして保持します。
同じquery_methods.rbbuild_whereというメソッドがあり、ここでbind_valuesを設定しています。
このbuild_wherewhereでクエリを追加した時に実行されるメソッドです。

    def build_where(opts, other = [])
      case opts
      when String, Array
        [@klass.send(:sanitize_sql, other.empty? ? opts : ([opts] + other))]
      when Hash
        opts = PredicateBuilder.resolve_column_aliases(klass, opts)

        tmp_opts, bind_values = create_binds(opts)
        self.bind_values += bind_values

        attributes = @klass.send(:expand_hash_conditions_for_aggregates, tmp_opts)
        add_relations_to_bind_values(attributes)

        PredicateBuilder.build_from_hash(klass, attributes, table)
      else
        [opts]
      end
    end

このため、今までのwhere_valuesでArelのノードを取得してwhereに渡すだけではbind_valuesの取得ができず、クエリ構築に必要な情報が揃わないため、クエリ構築に失敗します。

じゃあどうすればいいかというとbind_valuesもちゃんと取得して引き渡します。

a_scope = User.where(name: "joker1007")
a_where = a_scope.where_values.reduce(:and)
a_bind = a_scope.bind_values

b_scope = User.where(name: "Tomohiro")
b_where = b_scope.where_values.reduce(:and)
b_bind = b_scope.bind_values

User.where(a_where.or b_where).tap {|sc| sc.bind_values = a_bind + b_bind }

# もしくは

(a_bind + b_bind).inject(User.where(a.where.or b_where)) do |sc, b|
  sc.bind(b)
end

こんな感じで渡せばちゃんとクエリが構築できます。
この時、bind_valuesbindに渡す順番に気を付けないと意図しない場所にパラメーターが代入されてクエリが壊れるので注意しましょう。

個人的にこれはかなり面倒になっている気がします。
元々、明示的にドキュメント化されてる機能では無いので何とも言えないし、動作上不自然では無いのですが、ちょっとややこしいクエリ作る時の可読性に結構影響が出そうです。