こんにちは、hachi8833です。
今回は、Duck Type Labの記事「4 ways to filter has_many associations」を、原著者の許諾を得て掲載いたします。
今回は初の試みとして、Google翻訳にかけた元サイトも眺めながら翻訳してみました。比較してみると面白いかもしれません。
元記事について
- 元サイト: Duck Type Lab
- 元記事: 4 ways to filter has_many associations
- 著者: Sid Krishnan
本記事は原著者の許諾を得て翻訳・掲載しております。翻訳は例によって原文との1対1対応ではなく、多くの最適化を行ったりリンクを日本語サイトに変更したりしていますのでご了承ください。
TechRachoでは以前にも[翻訳] そのパッチをRailsに当てるべきかを考えるでSidの記事を翻訳でご紹介いたしましたので、合わせてご覧ください。
著者のSid Krishnanは無料のニュースレターを主催していますので、ご興味のある方は元記事の最下部またはニュースレターのサンプルで「Subscribe」をクリックしてニュースレターを購読いただけます。
翻訳: has_many 関連付けのフィルタテクニック4種
has_many関連付けが設定されたモデルが1つあるとしましょう。そのモデルと関連付けレコードの両方に条件をつける形で、関連付けられたモデルのコレクションを取得したい、なんてことはよくあります。たぶん皆さまもきっとこの種の作業をやってみたことがおありかと思いますが、思いつきの作業で無駄な苦労をするよりも(失礼、他意はありません)、もう少しまともな戦略を立ててから取り組めばよかったと感じたのではないでしょうか。
has_many関連付けのフィルタリングはそうした面倒な問題のひとつになることが多く、さまざまな資料を読み込んでSQLやActiveRecordの知識をブラッシュアップしておく必要があります。
たとえば、システムにUserモデルとProjectモデルがあり、指定の日付範囲で作成されたprojects
に関連付けられているusers
のコレクションを取り出すクエリを作成したいとしましょう。
クエリでどんな結果が欲しいかによって、次の4つのアプローチが考えられます。
1. 最も単純な方法
ActiveRecordのjoins
メソッドとwhere
メソッドを組み合わせて次のようなコードを書きます。
User.joins(:projects).where(projects: { zipcode: 30332 })
この方法は、レコードを1つ(またはそれ以上)の属性でフィルタする場合には適切です。このコードで、zipcodeが30332のプロジェクトに関連するUser
レコードのコレクションを取り出せますね。
ただし注意をおひとつ。このjoin
メソッドでは実際にはINNER JOIN
を行っているので、結果に重複が含まれる可能性があります。重複を回避する方法については2.をご覧ください。
2. merge
メソッドを使う方法
再利用するためのスコープがモデルに定義されていることがあります。こうしたスコープをフィルタで使うには、ActiveRecordのmerge
メソッドを知っておくと便利です。ActiveRecordのドキュメントによると、merge
は呼び出し元のActiveRecord::Relation
とmerge
に引数として渡したActiveRecord::Relation
のintersect(重複のない積集合)を配列として返します。
たとえば、最近10日以内に作成されたすべてのprojects
を返すopened_recently
というスコープがProject
モデルに定義されているとします。この場合、以下のように書くことができます。
User.joins(:projects).merge(Project.opened_recently)
このコードは、「最近10日以内に作成されたプロジェクトがある」という条件をすべて満たすUser
オブジェクトのリストを返します。
1.と同様、has_many
関連付けでjoin
を使うと、実際にはINNER JOIN
が行われる点に気をつけましょう。ここでは、返されるUser
オブジェクトが重複する可能性があります。基本的にSQL結果にマッチした1つ1つのレコードについてオブジェクトが返されるためです。
この重複は、uniq
メソッドで簡単に回避できます。
User.joins(:projects).merge(Project.opened_recently).uniq
uniq
メソッドを使うと、クエリがSELECT DISTINCT 'users' from...
のように変わります。
3. eager loadingとinclude
を使う方法
フィルタで絞り込んだレコードから何らかの情報を取り出す場合、特にレコードの関連付けに保存されている情報を取り出す場合は、include
メソッドによるeager loading(事前読み込み)を検討するとよいでしょう。ご存知かもしれませんが、eager loadingはN + 1クエリ問題を解決するのに有用です。
関連付けの属性やスコープは、以下のようにすれば簡単にフィルタできます。
User.includes(:projects).where(projects: { zipcode: '30332' })
このメソッドは、アトランタ州(郵便番号30332)でのプロジェクトに関連するusers
をすべて返します。この場合もプロジェクトはeager loadingされます。
.where
でSQLフラグメントを書きたい場合は、references
メソッドが必要です。
User.includes(:projects).where('projects.deleted_at IS NOT NULL').references(:projects)
merge
メソッドは、includes
メソッドやreferences
メソッドと併用できます。merge
メソッドを併用することで、関連付けモデルで定義されているスコープをいくつでも指定できるようになります。
User.includes(:projects).merge(Project.opened_recently).references(:projects)
なお、includes
メソッドを使う場合uniq
メソッドは不要です。ActiveRecord
がすべてよしなにやってくれます。
4. 関連付けそのものをフィルタ対象にする方法
では、関連付けそのものをフィルタで絞り込んでからその関連付けでコレクションを得たい場合はどうしたらよいでしょうか。筆者の場合、あるAPIエンドポイントを開発中に、アプリのユーザーから渡されたパラメータで関連付けをフィルタしたいことがありました。方法はいろいろ考えられますが、このときはオブジェクトを1つ返す必要がありました(このオブジェクトは最終的にJSONに変換されます)。そのため、最もシンプルな方法として、レコードとフィルタ済み関連付けを組み立ててハッシュにしました。
このときのコントローラはだいたい以下のような感じです。
def show
user_attributes.merge(projects: filtered_projects)
end
def user_attributes
# 与えられたユーザーについて、以下のような属性を含むハッシュを返す
# { id: 1, first_name: 'Rick', last_name: 'Sanchez' }
end
def filtered_projects
# 次のような感じで、Projectに関連するパラメータでプロジェクトをフィルタした
# Project.opened_after(params[:project_date]).as_json
# ハッシュを返すために as_json を使っている
end
このmerge
メソッドは、ActiveRecord
ではなくHash
のメソッドです。merge
をextendすればユーザーを複数返すこともできます。
もうひとつの方法は、has_many
関連付けをスコープ付きで定義することです。この方法にご関心がおありの場合は、記事のコメント欄に投稿いただければ別記事を作成します。
最後に
この記事がお役に立てば幸いです。この記事の内容に限らず、元記事にコメントいただければできる限りサポートいたしますので、どうぞよろしくお願いします。
筆者は、Ruby on RailsのWeb開発とビジネスをより短期間に、より簡単に、より楽しいものにするためのヒントや便利技や解説を皆さまにお届けすることを愛するものであります。
関連記事
- よくある?Rails失敗談 default_scope編
- [翻訳] そのパッチをRailsに当てるべきかを考える
- [翻訳]RailsチュートリアルがRails 5.0に完全対応しました!(第4版)
- 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)