KaQiita

新米エンジニアが適当なことを書いてます。温かく見守ってやってください。

【Rails】「条件付きアソシエーションの引数の処理」について調べたら Rails のソースコードの example を勘違いした話

はじめに

ここ最近行なっていた開発で「条件付きアソシエーション」というものに出会ったので、それについて書こうと思います。

「そもそも条件付きアソシエーションとはどんなものなのか」ということについては、「Rails ガイドの解説」をご覧いただけると良いかなと思います。

ここでは、タイトルにもある通り「引数」という観点から「条件付きアソシエーション」について見ていきます。

調べていく中で私が誤解して詰まってしまった部分があり、それについて書いている記事が見つからなかったので、ここにまとめようと思った次第です。

やりたかったこと

ぐるなび」などで見かけるような「駅ごとの一覧ページ」というものを作ろうとしていました。

たとえば、「東京駅周辺の居酒屋の一覧ページ」のようなものをイメージしていただけると良いです。

そして「ぐるなび」を見ていただけると分かりますが、たとえば「駅から 1km・3km・5km 」のように駅からの距離もユーザーが選択できる機能も実装しようと考えました。

現状

DB は以下のような設計になっていました(本来はもっと複雑ですが今回の記事に関係ある部分のみ載せています)。

railway_station_has_facilities

railway_station とは駅を表しており、facility とは一覧ページに表示させたい施設を表します。

railway_stationfacility は多対多の関係となっていて、railway_station_has_facilities がその中間テーブルです。

railway_station_has_facilities テーブルの distance は駅と施設の距離が km 単位で入っています。

何故こんなところに distance のデータを持たせるんだという意見もあるかと思うのですが、色々複雑な事情でこのようなテーブル構造となっています。

この DB はここでは所与のものとして、変更は加えないこととします。

着想

まず、以下のような形で書けることを目標にしました。

  • railway_station.facilities_within_default_distance
  • railway_station.facilities_within_minimum_distance
  • railway_station.facilities_within_maximum_distance

default_distance を 3km とし minimum_distancemaximum_distance はそれぞれ 1km と 5km とします。

上記の目標を実現するためにアプリケーション側で railway_station_has_facilities テーブルが、distance の距離によってあたかも複数の中間テーブルであるかのようにすることはできないだろうかと考えました。

これはたとえば、 railway_station_has_facilities_within_default_distance という distance < 3 であるレコードだけを集めた中間テーブルを Rails 側で作成できないかということです。

そこで、「条件付きアソシエーション」というものが使えるのではないかと考えました。

条件付きアソシエーション

すると rails ガイドの「Active Record の関連付け (アソシエーション)」の「4.3.3 has_manyのスコープについて」に使えそうな example を見つけました。

class Author < ApplicationRecord
  has_many :books, -> { where processed: true }
end

上記の example を使えば以下のように書くことができます。

railway_station.rb

class RailwayStation < ApplicationRecord
  DEFAULT_DISTANCE = 3
  MINIMUM_DISTANCE = 1
  MAXIMUM_DISTANCE = 5

  has_many :railway_station_has_facilities_within_default_distance, -> { where 'distance < ?', DEFAULT_DISTANCE }, class_name: 'RailwayStationHasFacility'
  has_many :railway_station_has_facilities_within_minimum_distance, -> { where 'distance < ?', MINIMUM_DISTANCE }, class_name: 'RailwayStationHasFacility'
  has_many :railway_station_has_facilities_within_maximum_distance, -> { where 'distance < ?', MAXIMUM_DISTANCE }, class_name: 'RailwayStationHasFacility'
  has_many :facilities_within_default_distance, through: :railway_station_has_facilities_within_default_distance, class_name: 'Facility'
  has_many :facilities_within_minimum_distance, through: :railway_station_has_facilities_within_minimum_distance, class_name: 'Facility'
  has_many :facilities_within_maximum_distance, through: :railway_station_has_facilities_within_maximum_distance, class_name: 'Facility'
end

これで一応最初に書きたかったように railway_station.facilities_within_default_distance で、ある駅から 3km 未満の距離にある facilities を取ってくることができ、一覧ページを作成することができます。

しかし、同じようなことが何度も書かれており、スマートではないなぁと感じました。

引数って渡せないの?

そこで次に考えたのが、「has_many に引数を渡すことはできないのか」ということです。

scope ではよく以下のように書くことがあると思います。

class Article < ApplicationRecord
  scope :created_before, ->(time) { where('created_at < ?', time) }
end

そこで以下のような感じで書くことはできないのかと思い、書いてみました。

railway_station.rb

class RailwayStation < ApplicationRecord
  DEFAULT_DISTANCE = 3
  MINIMUM_DISTANCE = 1
  MAXIMUM_DISTANCE = 5

  has_many :railway_station_has_facilities_in_distance, ->(distance) { where 'distance < ?', distance }, class_name: 'RailwayStationHasFacility'
  has_many :facilities_within_default_distance, through: railway_station_has_facilities_in_distance(DEFAULT_DISTANCE), class_name: 'Facility'
  has_many :facilities_within_minimum_distance, through: railway_station_has_facilities_in_distance(MINIMUM_DISTANCE), class_name: 'Facility'
  has_many :facilities_within_maximum_distance, through: railway_station_has_facilities_in_distance(MAXIMUM_DISTANCE), class_name: 'Facility'
end

結果

しかし、結果は動きません。

何 km 未満の facilities を取ってくるのかを引数として渡すことにしたのですが、このような書き方はできないようでした。

has_many には引数渡せないのだろう」と思いながらも一応 has_many で行なっていることを確認するために Railsソースコードを見に行きました。

Railsソースコードを見てみる

すると Rails のソースコードの 526 ~ 528 行目に以下のような example を見つけました。

class User < ActiveRecord::Base
  has_many :birthday_events, ->(user) { where(starts_on: user.birthday) }, class_name: 'Event'
end

ここが混乱ポイントです。

「えっ。。。引数渡してるやん。。。」と悩み、小一時間格闘しました。

分かったこと

この一見不思議に見える問題は、has_many の定義を丁寧に見ていくことで解決しました。

結論を言えば「has_many に引数を渡すことはできるが self しか渡すことができない」ということです。

そのため、たとえば一つ上の例で引数名を user から hoge のように書き換えても、引数には self が入ります。

私の書いたコードが動かなかったのは、distance という引数に好き勝手な定数をぶち込んでいたためです。

scope と書き方は似ていますが、自由な引数を渡せる scope とは挙動が異なるということが分かりました。

終わりに

なかなか注意深く見ていないと、私のように勘違いしてしまう方が多いところではないかなと思います。

この記事で、一人でもここに躓く人が減れば良いなと思います。

因みに格闘していた小一時間は「ソースコードに書いてある example が間違っている」と思っていました。。。