7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

qnoteAdvent Calendar 2022

Day 2

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

Last updated at Posted at 2022-12-01

qnote Advent Calendar 2022 の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 を渡した場合の判定結果が逆になってしまいました。

Laravel 5.5.50
>>> now()->gt(null)
=> true
>>> now()->gte(null)
=> true
>>> now()->lt(null)
=> false
>>> now()->lte(null)
=> false
Laravel 9.27.0
>>> now()->gt(null)
=> false
>>> now()->gte(null)
=> false
>>> now()->lt(null)
=> true
>>> now()->lte(null)
=> true

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

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

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

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

とりあえず対策

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

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

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

原因究明

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

Laravel 5.5.50, Carbon 1.0系

バージョンアップ前の lte() は以下のようなロジックでした。

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() は以下のようなロジックでした。

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);
    }
}
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);
}
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());
}
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() で引数チェックをして、nullboolean だった場合は E_USER_DEPRECATED のユーザーエラーを生成しています。
(そもそも null は非推奨扱いだった…)

次に、resolveCarbon() で引数を変換し、その結果を <= で比較していました。
resolveCarbon() の中では以下のようなことをやっています。

  • 引数が null だった場合、現在日時を返す
  • 引数が string だった場合、パースして返す
  • 上記以外の場合、DateTime オブジェクトかをチェックしつつ、Carbon インスタンスに変換して返す

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

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

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

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

さいごに

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

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?