DiffDaily

Deep & Concise - OSS変更の定点観測

[Rails] SQLite3のスキーマダンプでAUTOINCREMENTなし整数主キーを正しく復元可能に

rails/rails

背景

RailsでSQLite3を使用する際、create_table :payments, id: :integer, default: nil のように非AUTOINCREMENT整数主キーを持つテーブルを作成すると、スキーマダンパーが default: nil オプションを無視していました。その結果、db/schema.rb から復元されたテーブルは意図せずAUTOINCREMENT属性を持ってしまい、元のテーブル定義と異なる挙動を示す問題がありました。

この問題は #56485 で報告されており、本PRで修正されました。

問題の詳細

元のマイグレーション

create_table :payments, id: :integer, default: nil

このマイグレーションは以下のSQLを生成します:

CREATE TABLE "payments" ("id" integer NOT NULL PRIMARY KEY)

注目すべきは、AUTOINCREMENTキーワードが含まれていない点です。SQLite3では、主キーにAUTOINCREMENTを明示的に指定しない場合、再利用可能なROWIDが使用されます。

スキーマダンプの問題(修正前)

修正前のスキーマダンパーは以下のような出力を生成していました:

create_table "payments", force: :cascade do |t|
end

id: :integer, default: nil が欠落しているため、このスキーマから復元すると:

CREATE TABLE "payments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL)

AUTOINCREMENTが誤って追加され、元のテーブル定義と挙動が変わってしまいます。

技術的な解決策

本PRでは、ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper に以下の変更を加えています。

1. 主キーがAUTOINCREMENTを持つかの判定メソッドを追加

def primary_key_has_autoincrement?
  return false unless table_name

  table_sql = @connection.query_value(<<~SQL, "SCHEMA")
    SELECT sql FROM sqlite_master WHERE name = #{@connection.quote(table_name)} AND type = 'table'
    UNION ALL
    SELECT sql FROM sqlite_temp_master WHERE name = #{@connection.quote(table_name)} AND type = 'table'
  SQL

  table_sql.to_s.match?(/\bAUTOINCREMENT\b/i)
end

このメソッドは sqlite_master テーブルを直接クエリし、テーブル作成時のCREATE TABLE文を取得します。その文字列内にAUTOINCREMENTキーワードが含まれているかを正規表現で判定します。

技術的な注目点:
- sqlite_mastersqlite_temp_master の両方をクエリすることで、通常テーブルと一時テーブルの両方に対応
- 単語境界 \b を使用することで、部分一致を避けて正確な判定を実現
- 大文字小文字を区別しない /i フラグで柔軟な検出

2. デフォルト主キー判定ロジックの修正

def default_primary_key?(column)
  schema_type(column) == :integer && primary_key_has_autoincrement?
end

従来は単に schema_type(column) == :integer だけで判定していましたが、AUTOINCREMENTの有無も考慮するように変更されました。

3. 明示的なデフォルト値が必要なケースの判定

def explicit_primary_key_default?(column)
  column.bigint? || (column.type == :integer && !primary_key_has_autoincrement?)
end

このメソッドは、スキーマダンプ時に default: nil を明示的に出力すべきかを判定します。整数型の主キーでAUTOINCREMENTがない場合、default: nil を出力するようになりました。

修正後の挙動

修正後のスキーマダンプは以下のように出力されます:

create_table "payments", id: :integer, default: nil, force: :cascade do |t|
end

id: :integer, default: nil が正しく含まれるため、このスキーマから復元されるテーブルは元の定義と一致します。

テストの改善

def test_schema_dump_primary_key_integer_with_default_nil
  # skip if current_adapter?(:SQLite3Adapter)  # この行が削除された
  @connection.create_table(:int_defaults, id: :integer, default: nil, force: true)
  schema = dump_table_schema "int_defaults"
  assert_match %r{create_table "int_defaults", id: :integer, default: nil}, schema
end

既存のテストケースからSQLite3アダプター用のスキップ処理が削除されました。これにより、この問題が修正されたことが自動テストで継続的に検証されます。

まとめ

本PRは、SQLite3における非AUTOINCREMENT整数主キーのスキーマダンプ問題を、sqlite_master への直接クエリという実用的なアプローチで解決しています。この修正により、db/schema.rb を通じたデータベース復元の忠実性が向上し、特にROWIDの再利用挙動に依存するアプリケーションで重要な改善となります。

SQLite3のメタデータテーブルを活用した判定ロジックは、カラム情報だけでは取得できないテーブル作成時のオプションを正確に検出する優れた手法です。