【Rails・デザパタ】マイグレーションで学ぶCommandパターン


## Commandパターンとは
Commandは「命令」という意味で、「命令」をオブジェクトに表したデザインパターンです。命令オブジェクトごとにステータスを保持されることで、複数のコマンドの履歴管理や、複数の命令をまとめて実行・ロールバックすることが容易になります。

## Rails の ActiveRecord での Commandパターン
`Rails` の `ActiveRecord` のマイグレーション機能ではテーブルを追加したり、カラムの変更を行うことができます。本来はSQLを直接実行することでDBに変更を加えますが、マイグレーションは専用のスクリプトファイルを用意し、それを実行します。


## マイグレーションの基本動作

マイグレーションファイルの作成はコマンドで実行することができます
```terminal
rails generate model Cat name:string age:integer
```

```db/migration/20240315_create_cats.rb
class CreateCats < ActiveRecord::Migration[7.1]
  def change
    create_table :cats do |t|
      t.string :name
      t.integer :age

      t.timestamps
    end
  end
end
```

さらにテーブルを追加したい時も同様にマイグレーションファイルを作成します。
```terminal
rails generate model Dog name:string age:integer
```

```db/migration/20240315_create_dogs.rb
class CreateDogs < ActiveRecord::Migration[7.1]
  def change
    create_table :dogs do |t|
      t.string :name
      t.integer :age

      t.timestamps
    end
  end
end
```

DBに反映するためにはマイグレーションコマンドを実行します。
```terminal
rails db:migrate

== 20240316133121 CreateDogs: migrating =======================================
-- create_table(:cats)
   -> 0.0157s
== 20240316133121 CreateDogs: migrated (0.0165s) ==============================

== 20240316133130 CreateChicks: migrating =====================================
-- create_table(:dogs)
   -> 0.0109s
== 20240316133130 CreateChicks: migrated (0.0110s) ============================
```
マイグレーション実行されていないファイルをまとめて実行することができます。


また、 実行した内容を一つロールバックできます。
```terminal
rails db:rollback
== 20240316133146 CreateOwls: reverting =======================================
-- drop_table(:dogs)
   -> 0.0112s
== 20240316133146 CreateOwls: reverted (0.0316s) ==============================

```
仕組み自体はシンプルで、DBに変更を追加したい時、
**`db/migration/` 配下にマイグレーションファイルを作成し、`change`メソッドに応答できる`ActiveRecord::Migration` を継承したクラスを用意するだけ。**

これだけで、`rails db:migrate`コマンドで、未実行なマイグレーションファイルを全て実行してくれるのです。よくできた仕組みだと思います。

この仕組みについて深ぼってみます。
どのような仕組みで実現されているのかをコードリーディングしてみようと思います。

## コードリーディング時の環境
Rails 7.0.2.3

## マイグレーションの仕組み超絶ざっくり理解（飛ばしてOK）

`rails db:migrate` によって[Rakeタスク](https://github.com/adjust/streets/blob/master/activerecord/lib/active_record/railties/databases.rake)が実行されます。

### databases.rake
```activerecord-7.0.2.3/lib/active_record/railties/databases.rake
  task migrate: :load_config do
    db_configs = ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env)

    if db_configs.size == 1
      ActiveRecord::Tasks::DatabaseTasks.migrate
    else
      original_db_config = ActiveRecord::Base.connection_db_config
      mapped_versions = ActiveRecord::Tasks::DatabaseTasks.db_configs_with_versions(db_configs)

      mapped_versions.sort.each do |version, db_configs|
        db_configs.each do |db_config|
          ActiveRecord::Base.establish_connection(db_config)
          ActiveRecord::Tasks::DatabaseTasks.migrate(version)
        end
      end
    end

    db_namespace["_dump"].invoke
  ensure
    ActiveRecord::Base.establish_connection(original_db_config) if original_db_config
  end
```

注目したいのは以下の二行で
- データベース接続する
- 指定されたマイグレーションのバージョンを実行する
```ruby
ActiveRecord::Base.establish_connection(db_config)
ActiveRecord::Tasks::DatabaseTasks.migrate(version)
```
[DatabaseTasks.migrate](https://github.com/rails/rails/blob/6f0d1ad14b92b9f5906e44740fce8b4f1c7075dc/activerecord/lib/active_record/tasks/database_tasks.rb#L237)について深掘りしてみる


### DatabaseTasks.migrate
```activerecord-7.0.2.3/lib/active_record/tasks/database_tasks.rb
      def migrate(version = nil)
        check_target_version

        scope = ENV["SCOPE"]
        verbose_was, Migration.verbose = Migration.verbose, verbose?

        Base.connection.migration_context.migrate(target_version) do |migration|
          if version.blank?
            scope.blank? || scope == migration.scope
          else
            migration.version == version
          end
        end.tap do |migrations_ran|
          Migration.write("No migrations ran. (using #{scope} scope)") if scope.present? && migrations_ran.empty?
        end

        ActiveRecord::Base.clear_cache!
      ensure
        Migration.verbose = verbose_was
      end
```

`migration_context` は `MigrationContext` インスタンスを返却するだけなので
`MigrationContext#migrate` が主な仕事
```ruby
      def migration_context # :nodoc:
        MigrationContext.new(migrations_paths, schema_migration)
      end
```

`MigrationContext#migrate`についてみてみると、バージョン指定していないと`up`が実行され、対象バージョンが今より小さいと`down`が実行される。つまり、現状のバージョンからバーションを戻すか進めるかを判定する。
```ruby
    def migrate(target_version = nil, &block)
      case
      when target_version.nil?
        up(target_version, &block)
      when current_version == 0 && target_version == 0
        []
      when current_version > target_version
        down(target_version, &block)
      else
        up(target_version, &block)
      end
    end
```

`up`では`migrations`メソッドによって`db/migration`フォルダ配下にあるマイグレーションファイル（20040316_create_xxx.rb のようなファイル）を全て取得し、`migrate`させる。

```ruby    
    def up(target_version = nil, &block) # :nodoc:
      selected_migrations = if block_given?
        migrations.select(&block)
      else
        migrations
      end

      Migrator.new(:up, selected_migrations, schema_migration, target_version).migrate
    end
```

`migrate`では、`runnable`なファイル（未実行などマイグレーション可能なファイル）にフィルタし、マイグレーションを実行する。
```ruby
    def migrate_without_lock
      if invalid_target?
        raise UnknownMigrationVersionError.new(@target_version)
      end

      result = runnable.each(&method(:execute_migration_in_transaction))
      record_environment
      result
    end
```

その後、`execute_migration_in_transaction`の内部で`migrate(@direction)`によって`exec_migration`が実行される。
ようやくここで`change`が実行される。（長い...😅）
これは、自分で追加したマイグレーションファイルの`change`メソッドのことである。
```migrtion.rb
    def exec_migration(conn, direction)
      @connection = conn
      if respond_to?(:change)
        if direction == :down
          revert { change }
        else
          change
        end
      else
        public_send(direction)
      end
    ensure
      @connection = nil
    end
```

しかし、 `migrtion.rb`には`change`メソッド定義した`create_table`は定義されていない。そのため、同ファイル内の`method_missing`が実行される。
```migration.rb
    def method_missing(method, *arguments, &block)
      arg_list = arguments.map(&:inspect) * ", "

      say_with_time "#{method}(#{arg_list})" do
        unless connection.respond_to? :revert
          unless arguments.empty? || [:execute, :enable_extension, :disable_extension].include?(method)
            arguments[0] = proper_table_name(arguments.first, table_name_options)
            if method == :rename_table ||
              (method == :remove_foreign_key && !arguments.second.is_a?(Hash))
              arguments[1] = proper_table_name(arguments.second, table_name_options)
            end
          end
        end
        return super unless connection.respond_to?(method)
        connection.send(method, *arguments, &block)
      end
    end
```
`connection.send(method, *arguments, &block)`により実行され、`say_with_time`によって実行時間が表示される。



## マイグレーション要約

- 実際にDBを変更する処理を`db/migration`に分離して追加できる
- `rails db:migrate`実行時に未実行のマイグレーションファイルを取得して実行する
- 実行結果を履歴ファイルとして残すことで、未実行・実行済ファイルの区別ができる


以下では`up`メソッドと`down`メソッドを用意することでデータベーススキーマを先に進める(up)ことができ、戻す(down)こともできる。
```ruby
class AddColumnToCat < ActiveRecord::Migration[7.1]
  def up
    add_column :cat, :owner_id, :integer
  end

  def down
    remove_column :cat, :owner_id
  end
end
```
DBに変更を加える**コマンド（命令）をオブジェクトから分離しているおかげで、DBの変更をしたい時は新しいスクリプトファイル（マイグレーションファイル）を追加するだけ**で済みます。

**既存ファイルを意識しなくていい**のです！

こういう便利な機能のおかげで、railsに魔法感を感じてしまいます..😥

## コマンドパターンで何が嬉しいのか

### 拡張性が高い
各コマンドが個別のクラスとして実装されるため、新しいコマンドを追加したり既存のコマンドを変更が容易。

### 実行タイミングを柔軟に制御できる
コマンドオブジェクトでステータスを管理することで、実行状態から実行タイミングをコントロールしやすい。

### 履歴の管理
実行されたコマンドの履歴を管理することで、取り消しややり直しやの機能を実装しやすい。


## コマンドパターンを簡単なプログラムで表現してみる
猫を管理するプログラムを考える。
猫を追加・削除するコマンドのみを用意し、一気に実行させるとする。

DBクラス
猫の種類を配列で管理するだけのクラス
```ruby
class Database
    attr_reader :data
    
    def initialize
      @data = ['雑種', 'サイベリアン']
    end

    def insert(data)
      @data << data
    end

    def delete(data)
      @data.delete(data)
    end
end
```

DBにデータを`insert`, `delete` するためのコマンドクラスを用意する
コマンドを実行する時は、`execute`と、元に戻すには`rollback`などの一般的なメソッド名で用意する。
```ruby
# 抽象クラス
class Command
    def execute
      raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
    end

    def rollback
      raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
    end
end

# データベースへの挿入
class InsertCommand < Command
    def initialize(database, data)
      @database = database
      @data = data
    end

    def execute
      @database.insert(@data)
    end

    def rollback
      @database.delete(@data)
    end
end

# データベースから削除
class DeleteCommand < Command
    def initialize(database, data)
      @database = database
      @data = data
    end

    def execute
      @database.delete(@data)
    end
  
    def rollback
      @database.insert(@data)
    end
end
```

コマンドの管理と、一斉実行とロールバックなどの制御を行うクラスを用意する。
```ruby
class TransactionManager
    def initialize
      @commands = []
    end

    def add_command(command)
      @commands << command
    end

    def execute_commands
      @commands.each(&:execute)
    end

    def rollback_commands
      @commands.reverse_each(&:rollback)
    end
end
```

用意ができたので実行してみる ᕦ(ò_óˇ)ᕤ
```ruby
  database = Database.new

  # データの追加と削除をコマンドとして定義
  insert_command_1 = InsertCommand.new(database, "マンチカン")
  insert_command_2 = InsertCommand.new(database, "ラグドール")
  delete_command_1 = DeleteCommand.new(database, "サイベリアン")

  # トランザクションにコマンドを追加
  transaction_manager = TransactionManager.new
  transaction_manager.add_command(insert_command_1)
  transaction_manager.add_command(insert_command_2)
  transaction_manager.add_command(delete_command_1)

  # 実行
  transaction_manager.execute_commands
  puts database.data
  # => 雑種 マンチカン ラグドール

  # ロールバック
  transaction_manager.rollback_commands
  puts database.data
  # => 雑種 サイベリアン
```
上記のようにコマンドをクラスとして切り離すことで、コマンドの処理を閉じ込めることができました。

次に、**コマンドが追加される場合（機能追加）を想定してみる。**
例えば、猫の名前を変更したい要求が出てきました。
`update`専用のコマンドを用意してみましょう。
```ruby
class UpdateCommand < Command
    def initialize(database, old_data, new_data)
        @database = database
        @old_data = old_data
        @new_data = new_data
    end

    def execute
      @database.update(@old_data, @new_data)
    end

    def rollback
      @database.update(@new_data, @old_data)
    end
end
```

これを呼び出せるようにデータベースクラスにもロジックを追加する
```ruby
# データベースの操作を行うクラス
class Database
    attr_reader :data
    def initialize
      @data = ['雑種', 'サイベリアン']
    end

    def insert(data)
      @data << data
    end

    def delete(data)
      @data.delete(data)
    end

    # 以下を追加
    def update(old_data, new_data)
      index = @data.index(old_data)
      @data[index] = new_data if index
    end
end
```

実行してみる
```ruby
  # データベースへの操作を定義
  database = Database.new

  # データの更新をコマンドとして定義
  update_command_1 = UpdateCommand.new(database, "雑種", "雑種_更新")

  # トランザクションにコマンドを追加
  transaction_manager = TransactionManager.new
  transaction_manager.add_command(update_command_1)

  transaction_manager.execute_commands
  puts database.data
  # => 雑種_更新, サイベリアン

  # ロールバック
  transaction_manager.rollback_commands
  puts database.data
  # => 雑種, サイベリアン
```

コマンドをオブジェクトとして切り出していることで、処理（コマンド）を追加する際に **既存のロジックを変更するのではなく、新しいクラスを追加したり、メソッドを追加する** だけでよくなっています。

また、コマンドごとにクラスを分けているので、既存のロジックに変更が生じてもその場所が一目瞭然です。

## どんな時にこのパターンが採用しやすいか
### 複数のタスクを一括で実行したい時
**バッチ処理の実装**:
大量のジョブを処理する場合や、バッチ処理を途中で中断して再開する場合に、各処理をコマンドとして扱うことで管理がしやすそう。

**ダウンロード機能**
複数のコンテンツをDLする時にダウンロードの開始、停止、取り消し、やり直しなどの操作を柔軟に管理できそう。

etc...


## 懸念
コマンドパターン超便利だからどんどん使っていこう！ となるわけではなく...

これはデザパタ全般に共通するのですが、**「ハンマーを持つ人にはすべてが釘に見える」** という諺がある通り、デザパタを適用することが目的になると本末転倒なわけで、容量用法を守って使うことが必要です。

コマンドパターンで耐える想定を予め明確にしておきたいですね。

## 最後に
ここまで見ていただきありがとうございます。
`Rails`のマイグレーションを潜ることで、`Rails`の魔法感も多少拭えました。
それに、その設計にコマンドパターンが使用されていることを学ぶことができました。