Ruby
Rails
ActiveRecord
SQL
RubyOnRails

Railsでモデルを4段階joinする方法で、もう一度理解するjoinsとmerge

こんにちは、インターネットコンテンツ兼新米エンジニアのTerryです。

日曜日にQiitaを投稿しても伸びないことは知っている。
でもそこは何があったか察してほしい、すまない。

さて本日は、Railsでモデルを4段階joinする方法についてお伝えします。
その過程でjoinsについて分解して解説して、joinsへの理解を深めます。

最初に断っておくと、この書き方は僕の尊敬する先輩エンジニアに教えていただいたもので、僕の備忘と理解を深めるため無断で掲載しようと思った次第です怒られたら削除します

2段階(孫)とか3段階(ひ孫)は結構あるんですが、4段階(玄孫[やしゃご])はあまりノウハウが転がっていなかったので、こんなやりかたもあるよ、って感じで知っておくとどこかで使えるかもしれません。

4段階、玄孫ですよ。
どれだけ遠いかって言うと、大久保利通の玄孫が麻生太郎です。幕末か。

okubo_kakeizu-e1514640958101.jpg
(画像はUNDER THE VAILさんより引用)

いやいやお前、4段階もjoinするデータベースの設計がおかしい、見直せという、至極真っ当なご意見があるかと思いますが、とはいえ生きていればそういうこともあるよね。こんな大人になっちゃいけません。

さぁ、皆さんのご理解を頂いたところで、早速進めていきましょう。

テーブル構造

今回想定するテーブルはこんな感じです。

class Parent < ApplicationRecord
    belongs_to :children
end

class Child < ApplicationRecord
    has_many :parents
    has_many :grand_sons
    has_many :great_grandsons, through: :grandsons
end

class GrandSon < ApplicationRecord
    # 中間テーブルです
    belongs_to :child
    belongs_to :great_grand_son
end

class GreatGrandSon < ApplicationRecord
    belongs_to :great_great_grandson
    has_many :grand_sons
end

class GreatGreatGrandSon < ApplicationRecord
    has_many :great_grand_sons
end

ちょっとアソシエーションが複雑ですが、実際の実装に近いイメージを想定しているためご容赦を。
実際、キレーにhas_manyやbelongs_toが通ってる例だと、実践で使えない気もします。

ちなみに、grand sonは孫、great grand sonはひ孫、great great grand sonは玄孫という意味らしい。
greatが増えると、どんどん世代が深くなるイメージです。英語って表現力が貧弱やな。

ちなみにちなみに、図にするとこんな感じ。

スクリーンショット 2018-06-03 17.47.56.png

実践のアソシエーションにモデル名を無理やり乗っけたから、関係性がよくわからんことになっている。やばい。
しかし、他にわかりやすいのも思いつかなかったため、コレで行く。オレは正しい。

何がしたいか

今回は、GreatGreatGrandSonのidから、じっちゃんの名にかけて、それに紐づくParentを引きずり出してやりたい。

ちなみに、SQLで書くとこんなかんじ。
INNER JOIN大明神。

SELECT 
  *
FROM 
  parents
  INNER JOIN
    children
  ON
    children.id = parent.child_id    
  INNER JOIN
    grand_sons
  ON
    grand_sons.id = children.grand_son_id 
  INNER JOIN
    great_grand_sons
  ON
    great_grand_sons.id = grand_sons.great_grand_son_id
  INNER JOIN
    great_great_grand_sons
  ON
    great_great_grand_sons.id = great_grand_sons.great_great_grand_son_id
  AND
    great_great_grand_sons.id = {{funfun}}

整理するために、もう一度図で確認しましょう。

image.png

クエリだとINNER JOIN連発するだけで、特に難しいことはないです。
しかし、Railsでは結構面倒な書き方をしないといけないので、次で解説します。

Railsでの書き方

さて、先ほどのクエリをRailsでやってみましょう。
結論から言うと、こんな書き方です。
(無駄に長いので、折り返します)


Parent.joins(
  child: [
    great_grand_sons: :great_great_grand_son
  ]
).merge(GreatGreatGrandSon.where(id: funfun))

他にも書き方はいろいろありますが、とりあえず今回はこれ。

ちょっとやる気がなくなってきた。つらい。
しかし、頑張ってひとつずつ分解して説明します。

joinsメソッド

最初のjoinsメソッドですが、これは、モデル間の結合を行います。
形としては、こんな感じ。

Model.joins(繋げたいtable)

joins - リファレンス - - Railsドキュメント
http://railsdoc.com/references/joins)

つまり、joinsメソッドは、SQLでいうところINNER JOINを行ってくれるわけですね。
INNER JOINと少し違う点は、「条件に何も入れなければ、勝手にアソシエーションを使って紐付けてくれる(id同士を指定しなくても良い)」というところです。

上の例で発行されるクエリがこちら。

SELECT 
  models.* 
FROM 
  modes 
  INNER JOIN
    tables
  ON
    models.table_id = tables.id

ちなみに、他にも似たような動きをするメソッドにincludesがあります。
N+1問題に気を遣う場合はincludes使おうね。

似ているようで全然違う!?Activerecordにおけるincludesとjoinsの振る舞いまとめ
https://qiita.com/south37/items/b2c81932756d2cd84d7d)

ここまでは普通のjoinsなんですが、ちょっとうざいのが中身の条件節です。

Railsで書いたコードをもう一度見てみましょう。
joins下は、こうなっています。

.joins(child: [great_grand_sons: :great_great_grand_son])

気になる点が2つあると思います。

1つ目は、前半の「child: ...」となっている部分(:childじゃないの?)
2つ目は、[great_grand_sons: :great_great_grand_son]のところ。

さて、これら2つのポイントは、実は1つの表現に集約されています。
答えは「ネスト」です。

Model.joins(hoge: :fuga)とした場合、fugaとhogeがINNER JOINされ、その結果がmodelにINNER JOINされます。fugaがhogeにネストされている状態です。
SQLで書くとこうなります。

SELECT 
  models.* 
FROM 
  models 
  INNER JOIN
    hoges
  ON
    models.hoge_id = hoges.id
  INNER JOIN
    fugaes
  ON
    fugas.hoge_id = fugas.id

この説明でピンときた人もいるかも知れませんが、2つ目の[great_grand_sons: :great_great_grand_son]の部分も同じくネストです。

つまり、2重にネストしているだけの話なんですね。
もう一度、joins下のコードを見てみます。

.joins(child: [great_grand_sons: :great_great_grand_son])

考え方としては、後ろから解決するとわかりやすいです。

まずgreat_grand_sons: :great_great_grand_sonをINNER JOINします。
その結果を、今度はchildとINNER JOINするのです。

おいgrand_sonどこいった、って感じですが、grand_sonは中間テーブルです。
ちゃんとアソシエーション張っていれば、これで問題なく通ります。Railsって便利。

grand_sonが中間テーブルでないパターンを想定する場合は、childではなくgrand_sonにすれば良いです。

さて、これでやりたいことの8割位は説明できました。

SELECT 
  *
FROM 
  parents
  INNER JOIN
    children
  ON
    children.id = parent.child_id    
  INNER JOIN
    grand_sons
  ON
    grand_sons.id = children.grand_son_id 
  INNER JOIN
    great_grand_sons
  ON
    great_grand_sons.id = grand_sons.great_grand_son_id
  INNER JOIN
    great_great_grand_sons
  ON
    great_great_grand_sons.id = great_grand_sons.great_great_grand_son_id
  /* ここまで */
  AND
    great_great_grand_sons.id = {{funfun}}

それでは、最後にmergeメソッドに入りましょう。

mergeメソッド

mergeメソッドといっても、2つあります。
ひとつはRuby自体のメソッドで、ハッシュの結合に利用するものです(今回は説明を割愛します)。

merge, merge! (Hash) - Rubyリファレンス - AmiWiki
https://ref.xaio.jp/ruby/classes/hash/merge)

もうひとつは、ActiveRecordのmergeメソッドです。
今回は言うまでもなくこちらですね。
(mergeってドキュメントないから困る)

mergeは、複数の条件を併合(=マージ)するメソッドです。

ざっくり言うと、直前に行った条件に対して、さらに絞り込みを行いたい時に使います。
SQLでいうと、最後に書くWHERE句的な感じですかね。

ここで、もう一度コードを見ましょう。

Parent.joins(
  child: [
    great_grand_sons: :great_great_grand_son
  ]
).merge(GreatGreatGrandSon.where(id: funfun))

今回は、先ほどJOINした結果のParentにさらに絞り込みを掛けるイメージです。
絞り込む条件は、「idがfunfunであるGreatGreatGrandSon」ということですね。

mergeの中の条件は簡単なので説明はいらないと思います。

ちなみに、mergeの一般的な使い方はコレではなく、モデルのscopeを利用するパターンが多いと思います。
scopeについては、こちら。

Ruby On Railsのscopeメソッドで検索を効率化する
https://programming-beginner-zeroichi.jp/articles/62)

ざっくり言うと、「よく使う絞り込みを、あらかじめモデル内に設定しておく」ということですね。

今回はmergeのこの機能を使わず、単に条件を追加する用途で使っています。
モデルによってscopeをうまく設定して使ってあげることで、controllerの記述をシンプルにして、fat_controller化することを防げますので、効率的に使いましょう。

終わりです!

以上、4段階もjoinする話からjoinsとmergeについて説明してみました!

余裕があれば、クエリの実行計画や結果を吐き出して、どのメソッドや書き方が早いのか、4段階JOINで処理が遅くならないかなど検証したいと思います。