diff --git a/.github/expected-missing-return-types.diff b/.github/expected-missing-return-types.diff index adaeed91c254d..88c32b2d32184 100644 --- a/.github/expected-missing-return-types.diff +++ b/.github/expected-missing-return-types.diff @@ -1512,6 +1512,16 @@ diff --git a/src/Symfony/Component/Cache/Traits/FilesystemCommonTrait.php b/src/ + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); +diff --git a/src/Symfony/Component/Clock/ClockAwareTrait.php b/src/Symfony/Component/Clock/ClockAwareTrait.php +--- a/src/Symfony/Component/Clock/ClockAwareTrait.php ++++ b/src/Symfony/Component/Clock/ClockAwareTrait.php +@@ -33,5 +33,5 @@ trait ClockAwareTrait + * @return DatePoint + */ +- protected function now(): \DateTimeImmutable ++ protected function now(): DatePoint + { + $now = ($this->clock ??= new Clock())->now(); diff --git a/src/Symfony/Component/Config/ConfigCacheInterface.php b/src/Symfony/Component/Config/ConfigCacheInterface.php --- a/src/Symfony/Component/Config/ConfigCacheInterface.php +++ b/src/Symfony/Component/Config/ConfigCacheInterface.php diff --git a/src/Symfony/Component/Clock/CHANGELOG.md b/src/Symfony/Component/Clock/CHANGELOG.md index 254e71c794b5e..3b13157397f0f 100644 --- a/src/Symfony/Component/Clock/CHANGELOG.md +++ b/src/Symfony/Component/Clock/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.4 --- + * Add `DatePoint`: an immutable DateTime implementation with stricter error handling and return types * Throw `DateMalformedStringException`/`DateInvalidTimeZoneException` when appropriate * Add `$modifier` argument to the `now()` helper diff --git a/src/Symfony/Component/Clock/Clock.php b/src/Symfony/Component/Clock/Clock.php index a738eb0b31fb0..311e8fc07abd0 100644 --- a/src/Symfony/Component/Clock/Clock.php +++ b/src/Symfony/Component/Clock/Clock.php @@ -44,10 +44,14 @@ public static function set(PsrClockInterface $clock): void self::$globalClock = $clock instanceof ClockInterface ? $clock : new self($clock); } - public function now(): \DateTimeImmutable + public function now(): DatePoint { $now = ($this->clock ?? self::get())->now(); + if (!$now instanceof DatePoint) { + $now = DatePoint::createFromInterface($now); + } + return isset($this->timezone) ? $now->setTimezone($this->timezone) : $now; } diff --git a/src/Symfony/Component/Clock/ClockAwareTrait.php b/src/Symfony/Component/Clock/ClockAwareTrait.php index 02698d7fb222f..44ce044648894 100644 --- a/src/Symfony/Component/Clock/ClockAwareTrait.php +++ b/src/Symfony/Component/Clock/ClockAwareTrait.php @@ -29,8 +29,13 @@ public function setClock(ClockInterface $clock): void $this->clock = $clock; } + /** + * @return DatePoint + */ protected function now(): \DateTimeImmutable { - return ($this->clock ??= new Clock())->now(); + $now = ($this->clock ??= new Clock())->now(); + + return $now instanceof DatePoint ? $now : DatePoint::createFromInterface($now); } } diff --git a/src/Symfony/Component/Clock/DatePoint.php b/src/Symfony/Component/Clock/DatePoint.php new file mode 100644 index 0000000000000..dec8c1b38a2c3 --- /dev/null +++ b/src/Symfony/Component/Clock/DatePoint.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Clock; + +/** + * An immmutable DateTime with stricter error handling and return types than the native one. + * + * @author Nicolas Grekas
+ */ +final class DatePoint extends \DateTimeImmutable +{ + /** + * @throws \DateMalformedStringException When $datetime is invalid + */ + public function __construct(string $datetime = 'now', \DateTimeZone $timezone = null, parent $reference = null) + { + $now = $reference ?? Clock::get()->now(); + + if ('now' !== $datetime) { + if (!$now instanceof static) { + $now = static::createFromInterface($now); + } + + if (\PHP_VERSION_ID < 80300) { + try { + $timezone = (new parent($datetime, $timezone ?? $now->getTimezone()))->getTimezone(); + } catch (\Exception $e) { + throw new \DateMalformedStringException($e->getMessage(), $e->getCode(), $e); + } + } else { + $timezone = (new parent($datetime, $timezone ?? $now->getTimezone()))->getTimezone(); + } + + $now = $now->setTimezone($timezone)->modify($datetime); + } elseif (null !== $timezone) { + $now = $now->setTimezone($timezone); + } + + if (\PHP_VERSION_ID < 80200) { + $now = (array) $now; + $this->date = $now['date']; + $this->timezone_type = $now['timezone_type']; + $this->timezone = $now['timezone']; + $this->__wakeup(); + + return; + } + + $this->__unserialize((array) $now); + } + + /** + * @throws \DateMalformedStringException When $format or $datetime are invalid + */ + public static function createFromFormat(string $format, string $datetime, \DateTimeZone $timezone = null): static + { + return parent::createFromFormat($format, $datetime, $timezone) ?: throw new \DateMalformedStringException(static::getLastErrors()['errors'][0] ?? 'Invalid date string or format.'); + } + + public static function createFromInterface(\DateTimeInterface $object): static + { + return parent::createFromInterface($object); + } + + public static function createFromMutable(\DateTime $object): static + { + return parent::createFromMutable($object); + } + + public function add(\DateInterval $interval): static + { + return parent::add($interval); + } + + public function sub(\DateInterval $interval): static + { + return parent::sub($interval); + } + + /** + * @throws \DateMalformedStringException When $modifier is invalid + */ + public function modify(string $modifier): static + { + if (\PHP_VERSION_ID < 80300) { + return @parent::modify($modifier) ?: throw new \DateMalformedStringException(error_get_last()['message'] ?? sprintf('Invalid modifier: "%s".', $modifier)); + } + + return parent::modify($modifier); + } + + public function setTimestamp(int $value): static + { + return parent::setTimestamp($value); + } + + public function setDate(int $year, int $month, int $day): static + { + return parent::setDate($year, $month, $day); + } + + public function setISODate(int $year, int $week, int $day = 1): static + { + return parent::setISODate($year, $week, $day); + } + + public function setTime(int $hour, int $minute, int $second = 0, int $microsecond = 0): static + { + return parent::setTime($hour, $minute, $second, $microsecond); + } + + public function setTimezone(\DateTimeZone $timezone): static + { + return parent::setTimezone($timezone); + } + + public function getTimezone(): \DateTimeZone + { + return parent::getTimezone() ?: throw new \DateInvalidTimeZoneException('The DatePoint object has no timezone.'); + } +} diff --git a/src/Symfony/Component/Clock/MockClock.php b/src/Symfony/Component/Clock/MockClock.php index b5e4b2e8f5ed9..b742c4331e052 100644 --- a/src/Symfony/Component/Clock/MockClock.php +++ b/src/Symfony/Component/Clock/MockClock.php @@ -20,7 +20,7 @@ */ final class MockClock implements ClockInterface { - private \DateTimeImmutable $now; + private DatePoint $now; /** * @throws \DateMalformedStringException When $now is invalid @@ -38,20 +38,16 @@ public function __construct(\DateTimeImmutable|string $now = 'now', \DateTimeZon } } - if (\PHP_VERSION_ID >= 80300 && \is_string($now)) { - $now = new \DateTimeImmutable($now, $timezone ?? new \DateTimeZone('UTC')); - } elseif (\is_string($now)) { - try { - $now = new \DateTimeImmutable($now, $timezone ?? new \DateTimeZone('UTC')); - } catch (\Exception $e) { - throw new \DateMalformedStringException($e->getMessage(), $e->getCode(), $e); - } + if (\is_string($now)) { + $now = new DatePoint($now, $timezone ?? new \DateTimeZone('UTC')); + } elseif (!$now instanceof DatePoint) { + $now = DatePoint::createFromInterface($now); } $this->now = null !== $timezone ? $now->setTimezone($timezone) : $now; } - public function now(): \DateTimeImmutable + public function now(): DatePoint { return clone $this->now; } @@ -62,7 +58,7 @@ public function sleep(float|int $seconds): void $now = substr_replace(sprintf('@%07.0F', $now), '.', -6, 0); $timezone = $this->now->getTimezone(); - $this->now = (new \DateTimeImmutable($now, $timezone))->setTimezone($timezone); + $this->now = DatePoint::createFromInterface(new \DateTimeImmutable($now, $timezone))->setTimezone($timezone); } /** diff --git a/src/Symfony/Component/Clock/MonotonicClock.php b/src/Symfony/Component/Clock/MonotonicClock.php index bf4d34ce706fd..a834dde1dbc56 100644 --- a/src/Symfony/Component/Clock/MonotonicClock.php +++ b/src/Symfony/Component/Clock/MonotonicClock.php @@ -38,7 +38,7 @@ public function __construct(\DateTimeZone|string $timezone = null) $this->timezone = \is_string($timezone ??= date_default_timezone_get()) ? $this->withTimeZone($timezone)->timezone : $timezone; } - public function now(): \DateTimeImmutable + public function now(): DatePoint { [$s, $us] = hrtime(); @@ -56,7 +56,7 @@ public function now(): \DateTimeImmutable $now = '@'.($s + $this->sOffset).'.'.$now; - return (new \DateTimeImmutable($now, $this->timezone))->setTimezone($this->timezone); + return DatePoint::createFromInterface(new \DateTimeImmutable($now, $this->timezone))->setTimezone($this->timezone); } public function sleep(float|int $seconds): void diff --git a/src/Symfony/Component/Clock/NativeClock.php b/src/Symfony/Component/Clock/NativeClock.php index 7d4fe36d46100..9480dae5f6957 100644 --- a/src/Symfony/Component/Clock/NativeClock.php +++ b/src/Symfony/Component/Clock/NativeClock.php @@ -28,9 +28,9 @@ public function __construct(\DateTimeZone|string $timezone = null) $this->timezone = \is_string($timezone ??= date_default_timezone_get()) ? $this->withTimeZone($timezone)->timezone : $timezone; } - public function now(): \DateTimeImmutable + public function now(): DatePoint { - return new \DateTimeImmutable('now', $this->timezone); + return DatePoint::createFromInterface(new \DateTimeImmutable('now', $this->timezone)); } public function sleep(float|int $seconds): void diff --git a/src/Symfony/Component/Clock/Resources/now.php b/src/Symfony/Component/Clock/Resources/now.php index d4999fd922ad5..47d086c67d11d 100644 --- a/src/Symfony/Component/Clock/Resources/now.php +++ b/src/Symfony/Component/Clock/Resources/now.php @@ -15,27 +15,14 @@ /** * @throws \DateMalformedStringException When the modifier is invalid */ - function now(string $modifier = null): \DateTimeImmutable + function now(string $modifier = 'now'): DatePoint { - if (null === $modifier || 'now' === $modifier) { - return Clock::get()->now(); + if ('now' !== $modifier) { + return new DatePoint($modifier); } $now = Clock::get()->now(); - if (\PHP_VERSION_ID < 80300) { - try { - $tz = (new \DateTimeImmutable($modifier, $now->getTimezone()))->getTimezone(); - } catch (\Exception $e) { - throw new \DateMalformedStringException($e->getMessage(), $e->getCode(), $e); - } - $now = $now->setTimezone($tz); - - return @$now->modify($modifier) ?: throw new \DateMalformedStringException(error_get_last()['message'] ?? sprintf('Invalid date modifier "%s".', $modifier)); - } - - $tz = (new \DateTimeImmutable($modifier, $now->getTimezone()))->getTimezone(); - - return $now->setTimezone($tz)->modify($modifier); + return $now instanceof DatePoint ? $now : DatePoint::createFromInterface($now); } } diff --git a/src/Symfony/Component/Clock/Tests/ClockAwareTraitTest.php b/src/Symfony/Component/Clock/Tests/ClockAwareTraitTest.php index c472541c64934..bb2cfceb78e9f 100644 --- a/src/Symfony/Component/Clock/Tests/ClockAwareTraitTest.php +++ b/src/Symfony/Component/Clock/Tests/ClockAwareTraitTest.php @@ -13,19 +13,16 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Clock\ClockAwareTrait; +use Symfony\Component\Clock\DatePoint; use Symfony\Component\Clock\MockClock; class ClockAwareTraitTest extends TestCase { public function testTrait() { - $sut = new class() { - use ClockAwareTrait { - now as public; - } - }; + $sut = new ClockAwareTestImplem(); - $this->assertInstanceOf(\DateTimeImmutable::class, $sut->now()); + $this->assertInstanceOf(DatePoint::class, $sut->now()); $clock = new MockClock(); $sut = new $sut(); @@ -38,3 +35,10 @@ public function testTrait() $this->assertSame(1.0, round($sut->now()->getTimestamp() - $ts, 1)); } } + +class ClockAwareTestImplem +{ + use ClockAwareTrait { + now as public; + } +} diff --git a/src/Symfony/Component/Clock/Tests/ClockTest.php b/src/Symfony/Component/Clock/Tests/ClockTest.php index bf71543d3ce18..9b0b1a76ae405 100644 --- a/src/Symfony/Component/Clock/Tests/ClockTest.php +++ b/src/Symfony/Component/Clock/Tests/ClockTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; use Symfony\Component\Clock\Clock; +use Symfony\Component\Clock\DatePoint; use Symfony\Component\Clock\MockClock; use Symfony\Component\Clock\NativeClock; use Symfony\Component\Clock\Test\ClockSensitiveTrait; @@ -35,7 +36,7 @@ public function testMockClock() public function testNativeClock() { - $this->assertInstanceOf(\DateTimeImmutable::class, now()); + $this->assertInstanceOf(DatePoint::class, now()); $this->assertInstanceOf(NativeClock::class, Clock::get()); } diff --git a/src/Symfony/Component/Clock/Tests/DatePointTest.php b/src/Symfony/Component/Clock/Tests/DatePointTest.php new file mode 100644 index 0000000000000..4ebd0da7955c6 --- /dev/null +++ b/src/Symfony/Component/Clock/Tests/DatePointTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Clock\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Clock\DatePoint; +use Symfony\Component\Clock\Test\ClockSensitiveTrait; + +class DatePointTest extends TestCase +{ + use ClockSensitiveTrait; + + public function testDatePoint() + { + self::mockTime('2010-01-28 15:00:00'); + + $date = new DatePoint(); + $this->assertSame('2010-01-28 15:00:00 UTC', $date->format('Y-m-d H:i:s e')); + + $date = new DatePoint('+1 day Europe/Paris'); + $this->assertSame('2010-01-29 16:00:00 Europe/Paris', $date->format('Y-m-d H:i:s e')); + + $date = new DatePoint('2022-01-28 15:00:00 Europe/Paris'); + $this->assertSame('2022-01-28 15:00:00 Europe/Paris', $date->format('Y-m-d H:i:s e')); + } + + public function testCreateFromFormat() + { + $date = DatePoint::createFromFormat('Y-m-d H:i:s', '2010-01-28 15:00:00'); + + $this->assertInstanceOf(DatePoint::class, $date); + $this->assertSame('2010-01-28 15:00:00', $date->format('Y-m-d H:i:s')); + + $this->expectException(\DateMalformedStringException::class); + $this->expectExceptionMessage('A four digit year could not be found'); + DatePoint::createFromFormat('Y-m-d H:i:s', 'Bad Date'); + } + + public function testModify() + { + $date = new DatePoint('2010-01-28 15:00:00'); + $date = $date->modify('+1 day'); + + $this->assertInstanceOf(DatePoint::class, $date); + $this->assertSame('2010-01-29 15:00:00', $date->format('Y-m-d H:i:s')); + + $this->expectException(\DateMalformedStringException::class); + $this->expectExceptionMessage('Failed to parse time string (Bad Date)'); + $date->modify('Bad Date'); + } +}