21
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Railsで「複数カラム」かつ「共通テーブル」にアソシエーション(1対多・多対多・自己結合)

Last updated at Posted at 2021-12-14

はじめに

最近Ruby on Railsを勉強していて,アソシエーションについての知見が増えたので,せっかくなので共有したいと思います.
テーブルの複数カラムから共通のテーブルを参照するときや,別名主キーを設定したテーブルに対してアソシエーションを張る方法について紹介していきます.

今回紹介するアソシエーションをすべてまとめると以下の図のようになります.
qiita.png

同じものを作る場合上から順に実装を進めていけばうまくいくと思います.

※めちゃ長いです。すみません。

もくじ

複数カラム・共通テーブル・1対多

以下の図のようなアソシエーションを想定します.
1_many2one_same.png
Item(製品)は1社のVendor(販売業者)と1社のManufacturer(製造業者)を持ち,Company(会社)は複数のItem(製品)を持つ可能性があります.

複数のカラムから共通のテーブルに対して,1対多の関係を持たせるため,別名での参照が必要になってきます.

また,Company(会社)は主キーとして文字列型のcompany_codeを持ちます.

テーブルの作成

Companyテーブル

$ rails g model Company

マイグレーションファイルを修正します.

db/migrate/20211210133333_create_companies.rb
class CreateCompanies < ActiveRecord::Migration[6.1]
  def change
    create_table :companies, id: false do |t|
      t.string :company_code, null: false, primary_key: true
      t.string :name
      t.string :address
      t.string :mail
      t.string :phone
    end
  end
end

create_table :companies, id: false do |t|で自動生成の主キーを無効にします.
そして,t.string :company_code, null: false, primary_key: trueで新たな主キーを設定します.

マイグレーションを行います.

$ rails db:migrate

Itemテーブル

$ rails g model Item

こちらにはt.referencesで外部キー制約を追加します.

db/migrate/20211210133508_create_items.rb
class CreateItems < ActiveRecord::Migration[6.1]
  def change
    create_table :items do |t|
      t.string :name, null: false
      t.references :vendor, type: :string
      t.references :manufacturer, type: :string
    end
    add_foreign_key :items, :companies, column: :vendor_id, primary_key: :company_code
    add_foreign_key :items, :companies, column: :manufacturer_id, primary_key: :company_code
  end
end

しかし,参照先は自動生成の主キーではないため,よく見る記事の外部キー制約とは違います.

t.referencesにはforeign_key: trueを設定せず,キーの型がstringであるということだけ追加します.
また,外部キー名はcompanyではなく,別名としてvendormanufacturerとつけます.

上で外部キーであることを明示していない代わりに,add_foreign_keyを用いて外部キーの主キーがcompany_codeであると明示してあげます.

    add_foreign_key :items, :companies, column: :vendor_id, primary_key: :company_code
    add_foreign_key :items, :companies, column: :manufacturer_id, primary_key: :company_code

独自に主キーを設定した場合はこの方法で設定しないと,company_idという存在しない主キーを参照しようとしてエラーを起こしてしまいます.

設定したら,マイグレーションをして終了です.

$ rails db:migrate

アソシエーションを追加

外部キーを設定したので,それらを参照できるようにモデルにアソシエーションを追加します.

app/models/item.rb
class Item < ApplicationRecord
  belongs_to :vendor, class_name: "Company", optional: true
  belongs_to :manufacturer, class_name: "Company", optional: true
end

Itemテーブルから,vendermanfacturerCompanyテーブルを参照できるようにbelongs_toを設定します.
optional: trueで外部キーがnilであることを許可します.
belongs_toでは,これがないと,バリデーションにはじかれてDBへ保存できなくなります.
今回は,後から外部キー制約を追加するのですが,こういう時は必要となる設定です.

app/models/company.rb
class Company < ApplicationRecord
  self.primary_key = :company_code
    
  has_many :vendees, class_name: "Item", foreign_key: "vendor_id"
  has_many :manufacturees, class_name: "Item", foreign_key: "manufacturer_id"
end

self.primary_key = :company_codeCompanyの主キーがcomapny_codeであることを明示します.
これによって,Company.find("独自の主キー")での検索ができるようになります.

vendeesmanfactureesItemテーブルを参照できるようにhas_manyを設定します.複数保有する可能性があるため複数形で命名しています.

共通テーブル・多対多

以下の図のようなアソシエーションを想定します.
4_2_many2one_many2many_same.png
User(ユーザ)は複数のItem(製品)を持ち,User(ユーザ)は複数の欲しいItem(製品)があります.Item(製品)は複数のユーザから関連付けられます.
以上から,共通のテーブル(Item)に対してUserから二通りの参照が行われる多対多の関係であることが分かります.

Ruby on Railsでは,多対多を実現する場合,中間テーブルを用いて実装を行います.
今回は,UserWishlist(ユーザの欲しいものリスト用)テーブルとUserHaving(ユーザの所有物リスト用)テーブルを用意します.

テーブルの作成

Userテーブル

nameだけを持つUserテーブルを作成します.

$ rails g model user name
$ rails db:migrate

中間テーブル

UserWishlistテーブルとUserHavingテーブルを作成します.

$ rails g model UserHaving
$ rails g model UserWishlist

マイグレーションファイルを編集します.

db/migrate/20211210141358_create_user_havings.rb
class CreateUserHavings < ActiveRecord::Migration[6.1]
  def change
    create_table :user_havings do |t|
      t.references :having, foreign_key: { to_table: :items }
      t.references :user, foreign_key: true
    end
  end
end

UserHaving(Item)を中間テーブルの外部キーとして設定します.
Havingというテーブルは存在しないので,foreign_key: { to_table: :items }Itemテーブルを参照していることを明示します.

db/migrate/20211210141411_create_user_wishlists.rb
class CreateUserWishlists < ActiveRecord::Migration[6.1]
  def change
    create_table :user_wishlists do |t|
      t.references :wishlist, foreign_key: { to_table: :items }
      t.references :user, foreign_key: true
    end
  end
end

UserWishlist(Item)を中間テーブルの外部キーとして設定します.
Wishlistというテーブルは存在しないので,foreign_key: { to_table: :items }Itemテーブルを参照していることを明示します.

設定を完了したら,マイグレーションをして終了です.

$ rails db:migrate

アソシエーションを追加

それぞれにアソシエーションを追加していきます.

app/models/user_having.rb
class UserHaving < ApplicationRecord
  belongs_to :having, class_name: 'Item'
  belongs_to :user
end

class_name: 'Item'を書くことで,havingItemを参照していることを示します.

app/models/user_wishlist.rb
class UserWishlist < ApplicationRecord
  belongs_to :wishlist, class_name: 'Item'
  belongs_to :user
end

class_name: 'Item'を書くことで,wishlistItemを参照していることを示します.

app/models/user.rb
class User < ApplicationRecord  
  has_many :user_havings, dependent: :destroy
  has_many :havings, through: :user_havings
  
  has_many :user_wishlists, dependent: :destroy
  has_many :wishlists, through: :user_wishlists
end

through: :user_havingsUserHavingテーブルを通して,havings(Item)にアクセスすることを示しています.
同様に,through: :user_wishlistsUserWishlistテーブルを通して,wishlists(Item)にアクセスすることを示しています.

app/models/item.rb
class Item < ApplicationRecord
  belongs_to :vendor, class_name: "Company", optional: true
  belongs_to :manufacturer, class_name: "Company", optional: true

  # 以下を追加
  has_many :user_havings, dependent: :destroy, foreign_key: "having_id"
  has_many :had_users, through: :user_havings, source: :user
  
  has_many :user_wishlists, dependent: :destroy, foreign_key: "wishlist_id"
  has_many :wished_users, through: :user_wishlists, source: :user
end

ほとんど,user.rbと同様の考え方なのですが,2点だけ違います.
それは,外部キーとして,having_idwishlist_idを使用するように宣言していることと,source: :userUserを参照するように指定しているところです.

sourceを指定しないと,wished_userが中間テーブルのwishlistを示しているのか,userを示しているのかわからないという風なエラーが出てきます.

自己結合・多対多

以下の図のようなアソシエーションを想定します.
3_own.png
User(自分)は複数のUser(友人)を持ち,その逆も然りという関係です.
自己結合の多対多関係も,中間テーブルを用いて実装します.

テーブルの作成

Friendshipテーブル

交友関係を管理する中間テーブルとして,Friendshipテーブルを作成します.

$ rails g model friendship

マイグレーションファイルを修正します.

db/migrate/20211210143456_create_friendships.rb
class CreateFriendships < ActiveRecord::Migration[6.1]
  def change
    create_table :friendships do |t|
      t.references :user, foreign_key: true
      t.references :friend, foreign_key: { to_table: :users }
      t.index %i[user_id friend_id], unique: true
    end
  end
end

外部キーはuserfriend(User)となります.
Friendというテーブルはないので,foreign_key: { to_table: :users }Userテーブルを参照していることを明示します.

また,indexをユニークで張ることで,組み合わせを固有にし,友達なのに何度も友達になるようなこと回避します.

マイグレーションをして終了です.

$ rails db:migrate

アソシエーションを追加

app/models/friendship.rb
class Friendship < ApplicationRecord
  belongs_to :user
  belongs_to :friend, class_name: 'User'

  validates :user_id, presence: true
  validates :friend_id, presence: true
end

class_name: 'User'を書くことで,friendUserを参照していることを示します.
また,validatesでどちらか一方でもnullであることを許可しないようにします.

app/models/user.rb
class User < ApplicationRecord  
  has_many :user_havings, dependent: :destroy
  has_many :havings, through: :user_havings
  
  has_many :user_wishlists, dependent: :destroy
  has_many :wishlists, through: :user_wishlists

  # 以下を追加
  has_many :friendships, dependent: :destroy
  has_many :friends, through: :friendships, source: :friend
  has_many :my_friendships, class_name: "Friendship", foreign_key: "friend_id", dependent: :destroy
  has_many :users, through: :my_friendships, source: :user

  def friending(friend)
    unless self == friend
      self.friendships.find_or_create_by(friend_id: friend.id)
    end
  end

  def unfriending(friend)
    friendship_to = self.friendships.find_by(friend_id: friend.id)
    friendship_to.destroy if friendship_to
  end
end

実装は以下の記事を参考(ほぼコピー:sweat:)にさせていただきました.
めちゃくちゃ詳しく自己結合について書いてくださっているため,ここでの説明は省かせていただきます.

参考:Railsでフォロー機能を作る方法

複数カラム・共通テーブル・1対多&多対多

以下の図のようなアソシエーションを想定します.
4_2_many2one_many2many_same.png

Company(会社)は一つのPrefecture(本社の都道府県)を持ちます.
Company(会社)は複数のPrefecture(支社の都道府県)を持ちます.
Prefecture(都道府県)には,複数のCompany(会社)が存在する可能性があります.

共通のテーブルを多対多1対多で参照するようなシチュエーションです.

テーブルの作成

$ rails g model prefecture name
$ rails db:migrate
$ rails g model company_branch
$ rails g migration AddReferenceToCompany

マイグレーションファイルを修正します.

db/migrate/20211211145652_create_company_branches.rb
class CreateCompanyBranches < ActiveRecord::Migration[6.1]
  def change
    create_table :company_branches do |t|
      t.references :prefecture, foreign_key: true
      t.references :company, type: :string
    end
    add_foreign_key :company_branches, :companies, column: :company_id, primary_key: :company_code
  end
end

今までの流れを追っていれば特に説明する必要もないかと思います.

db/migrate/20211214033902_add_reference_to_company.rb
class AddReferenceToCompany < ActiveRecord::Migration[6.1]
  def change
    add_reference :companies, :prefecture, foreign_key: true
  end
end

Companyに外部キーとしてPrefectureを追加します.

マイグレーションして終了です.

$ rails db:migrate

アソシエーションを追加

多対多を追加

app/models/company_branch.rb
class CompanyBranch < ApplicationRecord
  belongs_to :branch, class_name: "Prefecture", foreign_key: :prefecture_id
  belongs_to :company
end

class_name: "Prefecture"branchPrefectureを参照することを示し,その外部キーはprefecture_idであることを明記しています.

app/models/prefecture.rb
class Prefecture < ApplicationRecord
  has_many :company_branches, dependent: :destroy
  has_many :branches, through: :company_branches
end

こちらは,先ほどもやったような別名の多対多アソシエーションです.

app/models/company.rb
class Company < ApplicationRecord
  self.primary_key = :company_code

  has_many :vendees, class_name: "Item", foreign_key: "vendor_id"
  has_many :manufacturees, class_name: "Item", foreign_key: "manufacturer_id"

  # 以下を追加
  has_many :company_branches, dependent: :destroy
  has_many :branches, through: :company_branches
end

こちらも,先ほどもやったような別名の多対多アソシエーションです.

1対多を追加

app/models/company.rb
class Company < ApplicationRecord
  self.primary_key = :company_code

  has_many :vendees, class_name: "Item", foreign_key: "vendor_id"
  has_many :manufacturees, class_name: "Item", foreign_key: "manufacturer_id"

  has_many :company_branches, dependent: :destroy
  has_many :branches, through: :company_branches

  # 以下を追加
  belongs_to :prefecture, class_name: "Prefecture", optional: true
end

class_name: "Prefecture"prefecturePrefectureを参照していることを示します.

app/models/prefecture.rb
class Prefecture < ApplicationRecord
  has_many :company_branches, dependent: :destroy
  has_many :branches, through: :company_branches
  # 以下を追加
  has_many :headees, class_name: "Company"
end

そして,class_name: "Company"headeesCompanyを参照することを示します.

データを登録してみる

seed.rb
cmp1 = Company.create(name: "販売店1", company_code: "v-001")
cmp2 = Company.create(name: "販売店2", company_code: "v-002")
cmp3 = Company.create(name: "販売店3", company_code: "v-003")
cmp4 = Company.create(name: "製造業者1", company_code: "m-001")
cmp5 = Company.create(name: "製造業者2", company_code: "m-002")
cmp6 = Company.create(name: "製造業者3", company_code: "m-003")
itm = Item.create(name: "製品1")
itm.vendor = cmp1
itm.manufacturer = cmp4
itm.save
itm2 = Item.create(name: "製品2")
itm2.vendor = cmp2
itm2.manufacturer = cmp5
itm2.save
itm3 = Item.create(name: "製品3")
itm3.vendor = cmp3
itm3.manufacturer = cmp6
itm3.save
f = Prefecture.create(name: "福岡県")
t = Prefecture.create(name: "東京都")
k = Prefecture.create(name: "京都府")
o = Prefecture.create(name: "大阪府")
h = Prefecture.create(name: "北海道")
cmp1.branches = [f, k]
cmp2.branches = [k]
cmp3.branches = [f, k, o, h]
cmp4.branches = [k, h]
cmp5.branches = [f, k, o]
cmp6.branches = [t]
cmp1.prefecture = t
cmp2.prefecture = t
cmp3.prefecture = t
cmp4.prefecture = t
cmp5.prefecture = t
cmp6.prefecture = h
cmp1.save
cmp2.save
cmp3.save
cmp4.save
cmp5.save
cmp6.save
us = User.create(name: "usr01")
us2 = User.create(name: "usr02")
us.friending(us2)
us2.wishlists = [itm3]

例えば以下のようなことができます.

> User.first.friends.first.wishlists.first.vendor.prefecture

これは,「ユーザ(1)」の「友達(1)」の「欲しいもの(1)」の「販売会社」の「本社」を取得ということです.
Railsコンソールで試したレスポンスは以下になりました.

#<Prefecture:0x000055cc4a07e0b0
 id: 2,
 name: "東京都">

さいごに

いかがでしょうか?

共通のテーブルを参照する外部キーカラムのアソシエーションをいろいろ扱ってみました.

共通テーブルを参照する際は,命名が競合してしまわないように細心の注意を払わなければいけないなと,記事用にプログラムを組んでみて再認識しました.
みなさまもお気を付けください.

それと,参考にさせていただいた様々な記事を最後に載せているので,さらに詳しく実装を知りたい方は,ぜひご覧になってください!

ソースコードもおいておきますね!

それでは:wave:

参考記事

21
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?