はじめに
ActiveRecordで関連先のテーブルのデータを取得したい時があると思います。カラムを絞り込む時の条件も複数指定しなければいけない時もあると思います。
そんな時にもActiveRecordのorメソッドを使うことができます。
その際の注意点について発生したエラーも参考にしながら、紐解いて説明していきます。
前提条件
リレーション
- Userモデル: ユーザーを表すモデル
- Postモデル: 投稿を表すモデル
- 1人のユーザーが複数の投稿を持つ(1対多の関係)
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
end
エラー内容
ArgumentError: Relation passed to #or must be structurally compatible. Incompatible values: [:includes]
和訳すると、「orに渡されるリレーションは構造的に互換性がある必要があります」
言い換えると、「orで複数の条件をする場合、同じテーブルの同じ種類のデータを参照する必要があります」
該当するコード
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メソッドにも関連付けを明示的に指定することで解決しました。
修正後のコード
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を書く
user = User.includes(:posts)
.where("posts.status = 'posted' OR posts.id IS NULL")
whereの中にSQLで直接OR条件を書く手間はかかりますが、クエリのパフォーマンスが向上して効率的にデータを取得できます。
補足
orメソッドについて
2つのリレーションをまたいでOR条件を使いたい場合は、1つ目のリレーションでorメソッドを呼び出し、そのメソッドの引数に2つ目のリレーションを渡すことで実現できます。
Customer.where(last_name: 'Smith').or(Customer.where(orders_count: [1, 3, 5]))
Railsガイドより引用
Q. なぜorメソッドの引数に2つ目のリレーションを入れる必要があるのか?
A. 異なるテーブルへのアクセスするため
他のモデルとの関連付け(リレーション)が定義されている場合、orで結合するwhere条件の中にその関連付けを明示的に指定する必要があります。
関連付けを指定することで、異なるモデルの属性に対する条件をor条件に含めることができます。
orメソッド内部の挙動
理由を追求するにあたって、公式ドキュメントからorメソッドの中身を見に行きました。
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
ポイント
-
or(other)
メソッドで、other
がRelationのインスタンスかどうかをis_a?
メソッドで確認している(Relationが今回のposts
にあたる) - Relationのインスタンスであれば、おそらく
spawn.or!
という内部のメソッドでクエリを結合している - インスタンスがなければ、raiseでArgumentErrorの例外エラーを発生させている
-
今回発生したエラー内容を見る限り、
or!(other)
メソッドの中でincludes
で取得したリレーション構造に互換性がないため、raiseで発生した例外エラーというのが読み取れます
上記のコードはRailsドキュメント(or)のページに記載されているソースコードのリンク先のページで見れます。
まとめ
ActiveRecordのorメソッドでor条件を指定するときは、モデルや関連先のテーブルも合わせて明示的に指定する。