2021年3月14日日曜日

ActiveRecordが便利すぎる【Rails備忘録】

いまRuby on Railsで作ってるアプリ,ERD描いたらこんな感じになってきてけっこう複雑化してきたんだけれども,ActiveRecordが優秀なもんで作ってて楽しい.備忘録がてら「こんな感じでできちゃうぞ」っていうメモを書いておく.

注目してほしいのは,ProjectとSchoolとUserの関係である.ひとつのProjectには複数のSchoolが参加していて(通常は2校だが別に3校以上でもいい),それぞれのSchoolにはUserが属している.なお,Userはstudentだったりteacherだったりadminだったり(場合によってはteacher兼adminだったり)する.ProjectとSchoolとUserの関係を,図で示しておこう.


今回,本稿でいろいろ考える状況は,Userが投稿するPostの取得である.1. ある特定のUserが投稿したPostのリスト(A)を得たい場合,2. ある特定のSchoolから発信されたPostのリスト(B)を得たい場合,そして,3. ある特定のProjectに関連するPostのリスト(C)を得たい場合を考える.得られるリストを集合として考えると,A ⊆ B ⊆ C となるはずだ.

そのUserが投稿したPostの一覧を得る

Userはメッセージ(Post)を投稿する.各PostにはCommentを付けられるが,とりあえずそれは置いておこう.あるUser(@userで示されるUser)の投稿したPostの一覧を取得したいときは,こうすればいい.

@posts = Post.where(user: @user)

これは簡単だろう.このコードに対応するSQLとして,次のようなものが発行される($1には対象とするUserのIDが指定される).そのままである.分かりやすい.

SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1

メッセージ投稿の一覧(Postテーブル)から「田中」の発言を抜き出すという状況を図示してみよう.こんな感じになる.赤枠で囲ったレコードが,指定された条件(Userが「田中」)に合致し抽出されたものである.簡単だ.

次からちょっとややこしくなる.

そのSchoolに属するUserが投稿したPostの一覧を得る

複数のUserによる投稿の一覧を得たい.先の例では@userが一人だったが,今回は,あるSchoolに属する複数のUserが投稿したPostを得たいということである.気分としては,こんな感じ(なんと,このコードでも動く).

@posts = Post.where(user@users User.where(school@school))

さて,PostとUserを内部結合させるメソッドjoins()を使ったコードは次のとおりである.@schoolで示されるSchoolに属するUserが投稿したPostの一覧を取得したいときは,次のようにすればよい.

@posts = Post.joins(:user).where(users: {school: @school})

PostのテーブルをUserテーブルにINNER JOINしてから,Userの条件として「schoolカラムが@schoolだ」というものを指定すればよい,というわけである.

SELECT "posts".* FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" WHERE "users"."school_id" = $1

いささかややこしくなったが,素直に読めばよい.すなわち,Userのschool_idが指定されたものであるようなUserに対して,そのUserのIDとPostのuser_idが一致するようなPostを取ってこい,ということだ.したがって,当初の目論見どおり,指定したschool_idに関連付けられたUser(たち)の投稿したPostが全て得られるということになる($1には対象とするSchoolのIDが指定される).

UserのテーブルにはSchoolが関連付けられている.すなわち,田中,佐藤は学校Aの生徒であり,山田と高橋は学校Bの生徒であり,という具合である(次図の右上).

PostとUserを内部結合して「学校A」に関する発言を抜き出せという状況を図示すると,次のようになる.内部結合したテーブルにおいてSchoolが「学校A」となっているレコードが赤枠で囲まれている(抽出されている).こうやって図示すると,それほど難しくはないことがわかるだろう.

なお,先のコード(これでも動く,としたもの)だと,次のような少し冗長なものになる.

SELECT "posts".* FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" WHERE "posts"."user_id" IN (SELECT "users"."id" FROM "users" WHERE "users"."school_id" = $1)

まあ,動けばよいと割り切れば,分かりやすいコードのほうがよいかな?

そのProjectに参加しているSchoolに属するUserが投稿したPostの一覧を得る

さらにややこしいケースである.Projectには複数のSchoolが関連付けられている.さらにSchoolをJOINする?JOINのネストはなんだかよく分からなくなってしまいそうで面倒だなあ(あとでちゃんと検証する).

まずは,シンプルに考えよう.先のコードで,@schoolが複数になっているという場合である.さて,これをどう書けばよいだろうか.

実は,次のように書くことができる.ProjectとSchoolのクラスにはhas_many,belongs_toの関係が指定されているので,あるプロジェクト @project に関連付けられたSchool(たち)は,@project.schoolsというコードでシンプルに得られる.それを,先のコードの@schoolの代わりにそのコード断片を入れてあげるだけでよい.簡単すぎる\(^o^)/

@posts = Post.joins(:user) 

             .where(users: {school: @project.schools})

さすがにこれに対応して発行されるSQLは,少し,というか,かなり複雑なものになる($1には対象とするProjectのIDが指定される).

SELECT "posts".* FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" WHERE "users"."school_id" IN (SELECT "schools"."id" FROM "schools" WHERE "schools"."project_id" = $1)

しかし,まあ,我々は気にすることはない.(パフォーマンス・チューニングが必要になるようなケースならいざしらず)プログラムのコードが分かりやすく書けていれば問題ないのだ.

JOINのネストという方法できちんと書くならば,次のように書く.joinsメソッドで複数の関連を並べるときは,joins(関連名1: :関連名2) と書けるらしい(3つ以上ネストさせるときはどうするの?…… 調べてみたら「a: { b: :c }」というようにハッシュをネストすればよいようだ).

@posts = Post.joins(user: :school)

             .where(schools: {project: @project})

このとき発行されるSQLは次のとおり.SELECT文がネストしないぶん,SQL的にはこちらのほうがすっきりしているね.

SELECT "posts".* FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" INNER JOIN "schools" ON "schools"."id" = "users"."school_id" WHERE "schools"."project_id" = $1

図にするとこうなる.「『Proj. X』に関するPostを全て抜き出せ」という状況である.右端の表は,各学校が参加しているProjectの関係を示す表である.図では,Projectが「Proj. X」となっているものが選択されている.やはり,図示してみると,それほど難しくない印象を受けるのではないか.INNER JOIN,恐るるに足らず,である.

いやはや,いずれにしてもActiveRecordの威力を実感した次第.ありがたやありがたや.

0 件のコメント:

コメントを投稿