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

# 概要
以下のような`User`を親モデルにもつ`Post`モデルについて
`post.user_id`をデータベースに存在しない`User`の`id`や`nil`でセットして保存しようとするとバリデーションエラーが走ります。
```ruby
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
```
```ruby
class Post < ApplicationRecord
  belongs_to :user
end
```
```ruby
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`というエラーが発生します。
```ruby
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`を保存することができません。
```ruby
class Post < ApplicationRecord
  belongs_to :user, optional: true
end
```

```ruby
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`をつけない場合で保存できるか試してみました。
```ruby
class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.timestamps
    end
  end
end
```
```ruby
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

```

```ruby
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
```


```ruby
class CreateNnPosts < ActiveRecord::Migration[7.0]
  def change
    create_table :nn_posts do |t|
      t.references :user, null: false

      t.timestamps
    end
  end
end

```

```ruby: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_to`に`optional: true`のオプションをつける（か`belongs_to`を設定しない）ことで
`user_id`が`nil`でも作成することができるし、
外部キー制約を持たないモデルは、`belongs_to`に`optional: true`のオプションをつける（か`belongs_to`を設定しない）ことで
`user_id`がデータベースに存在しない`User`の`id`でも作成することができるようでした！

# 補足
外部キー制約を持ったモデルを作成した後に、関連先の親モデルを削除しようとしても
`ActiveRecord::InvalidForeignKey`のエラーが発生します。
```ruby
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`を設定することで
関連づけられた子モデルと一緒に削除することができます。
```ruby
class User < ApplicationRecord
  has_many :fk_posts, dependent: :destroy
end

```


```ruby
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`にしてくれます。
```ruby
class User < ApplicationRecord
  has_many :fk_posts, dependent: :nullify
end
```


```ruby
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に更新され削除されていないことがわかる
```