注記: 本記事のコード例ではhas_and_belongs_to_many
関連付け(通称HABTM)が使われていますが、Railsでこれを使ったリレーションは悪手とされています。現在のRailsでは代わりにhas_many :through
関連付けを使うのが一般的です。
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Transactions in Ruby on Rails and Atomic Bugs
- 原文公開日: 2017/10/27
- 著者: Kevin Sylvestre
Railsのトランザクションと原子性のバグ(翻訳)
Ruby on Railsのトランザクションは、熟練Rails開発者もつまづくことがあるほど扱いが微妙です。次のいくつかの例では、単純なトランザクションすら意図と違う動きをする可能性があることと、それによって気づかないうちに原子性(訳注: atomicity、不可分性とも)が損なわれることを示します。
設定
本記事では、SurveyとQuestionというモデルを持つシンプルなアプリを使って調べます。Surveyには名前が1つ必要で、Questionには何らかのテキストが必要だとします。例では、SurveyとQuestionの間には多対多(has_and_belongs_to_many
)のリレーションが設定されています。
rails new sample
cd sample
rails generate model survey name:string
rails generate model question text:string
rails generate migration CreateJoinTableSurveyQuestion survey question
rake db:create
rake db:migrate
- app/models/survey.rb
class Survey < ApplicationRecord
validates :name, presence: true
has_and_belongs_to_many :questions
accepts_nested_attributes_for :questions
end
- app/models/question.rb
class Question < ApplicationRecord
validates :text, presence: true
has_and_belongs_to_many :questions
end
コード例A
has_many
やhas_and_belongs_to_many
関連付けによって提供されるヘルパーメソッドは興味深い挙動を示します。次のスニペットをご覧ください。
survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")
survey.attributes = { name: "", question_ids: [question.id] }
survey.save
BEGIN
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
COMMIT
BEGIN
ROLLBACK
上のとおり、attributes
でquestion_ids=
(またはquestions=
)の代入が行われると、バリデーションの外部でCOMMIT
されるINSERT
文がただちに実行され、その後ROLLBACK
します。
以下のようにattributes=
とsave
をupdate
に差し替えると、期待どおりに原子性が保たれます。update
は内部でwith_transaction_returning_status
のすべての変更をラップしています(with_transaction_returning_status
は、トランザクションにラップされたブロックを1つ取るメソッドで、ブロックが「真らしい」と評価された場合はCOMMIT
を実行し、ブロックが「偽らしい」と評価された場合はROLLBACK
します)。
survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")
survey.update({ name: "", question_ids: [question.id] })
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" IN (...)
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK
次のように書くこともできます。
survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")
survey.with_transaction_returning_status do
survey.attributes = { name: "", question_ids: [question.id] }
survey.save
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" IN (...)
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK
コード例B
transaction
メソッドは期待どおりに動作しないことがあります。次のスニペットをご覧ください。
survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")
Survey.transaction do
survey.update({ name: "", question_ids: [question.id] })
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
COMMIT
興味深いことに、コード例Aの修正方法はここでは効きません。デフォルトでネストするトランザクションでは、親のトランザクションだけが使われます。
以下のようにActiveRecord::Rollback
例外をraise
するか、親のトランザクションでjoinable: false
を指定することで、半端な変更が保存されないようになります。親のトランザクションでjoinable: false
を指定すると、多くのリレーショナルデータベースが備えるメカニズムとしての保存ポイントが内部で使われます。
survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")
Survey.transaction do
unless survey.update({ name: "", question_ids: [question.id] })
raise ActiveRecord::Rollback
end
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK
次のように書くこともできます。
survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")
Survey.transaction(joinable: false) do
survey.update({ name: "", question_ids: [question.id] })
end
BEGIN
SAVEPOINT active_record_...
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK TO SAVEPOINT active_record_...
COMMIT
まとめ
上の例から、いくつかの規則が得られます。
- 代入可能な関連付けを扱うときは、
attributes
APIをじかに使うことを避け、update
やcreate
を使うこと。 - (トランザクションの)ネストを扱う場合は、
ROLLBACK
を伝搬させる例外を使うか、親トランザクションをJOIN
できないようにすること。