概要
以下のようなUserを親モデルにもつPostモデルについて
post.user_idをデータベースに存在しないUserのidやnilでセットして保存しようとするとバリデーションエラーが走ります。
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
class Post < ApplicationRecord
belongs_to :user
end
post = Post.new(user_id: 1) # idが1のUserはまだ作られていないとする
post.save
# => false
post.errors.messages
# => {:user=>["を入力してください"]}
post = Post.new
post.save
# => false
post.errors.messages
# => {:user=>["を入力してください"]}
なお、saveではなくsave!とした場合はActiveRecord::RecordInvalidというエラーが発生します。
post = Post.new(user_id: 1) # idが1のUserはまだ作られていないとする
post.save!
# => /usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/validations.rb:80:in `raise_validation_error':
# バリデーションに失敗しました: Userを入力してください (ActiveRecord::RecordInvalid)
かといってoptional: trueのオプションを追加しバリデーションを実行しないようしたとしても
Postには外部キー制約(foreign_key: true)やnot null制約(null: false)をつけているので
Postを保存することができません。
class Post < ApplicationRecord
belongs_to :user, optional: true
end
post = Post.new(user_id: 1) # idが1のUserはまだ作られていないとする
post.save
# => /usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params':
# ERROR: insert or update on table "posts" violates foreign key constraint "fk_rails_5b5ddfd518"
# (ActiveRecord::InvalidForeignKey)
post.errors.messages
# => {}
post = Post.new
post.save
# => /usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': PG::NotNullViolation:
# ERROR: null value in column "user_id" of relation "posts" violates not-null constraint
# (ActiveRecord::NotNullViolation)
post.errors.messages
# => {}
post.user_idをデータベースに存在しないUserのidにした場合はActiveRecord::InvalidForeignKeyというエラーが発生し、
post.user_idをnilにした場合はActiveRecord::NotNullViolationというエラーが発生します。
バリデーションのエラーメッセージに何も登録されていないこともわかります。
そこでどういった条件ならPostを保存することができるのか気になったので検証してみました。
環境
- Rails 7.0.4
- PostgreSQL14
検証方法
以下のようなnot null制約と外部キー制約を持ったNnFkPostモデル、外部キー制約のみ持ったFkPostモデル、not null制約のみ持ったNnPostモデルを作成し、それぞれのモデルの関連付けをbelongs_to: userとした場合、belongs_to: user, optional: trueとした場合、belongs_toをつけない場合で保存できるか試してみました。
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.timestamps
end
end
end
class CreateNnFkPosts < ActiveRecord::Migration[7.0]
def change
create_table :nn_fk_posts do |t|
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
class CreateFkPosts < ActiveRecord::Migration[7.0]
def change
create_table :fk_posts do |t|
t.references :user, foreign_key: true
t.timestamps
end
end
end
class CreateNnPosts < ActiveRecord::Migration[7.0]
def change
create_table :nn_posts do |t|
t.references :user, null: false
t.timestamps
end
end
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "fk_posts", force: :cascade do |t|
t.bigint "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_fk_posts_on_user_id"
end
create_table "nn_fk_posts", force: :cascade do |t|
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_nn_fk_posts_on_user_id"
end
create_table "nn_posts", force: :cascade do |t|
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_nn_posts_on_user_id"
end
add_foreign_key "fk_posts", "users"
add_foreign_key "nn_fk_posts", "users"
検証結果
エラーが発生した場合はそのエラー名を記載し保存できた場合は◯で表現しています。
| 関連付け | NnFkPost.create!(user_id: 1) | NnFkPost.create! |
|---|---|---|
belongs_to :user |
ActiveRecord::RecordInvalid | ActiveRecord::RecordInvalid |
belongs_to :user, optional: true |
ActiveRecord::InvalidForeignKey | ActiveRecord::NotNullViolation |
| 関連付けなし | ActiveRecord::InvalidForeignKey | ActiveRecord::NotNullViolation |
| 関連付け | FkPost.create!(user_id: 1) | FkPost.create! |
|---|---|---|
belongs_to :user |
ActiveRecord::RecordInvalid | ActiveRecord::RecordInvalid |
belongs_to :user, optional: true |
ActiveRecord::InvalidForeignKey | ◯ |
| 関連付けなし | ActiveRecord::InvalidForeignKey | ◯ |
| 関連付け | NnPost.create!(user_id: 1) | NnPost.create! |
|---|---|---|
belongs_to :user |
ActiveRecord::RecordInvalid | ActiveRecord::RecordInvalid |
belongs_to :user, optional: true |
◯ | ActiveRecord::NotNullViolation |
| 関連付けなし | ◯ | ActiveRecord::NotNullViolation |
まとめ
not null制約を持たないモデルは、belongs_toにoptional: trueのオプションをつける(かbelongs_toを設定しない)ことで
user_idがnilでも作成することができるし、
外部キー制約を持たないモデルは、belongs_toにoptional: trueのオプションをつける(かbelongs_toを設定しない)ことで
user_idがデータベースに存在しないUserのidでも作成することができるようでした!
補足
外部キー制約を持ったモデルを作成した後に、関連先の親モデルを削除しようとしても
ActiveRecord::InvalidForeignKeyのエラーが発生します。
User.create(id: 100)
# => #<User:0x000055b0b4cbc450 id: 100>
FkPost.create(user_id: 100)
# => #<FkPost:0x000055b0b4d8f1c0 id: 1, user_id: 100>
User.find(100).destroy
# => /usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': PG::ForeignKeyViolation:
# ERROR: update or delete on table "users" violates foreign key constraint "fk_rails_1d06e2de76" on table "fk_posts"
# (ActiveRecord::InvalidForeignKey)
削除したい場合は親モデルにdependent: :destroyのオプションをつけたhas_manyを設定することで
関連づけられた子モデルと一緒に削除することができます。
class User < ApplicationRecord
has_many :fk_posts, dependent: :destroy
end
User.find(100).destroy
# =>
# User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 100], ["LIMIT", 1]]
# TRANSACTION (0.3ms) BEGIN
# FkPost Load (0.4ms) SELECT "fk_posts".* FROM "fk_posts" WHERE "fk_posts"."user_id" = $1 [["user_id", 100]]
# FkPost Destroy (0.8ms) DELETE FROM "fk_posts" WHERE "fk_posts"."id" = $1 [["id", 1]]
# User Destroy (1.7ms) DELETE FROM "users" WHERE "users"."id" = $1 [["id", 100]]
# TRANSACTION (0.9ms) COMMIT
逆に親モデルを削除するときに子モデルを削除したくないってケースのときは、
has_manyにdependent: :nullifyのオプションをつけることで親モデルを削除するときに子モデルの外部キーをnilにしてくれます。
class User < ApplicationRecord
has_many :fk_posts, dependent: :nullify
end
User.create(id: 200)
# => #<User:0x000055b0b434fc00 id: 200>
FkPost.create(user_id: 200)
# => #<FkPost:0x000055b0b499dd50 id: 1, user_id: 200>
User.find(200).destroy
# =>
# User Load (2.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 200], ["LIMIT", 1]]
# TRANSACTION (0.6ms) BEGIN
# FkPost Update All (0.9ms) UPDATE "fk_posts" SET "user_id" = $1 WHERE "fk_posts"."user_id" = $2 [["user_id", nil], ["user_id", 200]]
# User Destroy (1.3ms) DELETE FROM "users" WHERE "users"."id" = $1 [["id", 200]]
# TRANSACTION (1.6ms) COMMIT
FkPost.all
# => [#<FkPost:0x000055b0b4823628 id: 1, user_id: nil>]
# FkPostのuser_idはnilに更新され削除されていないことがわかる