destoroyとnullfityについて(ポリモーフィックあり)
ユーザーと日報とコメントと・・・
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
※ポリモーフィックになっていますが、詳細は割愛。
(元ソースのコメントが、日報と本とにコメントするという仕様なための実装です。)
流れ:
User削除→ has_many :reports, dependent: :destroy なので親が削除で子も削除。
上で日報が削除されると has_many :comments, as: :commentable, dependent: :destroy なので親が削除で子も削除。
よって、ユーザーを削除すると書いた日報とそこにあるコメントも削除される。
ミスったこと
これまでを踏まえて、
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禁止で行っているので生じたエラーでした。
以上です。