`through` アソシエーションの `unscope` がジョインモデルのデフォルトスコープを無視するバグを修正
has_many :through アソシエーションで unscope を使用した際、ジョインモデルのデフォルトスコープが誤って解除されてしまうバグが修正されました。unscope_values にテーブル名を付与することで、スコープ解除の対象を正確に限定します。
背景
has_many :through アソシエーションに unscope を含むラムダスコープを定義すると、対象のアソシエーションだけでなくジョインモデルのデフォルトスコープまで意図せず解除されるという問題がありました(#48548)。
問題の発生条件は以下のような構成です。Post モデルが default_scope { where(deleted_at: nil) } を持ち、Comment モデルも同様のデフォルトスコープを持つ場合に、has_many :comments, -> { unscope(where: :deleted_at) } を定義すると、User から through: :posts でコメントを取得する際に、posts テーブルの deleted_at スコープも解除されてしまいます。結果として、論理削除済みの投稿に紐づくコメントまで取得対象に含まれてしまいます。
unscope の値として渡される :deleted_at はテーブル名を持たないシンボルであるため、チェーンされたクエリのどのテーブルに対する条件なのかを区別できないことが根本原因です。
技術的な変更
unscope_values を適用する際にテーブル名で修飾するメソッドを追加し、アソシエーションスコープの結合処理でこれを使用するよう変更されました。
association_scope.rb の変更: アソシエーションチェーンを結合する add_constraints メソッド内で、unscope_values をそのまま適用していた箇所が新しいメソッドを使うよう変更されました。
変更前:
scope.unscope!(*item.unscope_values)
変更後:
scope.unscope!(*item.table_name_qualified_unscope_values)
query_methods.rb への追加: Relation クラスに table_name_qualified_unscope_values メソッドと、それが内部で使う qualify_attribute_with_table_name メソッドが追加されました。
def table_name_qualified_unscope_values
self.unscope_values.map do |scope|
case scope
when Hash
scope.transform_values do |target_value|
case target_value
when Array
target_value.map { |value| qualify_attribute_with_table_name(value) }
when Symbol, String
qualify_attribute_with_table_name(target_value)
else
target_value
end
end
else
scope
end
end
end
def qualify_attribute_with_table_name(attr)
attr.to_s.include?(".") ? attr : predicate_builder.resolve_arel_attribute(table_name, attr)
end
qualify_attribute_with_table_name は、属性名にすでにドット(.)が含まれている場合はそのまま返し、含まれていない場合は predicate_builder.resolve_arel_attribute を使って table_name.attr_name 形式の Arel 属性に変換します。これにより、unscope(where: :deleted_at) が comments.deleted_at として解釈され、posts.deleted_at のスコープには影響しなくなります。
unscope_values の構造は [{ where: :deleted_at }] のようなHashの配列であるため、変換処理はHashのvalueに対して行われます。valueが Array の場合は各要素を個別に修飾し、Symbol / String の場合は直接修飾することで、unscope(where: [:deleted_at, :published_at]) のような複数属性の指定にも対応しています。
設計判断
unscope_values を変換するメソッドを Relation 側に追加するというアプローチが採られました。
代替案として、unscope! の呼び出し前に add_constraints 側で変換処理を行うことも考えられますが、今回は Relation クラスのメソッドとして実装されています。これにより、変換ロジックがテーブル名の解決手段(predicate_builder)と同じスコープに置かれ、table_name への参照も自然な形で行えます。
また、すでにテーブル名で修飾された属性(ドットを含む文字列)はそのまま通すガードが設けられており、二重修飾を防いでいます。既存の unscope_values の構造を変更せず、読み取り時にのみ変換する設計であるため、unscope! 自体の動作には影響しません。
まとめ
本修正は、unscope_values を適用する時点でテーブル名を付与するという一点の変更によって、has_many :through チェーン上のスコープ解除が正確なテーブルに限定されるようになります。テーブル名を持たないシンボルがアンビギュアスに解釈されていた問題を、Arel の属性解決機構を活用することで根本から解消した修正です。