LoginSignup
5
4

More than 1 year has passed since last update.

【Rails】たくさんあるprimary_keyとforeign_keyの設定について、それぞれの役割を理解する

Last updated at Posted at 2023-02-11

はじめに

Railsで主キーや外部キーを指定する際、primary_keyforeign_keyをモデルに設定します。

モデル例.rb
class Office < ApplicationRecord
  self.primary_key = :company_cd
  has_many :users, primary_key: :office_cd, foreign_key: :company_cd
end

class User < ApplicationRecord
  belongs_to :office, primary_key: :office_cd, foreign_key: :company_cd
end

上記の例では合計5箇所にprimary_keyforeign_keyを書いていますが、一つ一つの設定の役割はなんなのでしょうか。
これらの設定は、いろんな記事でいろんな書き方や説明がされていますが、一つ一つを説明しているものはありませんでした。
また、検索上位にくるような記事でも説明が間違っていることが多く、結構混乱するポイントになってしまっています。
本記事の目標は、一つ一つを理解し、悩むことなく設定できる一助になることです。

前提

本記事はprimary_keyforeign_key自体はなんとなく理解されていることを前提とします。

おことわり

参考になる情報が見当たらず、ほとんど手探りでソースや動作を見てまとめました。
そのため、上で他の記事のこと言っておいてアレですが、この記事にも誤情報紛れ込んでいると思います。
誤りを見つけたらコメントを是非・・・。

環境

Rails 7.0.4.2
MySQL: 8.0.32

テーブル構成

以下のテーブル構成で確認をおこなっていきます。(後半は構成変わっていきます)
Screenshot 2023-02-07 at 21.12.31.png

  1. officesとusersの2つのテーブルがあり、多重度は1:多
  2. officesテーブルの主キーはoffice_cd(デフォルトのidから変更)
  3. usersテーブルの外部キーはcompany_cd(デフォルトのoffice_idから変更)

Rails上のソースはこんな感じ。

migrate/create_table.rb
    create_table :offices, id: false do |t|
      t.string :office_cd, primary_key: true
      t.string :office_name
      t.timestamps
    end
    create_table :users do |t|
      t.string :company_cd 
      t.string :user_name
      t.timestamps
    end

    # テーブル定義上の外部キーは外しています
    # add_foreign_key :users, :offices, column: :company_cd, primary_key: :office_cd

モデルはこんな感じです。

models.rb
class Office < ApplicationRecord
  has_many :users, foreign_key: :company_cd
end
class User < ApplicationRecord
  belongs_to :office, foreign_key: :company_cd
end

foreign_keyにcompany_cdを書いていますが、primary_keyはどこにも書いていません。これは後ほどのprimary_key側の検証でお話しします。

この状態で関連付けができていることを確認します。

irb.rb
> Office.joins(:users)
  Office Load (1.0ms)  SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`office_cd`
> User.joins(:office)
  User Load (1.5ms)  SELECT `users`.* FROM `users` INNER JOIN `offices` ON `offices`.`office_cd` = `users`.`company_cd`

office_cdとcompany_cdで結合しているので、問題なく動いてそうです。

foreign_keyの役割

それではこの状態から、まずはforeign_keyの役割を確認していきます。
こちらはprimary_keyに比べたらシンプルな話です。

has_many側のforeign_keyの役割

消した時どのような動きになるか確認してみましょう。

office.rb
class Office < ApplicationRecord
  # has_many :users, foreign_key: :company_cd
  has_many :users
end

irb.rb
> User.joins(:office) # こっちはOK
  User Load (0.7ms)  SELECT `users`.* FROM `users` INNER JOIN `offices` ON `offices`.`office_cd` = `users`.`company_cd`
> Office.joins(:users) #こっちはNG
  Office Load (0.5ms)  SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`office_id` = `offices`.`office_cd`
(Object doesn't support #inspect)
=>

親のモデルを基準にしたときに、子との関連付けに問題が起きます。
foreign_keyの設定がないので、デフォルトでoffice_idで結合しようとします。
イメージだとこんな感じ

belongs_to側のforeign_keyの役割

こちらは先ほどと逆です。

user.rb
class User < ApplicationRecord
  belongs_to :office
end

irb.rb
> Office.joins(:users) # こっちはOK
  Office Load (0.7ms)  SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`office_cd`
> User.joins(:office) # こっちはNG
  User Load (0.4ms)  SELECT `users`.* FROM `users` INNER JOIN `offices` ON `offices`.`office_cd` = `users`.`office_id`
(Object doesn't support #inspect)
=>

子のモデルを基準にしたときに、親との関連付けに問題が起きます。
foreign_keyの設定がないので、デフォルトでoffice_idで結合しようとします。

foreign_keyまとめ

このように、両方向で関連付けをおこなうためにhas_manyにもbelongs_toにも書いておく必要があります。
外部キーのカラム名をデフォルト(今回だとoffice_id)から変えたい場合は、とりあえず両方に書いておくのが良さそうです。
さて、問題はprimary_keyです。

primary_keyの役割

foreign_keyを両方に書いたことでお互いの関連付けはうまくいきました。
・・・先ほどからずっと奇妙な動きをしていることにお気づきでしょうか。
primary_keyをどこにも書いていないのに、親のカラムは常にoffice_cdを使っています。こわいですね。

この点は結構ややこしく、以下の流れで整理していきます。

  1. なぜクラスにprimary_keyを書いていないのに動くのか
  2. なぜassociationにprimary_keyを書いていないのに動くのか
  3. クラスのprimary_keyの役割
  4. associationのprimary_keyの役割
  5. has_manybelongs_toでのそれぞれの役割

どうしても説明が長くなってしまい、2が特に長いので、飛ばしていただいても大丈夫です。

1. なぜクラスにprimary_keyを書いていないのに動くのか

これは、データベース上のテーブル定義から自動でOfficeクラスのprimary_keyが設定されるためです。

irb.rb
> Office.primary_key
=> "office_cd"

Railsの内部ではクラス読み込み時、各DBMSアダプタにあるprimary_keysメソッドで主キーを取得し、primary_keyに設定します。
MySQLの場合は以下のソースです。

なので、クラスのprimary_key =メソッドは、テーブル定義の主キーと同じであれば実は書かなくても動きます。

office.rb
class Office < ApplicationRecord
  self.primary_key = :company_cd # ←ここの話
  has_many :users, foreign_key: :company_cd
end

ただし、ソース側で主キーがidではないことが判断できるので、書いておいた方が親切だとは思います。

2. なぜassociationにprimary_keyを書いていないのに動くのか

これも、クラスのprimary_keyをデフォルトで設定してくれるからです。
設定している箇所はActiveRecord::Reflectionの中です。全部説明するとめちゃくちゃ長くなるのと、自分もあまり詳しくないので、一部分だけソースを転記していきます。

has_manyの場合

Office.joins(:users)実行時のSQL

join.sql
`SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`office_cd`

の、結合条件の右辺にoffice_cdを選択する処理部分を見てみます。

reflection.rb
class AbstractReflection
  ...
  def join_scope(table, foreign_table, foreign_klass)
    ...
    primary_key = join_primary_key # 左辺に何を使うか
    foreign_key = join_foreign_key # 右辺に何を使うか
    
    klass_scope.where!(table[primary_key].eq(foreign_table[foreign_key]))
    ...
    klass_scope
  end
end

class AssociationReflection < MacroReflection
  ...
  # 右辺に何使うの?
  def join_foreign_key
    active_record_primary_key
  end
  # has_manyにprimary_key書いてたらそれを使うけど、入ってないからactive_record(Office)のprimary_keyを使うよ
  def active_record_primary_key
    @active_record_primary_key ||= -(options[:primary_key]&.to_s || primary_key(active_record))
  end
end

という流れで、Office.primary_keyが使用されます。

belongs_toの場合

Office.joins(:users)実行時のSQL

join.sql
SELECT `users`.* FROM `users` INNER JOIN `offices` ON `offices`.`office_cd` = `users`.`company_cd`

の、左辺にoffice_cdを選択する処理部分を見てみます。
join_scopeが動くところまでは一緒です。

reflection.rb
class BelongsToReflection < AssociationReflection
  ...
  # 左辺に何使うの?
  def join_primary_key(klass = nil)
    polymorphic? ? association_primary_key(klass) : association_primary_key
  end

  # belongs_toにprimary_key書いてたらそれを使うけど、入ってないからself.klass(Office)のprimary_keyを使うよ
  def association_primary_key(klass = nil)
    if options[:foreign_key] && options[:foreign_key].is_a?(Array)
      (klass || self.klass).query_constraints_list
    elsif primary_key = options[:primary_key]
      @association_primary_key ||= -primary_key.to_s
    else
      primary_key(klass || self.klass) # ← self.klassがOffice
    end
  end
  ...
end

Userを基準にしても、associationの動きでOffice側のprimary_keyを見てくれています。

ここまでの動きをまとめると、

  1. クラスのprimary_keyはRailsがテーブル定義から自動で設定する
  2. associationはそれをデフォルトとする
  3. primary_keyをどこにも書かなくても動く

となります。

ちなみに、外部キー制約がテーブル定義上にあっても、Railsは自動でforeign_keyを設定してはくれません。
最後に記事を見返しているときにこの可能性に気づきました。もし設定してくれていたら大幅に書き直しでした。

3. クラスのprimary_keyの役割

これでprimary_key省略時の動きは分かりました。
では、primary_keyを必ず書く必要があるケースは何があるのでしょうか。

これは、テーブル定義上の主キーとRails上の主キーを変えたいときが考えられます。
ありそうなパターンだと、idは持っているけど別のコード値で扱いたい場合でしょうか。

create_table.rb
    create_table :offices do |t|
      t.string :office_cd  # テーブル定義ではidが主キーだけど、Rails的にはこっちを主キー扱いにしたい
      t.string :office_name
      t.timestamps
    end

この場合は、何も書かないとOfficeのprimary_keyは当然idになり、結合もそちらを参照します。

hoge.sql
SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`id`

そのため、明示的に書く必要がでてきます。

office.rb
class Office < ApplicationRecord
  self.primary_key = :company_cd
  has_many :users, foreign_key: :company_cd
end

association側のprimary_keyは引き続き書いても書かなくてもよいです。(親クラスのprimary_keyをデフォルトとするため)
この状態で動作を確認してみましょう。こわくなってきたので更新系も一回みておきます。

irb.rb
> Office.joins(:users)
  Office Load (0.7ms)  SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`office_cd`
> User.joins(:office)
  User Load (1.1ms)  SELECT `users`.* FROM `users` INNER JOIN `offices` ON `offices`.`office_cd` = `users`.`company_cd`
> Office.first.users.create! 
  Office Load (0.8ms)  SELECT `offices`.* FROM `offices` ORDER BY `offices`.`office_cd` ASC LIMIT 1
  TRANSACTION (0.2ms)  BEGIN
  Office Load (0.4ms)  SELECT `offices`.* FROM `offices` WHERE `offices`.`office_cd` = 'C_1' LIMIT 1
  User Create (0.4ms)  INSERT INTO `users` (`company_cd`, `user_name`, `created_at`, `updated_at`) VALUES ('C_1', NULL, '2023-02-11 10:55:03.550294', '2023-02-11 10:55:03.550294')
  TRANSACTION (0.6ms)  COMMIT

問題なく動作しています。

4. associationのprimary_keyの役割

こちらを書く必要があるケースは親クラスのprimary_keyとは別のカラムで関連付けたい場合が考えられます。

create.rb
create_table :offices do |t|
  t.string :office_cd  # テーブル定義ではidが主キーだけど、Rails的にはこっちを主キー扱いにしたい
  t.string :other_cd # usersとはこちらで関連付けたい
  t.string :office_name
  t.timestamps
end

これも当然指定しないとother_cdで関連付けてはくれません。
ですので、associationに書く必要がでてきます。

models.rb
class Office < ApplicationRecord
  self.primary_key = :office_cd
  has_many :users, primary_key: :other_cd, foreign_key: :company_cd
end
class User < ApplicationRecord
  belongs_to :office, primary_key: :other_cd ,foreign_key: :company_cd
end

動作を確認します。

irb.rb
> Office.joins(:users)
  Office Load (1.0ms)  SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`other_cd`
> User.joins(:office)
  User Load (0.9ms)  SELECT `users`.* FROM `users` INNER JOIN `offices` ON `offices`.`other_cd` = `users`.`company_cd
> Office.first.users.create!
  Office Load (0.8ms)  SELECT `offices`.* FROM `offices` ORDER BY `offices`.`office_cd` ASC LIMIT 1
  TRANSACTION (0.1ms)  BEGIN
  Office Load (0.2ms)  SELECT `offices`.* FROM `offices` WHERE `offices`.`other_cd` = 'XXX' LIMIT 1
  User Create (0.4ms)  INSERT INTO `users` (`company_cd`, `user_name`, `created_at`, `updated_at`) VALUES ('XXX', NULL, '2023-02-11 11:33:37.433963', '2023-02-11 11:33:37.433963')
  TRANSACTION (1.0ms)  COMMIT

問題なく動きました。

長くなりましたが、ようやく次で最後です。(ここまで見てくれている人が一人でもいることを願います。)

has_manybelongs_toでのそれぞれの役割

最後はシンプルな話に戻ります。
foreign_keyは両方向の関連付けのために両方に書いていました。
primary_keyについても、両方向で関連付けを行いたい場合は、両方のassociationにprimary_keyを書いておきましょう。
体力が完全に尽きたので、この章は省略させてください。(foreign_keyの説明を見てね)

まとめ

基本的には以下の形になるかと思います。

・外部キーのカラム名がデフォルト(xxx_id)と異なる場合→associationの両方にforiegn_keyを書く
・テーブル定義上の主キーと、Rails上の主キーを変えたい場合→親クラスのprimary_keyを書く
・親クラスのprimary_keyとは別のカラムで関連付けたい場合→associationの両方にprimary_keyを書く
・書かないでおくのが不安、書いたほうが分かりやすいなど→とりあえず全部書く

おわりに

primary_keyの内容を書くのが、想像の20倍ぐらい大変でした。
おかげでArelやRelationにちょっと詳しくなった。

5
4
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
5
4