【Rails】エラー「ArgumentError: Relation passed to #or must be structurally compatible. 」とorメソッドについて

# はじめに
ActiveRecordで関連先のテーブルのデータを取得したい時があると思います。カラムを絞り込む時の条件も複数指定しなければいけない時もあると思います。

そんな時にもActiveRecordのorメソッドを使うことができます。
その際の注意点について発生したエラーも参考にしながら、紐解いて説明していきます。

# 前提条件
## リレーション
- Userモデル: ユーザーを表すモデル
- Postモデル: 投稿を表すモデル
- 1人のユーザーが複数の投稿を持つ（1対多の関係）

```models/user.rb
class User < ApplicationRecord
  has_many :posts
end
```
```models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end
```

# エラー内容
```
ArgumentError: Relation passed to #or must be structurally compatible. Incompatible values: [:includes]
```
和訳すると、「orに渡されるリレーションは構造的に互換性がある必要があります」
言い換えると、**「orで複数の条件をする場合、同じテーブルの同じ種類のデータを参照する必要があります」**

# 該当するコード
```ruby
  def has_posted
    user = User.includes(:posts)
      .where(posts: { status: 'posted' })
      .or(where(posts: { id: nil })
  end
```

usersテーブルに関連づいているpostsテーブルの、**投稿済み（posted）のレコード**を持つユーザー
または投稿が1件もないユーザーをincludesで取得しています

# 原因
上記1つ目のwhere条件は、`User.includes(:posts)`でモデルを参照していたのに対して、
2つ目のorメソッドの中のwhere条件は、モデルの参照が行われていない。
つまり、両方の条件で参照するデータの**構造が不一致**のために発生しています。

# 解決方法
orメソッド内のwhereメソッドにも関連付けを明示的に指定することで解決しました。

## 修正後のコード
```ruby
  def most_or_not_posted_user
    user = User.includes(:posts)
      .where(posts: { status: 'posted' })
      .or(includes(:posts).where(posts: { id: nil }))
  end
```

## 代替案
上記修正後のコードで簡潔に書けますが、includesメソッドを2回使用しているため、冗長で非効率かもしれません。
## whereメソッドの中に直接SQLを書く
```ruby
user = User.includes(:posts)
      .where("posts.status = 'posted' OR posts.id IS NULL")
```
whereの中にSQLで直接OR条件を書く手間はかかりますが、クエリのパフォーマンスが向上して効率的にデータを取得できます。


# 補足
## orメソッドについて
> ２つのリレーションをまたいでOR条件を使いたい場合は、１つ目のリレーションでorメソッドを呼び出し、そのメソッドの引数に２つ目のリレーションを渡すことで実現できます。
```ruby
Customer.where(last_name: 'Smith').or(Customer.where(orders_count: [1, 3, 5]))
```
[Railsガイド](https://railsguides.jp/active_record_querying.html#or%E6%9D%A1%E4%BB%B6)より引用

## Q. なぜorメソッドの引数に2つ目のリレーションを入れる必要があるのか？
A. <INS>**異なるテーブルへのアクセスするため**</INS>

他のモデルとの関連付け（リレーション）が定義されている場合、orで結合するwhere条件の中にその**関連付けを明示的に指定する**必要があります。

関連付けを指定することで、異なるモデルの属性に対する条件をor条件に含めることができます。

## orメソッド内部の挙動
理由を追求するにあたって、公式ドキュメントからorメソッドの中身を見に行きました。
```activerecord/lib/active_record/relation/query_methods.rb
    def or(other)
      if other.is_a?(Relation)
        spawn.or!(other)
      else
        raise ArgumentError, "You have passed #{other.class.name} object to #or. Pass an ActiveRecord::Relation object instead."
      end
    end

    def or!(other) # :nodoc:
      incompatible_values = structurally_incompatible_values_for(other)

      unless incompatible_values.empty?
        raise ArgumentError, "Relation passed to #or must be structurally compatible. Incompatible values: #{incompatible_values}"
      end

      self.where_clause = self.where_clause.or(other.where_clause)
      self.having_clause = having_clause.or(other.having_clause)
      self.references_values |= other.references_values

      self
    end
```
### ポイント
1. `or(other)`メソッドで、`other`がRelationのインスタンスかどうかを[`is_a?`](https://docs.ruby-lang.org/ja/latest/method/Object/i/is_a=3f.html)メソッドで確認している（Relationが今回の`posts`にあたる）
2. Relationのインスタンスであれば、おそらく[`spawn.or!`](https://docs.ruby-lang.org/ja/latest/method/Kernel/m/spawn.html)という内部のメソッドでクエリを結合している
3. インスタンスがなければ、raiseでArgumentErrorの例外エラーを発生させている
4. [今回発生したエラー内容](#エラー内容)を見る限り、`or!(other)`メソッドの中で`includes`で取得したリレーション構造に互換性がないため、raiseで発生した例外エラーというのが読み取れます

上記のコードは[Railsドキュメント(or)](https://railsdoc.com/page/model_or)のページに記載されている[ソースコードのリンク先のページ](https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation/query_methods.rb#L842)で見れます。

# まとめ
ActiveRecordのorメソッドでor条件を指定するときは、モデルや関連先のテーブルも合わせて明示的に指定する。

# 参考記事
- [Railsガイド（OR条件）](https://railsguides.jp/active_record_querying.html#or%E6%9D%A1%E4%BB%B6)より引用
- [Railsドキュメント(or)](https://railsdoc.com/page/model_or)
- [query_methods.rb](https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation/query_methods.rb#L842)