【Rails】データの状態遷移をわかりやすく定義できるGem「AASM」

どうもこんにちは。

コード理解を進めている中でAASMというライブラリが何者なのか知る必要がありそうなので、調べてみました。

# AASMについて

ChatGPTに聞いてみたところ、以下のような返答が返ってきました。

> `AASM（Acts As State Machine）`は、オブジェクトの状態を管理し、状態間の遷移を定義するためのRubyライブラリです。このライブラリを使用すると、モデルの状態遷移を宣言的に記述でき、コールバックや条件付き遷移などの高度な機能を利用することができます。

ちょっとよくわからなかったですが、よく調べてみると、
**データの状態を変更する前と後で明示的に処理を分けることができてメソッド化できる**ライブラリということです。

これは実際にコードを書いて理解するのがわかりやすそうです。

## modelを用意

以下のようにテーブル,モデルを用意します。

contentsテーブル
| カラム名 | 型 |
| --- | --- |
| id | integer |
| title | string |
| status | integer |
| content | text |

```ruby
class Content < ApplicationRecord
    include AASM
    # statusカラムはenumを定義
    enum status: {
        initial:  1, # 初期状態
        pending:  2, # 未承認
        approved: 3, # 承認
        rejected: 4, # 却下
        draft:   5   # 下書き
    }

    # AASMを使用してイベントを定義
    aasm column: :status, enum: true do
        # 状態の定義(AASMを使用するにはこれが必要です)
        state :initial, initial: true
        state :pending, :approved, :rejected, :draft
        
        # イベントメソッドを定義
        ## 下書き状態で保存する
        event :to_draft do
            # ステータスは「初期状態」または「下書き」から「下書き」への遷移のみ受け付ける
            transitions from: %i[initial draft], to: :draft
        end
        
        ## 未承認状態から承認状態へ保存する
        event :to_pending do
            # ステータスは「未承認」から「承認」への遷移のみ受け付ける
            transitions from: :pending, to: :approved
        end
        
        ## 下書き状態から承認状態へ変更
        event :draft_to_approved do
            # ステータスは「下書き」から「承認」への遷移のみ受け付ける
            transitions from: :draft, to: :approved
        end
    end
end
```

### 補足

#### カラムの定義

`aasm column: :status, enum: true do`部分の`column`を定義することを忘れてしまった場合、`aasm_state`というカラムがメモリ上で自動的に作成され、メモリ上での状態遷移が実行されます。

ただし、メモリ上での状態遷移のため、画面をリロードすると初期状態に戻ります。

#### 状態の定義

`state`を使用した状態の定義は忘れずに記述してください。
あくまでもAASMは「状態遷移」ライブラリなので、`state`を定義していないと正常に動作しません。

## Railsコンソールで試してみる

```ruby
# 仮のコンテンツを作成
pry(main)> content = Content.create(title: 'テストコンテンツ', status: 'initial', content: '内容だよ')

# コンテンツがDBに登録されていることを確認
pry(main)> Content.last
=> < id: 1, title: 'テストコンテンツ', status: 'initial', content: '内容だよ' ... >    

# initial→draftへ遷移
pry(main)> content.to_draft

# DBに保存
pry(main)> content.save
```

上記の例では、saveメソッドを明示的に呼び出してデータを保存しましたが、to_draftのあとに「！」をつければ自動的にDBに変更が反映されます。

```ruby
# 仮のコンテンツを作成
pry(main)> content = Content.create(title: 'テストコンテンツ_2', status: 'initial', content: '内容2だよ')

# コンテンツがDBに登録されていることを確認
pry(main)> Content.last
=> < id: 2, title: 'テストコンテンツ_2', status: 'initial', content: '内容2だよ' ... >    

# initial→draftへ遷移 & DBに保存
pry(main)> content.to_draft!
```

今回はRailsコンソールで試してみましたが、コントローラで実行することも可能です。

## AASMで使用できる便利なオプション

よく使われるであろうオプションを一部紹介します。

- before
- after
- after_commit

モデルに定義します。

```ruby
class Content < ApplicationRecord
    include AASM
    # statusカラムはenumを定義
    enum status: {
        initial:  1, # 初期状態
        pending:  2, # 未承認
        approved: 3, # 承認
        rejected: 4, # 却下
        draft:   5   # 下書き
    }

    # AASMを使用してイベントを定義
    aasm column: :status, enum: true do
        # 状態の定義
        state :initial, initial: true
        state :pending, :approved, :rejected, :draft
        
        # イベントメソッドを定義
        ## 下書き状態で保存する
        event :to_draft, before: :log_start, after: :log_finish do
            # ステータスは「初期状態」または「下書き」から「下書き」への遷移のみ受け付ける
            transitions from: %i[initial draft], to: :draft
        end
        
        ## 未承認状態から承認状態へ保存する
        event :to_pending do
            # ステータスは「未承認」から「承認」への遷移のみ受け付ける
            transitions from: :pending, to: :approved, after_commit: :send_mail_pending
        end
    end

    def log_start
        Rails.logger.info "状態遷移開始: #{Time.Current}"
    end

    def log_finish
        Rails.logger.info "状態遷移終了: #{Time.Current}"
    end

    def send_mail_pending
        title = self.title
        # メール送信メソッド（だいたいapp/mailers/notification_mailer.rbとかに定義されている）
        mail_content = '承認が必要なコンテンツが登録されました。'
        NotificationMailer.send_mail(title, mail_content)
    end
end
```

上記の例では、`to_draft`メソッドが実行された前に`log_start`が実行され、実行された後に`log_finish`メソッドが実行されます。

また、`to_pending`メソッドが実行されてDBにデータが保存された後で`send_mail_pending`メソッドが実行されます。
ただし、`after_commit`として定義されているので、`to_pending!`として実行されるか明示的に`save`や`update`が実行された時でないと実行されません。

## 引数も渡せる

AASMでは引数も渡せます。以下のように`to_draft`メソッドが実行されるとします。

```ruby
@content.to_draft!(current_user)
```

この時、`to_draft`メソッドには`self`である`@content`と引数である`current_user`が渡されます。

引数は以下のように`before`や`after`などで使用することができます。

```ruby
class Content < ApplicationRecord
    include AASM
    # statusカラムはenumを定義
    enum status: {
        initial:  1, # 初期状態
        pending:  2, # 未承認
        approved: 3, # 承認
        rejected: 4, # 却下
        draft:   5   # 下書き
    }

    # AASMを使用してイベントを定義
    aasm column: :status, enum: true do
        # 状態の定義
        state :initial, initial: true
        state :pending, :approved, :rejected, :draft
        
        # イベントメソッドを定義
        ## 下書き状態で保存する
        event :to_draft do
            before do |user|
                log_start(self.title, self.status, user.name)
                @before_status = self.status
            end
            after do |user|
                log_finish(self.title, @before_status, self.status, user.name)
            end
            # ステータスは「初期状態」または「下書き」から「下書き」への遷移のみ受け付ける
            transitions from: %i[initial draft], to: :draft
        end
    end

    def log_start(title, status, user_name)
        Rails.logger.info "#{Time.Current.strftime('%y/%m/%d %H:%M:%S')}にコンテンツ「#{title}」が#{user_name}によって変更されます。"
    end

    def log_finish(title, before_status, status, user_name)
        Rails.logger.info "#{Time.Current.strftime('%y/%m/%d %H:%M:%S')}にコンテンツ「#{title}」が#{user_name}によって#{before_status}から#{status}へ変更されました。"
    end
end
```

上記のように書くことで引数を使用することができます。

また、beforeブロック内でインスタンス変数として状態遷移前の状態を保存しておくと、afterブロックなどの他のブロックでも遷移前のデータを使用することができます。

## まとめ

AASMがわかりやすい人には、使いやすいGemだと思いますが、使いづらい人はモデルやコントローラーでガチガチにコードを記述してもいいかもしれないですね。

以上