はじめに
Railsで主キーや外部キーを指定する際、primary_key
やforeign_key
をモデルに設定します。
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_key
とforeign_key
を書いていますが、一つ一つの設定の役割はなんなのでしょうか。
これらの設定は、いろんな記事でいろんな書き方や説明がされていますが、一つ一つを説明しているものはありませんでした。
また、検索上位にくるような記事でも説明が間違っていることが多く、結構混乱するポイントになってしまっています。
本記事の目標は、一つ一つを理解し、悩むことなく設定できる一助になることです。
前提
本記事はprimary_key
とforeign_key
自体はなんとなく理解されていることを前提とします。
おことわり
参考になる情報が見当たらず、ほとんど手探りでソースや動作を見てまとめました。
そのため、上で他の記事のこと言っておいてアレですが、この記事にも誤情報紛れ込んでいると思います。
誤りを見つけたらコメントを是非・・・。
環境
Rails 7.0.4.2
MySQL: 8.0.32
テーブル構成
以下のテーブル構成で確認をおこなっていきます。(後半は構成変わっていきます)
- officesとusersの2つのテーブルがあり、多重度は1:多
- officesテーブルの主キーは
office_cd
(デフォルトのid
から変更) - usersテーブルの外部キーは
company_cd
(デフォルトのoffice_id
から変更)
Rails上のソースはこんな感じ。
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
モデルはこんな感じです。
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側の検証でお話しします。
この状態で関連付けができていることを確認します。
> 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の役割
消した時どのような動きになるか確認してみましょう。
class Office < ApplicationRecord
# has_many :users, foreign_key: :company_cd
has_many :users
end
↓
> 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の役割
こちらは先ほどと逆です。
class User < ApplicationRecord
belongs_to :office
end
↓
> 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を使っています。こわいですね。
この点は結構ややこしく、以下の流れで整理していきます。
- なぜクラスに
primary_key
を書いていないのに動くのか - なぜassociationに
primary_key
を書いていないのに動くのか - クラスの
primary_key
の役割 - associationの
primary_key
の役割 -
has_many
とbelongs_to
でのそれぞれの役割
どうしても説明が長くなってしまい、2が特に長いので、飛ばしていただいても大丈夫です。
1. なぜクラスにprimary_key
を書いていないのに動くのか
これは、データベース上のテーブル定義から自動でOfficeクラスのprimary_keyが設定されるためです。
> Office.primary_key
=> "office_cd"
Railsの内部ではクラス読み込み時、各DBMSアダプタにあるprimary_keys
メソッドで主キーを取得し、primary_keyに設定します。
MySQLの場合は以下のソースです。
なので、クラスのprimary_key =
メソッドは、テーブル定義の主キーと同じであれば実は書かなくても動きます。
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
`SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`office_cd`
の、結合条件の右辺にoffice_cdを選択する処理部分を見てみます。
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
SELECT `users`.* FROM `users` INNER JOIN `offices` ON `offices`.`office_cd` = `users`.`company_cd`
の、左辺にoffice_cdを選択する処理部分を見てみます。
join_scopeが動くところまでは一緒です。
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を見てくれています。
ここまでの動きをまとめると、
- クラスのprimary_keyはRailsがテーブル定義から自動で設定する
- associationはそれをデフォルトとする
- primary_keyをどこにも書かなくても動く
となります。
ちなみに、外部キー制約がテーブル定義上にあっても、Railsは自動でforeign_keyを設定してはくれません。
最後に記事を見返しているときにこの可能性に気づきました。もし設定してくれていたら大幅に書き直しでした。
3. クラスのprimary_key
の役割
これでprimary_key省略時の動きは分かりました。
では、primary_keyを必ず書く必要があるケースは何があるのでしょうか。
これは、テーブル定義上の主キーとRails上の主キーを変えたいときが考えられます。
ありそうなパターンだと、idは持っているけど別のコード値で扱いたい場合でしょうか。
create_table :offices do |t|
t.string :office_cd # テーブル定義ではidが主キーだけど、Rails的にはこっちを主キー扱いにしたい
t.string :office_name
t.timestamps
end
この場合は、何も書かないとOfficeのprimary_keyは当然id
になり、結合もそちらを参照します。
SELECT `offices`.* FROM `offices` INNER JOIN `users` ON `users`.`company_cd` = `offices`.`id`
そのため、明示的に書く必要がでてきます。
class Office < ApplicationRecord
self.primary_key = :company_cd
has_many :users, foreign_key: :company_cd
end
association側のprimary_keyは引き続き書いても書かなくてもよいです。(親クラスのprimary_keyをデフォルトとするため)
この状態で動作を確認してみましょう。こわくなってきたので更新系も一回みておきます。
> 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_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に書く必要がでてきます。
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
動作を確認します。
> 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_many
とbelongs_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にちょっと詳しくなった。