【Rails】簡単な事例で学ぶモデルの多対多関連付け(アソシエーション)の書き方

ここでは Rails においてテーブル同士の多対多の関連付け(アソシエーション)の書き方について実験していきます。事例を通じて実際の動き方を確かめてみましょう。

【Rails】簡単な事例で学ぶモデルの一対多関連付け(アソシエーション)の書き方と併せて読むと,より理解が深まるでしょう。

(この記事では,実際のコードをコントローラー内で書くことを想定して変数に@を付けていますが,本来コンソール上で動かす際には必要ありません。)

英会話スクールの予約モデル

英会話スクールの予約を管理するモデルを考えてみます。

ここでは4つのテーブルが登場します。テーブル lecturer には2名の講師がいます。また,テーブル lecture にはどの講師が何時に授業を行うかを表すデータが入っています。lecturer_id=1 は田中先生を指し,10:00に授業を行います。また lecturer_id=2 は佐藤先生を指し,15:00に授業を行います。

このときテーブル lecture を参照元,テーブル lecturer を参照先を呼びます。カラムの命名にはルールがあり,参照元には参照先のテーブル名_idというカラムが必要です。

また,テーブル student には2名の生徒がいます。

最終的に講師と生徒をつなぐテーブルが appointment です。レコードは田中先生の10:00に野口さんが予約したことを意味しています。

このモデルは複数の講師と複数の生徒を関連付けるので,多対多関連付け(アソシエーション)と呼びます。そして,両者の間にテーブル appointment があり,講師と生徒を結びつける役割を果たしています。このようなテーブルを中間テーブルと呼びます。多対多のモデルでは一般的に中間テーブルを必要とします。

考察1

ここで lecturer と student が多対多の関係であるという言い方はやや正確さを欠きます。上のモデルで実際に中間テーブルを介して結びついているのは lecture と student だからです。

appointment 側から見ると lecture は具象であり直接的な関係,lecturerは抽象であり間接的な関係です。

モデルを構築するときには,このように直接結びついているもの,間接的に結びついているものを頭の中で整理しておくと,具体的なコードの書き方がひらめくようになるでしょう。

テーブルの作成

テーブルを作成していきましょう。

> rails g model lecturer name:string
> rails g model student name:string 
> rails rails g model lecture lecturer_id:integer time:string
> rails g model appointment lecture_id:integer student_id:integer

関連付けを行いましょう。とりあえず,はじめの図に沿って以下のように書いてみます。

class Lecturer < ApplicationRecord
  has_many :lectures, dependent: :destroy
end

class Lecture < ApplicationRecord
  has_many :appointments, dependent: :destroy
end

class Student < ApplicationRecord
  has_many :appointments, dependent: :destroy
end

class Appointment < ApplicationRecord
  belongs_to :lecture
  belongs_to :student
end
> rails db:migrate

Railsコンソールを立ち上げて,lecturer のレコードを追加します。

>> Lecturer.create(name:"田中")
>> Lecturer.create(name:"佐藤")

lecture のレコードを追加します。

>> @lecturer=Lecturer.find_by(name:"田中")
>> @lecturer.lectures.create(time:"10:00")
=> id: 1, lecturer_id: 1, time: "10:00"

本来,時刻を扱う場合はDATETIME型のカラムにしますが,ここでは話を簡単にするため,単に文字列として記述しています。

上のような書き方をすることで,レコードに lecturer_id が自動的に挿入されていることが分かります。

他のレコードも追加しましょう。

>> @lecturer=Lecturer.find_by(name:"佐藤")
>> @lecturer.lectures.create(time:"15:00")

>> Lecture.all
=> id: 1, lecturer_id: 1, time: "10:00"
   id: 2, lecturer_id: 2, time: "15:00"
>> Student.create(name:"秋山")
>> Student.create(name:"野口")

>> Student.all
=> id: 1, name: "秋山",
   id: 2, name: "野口"

予約する

野口さんが10:00に予約を入れるという動作をコードで表現してみましょう。

>> @student=Student.find_by(name:"野口")
>> @lecture=Lecture.find_by(time:"10:00")
>> @lecture.students=@student

/usr/local/bundle/gems/activemodel-6.1.5/lib/active_model/attribute_methods.rb:469:in `method_missing': undefined method `students=' for ・・・ 

エラーが発生しました。モデルの書き方に問題があるようです。

student は lecture に直接に属しているわけではないので,@lecture.students という書き方はできません。両者の間には appointment が存在します。

モデルの修正

class Lecturer < ApplicationRecord
  has_many :lecture, dependent: :destroy
end

class Lecture < ApplicationRecord
  has_many :appointments, dependent: :destroy
  has_many :students, through: :appointments
end

class Student < ApplicationRecord
  has_many :appointments, dependent: :destroy
  has_many :lectures, through: :appointments
end

class Appointment < ApplicationRecord
  belongs_to :lecture
  belongs_to :student
end

問題を解決するために lecture と student に through を追加しました。これは lecture が appointments を通じて間接的に student を持つことを意味します。

もう一度予約を入れてみましょう。

>> @student=Student.find_by(name:"野口")
>> @lecture=Lecture.find_by(time:"10:00")
>> @lecture.students<<@student

>>Appointment.all
=> id: 1, lecture_id: 1, student_id: 2

今度はうまくいきました。

ここで << という書き方が登場しています。これは配列に要素を追加するもので配列演算子と呼びます。文字数が少なくて済むので使用していますが,push()を使っても同じことができます。

>> @lecture.students.push(@student)

has_many を設定することで lecture は複数の student を持つことができるようになり,どの student が属しているかは @lecture.students の中に配列として格納されます(はじめは空の配列の状態です)。そこに <<@student とすることで新たな student を追加することができます。

このとき,中間テーブル appointment にレコードが自動的に追加される仕組みです。

不正なデータ

仮に,レコードに存在しない student を登録しようとしたらどうなるか,という実験をしてみましょう。

>> Appointment.all
=> id: 1, lecture_id: 1, student_id: 2

>> @appointment=Appointment.id(1)
>> @appointment[:student_id]=3
>> @appointment
=> id: 1, lecture_id: 1, student_id: 3

テーブルには id が 3 の student は存在しないのですが,強引にデータを改変してみました。これを保存してみましょう。

>> @appointment.save
・・・
TRANSACTION (0.2ms)  ROLLBACK
=> false

保存に失敗しました。データをもとに戻してみましょう。

>> @appointment[:student_id]=2
>> @appointment.save
・・・
TRANSACTION (0.2ms)  COMMIT
=> true

今度は成功しました。

このように,テーブルの関連付けを行うことで不正なデータの入力を防ぐことができます。

考察2

先ほど予約を入れるときに,@lecture.students<<@student という書き方をしました。これは一つの lecture は複数の student を持つ,という解釈です。ここには疑問点があります。

この考え方を用いて,一つの lecture がどの student を持っているかを調べることはできるのですが,逆に一人の student がどの lecture を持っているかを調べるにはどのようにしたらよいでしょうか。

>> @lecture
=> id: 1, lecturer_id: 1, time: "10:00"

>> @student
=> id: 2, name: "野口"

>> @lecture.students
=> id: 2, name: "野口"

>> @student.lectures
=> id: 1, lecturer_id: 1, time: "10:00"

答えは,書き方をひっくり返して @student.lectures とすることです。

このように,lecture と student の両方に has_many ~ through ・・・ を設定することで,どちら側からでも情報を取得することができます。

まとめ

ここでは英会話スクールの予約モデルを通じて,多対多の関連付けについて学びました。ポイントは has_many ~ through ・・・を用いて,2つのテーブルを中間テーブルを通して関連付けることです。

関連付けを行うことで,中間テーブルの存在を意識せずに2つのテーブルに存在するレコード同士を結びつけ,また不正なデータが中間テーブルに登録されることを防止できます。