2
0

More than 1 year has passed since last update.

【Rails】belongs_toのバリデーションと外部キー制約についてまとめてみた

Last updated at Posted at 2023-02-15

概要

以下のようなUserを親モデルにもつPostモデルについて
post.user_idをデータベースに存在しないUseridnilでセットして保存しようとするとバリデーションエラーが走ります。

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をデータベースに存在しないUseridにした場合はActiveRecord::InvalidForeignKeyというエラーが発生し、
post.user_idnilにした場合は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

schema.rb
  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_tooptional: trueのオプションをつける(かbelongs_toを設定しない)ことで
user_idnilでも作成することができるし、
外部キー制約を持たないモデルは、belongs_tooptional: trueのオプションをつける(かbelongs_toを設定しない)ことで
user_idがデータベースに存在しないUseridでも作成することができるようでした!

補足

外部キー制約を持ったモデルを作成した後に、関連先の親モデルを削除しようとしても
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_manydependent: :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に更新され削除されていないことがわかる
2
0
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
2
0