🌊

Ruby on Railsのマイグレーション管理を生のSQLファイルで行いたい人のための備忘録

2025/01/09に公開

Rails、はじめました

あけましておめでとうございます。このアカウントでは初めての記事になります。
今回は「Railsのマイグレーションを生SQLでやっちゃおうぜ」という趣旨の記事となります。

やりたいこと

ここで言う「マイグレーション」とは、いわゆる「データベースマイグレーション」を指します。「データベースマイグレーションとはなにか?」という野暮な説明は省きまして、ここではRailsガイドを引用させていただきます。

マイグレーションは、再現可能な方法でデータベーススキーマを継続的に進化させる便利な方法です。
マイグレーションではRubyのDSLを利用しているので、SQLを手動で記述しなくても済み、スキーマやスキーマの変更がデータベースに依存しないようにできます。

ざっくりと、データベーススキーマの変更をRubyのコードで使って管理するので、テーブルの作成や更新のためのSQL(DDL)を書く必要のない便利な機能ということになります。

この記事では、あえてデータベーススキーマの管理にRubyのコードを使わず、旧来のSQLファイルを用いるようにするための手順を紹介します。ActiveRecordに頼らない、SQLファイルによるマイグレーション管理にはメリットもデメリットもありますが、スキーマ管理をActiveRecordに任せたくない、SQLファイルを直接記述してDBMS固有の機能を扱いたい、といった(ちょっと特殊な)ユースケースでご活用いただければ幸いです。

前提条件

$ ruby --version
ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [aarch64-linux]
$ rails --version
Rails 8.0.1

実際にやってみた

1. マイグレーションフォーマットをSQLにする

config/application.rbを開き、class Application < Rails::Application内に追記します。(追記する位置はclass内の任意の位置でよいと思います)

require_relative "boot"

require "rails/all"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module Sample # ここはプロジェクト名によって異なります
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 8.0

    # Please, add to the `ignore` list any other `lib` subdirectories that do
    # not contain `.rb` files, or that should not be reloaded or eager loaded.
    # Common ones are `templates`, `generators`, or `middleware`, for example.
    config.autoload_lib(ignore: %w[assets tasks])
    config.autoload_paths << Rails.root.join("lib")

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")
+    config.active_record.schema_format = :sql
  end
end

基本的にはこれだけでOKです。この設定を行うと、rails db:migrateを実行した際に作成されるスキーマファイルは標準のschema.rbではなく、structure.sqlが作成されるようになります。
rails g migration {モデル名}コマンドを実行すると、通常どおりRubyのマイグレーションファイルが作成されます。例えばマイグレーションファイルを以下のように実装すると、SQLを直接実行することができます。

class CreateUsers < ActiveRecord::Migration[6.1]
  def up
    execute <<-SQL
      CREATE TABLE users (
        id serial PRIMARY KEY,
        name varchar(255) NOT NULL,
        email varchar(255) UNIQUE NOT NULL,
        created_at timestamp NOT NULL,
        updated_at timestamp NOT NULL
      );
    SQL
  end

  def down
    execute "DROP TABLE users;"
  end
end

2. SQLを別ファイルから読み込むようにする

上の手順だけだと、SQLはファイルとして切り出すことができず、またRubyコードの一部となってしまうためシンタックスチェックやフォーマッタの恩恵を受けることができません。
以下のような条件のもと、マイグレーションファイルとは別のファイルからSQLを読み込むようにしてみます。

  • マイグレーションファイルのあるディレクトリにsqlサブディレクトリを作る。SQLファイルはこの中に配置する
  • SQLファイルの名称は、{拡張子を除くマイグレーションファイルの名称}.{up|down}.sqlとする
    • たとえば20250108063214_create_users.rbというマイグレーションファイルがあった場合、実行するSQLファイルは20250108063214_create_users.up.sql、および20250108063214_create_items.down.sqlとなるようにします

ヘルパーモジュールを作る

SQLファイルを読み込むためのヘルパーモジュールとメソッドを作ります。

tasks/migration_helpers.rb
module MigrationHelpers
  DEST = { up: "up", down: "down" }
  def get_sql_filename(migration_file_name, dest: DEST[:up])
    base_name = File.basename(migration_file_name, ".*")
    sql_file = File.join(File.dirname(migration_file_name), "sql", "#{base_name}.#{dest}.sql")

    if File.exist?(sql_file)
      sql_file
    else
      puts "対応するSQLファイルが見つかりません: #{sql_file}"
    end
  end
end

class CustomMigration < ActiveRecord::Migration[6.1]
  include MigrationHelpers
end

このモジュールを作成すると、ActiveRecordを継承したCustomMigrationクラスの中でget_sql_filenameメソッドを呼び出せるようになります。

マイグレーションファイルの変更

マイグレーションファイルを変更し、作成したヘルパーメソッドを使ってSQLファイルを読み込むようにします。以下は一例です。

20250108063214_create_hogehoges.rb
require 'migration_helpers'

class CreateHogehoges < CustomMigration
  def up
    sql_file = get_sql_filename(__FILE__, dest: :up)
    if sql_file.nil?
      raise "SQLファイル(up)が見つかりません"
    end
    sql = File.read(sql_file)
    execute sql
  end

  def down
    sql_file = get_sql_filename(__FILE__, dest: :down)
    if sql_file.nil?
      raise "SQLファイル(down)が見つかりません"
    end
    sql = File.read(sql_file)
    execute sql
  end
end

__FILE__特殊変数を使って自分自身のファイル名をメソッドに渡し、返されたSQLファイルを読み込んで実行しています。
あとは、マイグレーションファイルのあるディレクトリにsqlサブディレクトリを作成し、先ほどのルールに従って命名したSQLファイルを配置します。

ここまでの手順で、rails db:migrateを実行するとマイグレーションが行われ、命名ルールに沿ったSQLファイルを読み込む仕組みができました。

3. マイグレーションファイルを自動作成する

仕組みはできましたが、今の状態だとマイグレーションファイルを毎回手作業で編集する必要があります。ヘルパーメソッドのおかげでマイグレーションファイルの内容はすべて共通化できるので、せっかくならrails g migrationで自動で作成できるようにしたいところです。

次の手順として、Railsのマイグレーションジェネレータをカスタマイズし、マイグレーションファイルの内容が自動生成されるようにします。
プロジェクト直下のlibディレクトリにgenerators/migration/ディレクトリを作成し、以下の内容でジェネレータファイルを作成します。

lib/generators/migration/migration_generator.rb
class MigrationGenerator < Rails::Generators::NamedBase
  source_root File.expand_path("templates", __dir__)

  def create_migration_file
    timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
    migration_filename = "#{timestamp}_#{file_name}"

    template "migration.tt", "db/migrate/#{migration_filename}.rb"
  end

  private

  def migration_class_name
    file_name.camelize
  end
end

さらにtemplatesサブディレクトリを作成し、ジェネレータが読み込むテンプレートファイルを以下の内容で作成します。テンプレート内でクラス名の作成に用いているmigration_class_name関数は、先ほどのジェネレータファイルで定義しています。

lib/generators/migration/templates/migration.tt
require 'migration_helpers'

class <%= migration_class_name %> < CustomMigration
  def up
    sql_file = get_sql_filename(__FILE__, dest: :up)
    if sql_file.nil?
      raise "SQLファイル(up)が見つかりません"
    end
    sql = File.read(sql_file)
    execute sql
  end

  def down
    sql_file = get_sql_filename(__FILE__, dest: :down)
    if sql_file.nil?
      raise "SQLファイル(down)が見つかりません"
    end
    sql = File.read(sql_file)
    execute sql
  end
end

仕上げとして、ジェネレータファイルをRailsが読み込むために、application.rbに自動ロードのパスを追記します。

application.rb
require_relative "boot"

require "rails/all"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module Sample
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 8.0

    # Please, add to the `ignore` list any other `lib` subdirectories that do
    # not contain `.rb` files, or that should not be reloaded or eager loaded.
    # Common ones are `templates`, `generators`, or `middleware`, for example.
    config.autoload_lib(ignore: %w[assets tasks])
+    config.autoload_paths << Rails.root.join("lib")

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")
    config.active_record.schema_format = :sql
  end
end

これで、マイグレーションファイルのカスタマイズができました。rails g migration {モデル名}を実行して生成されるマイグレーションファイルは、SQLファイルを読み込む処理があらかじめ記述された状態で生成されます。

4. SQLファイルも自動作成する

さて、もう一歩改善してみます。ここまでの手順でマイグレーションファイルをカスタマイズし、決まった名称のSQLファイルを読み込み実行するまではできるようになりましたが、まだ以下の手順が手動のままです。

  • マイグレーションファイルの名称をコピーする
  • コピーした名前でsqlサブディレクトリ下にファイルを作成し、拡張子を.up.sqlにする
  • もう1つファイルを作成し、拡張子をdown.sqlにする

これも面倒なので、ジェネレータの処理に含めて自動化してしまいます!

lib/generators/migration/migration_generator.rb
class MigrationGenerator < Rails::Generators::NamedBase
  source_root File.expand_path("templates", __dir__)

  def create_migration_file
    timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
    migration_filename = "#{timestamp}_#{file_name}"

    template "migration.tt", "db/migrate/#{migration_filename}.rb"

+    base_sql_path = "db/migrate/sql/#{migration_filename}"
+    create_file "#{base_sql_path}.up.sql", ""
+    create_file "#{base_sql_path}.down.sql", ""
  end

  private

  def migration_class_name
    file_name.camelize
  end
end

migration_filename変数を使い回して、それぞれupdownのSQLファイルを作成しています。SQLファイルの内容は空なので、実行したいSQLを適宜記述してください。

まとめ

以下の手順を行うことで、Railsのマイグレーション管理をSQLファイルで行うことができるようになりました。

  1. マイグレーションフォーマットを「SQL」に指定する
  2. SQLファイルを読み込んで実行するヘルパーを作る
  3. マイグレーションジェネレータをカスタマイズする

「ここ間違ってるよ」「こうしたほうがいいよ」などのご指摘があれば、何卒お手柔らかに、よろしくお願いします。

ではまた!

Discussion