giftee Tech Blog

ギフティの開発を支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信しています。

Rails 8.1 で schema.rb が ABC 順になったので structure.sql に移行してみた

こんにちは。エンジニアの安達です。カタログギフトなどの開発に携わっています。

今回は、ギフティ Advent Calendar 2025 の 12 日目の記事として書きます。

いつものように Dependabot の自動アップデートで Rails 8.1 に上げたところ、急に schema.rb に不可解な差分が出るようになって驚きました。

差分をよく見ると、テーブルのカラムが ABC 順に並び替えられていることに気づきました。

railsguides.jp

Active Recordは、schema.rb内のテーブルカラムをデフォルトでアルファベット順にソートするようになりました。これにより、マシン間でスキーマダンプが一貫するようになり、マイグレーションの順序によって左右されなくなり、結果としてノイズの多い差分が削減されます。structure.sqlは、カラム順序を厳密に維持するために引き続き利用できます。スキーマ変更のアルファベット順化の詳細については、#53281を参照してください。

Railsガイドで、8.1 で追加された仕様変更としてこの挙動が解説されています。

github.com

さらに詳しい経緯は、該当の PR を読むことで把握できます。

当初は「オプションで選べるようにする」機能として PR が立てられていました。@byroot によって、オプションにすべきかどうかに疑問が投げかけられます。@matthewd は schema.rb でカラムの並び順を参照するユースケースについて言及するなどして、慎重な見解を述べています。

そのような議論の中で、DHH の鶴の一声により「オプションにはせず、常に ABC 順にする」という方針が決定されます。

This should not be an option. This should just be default behavior. Schema dumping should be deterministic regardless of the platform.

これはオプションにするべきではない。これは単にデフォルトの挙動であるべきだ。スキーマのダンプは、プラットフォームによらず決定的であるべきだ。

Rails における DHH の、非常に強い最終決定権を感じさせる一幕です。3つの文のすべてに should が入っていて、「そうあるべきだ」という強い意志がにじみ出ています。

方針決定後の @matthewd によるフォローのコメントでは、カラムの並び順を管理したい場合は structure.sql を使うべきであることが示唆されています。

変更前の schema.rb の課題

変更前の schema.rb の仕様では、複数の開発者が同時に DB マイグレーションを実装するような場合に課題がありました。各開発者がローカル環境でどのような順番でマイグレーションを実行したかによって、カラムの並び順が変わってしまう可能性があったためです。

そのため「rails db:migrate:reset コマンドを実行した場合の並び順を正とする」といった運用ルールをあらかじめ決めておかないと、カラムの並び順が一意に定まるようにファイルを更新することができませんでした。

この問題に対して、カラムを ABC 順に並べることで、常に一意の並び順に決定できるようにしたのが、この PR による仕様変更です。

とはいえ、カラムの並び順は重要では?

一方で、MySQL にはカラム追加の際に任意の並び順を指定できる機能があるため、可読性などの観点からカラムの並び順を意識的に管理しているケースもあると思います。私のチームでも、カラムの並び順を意識する運用にしていました。

カラムの並び順を意識する開発方針を取る場合、コードレビューでは「カラムの並び順が妥当かどうか」も確認対象になります。このとき、カラムの並び順の差分を確認できるファイルが存在しないと、レビュワーは手元で DBMS を動かしてスキーマを確認しなければなりません。これは手間がかかりますし、チームとして実装・コードレビューの運用方針を徹底することも難しくなります。

そのため、カラムの並び順を意識する開発方針を取るのであれば、「実際のカラムの並び順を参照できるファイルがあること」は、運用上ほぼ必須要件になりそうです。

こうした背景から、私のチームでは schema.rb から structure.sql へスキーマダンプのフォーマットを移行することにしました。

structure.sql とは

config.active_record.schema_format = :sql

config.active_record.schema_format:sql に設定すると、デフォルトの schema.rb ではなく、structure.sql によってスキーマダンプが管理されるようになります。

structure.sql では、データベース固有のツールを用いてデータベースの構造がダンプされます。たとえば MySQL の場合は、mysqldump ユーティリティ が用いられます。

structure.sql の中身は、以下の部分例のように「そのまま SQL」となります。

/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `announcements`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `announcements` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `site_id` bigint NOT NULL,
  `title` varchar(255) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT 'タイトル',
  `content` text COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '内容',
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `index_announcements_on_site_id` (`site_id`),
  CONSTRAINT `fk_rails_81bca04b30` FOREIGN KEY (`site_id`) REFERENCES `sites` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='お知らせ';

structure.sql に含まれる SQL を実行することで、定義されたテーブル構造を再現できる仕組みになっています。

structure.sql では環境ごとの差異をなくすために工夫が必要

実際に試してみると、structure.sql は、開発環境ごとの差異をなくして、純粋にスキーマの差分だけを管理できるようにするために、いくつか工夫が必要であることに気づきました。

mysqldump を実行するクライアントの違い

FROM ruby:3.4.7

RUN apt-get update \
  && apt-get install -y --no-install-recommends default-mysql-client \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

例えば、上のように ruby の Docker イメージをベースに環境構築した場合、OS は Debian になるため、MySQL 互換の実装として MariaDB が標準になっています。Debian の APT の初期状態では、Oracle MySQL の mysql-client はインストールできず、default-mysql-client の実態は mariadb-client となっています。

この環境では、mysqldump コマンドは mariadb-dump へのシンボリックリンクとして提供されます。

mysqldump コマンドの実態が本物の mysqldump なのか、mariadb-dump へのシンボリックリンクなのかによって、利用できるオプションやスキーマダンプの生成結果に差異が生じる点に注意が必要です。

AUTO_INCREMENT= の値が開発環境ごとにばらばらになる

CREATE TABLE `announcements` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `site_id` bigint NOT NULL,
  `title` varchar(255) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT 'タイトル',
  `content` text COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '内容',
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `index_announcements_on_site_id` (`site_id`),
  CONSTRAINT `fk_rails_81bca04b30` FOREIGN KEY (`site_id`) REFERENCES `sites` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='お知らせ';

mysqldump の出力結果では、上の AUTO_INCREMENT=6 のように、開発環境のデータがどのように入っているかによって「次に INSERT されるプライマリキーの採番値」に差異が生じてしまいます。

スキーマロードするかマイグレーションするかで CHARACTER SET の有無が異なる

`title` varchar(255) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT 'タイトル',
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT 'タイトル',

上のように、varchar カラムに対する CHARACTER SET utf8mb4 の有無が、Rails でスキーマロードするか、マイグレーションするかによって異なる問題にも遭遇しました。

zenn.dev

これは mysqldump の内部実装による複雑な事象ですが、上の記事で詳しく解説してくださっている方がいたおかげで、何とか理解することができました。

  • 照合順序(Collation)をデフォルトから変更した場合に、この差異が生じる
  • 差分の有無による実際の DB スキーマの仕様の違いは存在しない
  • structure.sql の SQL を実行した場合と、Rails の通常のマイグレーションで実行される SQL が異なる
    • structure.sql の SQL ではカラムごとに COLLATE が明示されるが、Rails のマイグレーションでは明示されない
  • Rails では DB マイグレーション系のコマンドの実行方法によって、rails db:schema:load が実行され、structure.sql の SQL が実行される場合がある
  • この違いによって mysqldump の出力結果に差異が生じる

環境差異の解消方法

structure.sql を生成する際の mysqldump の挙動をカスタマイズする方法は、どのレイヤーで上書きするかによっていくつかの選択肢がありそうです。私はその中から、config/initializers/mysql_database_tasks.rb に以下のようなファイルを配置して設定を上書きする方法を選びました。

# development と test 環境でのみ設定する
if Rails.env.local?
  # mysqldump で structure.sql を生成する方法を設定する
  ActiveSupport.on_load(:active_record) do
    # mysqldump 実行時に TLS 接続をスキップする
    # 開発環境ではシンボリックリンクで mariadb-dump が実行されるため、 --ssl-mode オプションが存在しない
    # GitHub Actions の環境では mysqldump が実行される
    mysqldump_flags = ENV['GITHUB_ACTIONS'] == 'true' ? %w[--ssl-mode=DISABLED] : %w[--skip-ssl]

    ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = {
      mysql2: mysqldump_flags
    }

    ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = {
      mysql2: mysqldump_flags
    }

    ActiveRecord::Tasks::MySQLDatabaseTasks.prepend(Module.new do
      def structure_dump(filename, extra_flags)
        super
        content = File.read(filename)
        # AUTO_INCREMENT= の値は個別の開発者の環境のデータの入り方によって異なるため、structure.sql から削除する
        content.gsub!(/\sAUTO_INCREMENT=\d+/, '')

        # DDL で varchar カラムに COLLATE を指定するかによって structure.sql に `CHARACTER SET utf8mb4` の有無の差異が生じる
        # Rails のマイグレーションでは COLLATE指定なし、structure.sql をロードした場合は COLLATE 指定ありの DDL になる
        # `CHARACTER SET utf8mb4` の有無による仕様の違いは存在しないため、開発環境ごとの差異をなくす目的で、structure.sql から削除する
        content.gsub!(' CHARACTER SET utf8mb4', '')
        File.write(filename, content)
      end
    end)
  end
end
# mysqldump 実行時に TLS 接続をスキップする
# 開発環境ではシンボリックリンクで mariadb-dump が実行されるため、 --ssl-mode オプションが存在しない
# GitHub Actions の環境では mysqldump が実行される
mysqldump_flags = ENV['GITHUB_ACTIONS'] == 'true' ? %w[--ssl-mode=DISABLED] : %w[--skip-ssl]

この部分では、開発環境で実行される mysqldump の実態が mariadb-dump となっている一方で、GitHub Actions の Ubuntu 環境では mysqldump となっている問題に対処しています。

content = File.read(filename)
# AUTO_INCREMENT= の値は個別の開発者の環境のデータの入り方によって異なるため、 structure.sql から削除する
content.gsub!(/\sAUTO_INCREMENT=\d+/, '')

# DDL で varchar カラムに COLLATE を指定するかによって structure.sql に `CHARACTER SET utf8mb4` の有無の差異が生じる
# Rails のマイグレーションでは COLLATE指定なし、 structure.sql をロードした場合は COLLATE 指定ありの DDL になる
# `CHARACTER SET utf8mb4` の有無による仕様の違いは存在しないため、開発環境ごとの差異をなくす目的で、structure.sql から削除する
content.gsub!(' CHARACTER SET utf8mb4', '')
File.write(filename, content)

この部分では、正規表現による文字列置換を用いて、開発環境による差異が出る部分を削除しています。

文字列の置換ではなく、mysqldump のオプション指定によって解消できないかも検討しましたが、私の試した範囲では難しいと感じました。インターネットで事例を検索しても、愚直に文字列を置換しているソリューションしか見つけられませんでした。

この設定を行うことによって、環境ごとの差異がなく、スキーマの差分のみを綺麗に確認できる structure.sql の運用が可能になりました。

structure.sql を使ってみた所感

その後、structure.sql を運用してみて、実際の SQL が表示されることで MySQL 上の設定に自覚的になれるというメリットを感じました。

一方で、structure.sql に書かれる SQL は情報量が多く、ごちゃごちゃしていて読みづらいのも事実です。「schema.rb でサッとテーブル情報を確認できたのは、やっぱり良い体験だったなあ」という思いも正直なところあります。

トレードオフのある論点を通じて、ライブラリの方針決定の奥深さを垣間見ることができた、興味深い事例だったと思います。