日時を扱う処理を書くとき、月をまたぐ部分はうっかりすると不具合の原因になりがち。
たとえば現在の日付が31日であるタイミングでこういうことをやると
\DateTime::createFromFormat('Y-m', '2018-02')
「年」は2018で「月」は2だけど「日」が現在日時の30になるため、月をこえてしまって結果は3月3日になるという悲劇が起きます。
さて今回は日時を生成するときじゃなくて「一ヶ月後」などのお話。
このへん、PHP の日時ライブラリ Carbon と Chronos はどう扱っているのか確認してみます。
Carbon の場合
動作
1月31日の一ヶ月後を求めようとして
\Carbon\Carbon::create(2018, 1, 31)->addMonths(1)
とすると、
2018-03-03
になります。
ただし先にこれをやっておくと
Carbon::useMonthsOverflow(false);
結果はこうなる。
2018-02-28
実装
Carbon の addMonths()
ではこういう判定をしてます。
public function addMonths($value)
{
if (static::shouldOverflowMonths()) {
return $this->addMonthsWithOverflow($value);
}
return $this->addMonthsNoOverflow($value);
}
static::shouldOverflowMonths()
が見ているのはこれで
public static function shouldOverflowMonths()
{
return static::$monthsOverflow;
}
さきほどの Carbon::useMonthsOverflow(false);
によって $monthsOverflow
がセットされます。
public static function useMonthsOverflow($monthsOverflow = true)
{
static::$monthsOverflow = $monthsOverflow;
}
実際に月を足し引きしてるのはこのふたつ。
public function addMonthsWithOverflow($value)
{
return $this->modify((int) $value.' month');
}
public function addMonthsNoOverflow($value)
{
$day = $this->day;
$this->modify((int) $value.' month');
if ($day !== $this->day) {
$this->modify('last day of previous month');
}
return $this;
}
$monthsOverflow
のデフォルト値は true
になってるからそのままだと月をこえて計算されるけど、これを false
にすることによって
一ヶ月後を計算して
day
が変わるようならそれは月を越えたサインだから、前月の末日に戻す
という処理になります。
Chronos の場合
動作
\Cake\Chronos\Chronos::create(2018, 1, 31)->addMonths(1)
は最初から
2018-02-28
になります。
実装
これは Chronos の実装がこうなっているためで
public function addMonths($value)
{
$day = $this->day;
$date = $this->modify((int)$value . ' month');
if ($date->day !== $day) {
return $date->modify('last day of previous month');
}
return $date;
}
もう月を越えたら無条件に前月の末日に巻き戻してる。
Carbon のデフォルト処理のように月を越えてもそのまま使いたい場合は、明示的に
\Cake\Chronos\Chronos::create(2018, 1, 31)->addMonthsWithOverflow(1)
とすることになるようです。
「一ヶ月後」という処理をするときには Chronos のデフォルト動作の方が求められていることが多いような気もするけど、「Chronos は Carbon の immutable 版」とだけ覚えてしまうとこのへんの違いで思わぬ問題が起きるかも。テストだいじ。