Tech Racho エンジニアの「?」を「!」に。
  • 開発

Ruby on Rails のhas_many 関連付けのフィルタテクニック4種(翻訳)

こんにちは、hachi8833です。

今回は、Duck Type Labの記事「4 ways to filter has_many associations」を、原著者の許諾を得て掲載いたします。

今回は初の試みとして、Google翻訳にかけた元サイト眺めながら翻訳してみました。比較してみると面白いかもしれません。

元記事について

本記事は原著者の許諾を得て翻訳・掲載しております。翻訳は例によって原文との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::Relationmergeに引数として渡した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開発とビジネスをより短期間に、より簡単に、より楽しいものにするためのヒントや便利技や解説を皆さまにお届けすることを愛するものであります。

関連記事


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。