destoroyとnullfityについて(ポリモーフィックあり)

はじめに

railsguides.jp

4.2.2.4 :dependent
オブジェクトのオーナーがdestroyされたときの、それに関連付けられたオブジェクトの扱いを制御します。

ユーザーと日報とコメントと・・・

Aというユーザー(User)がいます。
日報(Report)は、N個書けます。
そしてその日報には、N個コメント(Comment)が書けます。
というシステムを作る時に、
じゃあユーザー削除したときや日報削除したときの関連したデータの扱いをどうしましょう?といった状況が起きます。

ひとつの例

では、ユーザーを削除したとき。
①そのユーザーが持っている日報は削除します。
②その削除された日報に、書かれていたコメントも削除します。
③ただし、削除されたユーザーが他のユーザーの日報にコメントしていたものは残します。
という運用にしたいと思います。

コメントについて

コメントはポリモーフィックにて実装。
また③の場合、ビュー側ではユーザー名が取得できないときは、「不明なユーザー」など表示できる記述にする必要があります。

User、Report、Commentの各関連性を書きます。

正解の書き方。
ネタバレ対策のためだいぶ端折って書くので、恐縮ですが、、

class Report < ApplicationRecord
  belongs_to :user
  has_many :comments, as: :commentable, dependent: :destroy
end
class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
  belongs_to :user
end
class User < ApplicationRecord
  has_many :reports, dependent: :destroy
  has_many :comments, dependent: :nullify

ここでの注目は、destroyとnullifyです。
ユーザーでnullifyを使用しているのは、ユーザー削除時にもコメントを残す仕様のためです。

DB側の設定、schemaは

"reports"
  t.integer "user_id", null: false

"comments"
    t.integer "user_id"
    t.string "commentable_type", null: false
    t.integer "commentable_id", null: false

  add_foreign_key "comments", "users"
  add_foreign_key "reports", "users"

null: falseの設定も確認します。※このあとのアンチパターンにて重要になります。
コメントは、commentable_typeとcommentable_idがnull: false
ポリモーフィックになっていますが、詳細は割愛。
(元ソースのコメントが、日報と本とにコメントするという仕様なための実装です。)

railsguides.jp


流れ:
User削除→ has_many :reports, dependent: :destroy なので親が削除で子も削除。
上で日報が削除されると has_many :comments, as: :commentable, dependent: :destroy なので親が削除で子も削除。
よって、ユーザーを削除すると書いた日報とそこにあるコメントも削除される。

nullify

ただし、 ユーザー側で has_many :comments, dependent: :nullify
とすることでユーザーIDを持たない(NULL)としてあげると、ユーザーを削除しても他人の日報に残したコメントを表示できるようになる。
NULL許容も必要。

nullify
外部キーがNULLに設定されます。ポリモーフィックなtypeカラムもポリモーフィック関連付けでNULLに設定されます。コールバックは実行されません

これはNULL(nil)でアップデートすることを意味します。

ミスったこと

これまでを踏まえて、

class Report < ApplicationRecord

has_many :comments, as: :commentable, dependent: :nullify

この変更を行うとどうなるでしょうか?

アカウント作成(userA) -> userAで日報作成 -> その日報にコメント -> そのままこのuserAをアカウント削除
この操作を行うとエラーになります。

流れ

irb(main):002:0> User.find(1)
   (0.7ms)  SELECT sqlite_version(*)
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "sample-0@example.com", created_at: "2022-04-21 22:15:28.102283000 +0900", updated_at: "2022-04-21 22:15:28.102283000 +0900", name: "新井 太一", postal_code: "123-0000", address: "Apt. 956 12893 愛, 佐野村, 41 304-0049", self_introduction: "こんにちは、新井 太一です。">
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Report Load (0.1ms)  SELECT "reports".* FROM "reports" WHERE "reports"."user_id" = ?  [["user_id", 1]]
=> 
[#<Report:0x00007f94838e4bc0
  id: 56,
  title: "user_id: 1 の日報",
  content: "",
  created_at: Thu, 21 Apr 2022 22:18:02.649311000 JST +09:00,
  updated_at: Thu, 21 Apr 2022 22:18:02.649311000 JST +09:00,
  user_id: 1>]

irb(main):005:0> User.find(1).reports.first.comments
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Report Load (0.2ms)  SELECT "reports".* FROM "reports" WHERE "reports"."user_id" = ? ORDER BY "reports"."id" ASC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
  Comment Load (0.2ms)  SELECT "comments".* FROM "comments" WHERE "comments"."commentable_id" = ? AND "comments"."commentable_type" = ?  [["commentable_id", 56], ["commentable_type", "Report"]]
=> 
[#<Comment:0x00007f9483d244b0
  id: 1,
  body: "この日報が消えてもコメントは残る想定",
  commentable_type: "Report",
  commentable_id: 56,
  user_id: 2,
  created_at: Thu, 21 Apr 2022 22:18:37.814100000 JST +09:00,
  updated_at: Thu, 21 Apr 2022 22:18:37.814100000 JST +09:00>] # ユーザid: 1 の日報にコメントがついていることを確認

irb(main):006:0> User.find(1).destroy!
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.1ms)  begin transaction
  
  # ...
  # 実際に更新対象となる comment が存在するので、更新が走ってエラーになる

  Report Load (0.1ms)  SELECT "reports".* FROM "reports" WHERE "reports"."user_id" = ?  [["user_id", 1]]
  Comment Update All (1.0ms)  UPDATE "comments" SET "commentable_id" = ?, "commentable_type" = ? WHERE "comments"."commentable_id" = ? AND "comments"."commentable_type" = ?  [["commentable_id", nil], ["commentable_type", nil], ["commentable_id", 56], ["commentable_type", "Report"]]
  TRANSACTION (0.6ms)  rollback transaction
/Users/k.fukai/.anyenv/envs/rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: NOT NULL constraint failed: comments.commentable_type (ActiveRecord::NotNullViolation)
/Users/k.fukai/.anyenv/envs/rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': NOT NULL constraint failed: comments.commentable_type (SQLite3::ConstraintException)


アカウント削除→日報削除される→付随したコメントを削除しようとする。
その commentable_type や commentable_id を NULL に更新しようとする。

DB(schema)は、NULL許容していない。

"comments"
    t.integer "user_id"
    t.string "commentable_type", null: false
    t.integer "commentable_id", null: false

なのでエラーとなります。


以下、頂いた指摘。

①現状のポリモーフィック関連の実装では commentable_type, commentable_id は NULL 禁止にしていますよね。これは安全で、基本的に良い実装だと思います。

Rails の関連付けでは、あるレコードが削除されたときの関連オブジェクトの処理を dependent オプションを用いて制御できますよね。
例えば :destroy を指定すると、親のレコードが削除されたら子のレコードも削除されます。
一方、:nullify にすると、親が削除されると子のレコードは ***_type, ***_id を NULL にして親との関連を切りつつ、レコード自体は残ったままになります。
現状の実装は後者なわけですが、これが「commentable_type, commentable_id は NULL 禁止」という DB スキーマとマッチしていないのが事の原因です。

commentable_type, commentable_id を NULL 禁止にしていると、今回のように「レコードは残しつつ、親との関連を示す情報は NULL にする」というのはできません。
言い換えると、それでも「親が消えても子のレコード自体は残す」という仕様にしたいなら NULL 許容にしないと成り立たないというわけです。(※)

まとめ

実装前には、しっかりとモデルの関連性とメソッドが行う動作を確認する必要があるという話でした。
DBのNULL許容、各モデルの関係などを理解しないとポリモーフィックで余計に混乱します。

ここでの実装の指針は、DBでは基本的にNULL禁止で行っているので生じたエラーでした。


以上です。