Rails (ActiveRecord) の Transaction で ネストやら after_commit を試してみた

# ActiveRecord で，rollback するかどうか

うろ覚えだし，いまいちよくわからなかったので，試してみた．

## とりあえずテストするモデルを作る

### 準備

```
$ devcontainer up --workspace-folder .
```

### 作る

```
$ devcontainer exec --workspace-folder . rails generate model Book title:string author:string
$ devcontainer exec --workspace-folder . rails db:migrate
```

```diff_ruby:app/models/book.rb
class Book < ApplicationRecord
+  validates :title, presence: true, uniqueness: true
+  validates :author, presence: true
end
```

### 試す

- ROLLBACK ということになっている
    - 今回は，INSERT より前(validate) 時に発行しているので，RDBMS で ROLLBACK したっぽくはない．

```rb
$ devcontainer exec --workspace-folder . rails c
sample-rails(dev)> Book.create!
  TRANSACTION (4.5ms)  BEGIN /*application='SampleRails'*/
  Book Exists? (22.5ms)  SELECT 1 AS one FROM `books` WHERE `books`.`title` IS NULL LIMIT 1 /*application='SampleRails'*/
  TRANSACTION (0.8ms)  ROLLBACK /*application='SampleRails'*/
(sample-rails):7:in `<main>': Validation failed: Title can't be blank, Author can't be blank (ActiveRecord::RecordInvalid)
sample-rails(dev)> 
```

- シンプルなトランザクションは期待通りに動作する
    - 一度は RDBMS に INSERT したデータが無くなっていることがわかる

```rb
ActiveRecord::Base.transaction do
  Book.create!(title: 'a', author: 'b')
  Book.create!
end
```

```rb
sample-rails(dev)* ActiveRecord::Base.transaction do
sample-rails(dev)*   Book.create!(title: 'a', author: 'b')
sample-rails(dev)*   Book.create!
sample-rails(dev)> end
  TRANSACTION (0.5ms)  BEGIN /*application='SampleRails'*/
  Book Exists? (2.5ms)  SELECT 1 AS one FROM `books` WHERE `books`.`title` = 'a' LIMIT 1 /*application='SampleRails'*/
  Book Create (0.9ms)  INSERT INTO `books` (`title`, `author`, `created_at`, `updated_at`) VALUES ('a', 'b', '2024-12-16 05:49:22.342478', '2024-12-16 05:49:22.342478') /*application='SampleRails'*/
  Book Exists? (0.9ms)  SELECT 1 AS one FROM `books` WHERE `books`.`title` IS NULL LIMIT 1 /*application='SampleRails'*/
  TRANSACTION (3.5ms)  ROLLBACK /*application='SampleRails'*/
(sample-rails):14:in `block in <main>': Validation failed: Title can't be blank, Author can't be blank (ActiveRecord::RecordInvalid)
        from (sample-rails):12:in `<main>'
sample-rails(dev)> Book.count
  Book Count (0.5ms)  SELECT COUNT(*) FROM `books` /*application='SampleRails'*/
=> 0
```

## トランザクションのネスト

- ロールバックできる
    - ログから見ると，トランザクションがネストされている訳ではないかも
    - > transaction calls can be nested. By default, this makes all database statements in the nested transaction block become part of the parent transaction.

```rb
ActiveRecord::Base.transaction do
  Book.create!(title: 'a', author: 'b')
  ActiveRecord::Base.transaction do
    Book.create!
  end
end
```

```rb
sample-rails(dev)* ActiveRecord::Base.transaction do
sample-rails(dev)*   Book.create!(title: 'a', author: 'b')
sample-rails(dev)*   ActiveRecord::Base.transaction do
sample-rails(dev)*     Book.create!
sample-rails(dev)*   end
sample-rails(dev)> end
  TRANSACTION (0.5ms)  BEGIN /*application='SampleRails'*/
  Book Exists? (11.5ms)  SELECT 1 AS one FROM `books` WHERE `books`.`title` = 'a' LIMIT 1 /*application='SampleRails'*/
  Book Create (0.6ms)  INSERT INTO `books` (`title`, `author`, `created_at`, `updated_at`) VALUES ('a', 'b', '2024-12-23 09:50:34.848218', '2024-12-23 09:50:34.848218') /*application='SampleRails'*/
  Book Exists? (0.5ms)  SELECT 1 AS one FROM `books` WHERE `books`.`title` IS NULL LIMIT 1 /*application='SampleRails'*/
  TRANSACTION (1.9ms)  ROLLBACK /*application='SampleRails'*/
(sample-rails):4:in `block (2 levels) in <main>': Validation failed: Title can't be blank, Author can't be blank (ActiveRecord::RecordInvalid)
        from (sample-rails):3:in `block in <main>'
        from (sample-rails):1:in `<main>'
sample-rails(dev)> Book.count
  Book Count (1.7ms)  SELECT COUNT(*) FROM `books` /*application='SampleRails'*/
=> 0
```

## commmit 後の失敗

- トランザクションをネストしている訳ではないが，commit 後に 失敗するとどうなるか試した

```
$ devcontainer exec --workspace-folder . rails generate model Publisher name:string
$ devcontainer exec --workspace-folder . rails db:migrate
```

```diff_ruby:app/models/publisher.rb
class Publisher < ApplicationRecord
+  validates :name, presence: true
end
```

```diff_ruby:app/models/book.rb
class Book < ApplicationRecord
+  after_create_commit do
+    Publisher.create!(name: "Publisher #{title}" && nil) # わざと失敗する
+  end
+
  validates :title, presence: true, uniqueness: true
  validates :author, presence: true
end
```

- test

```rb
ActiveRecord::Base.transaction do
  Book.create(title: 'a', author: 'b')
end
```

```rb
$ devcontainer exec --workspace-folder . rails c
sample-rails(dev)* ActiveRecord::Base.transaction do
sample-rails(dev)*   Book.create(title: 'a', author: 'b')
sample-rails(dev)> end
  TRANSACTION (0.4ms)  BEGIN /*application='SampleRails'*/
  Book Exists? (2.1ms)  SELECT 1 AS one FROM `books` WHERE `books`.`title` = 'a' LIMIT 1 /*application='SampleRails'*/
  Book Create (0.5ms)  INSERT INTO `books` (`title`, `created_at`, `updated_at`, `author`) VALUES ('a', '2024-12-25 07:09:36.667896', '2024-12-25 07:09:36.667896', 'b') /*application='SampleRails'*/
  TRANSACTION (2.9ms)  COMMIT /*application='SampleRails'*/
app/models/book.rb:3:in `block in <class:Book>': Validation failed: Name can't be blank (ActiveRecord::RecordInvalid)
        from (sample-rails):3:in `<main>'
sample-rails(dev)> Book.count
  Book Count (1.8ms)  SELECT COUNT(*) FROM `books` /*application='SampleRails'*/
=> 1
sample-rails(dev)> Publisher.count
  Publisher Count (0.5ms)  SELECT COUNT(*) FROM `publishers` /*application='SampleRails'*/
=> 0
```

- 特にrollback はしなかった

## 終了

```
$ docker compose -f .devcontainer/compose.yaml stop
```

## Refs

https://qiita.com/atm-snag/items/72debb854f3cf8c1c425
 
https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html

> Exception handling and rolling back
> Also have in mind that exceptions thrown within a transaction block will be propagated (after triggering the ROLLBACK), so you should be ready to catch those in your application code.
> 
> One exception is the ActiveRecord::Rollback exception, which will trigger a ROLLBACK when raised, but not be re-raised by the transaction block. Any other exception will be re-raised.

> Exception処理とロールバック
また、トランザクション ブロック内でスローされた例外は (ROLLBACK をトリガーした後) 伝播されることに留意し、アプリケーション コードでそれらをキャッチできるように準備しておく必要があります。
>
> 1 つの例外はActiveRecord::Rollback、発生したときに ROLLBACK をトリガーしますが、トランザクション ブロックによって再度発生することはありません。その他の例外は再度発生します。

https://railsguides.jp/active_record_basics.html

- 例が微妙だったかも……