PostgreSQL range カラムの schema ダンプで無効な Ruby を生成するバグを修正
Rails のスキーマダンプは PostgreSQL の range カラムのデフォルト値を schema.rb に Ruby リテラルとして書き出しますが、Date や Time を含む range が無効な文字列になり、db:schema:load が SyntaxError で失敗していました。本修正では OID::Range#type_cast_for_schema がサブタイプ固有のキャストを利用するよう変更し、全ての range サブタイプで正しい Ruby が生成されるようにしました。
背景
OID::Range#type_cast_for_schema はスキーマダンプ時に range カラムのデフォルトを文字列化する役割を持ち、従来は Range#inspect に委譲していました。Range#inspect は境界が Date や Time の場合、ActiveSupport が上書きした人間向け表記(例: Mon, 01 Jan 2024...Wed, 01 Jan 2025)を返すため、schema.rb に書き込まれたリテラルは Ruby の構文として無効になります。結果として bin/rails db:schema:load が SyntaxError を投げ、デプロイやテスト環境の再構築が失敗しました。
この問題は PostgreSQL の range 型だけで顕在化し、整数や浮動小数点の range には影響がありませんでしたが、日付系 range が必須なアプリケーションでは致命的な障害となっていました。
技術的な変更
新しい実装では、range の開始点と終了点を個別に bound_for_schema に渡し、サブタイプ固有の type_cast_for_schema で文字列化します。bound_for_schema は nil、::Float::INFINITY、-::Float::INFINITY を特別扱いし、それ以外は @subtype.type_cast_for_schema に委譲します。
変更前:
def type_cast_for_schema(value)
value.inspect.gsub("Infinity", "::Float::INFINITY")
end
変更後:
def type_cast_for_schema(value)
from = bound_for_schema(value.begin)
to = bound_for_schema(value.end)
op = value.exclude_end? ? "..." : ".."
"#{from}#{op}#{to}"
end
private
def bound_for_schema(bound)
case bound
when nil
"nil"
when ::Float::INFINITY
"::Float::INFINITY"
when -::Float::INFINITY
"-::Float::INFINITY"
else
@subtype.type_cast_for_schema(bound)
end
end
この変更により daterange、tsrange、tstzrange のデフォルトは "2024-01-01"..."2025-01-01" のような Ruby が評価可能な文字列となり、他のサブタイプは従来通り動作します。
設計判断
設計上は既存の type_cast_for_schema インターフェースを拡張し、サブタイプのロジックを再利用する方針が採られました。新たな設定キーやフラグを導入せず、既存メソッドに最小限のロジックを追加することで後方互換性を維持しています。
nil と Infinity の明示的な文字列化は、生成されたリテラルが自己文書化的になるよう意図され、他のコードパスへの影響はありません。テストスイートに新たに type_cast_for_schema 用のケースを追加し、全サブタイプで Ruby がコンパイル可能であることを検証しています。
結果として、バグ修正は限定的かつ安全に実装され、Rails のスキーマダンプ機能の堅牢性が向上しました。
まとめ
この PR は PostgreSQL の range カラムのスキーマダンプで発生していた Ruby 構文エラーを解消し、Date と Time の bound を正しくエスケープするように改修しました。既存の挙動を壊さず新しいテストで回帰防止を追加したため、今後同様の問題が再発するリスクは低減しています。