GitHub - rubysherpas/paranoia: acts_as_paranoid for Rails 5, 6 and 7
acts_as_paranoid for Rails 5, 6 and 7. Contribute to rubysherpas/paranoia development by creating an account on GitHub.
https://github.com/rubysherpas/paranoia
こんにちは。市古 (wantedly: @igsr5, threads: @igsr5_) です。
今回はバックエンド (特に Ruby on Rails) 開発者として知っておくと役に立ちそうな情報を詰め込んでいきます。
話すこと
ActiveRecord::Core::freeze
周りの話記事本文ではいくつか寄り道もしたいので先に要約を載せておきます。なお問題解決のコツに関する話は長いので省いています。気になる方は是非本文まで。
Q. Model クラスの after_destroy で attributes の更新はできる?できない?
A. できない
attributes
を更新しようとするとエラーになる (FrozenError
)@attributes
が freeze
されている (ActiveRecord::Core::freeze)
attributes
を更新すると FrozenError
で失敗するのか?attributes
に対する変更を行っても永続化ができないため (ソース)@attributes
は freeze されているな」というメンタルモデルが頭の中に構築されていると便利Q. 社内に 10 年前から after_destroy で attributes の更新してるコードがあるんだけどこれ何、、?
A. third-party gem によって論理削除されていた!
@attributes
は freeze されているな」というメンタルモデルが役に立つ@attributes
も freeze されていないはず以上がこの記事の要約です。
結論、普段から技術的な仕様や機能を点で捉えず多面的な視点で見ることによってより活用可能なメンタルモデルを培っていくことは重要ということが言いたいです。
それでは本文に移ります。
以下のようなコードを考えます。
ちなみに今回みたいに軽く Ruby on Rails で検証コードを書きたい時は rails/rails リポジトリの bug_report 用のテンプレートファイルをよく使ってます。1ファイルで簡単に実行できてお手軽です。
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails", '7.0.6'
gem "sqlite3"
end
require "active_record"
require "minitest/autorun"
require "logger"
# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Schema.define do
create_table :posts, force: true do |t|
t.string :title
end
create_table :comments, force: true do |t|
t.string :title
t.datetime :deleted_at
end
end
class Post < ActiveRecord::Base
after_destroy do
self.title = "foo"
end
end
class AfterDestroyTest < Minitest::Test
def test_after_destroy
post = Post.create!(title: "bar")
assert_equal 1, Post.count
assert_equal post.title, "bar"
assert_raises(FrozenError) do
post.destroy!
end
end
end
ここで話したいのは以下のコードです。
class Post < ActiveRecord::Base
after_destroy do
# FrozenErrorが発生!
self.title = "foo"
end
end
class AfterDestroyTest < Minitest::Test
def test_after_destroy
post = Post.create!(title: "bar")
assert_equal 1, Post.count
assert_equal post.title, "bar"
assert_raises(FrozenError) do
post.destroy!
end
end
end
まず Rails の知識として、after_destroy 内で attributes
を更新しようとするとエラーになります。上記のコードを実際に手元で実行してみるとわかりやすいと思います。
ただしここまでは Ruby on Rails にさほど詳しくなくても知識として知っている方も多いかと思いますが、なぜそうなっているか?については意外と忘れ去られがちです。
いきなり結論から書いてしまいますが、それは after_destroy 時点では既に DB にレコードが存在せず、attributes
に対する変更を行っても永続化ができないためです。
このことは ActiveRecord::Core::freeze (旧 ActiveRecord::Base::freeze) が実装された際の commit や 2023/07 時点の最新の ActiveRecord::Persistence::destroy の実装を見ると分かります。
# from. https://github.com/rails/rails/blob/v7.0.6/activerecord/lib/active_record/persistence.rb#L676
# Deletes the record in the database and freezes this instance to reflect
# that no changes should be made (since they can't be persisted).
#
# There's a series of callbacks associated with #destroy. If the
# <tt>before_destroy</tt> callback throws +:abort+ the action is cancelled
# and #destroy returns +false+.
# See ActiveRecord::Callbacks for further details.
def destroy
_raise_readonly_record_error if readonly?
destroy_associations
@_trigger_destroy_callback = if persisted?
destroy_row > 0
else
true
end
@destroyed = true
freeze
end
また後で詳しく書きますが、この事実をもとに after_destroy とは関係なく「変更を永続化できない状態ということは @attributes
は freeze されているな」というメンタルモデルが頭の中に構築されていると普段の Ruby on Rails アプリケーションの開発で良いことがあります。
さてここで (プチ) 事件です。先ほど
after_destroy 内で @attributes
を更新しようとするとエラーになります。
と書きましたが、Rails リポジトリで開発を行なっていると after_destroy で attributes
を更新しているのにエラーになっていないコードが発見されました。しかもこのコードは10年前から存在しており現在に至るまで壊れることなく元気に動き続けています。
しかもこれだけならびっくり案件で済みますが、該当の after_destroy を改修時に他のモデルに移したところ急に FrozenError
が発生してしまい動かなくなってしまいました。
このままでは困ってしまうので事件解決に向け調査します。
結論を書いてしまえば早いのですが、せっかくなので筆者が真実に辿り着くまでの道のりも紹介しておきます。原因が早く知りたい方は記事冒頭の要約を読むかスクロールしてください。
① まず該当リポジトリで再現テスト (Regression Test) を書く
実装で何かよく分からないことが起こっているときはとりあえず再現テスト (Regression Test) を書いてみましょう。事実整理にも役立ちますし、その後の調査にも使えます。
② FrozenError がなぜ発生しているのかを明確にする
エラーの内容的に「問題のモデルで @attributes
が freeze されていないんだな」という予想はある程度働きますが、個人的によく分からないことが起こっているときは初手で推測に頼らず適切に事実を収集していくことが重要だと思っています。(より正確には推測で終わらせず、事実確認をするということが言いたい)
ちなみに ActiveRecord::Core::freeze にこんなモンキーパッチを当ててログを取りました。
module ActiveRecord::Core::FreezeWithLogging
def freeze
log_attributes
super
end
private
def log_attributes
p "feeze class: #{self.class}"
p "freeze method targets: #{@attributes.to_h}"
end
end
ActiveRecord::Core.prepend ActiveRecord::Core::FreezeWithLogging
③ 分からないことを明確にする
user.destroy が正常に終了するケースでも Profile モデルは1回も freeze されない。この謎を解く
と書いてある部分です。ここまでである程度情報が集まっているので、当初の「after_destroy 内で attributes がなぜか更新できる」という問題を「destroy されているのになぜか freeze
が呼ばれない」という問題に狭めてみて考えます。
④ 少し手詰まったのでもう少し情報を集めてみる
ここまでで 「destroy されているのになぜか freeze
が呼ばれない」問題を解けばいいことまで分かりましたが、しばらく思うように原因を特定できず手詰まってしまいます。
そういう時は一旦立ち止まって再度情報を集めましょう。ここで自分は 「AcitiveRecord::Persistence::destroy
がなんかおかしいな」という推測のもと AcitiveRecord::Persistence::destroy
の caller を確認しにいきました。
⑤ 怪しそうな実装に気づく
ここで事態が進展します。④ で取得した AcitiveRecord::Persistence::destroy
の caller を眺めてみると
lib/ruby/gems/3.0.0/gems/paranoia-2.4.3/lib/paranoia.rb:61:in `paranoia_destroy'
paranoia という third-party gem の実装が挟まっていることに気づきます。paranoia は Ruby on Rails で論理削除を実現するための gem です。なんだか怪しい匂いがします。
⑥ テストを書く
さてここでもう一度テストを書きます。今度は ⑤ で発見した paranoia gem の利用の有無で問題再現を確認してみます。
# 物理削除するモデル
class Post < ActiveRecord::Base
after_destroy do
self.title = "foo"
end
end
# 論理削除するクラス
class Comment < ActiveRecord::Base
after_destroy do
self.title = "foo"
end
acts_as_paranoid # こいつを追加
end
class AfterDestroyTest < Minitest::Test
def test_after_destroy
post = Post.create!(title: "bar")
assert_equal 1, Post.count
assert_equal post.title, "bar"
assert_raises(FrozenError) do
post.destroy!
end
end
end
class AfterDestroyWithParanoiaTest < Minitest::Test
def test_after_destroy
comment = Comment.create!(title: "bar")
assert_equal 1, Comment.count
assert_equal comment.title, "bar"
assert_raises(FrozenError) do # 通らない
comment.destroy!
end
end
end
上記のコードを実行してみると...
F
Failure:
AfterDestroyWithParanoiaTest#test_after_destroy [./activerecord_after_destroy_freeze.rb:92]:
FrozenError expected but nothing was raised.
rails test ./activerecord_after_destroy_freeze.rb:83
Finished in 0.003708s, 539.3743 runs/s, 1618.1228 assertions/s.
2 runs, 6 assertions, 1 failures, 0 errors, 0 skips
テストが落ちました!落ちたテストは次の箇所です。
assert_raises(FrozenError) do # 通らない
comment.destroy!
end
本来 FrozenError
が raise されるべきタイミングで FrozenError
が raise されていません。これは今調べている問題と同様の現象です。
⑦ 問題解決へ
ここまででほとんど問題を解決できたようなものですが、⑥ では書き捨てのRubyスクリプトを利用していたので念の為当初問題が起きていたリポジトリにも同様の変更を行って確かめます。
class PlatformProfile < ApplicationRecord
acts_as_paranoid # こいつを追加
end
問題の起こっていたモデルを論理削除に変えて...問題が解消されました!
長々と調査過程を書きましたがこれで晴れて問題解決です。
さて、思いのほか記事が長くなりましたがこれが最後のトピックです。この章ではここまでの話をもとに自分の中で形成された Ruby on Rails に関する1つのメンタルモデルの話をします。
今までの自分は after_destroy 内で attributes
が更新できないことを一つの知識的なメンタルモデルとして持っている状態でした。
しかし "なぜafter_destroy 内で attributes を更新すると FrozenError で失敗するのか?" を整理したり、論理削除を行うケースで例外パターンが存在することを認識したりしたことで、新たに別のメンタルモデルを形成しました。
@attributes
は freeze されているこのようなメンタルモデルを自分の中に持ったことで今後、論理削除時に after_destroy で attributes
を更新できることに迷ったりせず、他の類似ケースでも同様の考慮ができるように思います。
また今回の件で、普段から技術的な仕様や機能を点で捉えず多面的な視点で見ることによってより活用可能なメンタルモデルを培っていくことは重要だなと再認識できました。
メインのネタだけでなく直接関係ないことまでいろいろ書きました。たまにはこういう記事もいいですね。