Hit the books!!

プログラミング学習記録

モデルのアソシエーション

Railsにおいて、正規化を用いてモデル間の関係をシンプルに実現する仕組みをアソシエーションという。

モデルの関係

モデルの関係は、以下の通りにわけることができる。

  • 1対多(has_many)
  • 1対1(has_one)
  • 所属関係(belongs_to)
  • 多対多

has_many/belongs_to

以下のモデル構成で具体例をみていく。

f:id:ud_ike:20201015103109j:plain

managementアプリケーションを作成し、ProjectManagerモデルを生成する。

% bin/rails g model project_manager name:string
Running via Spring preloader in process 3956
      invoke  active_record
      create    db/migrate/20201014131955_create_project_managers.rb
      create    app/models/project_manager.rb
      invoke    test_unit
      create      test/models/project_manager_test.rb
      create      test/fixtures/project_managers.yml

Projectモデルを生成する。

% bin/rails g model project name:string project_manager:references
Running via Spring preloader in process 3982
      invoke  active_record
      create    db/migrate/20201014132028_create_projects.rb
      create    app/models/project.rb
      invoke    test_unit
      create      test/models/project_test.rb
      create      test/fixtures/projects.yml

references型を利用すると、子モデル側にbelongs_toのアソシエーションも自動生成してくれる。

# app/models/project.rb
class Project < ApplicationRecord
  belongs_to :project_manager
end

Memberモデルを生成する。

% bin/rails g model member name:string project:references     
Running via Spring preloader in process 4004
      invoke  active_record
      create    db/migrate/20201014132111_create_members.rb
      create    app/models/member.rb
      invoke    test_unit
      create      test/models/member_test.rb
      create      test/fixtures/members.yml

has_manyメソッドは自動で設定されないため、ProjectManagerモデルとProjectモデルを以下のように修正する。

# app/models/project_manager.rb

class ProjectManager < ApplicationRecord
  has_many :projects
end
# app/models/project.rb

class Project < ApplicationRecord
  belongs_to :project_manager
  has_many :members
end

ちなみにMemberモデルは以下の内容。

# app/models/member.rb 

class Member < ApplicationRecord
  belongs_to :project
end

db:migrateしてRailsコンソールで動作確認する。

ローカル変数pmとして新規のプロジェクトマネージャーのインスタンスを作成する。createメソッドを使用しているためデータベーステーブルにも登録される。インスタンスを作成するだけの場合はcreateではなくnew(またはbuild)を使用する。

irb(main):001:0>  pm = ProjectManager.create(name: 'Taro Yamada')
   (0.7ms)  SELECT sqlite_version(*)
   (0.1ms)  begin transaction
  ProjectManager Create (0.5ms)  INSERT INTO "project_managers" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Taro Yamada"], ["created_at", "2020-10-15 00:09:22.621512"], ["updated_at", "2020-10-15 00:09:22.621512"]]
   (1.7ms)  commit transaction

pmに所属するプロジェクトをpm.projectsのように(アソシエーションメソッドと呼ぶ)関係モデルを参照し、新しいプロジェクトインスタンスpjを作成する。

irb(main):002:0> pj = pm.projects.create(name: 'Ruby Promotion PJ')
   (0.1ms)  begin transaction
  Project Create (0.5ms)  INSERT INTO "projects" ("name", "project_manager_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "Ruby Promotion PJ"], ["project_manager_id", 1], ["created_at", "2020-10-15 00:11:50.282743"], ["updated_at", "2020-10-15 00:11:50.282743"]]
   (1.2ms)  commit transaction

次に、プロジェクト参加メンバーを紐づけるためpj.membersのようにpjの子モデルMemberをmembersメソッドで参照し登録する。

irb(main):003:0> pj.members.create(name: 'Ichiro Suzuki')
   (0.1ms)  begin transaction
  Member Create (0.5ms)  INSERT INTO "members" ("name", "project_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "Ichiro Suzuki"], ["project_id", 1], ["created_at", "2020-10-15 00:13:02.033894"], ["updated_at", "2020-10-15 00:13:02.033894"]]
   (1.2ms)  commit transaction
=> #<Member id: 1, name: "Ichiro Suzuki", project_id: 1, created_at: "2020-10-15 00:13:02", updated_at: "2020-10-15 00:13:02">

この結果、プロジェクトマネージャーTaro Yamadaに紐づくプロジェクトRuby Promotion PJが登録され、それに紐づくメンバーIchiro Suzukiが登録された。pj.membersを呼び出すと、次のようにIchiro Suzukiを含むメンバーインスタンス配列が表示される。

irb(main):004:0> pj.members
  Member Load (0.4ms)  SELECT "members".* FROM "members" WHERE "members"."project_id" = ? LIMIT ?  [["project_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Member id: 1, name: "Ichiro Suzuki", project_id: 1, created_at: "2020-10-15 00:13:02", updated_at: "2020-10-15 00:13:02">]>

別のメンバーKeiko Saitoをこの配列に追加してみる。 次のようにメンバーのインスタンス化を行い<<メソッドを使用して、生成されたインスタンスksをpj.membersの配列に追加する。

irb(main):006:0> ks = Member.new(name: 'Keiko Saito')
irb(main):007:0> pj.members << ks
   (0.1ms)  begin transaction
  Member Create (0.6ms)  INSERT INTO "members" ("name", "project_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "Keiko Saito"], ["project_id", 1], ["created_at", "2020-10-15 00:28:06.515357"], ["updated_at", "2020-10-15 00:28:06.515357"]]
   (1.9ms)  commit transaction
  Member Load (0.3ms)  SELECT "members".* FROM "members" WHERE "members"."project_id" = ? LIMIT ?  [["project_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Member id: 1, name: "Ichiro Suzuki", project_id: 1, created_at: "2020-10-15 00:13:02", updated_at: "2020-10-15 00:13:02">, #<Member id: 2, name: "Keiko Saito", project_id: 1, created_at: "2020-10-15 00:28:06", updated_at: "2020-10-15 00:28:06">]>

そしてksをsaveメソッドで保存すると、データベースのmembersテーブルにも反映される。

irb(main):008:0> ks.save
=> true
irb(main):009:0> pj.members
  Member Load (0.2ms)  SELECT "members".* FROM "members" WHERE "members"."project_id" = ? LIMIT ?  [["project_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Member id: 1, name: "Ichiro Suzuki", project_id: 1, created_at: "2020-10-15 00:13:02", updated_at: "2020-10-15 00:13:02">, #<Member id: 2, name: "Keiko Saito", project_id: 1, created_at: "2020-10-15 00:28:06", updated_at: "2020-10-15 00:28:06">]>

子のインスタンスから親のインスタンスを確認する方法として、メンバーIchiro Suzukiからプロジェクトマネージャー名を調べる例をみる。

Ichiro Suzukiをインスタンス化し、そのプロジェクト名とプロジェクトマネージャー名を次のように確認する。

irb(main):010:0> mem = Member.find_by(name: 'Ichiro Suzuki')
  Member Load (0.3ms)  SELECT "members".* FROM "members" WHERE "members"."name" = ? LIMIT ?  [["name", "Ichiro Suzuki"], ["LIMIT", 1]]
irb(main):011:0> proj = mem.project
  Project Load (0.2ms)  SELECT "projects".* FROM "projects" WHERE "projects"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
irb(main):012:0> proj.name
=> "Ruby Promotion PJ"
irb(main):013:0> proj.project_manager.name
  ProjectManager Load (0.2ms)  SELECT "project_managers".* FROM "project_managers" WHERE "project_managers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> "Taro Yamada"

アソシエーションメソッドを使用しなくても、findメソッドやwhereメソッドを使用することで同じように情報を得ることができるが、Railsでは、アソシエーションメソッドをうまく使用して処理をシンプルに記述することが推奨されている。

多対多

多対多の関係を実装する方法はhmt型とhabtm型の2種類の方法がある。

hmt型

has_manyメソッドのthroughオプションを使用する方法。2つのモデルを双方の子モデルを通して関係付ける。

図書館の図書とユーザの関係モデルを考えてみる。一般的には、2つのモデルの関係が直接多対多になることはなく、2つのモデルの間には必ず双方に関連する子モデルが存在する。

f:id:ud_ike:20201015103148j:plain

モデルの関係図↓

f:id:ud_ike:20201015103226p:plain

LibraryアプリケーションにRentalモデルとして貸し出し機能を追加していく。Rentalモデルを生成する。 (貸し出し処理はコントローラーとして実装することになるが、ここでは貸し出し機能のためのRentalモデルについてだけ考える)

% bin/rails g model rental user:references book:references rental_date:date
Running via Spring preloader in process 2701
      invoke  active_record
      create    db/migrate/20201015015339_create_rentals.rb
      create    app/models/rental.rb
      invoke    test_unit
      create      test/models/rental_test.rb
      create      test/fixtures/rentals.yml

生成されたRentalモデルは次のようになる。

# app/models/rental.rb

class Rental < ApplicationRecord
  belongs_to :user
  belongs_to :book
end

そしてUserモデルとBookモデルにthroughオプションを指定して1対多の関係を設定する。

# app/models/user.rb

class User < ApplicationRecord
  has_many :rentals
  has_many :books, through: :rentals
end
# app/models/book.rb

class Book < ApplicationRecord
  has_many :rentals
  has_many :users, through: :rentals

  ...(省略)
end

マイグレーションし生成されたファイルは以下のようになっている。

# db/migrate/20201015015339_create_rentals.rb

class CreateRentals < ActiveRecord::Migration[6.0]
  def change
    create_table :rentals do |t|
      t.references :user, null: false, foreign_key: true
      t.references :book, null: false, foreign_key: true
      t.date :rental_date

      t.timestamps
    end
  end
end

references型として設定したuserとbookは外部キーであり、そのためのオプションとしてforeign_key: trueが付加されている。

Railsコンソールを使ってアソシエーション機能を確認してみる。 まずはuserを作成する。

irb(main):001:0> user = User.create(name: '山田花子', email: 'hanako@yahoo.co.jp')
   (0.5ms)  SELECT sqlite_version(*)
   (0.1ms)  begin transaction
  User Create (0.6ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "山田花子"], ["email", "hanako@yahoo.co.jp"], ["created_at", "2020-10-15 02:13:37.672782"], ["updated_at", "2020-10-15 02:13:37.672782"]]
   (1.1ms)  commit transaction

user.booksを呼び出すと、ユーザからみた貸し出し図書のインスタンス配列ActiveRecord::Associations::CollectionProxy []が作成される。空であるためまだ1件も貸し出し図書を持っていないことがわかる。

irb(main):002:0> user.books
  Book Load (0.2ms)  SELECT "books".* FROM "books" INNER JOIN "rentals" ON "books"."id" = "rentals"."book_id" WHERE "rentals"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy []>

新しい図書インスタンスbookを作成し、user.booksに追加することで貸し出し図書を持たせる。

irb(main):003:0> book = Book.create(title: 'Railsの夜明け')
   (0.1ms)  begin transaction
  Book Create (0.5ms)  INSERT INTO "books" ("title", "created_at", "updated_at") VALUES (?, ?, ?)  [["title", "Railsの夜明け"], ["created_at", "2020-10-15 02:14:31.562011"], ["updated_at", "2020-10-15 02:14:31.562011"]]
   (1.2ms)  commit transaction
irb(main):004:0> user.books << book
   (0.1ms)  begin transaction
  Rental Create (0.5ms)  INSERT INTO "rentals" ("user_id", "book_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 1], ["book_id", 6], ["created_at", "2020-10-15 02:15:05.114613"], ["updated_at", "2020-10-15 02:15:05.114613"]]
   (1.2ms)  commit transaction
  Book Load (0.2ms)  SELECT "books".* FROM "books" INNER JOIN "rentals" ON "books"."id" = "rentals"."book_id" WHERE "rentals"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Book id: 6, title: "Railsの夜明け", description: nil, created_at: "2020-10-15 02:14:31", updated_at: "2020-10-15 02:14:31">]>

他の新しい本を借りるとして、同様に関係付けを行う。

irb(main):005:0> book = Book.create(title: 'Rubyの魔法')
   (0.1ms)  begin transaction
  Book Create (0.5ms)  INSERT INTO "books" ("title", "created_at", "updated_at") VALUES (?, ?, ?)  [["title", "Rubyの魔法"], ["created_at", "2020-10-15 02:16:01.915280"], ["updated_at", "2020-10-15 02:16:01.915280"]]
   (1.8ms)  commit transaction
irb(main):006:0> user.books << book
   (0.1ms)  begin transaction
  Rental Create (0.7ms)  INSERT INTO "rentals" ("user_id", "book_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 1], ["book_id", 7], ["created_at", "2020-10-15 02:16:21.719510"], ["updated_at", "2020-10-15 02:16:21.719510"]]
   (1.8ms)  commit transaction
  Book Load (0.3ms)  SELECT "books".* FROM "books" INNER JOIN "rentals" ON "books"."id" = "rentals"."book_id" WHERE "rentals"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Book id: 6, title: "Railsの夜明け", description: nil, created_at: "2020-10-15 02:14:31", updated_at: "2020-10-15 02:14:31">, #<Book id: 7, title: "Rubyの魔法", description: nil, created_at: "2020-10-15 02:16:01", updated_at: "2020-10-15 02:16:01">]>

この結果、ユーザ山田花子が借りている(関連付けられている)図書の一覧を取得する。

irb(main):007:0> hanako = User.find_by(name: '山田花子')
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."name" = ? LIMIT ?  [["name", "山田花子"], ["LIMIT", 1]]
irb(main):008:0> hanako.books.pluck(:title)
   (0.3ms)  SELECT "books"."title" FROM "books" INNER JOIN "rentals" ON "books"."id" = "rentals"."book_id" WHERE "rentals"."user_id" = ?  [["user_id", 1]]
=> ["Railsの夜明け", "Rubyの魔法"]

まずはインスタンスhanakoを生成し、アソシエーションメソッドを使用してhanako.booksで貸し出し図書の情報を取得している。タイトル名だけを取り出すため、pluckメソッドでタイトルを指定している。

図書側から借主を参照することもできる。

irb(main):009:0> book = Book.find_by(title: 'Rubyの魔法')
  Book Load (0.2ms)  SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ?  [["title", "Rubyの魔法"], ["LIMIT", 1]]
irb(main):010:0> book.users.pluck(:name)
   (0.3ms)  SELECT "users"."name" FROM "users" INNER JOIN "rentals" ON "users"."id" = "rentals"."user_id" WHERE "rentals"."book_id" = ?  [["book_id", 7]]
=> ["山田花子"]

habtm型

2つめはhas_and_belongs_to_manyメソッドを使う方法で、具体的には、意味のある子モデルを介した結合ではなく、仲介のためのテーブル(中間テーブル)を作成し関連付けを行う。

f:id:ud_ike:20201015115219p:plain

中間テーブルとは、対応するモデルを持たないテーブルであり、結合する双方のモデルで外部キーとして使うidのみを持つテーブルである。中間テーブルのテーブル名は、結合する2つのモデル名の複数形をアルファベット順に_で区切って並べたものになる。

ユーザと図書モデルを考える。まずは相互のモデルを参照するためにhas_and_belongs_to_manyメソッドを設定する。

# app/models/user.rb

class User < ApplicationRecord
  has_and_belongs_to_many :books
end
# app/models/book.rb

class Book < ApplicationRecord
  has_and_belongs_to_many :users

  ...(省略)
end

子モデルを必要としないため、仲介テーブルを作成するためのマイグレーションファイルのみを作成する。

% bin/rails g migration create_books_users book:references user:references

この結果、次のマイグレーションファイルが生成される。

class CreateBooksUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :books_users do |t|
      t.references :book, foreign_key: true
      t.references :user, foreign_key: true
    end
  end
end

一般的には、モデルとモデルが対等な関係を持つ場合、双方向の手続きに相当するオブジェクトが存在すると考えるのが自然である。その手続きに関する属性を持つ1つのモデルとして考えることができるため、通常多対多の関係はhmt型で構築するのが自然であると考えられる。