Laravel をバージョンアップしたら Carbon 比較の判定結果が逆になった!?

[qnote Advent Calendar 2022](https://qiita.com/advent-calendar/2022/qnote) の2日目です。

# はじめに
先日、担当しているプロジェクトを Laravel 5.5 から 9.x にバージョンアップする対応を行なったのですが、その際にタイトルの事象に遭遇したので掘り下げてみたいと思います。
（アドベントカレンダーの記事っぽくないけど許してください…）

**バージョン情報**

| | Laravel | Carbon |
|:---|:---|:---|
| 対応前 | 5.5.50 | 1.0系 |
| 対応後 | 9.27.0 | 2.0系 |

# 事象
上記の通り Laravel 5.5.50 から 9.27.0 にバージョンアップしたところ、Carbon の日時比較関数の引数に `null` を渡した場合の判定結果が逆になってしまいました。

```php:Laravel 5.5.50
>>> now()->gt(null)
=> true
>>> now()->gte(null)
=> true
>>> now()->lt(null)
=> false
>>> now()->lte(null)
=> false
```

```php:Laravel 9.27.0
>>> now()->gt(null)
=> false
>>> now()->gte(null)
=> false
>>> now()->lt(null)
=> true
>>> now()->lte(null)
=> true
```

その結果、想定通りの動きをしないロジックが出てきてしまいました…
そもそも引数に `null` を渡すことなんてあるの？って感じなんですが、実際のソースコードは以下のような形でした。

```php:修正前ロジック
// $date は 「string型の日付文字列」 or 「空文字」 or 「null」 の可能性があるため、
$date = empty($date)
    ? null               // 「空文字」 or 「null」 である場合: null に変換
    : new Carbon($date); // 「空文字」 or 「null」 でない場合: Carbon に変換

// 現在日時以降の場合のみ処理を実行
if (now()->lte($date)) {
    ...
}
```

現在日時以降の場合のみ処理を実行したかったのですが、「空文字」 or 「null」の場合も処理が実行されるようになってしまいました。。

# とりあえず対策
想定通りの動きとなるよう、以下のように修正を行いました。

```php:修正後ロジック
$date = empty($date)
    ? null
    : new Carbon($date);

if ($date && now()->lte($date)) { // $date = truthy の判定を追加
    ...
}
```

# 原因究明
上記修正により問題は解決したのですが、どうしてそうなったのか気になったので Carbon のソースを追ってみました。
※各メソッド大枠は一緒なので、今回は `lte()` についてのみ記載します。

## Laravel 5.5.50, Carbon 1.0系
バージョンアップ前の `lte()` は以下のようなロジックでした。
```php:vendor/nesbot/carbon/src/Carbon/Carbon.php
/**
 * Determines if the instance is less (before) or equal to another
 *
 * @param \Carbon\Carbon|\DateTimeInterface|mixed $date
 *
 * @return bool
 */
public function lte($date)
{
    return $this <= $date;
}
```

## Laravel 9.27.0, Carbon 2.0系
バージョンアップ後の `lte()` は以下のようなロジックでした。
```php:vendor/nesbot/carbon/src/Carbon/Traits/Comparison.php
/**
 * Determines if the instance is less (before) or equal to another
 *
 * @example
 * ```
 * Carbon::parse('2018-07-25 12:45:16')->lte('2018-07-25 12:45:15'); // false
 * Carbon::parse('2018-07-25 12:45:16')->lte('2018-07-25 12:45:16'); // true
 * Carbon::parse('2018-07-25 12:45:16')->lte('2018-07-25 12:45:17'); // true
 * ```
 *
 * @param \Carbon\Carbon|\DateTimeInterface|mixed $date
 *
 * @see lessThanOrEqualTo()
 *
 * @return bool
 */
public function lte($date): bool
{
    return $this->lessThanOrEqualTo($date);
}

/**
 * Determines if the instance is less (before) or equal to another
 *
 * @example
 * ```
 * Carbon::parse('2018-07-25 12:45:16')->lessThanOrEqualTo('2018-07-25 12:45:15'); // false
 * Carbon::parse('2018-07-25 12:45:16')->lessThanOrEqualTo('2018-07-25 12:45:16'); // true
 * Carbon::parse('2018-07-25 12:45:16')->lessThanOrEqualTo('2018-07-25 12:45:17'); // true
 * ```
 *
 * @param \Carbon\Carbon|\DateTimeInterface|mixed $date
 *
 * @return bool
 */
public function lessThanOrEqualTo($date): bool
{
    $this->discourageNull($date);
    $this->discourageBoolean($date);

    return $this <= $this->resolveCarbon($date);
}

...

private function discourageNull($value): void
{
    if ($value === null) {
        @trigger_error("Since 2.61.0, it's deprecated to compare a date to null, meaning of such comparison is ambiguous and will no longer be possible in 3.0.0, you should explicitly pass 'now' or make an other check to eliminate null values.", \E_USER_DEPRECATED);
    }
}

private function discourageBoolean($value): void
{
    if (\is_bool($value)) {
        @trigger_error("Since 2.61.0, it's deprecated to compare a date to true or false, meaning of such comparison is ambiguous and will no longer be possible in 3.0.0, you should explicitly pass 'now' or make an other check to eliminate boolean values.", \E_USER_DEPRECATED);
    }
}
```

```php:vendor/nesbot/carbon/src/Carbon/Traits/Date.php
/**
 * Return the Carbon instance passed through, a now instance in the same timezone
 * if null given or parse the input if string given.
 *
 * @param Carbon|DateTimeInterface|string|null $date
 *
 * @return static
 */
protected function resolveCarbon($date = null)
{
    if (!$date) {
        return $this->nowWithSameTz();
    }

    if (\is_string($date)) {
        return static::parse($date, $this->getTimezone());
    }

    static::expectDateTime($date, ['null', 'string']);

    return $date instanceof self ? $date : static::instance($date);
}
```

```php:vendor/nesbot/carbon/src/Carbon/Traits/Date.php
/**
 * Returns a present instance in the same timezone.
 *
 * @return static
 */
public function nowWithSameTz()
{
    return static::now($this->getTimezone());
}
```

```php:vendor/nesbot/carbon/src/Carbon/Traits/Creator.php
/**
 * Get a Carbon instance for the current date and time.
 *
 * @param DateTimeZone|string|null $tz
 *
 * @return static
 */
public static function now($tz = null)
{
    return new static(null, $tz);
}
```
## ざっくり解説
バージョンアップ前は、単純に Carbon インスタンスと引数を `<=` で比較しているだけでした。

一方、バージョンアップ後は結構色々ごにょごにょしていますね。

まず、 `discourageNull()` と `discourageBoolean()` で引数チェックをして、`null` や `boolean` だった場合は [E_USER_DEPRECATED](https://www.php.net/manual/ja/errorfunc.constants.php) のユーザーエラーを生成しています。
（そもそも `null` は非推奨扱いだった…）

次に、`resolveCarbon()` で引数を変換し、その結果を `<=` で比較していました。
`resolveCarbon()` の中では以下のようなことをやっています。
* 引数が `null` だった場合、現在日時を返す
* 引数が `string` だった場合、パースして返す
* 上記以外の場合、`DateTime` オブジェクトかをチェックしつつ、`Carbon` インスタンスに変換して返す

引数が `null` だった場合、バージョンアップ前は `null` のまま比較していたのに対し、バージョンアップ後は `現在日時` に変換してから比較されていたため、判定結果が逆になる動きとなっていました。

```php
// バージョンアップ前 : null のまま比較
now()->lte(null)
→ $this <= $date
→ 現在日時 <= null
→ false

// バージョンアップ後 : 現在日時に変換した結果と比較
now()->lte(null)
→ $this <= $this->resolveCarbon($date)
→ 現在日時 <= 現在日時 // ※厳密には全く同じ「現在日時」ではない(下部注釈参照)
→ true

/*
* ※注釈※
* 「左辺: now()->lte(null) を呼び出した時点で生成された現在日時」、「右辺: 変換処理を経て生成された現在日時」なので、
* 厳密には全く同じ「現在日時」ではなく右辺の方が微妙に(μsとかが)未来の時刻になる
*/
```

# さいごに
そもそも比較関数の引数に `null` を渡すような愚行はやめましょう。