ActiveRecordのトランザクションを理解する

Railsのトランザクションについて理解が曖昧だったため調べてみました。トランザクションって何？という方から、トランザクションについて知ってるつもりな方まで読んでいただけると幸いです。

## この記事の目的
- トランザクションを理解する
- Ruby on RailsのActive Recordでのトランザクションの使い方を理解する

## トランザクションとは？
トランザクションとは、複数のSQL文によるデータの更新を「1つの処理」とし、全てのSQLの実行が成功した時にデータベースに更新分を反映させることです。データベースの整合性を保つ目的があります。

基本的に、複数のSQLを同時に実行する際はトランザクションを使う必要があります。

### トランザクションの使用例
代表的なトランザクションの使用例は、銀行での取引です。

カエル🐸さんがクマ🐻さんに2万円送金するとします。🐸さんは日本におり、🐻さんはロンドンにいます。その際のデータベースの処理は下記のようになります。

1. 🐸さんの銀行口座に2万円の残高があるか確認する
1. 🐸さんの銀行口座から2万円を引き落とす
1. 2万円を🐻さん側の通貨（今回はポンド）に変換する
1. 🐻さんの銀行口座にお金を振り込む

この場合の処理をRubyのコードに落とし込むと、下記のようになります。

```ruby
amount = 20000
kaeru.debit_account(amount) if kaeru.sufficient_balance(amount)
credit_amount = convert_currency(amount, kuma)
perform_transfer(kuma, credit_amount, kaeru)
```

**さて、もしこの処理の途中でプログラムに問題が起こってしまったらどうなるでしょうか？** これが🐸さんの口座からお金が引き落とされた後、そして🐻さんの口座にお金が振り込まれる前だったら...？**お金は一体どこに行ってしまうのでしょうか？**

このような問題が起こった時に、**誤ったデータをデータベースへ書き込む処理を止め、元の状態へ戻す処理**が必要です。そこで、上記のコードを`transaction`で囲むことで、もし`transaction`ブロック内の処理中に何らかの問題が起こったら、例外エラーが発生するようになります。これでデータの整合性を保つことができます。

```ruby
amount = 20000

ActiveRecord::Base.transaction do
    kaeru.debit_account(amount) if kaeru.sufficient_balance(amount)
    credit_amount = convert_currency(amount, kuma)
    perform_transfer(kuma, credit_amount, kaeru)
end
```

## トランザクションの使い方

トランザクションはクラスメソッドのため、ActiveRecordクラス上で呼び出す必要があります。ただし、トランザクションモデルではなくデータベースとの連携のため、全ての場合でクラスインスタンスである必要はありません。

例えば、`Balance`と`Account`という二つのモデルがあり、この二つを同時に更新する必要があるとします。

この場合、下記のように`Account`クラスに対しトランザクションメソッドを実行することができます。トランザクションが実行されたクラスに関係なく、`save!`メソッドを実行することができます。

```ruby
Account.transaction do
    balance.save!
    account.save!
end
```

このように記述することで`Account`クラスでメソッドを実行したとしても、`balance`と`account`の両方が更新されます。

また、`transaction`メソッドは、下記のようにモデルインスタンスに対しても呼び出しが可能です。

```ruby
account.transaction do
    balance.save!
    account.save!
end
```

## ActiveRecord::Base・クラス・インスタンスのどれでトランザクションを使えばいいの？

先ほど述べたように`transaction`メソッドは`ActiveRecord::Base`クラス、モデルクラス、モデルインスタンスのどこからでも実行が可能です。

それではどう使い分ければいいのでしょうか？

Ruby on Railsは`ActiveRecord::Base`クラスで`transaction`メソッドを提供しています。全てのモデルは`ActiveRecord::Base`を継承しているので、全てのモデルで`transaction`メソッドが使用できます。

モデルインスタンスで`transaction`メソッドを実行することで、シンプルでわかりやすいコードが書けるというメリットがあります。

```ruby
account = Account.find(1)
account.transaction do
    account.save!
    balance.save!
end
```

下記では、`Account`クラスで`transaction`メソッドを実行しています。上のコードと全く同じ処理です。

```ruby
account = Account.find(1)
Account.transaction do
    account.save!
    balance.save!
end
```

## `save`と`destroy`メソッドのトランザクション
Active Recordの`save`と`destroy`メソッドには、`transaction`が自動的にラップされているため、バリデーションやコールバック時も`transaction`が実行されます。

## 例外処理とロールバック
トランザクションで例外を実行するには、下記の２パターンがあります。

- `save!`や`update!`などのActive Recordメソッドを使用する
- 手動で例外を実行する

```ruby
Account.transaction do
    @new_account = Account.create!(account_params)
    @referrer.update!(params[:reference_record])
end
```

もし問題が起こったときは、`create!`や`update!`メソッドが例外を発生させます。

`!`をつけない場合は、問題が起こっても処理は実行され続けます。その場合は、明示的に例外処理を書く必要があります。

```ruby
Account.transaction do
    @new_account = Account.create(account_params)
    raise ActiveRecords::RecordInvalid unless @new_user.persisted?
end
```

このコードを下記のように書き直すこともできます。

```ruby
Account.transaction do
    @new_account = Account.create(account_params)
    raise ActiveRecords::RecordInvalid unless @new_user.persisted?
rescue ActiveRecord::RecordInvalid => exception
     # エラー処理をここに書く
end
```

### トランザクションの例外処理の注意点
トランザクションの例外処理の時に`ActiveRecord::StatementInvalid`をキャッチしないように注意しましょう。`ActiveRecord::StatementInvalid`は、unique制約エラーなど、データベース側で起こるエラーだからです。

PostgreSQLなどのデータベースシステムでは、トランザクション内のデータベースエラーにより、全てのトランザクションがリスタートされるまで使用不可になってしまう可能性があります。

下記は、Userモデルで`email`のunique制約エラーが起こる場合のコードです。このような事態が起こった場合は、トランザクションをリスタートする必要があります。

```ruby
ActiveRecord::Base.transaction do
    User.create(email: "emiko@example.com")
    
    begin
        # ここでunique制約エラーが発生する
        User.create(email: "emiko@example.com")
    rescue ActiveRecord::StatementInvalid
        # ActiveRecord::StatementInvalidをキャッチしたことにより、
        # 全てのトランザクションが使用不可になってしまう
    end

    # PostgreSQLのトランザクションが使用不可の状態になっている

    # 下記のコードはunique制約に違反していないが、エラーになってしまう
    User.create(email: "satoshi@example.com")
    # => "PG::Error: ERROR: current transaction is aborted, commands 
    # ignored until end of transaction block"
end
```

## トランザクションのコールバック
トランザクションには大きく２つのコールバックがあります。

- `after_commit`
- `after_rollback`

**`after_commit`** は、レコードがトランザクション内で`save`または`destroy`され、トランザクションが適用（commit)される度に呼び出されます。
**`after_rollback`** は、レコードがトランザクション内で`save`または`destroy`され、トランザクションがロールバックされた時に呼び出されます。


## トランザクションをネストする
ここまでは、データベースが1種類の場合のみのトランザクションの扱い方を説明しました。ここからは複数のデータベースを扱う場合のトランザクションの扱い方をご紹介します。

`User`と`Account`という二つのモデルがあり、それぞれ別のデータベースで扱っているとします。`Account`にあるサブスクリプション用の残高（`subscription amount`）から引き出しが完了したら、`User`の`subscription status`を更新するとします。このような場合、下記のような`transaction`になります。

```ruby
Account.transaction do
    account = Account.find(user_id: params[:user_id])
    account.withdraw(subscription_amount)
    User.transaction do
        user = User.find(id: params[:user_id])
        user.update!(subscribed: true)
    end
end
```

もし`User`側のトランザクションが失敗した場合、`Account`側のトランザクションは中止されます。もし`Account`側のトランザクションが失敗しても`User`側のトランザクションは中止されます。このように複数のデータベースをまたがった場合でも、整合性を保つことができます。

#### `ActiveRecord::Rollback`の注意点

`ActiveRecord::Rollback`の例外エラーがraiseされた際は、注意が必要です。

下記のコードの場合、`transaction`を使っても、`Momoko`も`Satoshi`も作成されてしまいます。

```ruby
User.transaction do
    User.create(username: 'Momoko')
    User.transaction do
        User.create(username: 'Satoshi')
        raise ActiveRecord::Rollback
    end
end
```

これには理由が2つあります。

`ActiveRecord::Rollback`の例外エラーはRailsでraiseされます。上のコードだと、`ActiveRecord::Rollback`は内側（３行目）の`transaction`によってraiseされます。この場合、外側（１行目）のトランザクションは、内側のトランザクションで例外エラーが発生したことを検知することができません。

2つ目は、ネストされたトランザクションはRails側でのみサポートされていることにあります。データベースはネストされたトランザクションをサポートしていません。（現時点ではMS-SQLのみサポートしているとのことです。詳しくはご自身でお調べください）このため、内側のトランザクションで例外エラーが発生しても、外側のトランザクションはこのことを検知することができず、コードに書かれた通りにデータを更新してしまうのです。

この問題の回避策として`requires_new: true`を内側のトランザクションメソッドに渡しましょう。

```ruby
User.transaction do
    User.create(username: 'Momoko')
    User.transaction(requires_new: true) do
        User.create(username: 'Satoshi')
        raise ActiveRecord::Rollback
    end
end
```

こうすることで、`Momoko`レコードのみ作成されます。もし内側のトランザクション内で問題が起こりロールバックが起こった場合、内側のトランザクションの最初の行（４行目）に戻ります。

## まとめ
以上Railsでのトランザクションの解説でした。もし理解が曖昧で内容に間違いなどある場合は、コメント欄でご指摘いただけると幸いです。

## 参考
- [トランザクション処理がデータの整合性を保証してくれる
](https://ec-orange.jp/ec-media/?p=17184)
- [トランザクション](https://docs.oracle.com/cd/F19136_01/cncpt/transactions.html#GUID-B97790CB-DF82-442D-B9D5-50CCE6BF9FBD)
- [ActiveRecord Transactions in depth](https://www.bigbinary.com/books/learn-rubyonrails-book/activerecord-transactions-in-depth)
- [Understanding Database Transactions in Rails](https://www.honeybadger.io/blog/database-transactions-rails-activerecord/)