hotoolong's blog

プログラムのことやエンジニアリングに関することを記事にしています。

ActiveRecordでOR文を作るときのエラー対処

少し複雑なSQLのOR文をActiveRecordで作るときにはすこし億劫になりますね。

ArgumentError: Relation passed to #or must be structurally compatible. Incompatible values: [:joins, :references]

とエラーが出てしまったのでいろいろ調べてみました。

今回のエラーはRails5.2の以下のコードで ArgumentError が発生しています。

rails/query_methods.rb at c81a7fcf76663e6d189792d6eed57b1162199635 · rails/rails · GitHub

structurally_incompatible_values_for_or が何なのかと見てみると

rails/query_methods.rb at c81a7fcf76663e6d189792d6eed57b1162199635 · rails/rails · GitHub

      STRUCTURAL_OR_METHODS = Relation::VALUE_METHODS - [:extending, :where, :having, :unscope, :references]
      def structurally_incompatible_values_for_or(other)
        STRUCTURAL_OR_METHODS.reject do |method|
          get_value(method) == other.get_value(method)
        end
      end

Relation::VALUE_METHODS が 以下で登録されているのですが、

rails/relation.rb at ac1efe7a947ba04b276a7109f1a86e559a6ab683 · rails/rails · GitHub

[:includes, :eager_load, :preload, :select, :group, :order, :joins, :left_joins, :left_outer_joins, :references, :extending, :unscope, :limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :where, :having, :from]

ここから [:extending, :where, :having, :unscope, :references] を省いた項目が一致してることをチェックしてるので
[:includes, :eager_load, :preload, :select, :group, :order, :joins, :left_joins, :left_outer_joins, :limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :from] の項目が一致してないと ArgumentError になるということですね。
これを個別に対応していくのは大変なのですね。。

これらの項目を設定するメソッドはいろいろ用意されてはいますが、

ActiveRecord::QueryMethods#methods:
  extending_values   group_values     includes_values=  left_joins_values        left_outer_joins_values=  lock_value    offset_value=  preload_values   readonly_value=     reordering_value     reverse_order_value=  set_value
  eager_load_values   extending_values=  group_values=    joins_values      left_joins_values=       limit_value               lock_value=   order_values   preload_values=  references_values   reordering_value=    select_values         unscope_values
  eager_load_values=  get_value          includes_values  joins_values=     left_outer_joins_values  limit_value=              offset_value  order_values=  readonly_value   references_values=  reverse_order_value  select_values=        unscope_values=

a の オブジェクトから b のオブジェクトに移すみたいなことは項目数から考えても面倒ですね。

b.joins(a.joins_values)

項目を並べてget_value と set_value で移すこともできますが、、、

[:includes, :eager_load, :preload, :select, :group, :order, :joins, :left_joins, :left_outer_joins, :limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :from].each do |method|
  b.set_value(a.get_value(method))
end

流石に面倒そうです。

User.
  joins(:accounts).
  group(:id).
  having('count(accounts.id) > 2').
  whrere(created_at: Time.zone.today.all_day)

このSQLにORを付けたいのですが、
当日と5日前に追加されたユーザを調べたいとします。

today = Time.zone.today
relation = User.
          joins(:accounts).
          group(:id).
          having('count(accounts.id) > 2')
relation.where(created_at: today.all_day).or(scope.whrere(created_at: today.days_ago(5).all_day))

このようにローカル変数にor前のRelationを渡してしまってwhereをつくればいいということですね。 少し面倒ですが、これで大丈夫そうです。

スコープを使ったケースを見てみます。

  scope :hogehoge, -> {
     where(created_at: Time.zone.today.all_day)
  }

このようなscopeがあるとします。まだ条件は当日のみです。

User.
  joins(:accounts).
  references(:accounts).
  having('count(accounts.id) > 2').
  group(:id).
  extending(Pagination).
  unscoped.
  hogehoge

先程の処理にhogehogeを追加してみました。 hogehogeのなかでOR文を作ってみます。

  scope :hogehoge, -> {
    today = Time.zone.today
    where(created_at: today.all_day).or(where(created_at: today.days_ago(5).all_day))
  }

ここではローカル変数なしに設定できました。

スコープではない場合は一旦変数に入れないといけないというのは不便ではあります。

これ自体が面倒な場合は String で直接SQLを記載するのがいいかもしれないです。

今回のケースはDateTime型で日付に対する条件になるので BETWEENが使われます。

day1 = today.all_day
day2 = today.days_ago(5).all_day
where('(users.created_at BETWEEN ? AND ?) OR (users.created_at BETWEEN ? AND ?)', day1.first, day1.last, day2.first, day2.last)

このような感じなります。
? に 設定する値がnilだった場合はこれだと面倒になったりするので
ケースによって使い方を選択してもいい気がします。

以下、参考にしたサイトです。

Rails 5 の or を色々試してみた - Qiita

sql - Relation passed to #or must be structurally compatible. Incompatible values: [:references] - Stack Overflow