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

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

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

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

Railsガイドで、8.1 で追加された仕様変更として解説されている。

さらに詳しい経緯は、該当の 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 MySQLmysql-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スキーマロードするか、マイグレーションするかによって異なる問題に遭遇した。

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

  • 照合順序(Collation)をデフォルトから変更した場合に、この差異が生じる
  • 差分の有無による実際の DB スキーマの仕様の違いは存在しない
  • structure.sqlSQL を実行した場合と、Rails の通常のマイグレーションで実行される SQL が異なる
  • Rails では DB マイグレーション系のコマンドの実行方法によって、 rails db:schema:load が実行され、 structure.sqlSQL が実行される場合がある
  • この違いによって 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 でサッとテーブル情報を確認できたのは、やっぱり良い体験だったなあ」という思いは正直なところある。

トレードオフのある論点について、ライブラリの意思決定の難しさを垣間見ることができたと感じている。