Time#advance と DateTime#advance が呼び出し元ハッシュを変更しないように修正
Time#advance と DateTime#advance が受け取ったオプションハッシュをコピーして処理するように変更し、呼び出し元のハッシュが変化せず、凍結ハッシュでも例外を発生させずに利用できるようになりました。
背景
この PR が対象とした問題は、Time#advance と DateTime#advance が受け取った options ハッシュをその場で書き換えていたことです。weeks を days に、days を hours に変換する際にハッシュへ新しいキーが追加され、呼び出し元の変数が予期せず変更されました。さらに、呼び出し元が凍結されたハッシュを渡すと FrozenError が発生し、例外が投げられました。
一方 Date#advance はハッシュを読み取るだけで内容を変更せず、同一 API で挙動が揃っていないことが開発者に混乱を与えていました。この不整合が原因で、オプションハッシュを再利用したコードや定数ハッシュを引数にしたコードが壊れるケースがありました。この状況を是正することが本修正の目的です。
技術的な変更
Time#advance と DateTime#advance の実装冒頭に options = options.dup を追加し、以降の処理をコピー上で行うようにしました。これによりハッシュのキー変換は呼び出し元に影響せず、凍結ハッシュでも変更可能なミュータブルなコピーが利用されます。変更は activesupport/lib/active_support/core_ext/time/calculations.rb と activesupport/lib/active_support/core_ext/date_time/calculations.rb のみで、ロジック自体は変わっていません。
変更前 (Time):
def advance(options)
unless options[:weeks].nil?
options[:weeks], partial_weeks = options[:weeks].divmod(1)
options[:days] = options.fetch(:days, 0) + 7 * partial_weeks
# ...
end
end
変更後 (Time):
def advance(options)
+ options = options.dup
unless options[:weeks].nil?
options[:weeks], partial_weeks = options[:weeks].divmod(1)
options[:days] = options.fetch(:days, 0) + 7 * partial_weeks
# ...
end
end
同様に DateTime#advance でも先頭に options = options.dup が挿入されています。これにより、キー操作はコピーに対して行われ、元のハッシュはそのまま残ります。
同時に activesupport/test/core_ext/time_ext_test.rb と activesupport/test/core_ext/date_time_ext_test.rb に2つずつテストが追加され、ハッシュが変化しないことと凍結ハッシュでも例外が出ないことを検証しています。すべての既存テストは緑のまま通過し、回帰がなく安全にリリースできます。
設計判断
メソッドを副作用なしにする設計は、Ruby の慣例に合致し API の予測可能性を高めます。options.dup という最小限の変更で済むため、コードベースへの侵入度が低く、パフォーマンスへの影響も無視できる程度です。また、Date#advance と同様の振る舞いに統一することで、ドキュメントと実装の整合性が取れました。
既存の呼び出し側が options ハッシュを再利用していた場合でも、コピーを操作するだけなので動作は従来と同一で、後方互換性が保たれます。したがって、アプリケーションコードの修正は不要です。
まとめ
この変更により Time#advance と DateTime#advance が呼び出し元ハッシュを汚染せず、凍結ハッシュでも安全に利用できるようになりました。API の一貫性と安全性が向上したことで、開発者はオプションハッシュを自由に再利用でき、予期せぬ副作用を回避できます。機能的な差分はなく、既存コードへの影響はありません。