Skip to content

Commit

Permalink
[Clock] Add DateTime: an immutable implementation with strict error…
Browse files Browse the repository at this point in the history
… handling and return types
  • Loading branch information
nicolas-grekas committed Aug 21, 2023
1 parent 3265ec2 commit 6bd09cf
Show file tree
Hide file tree
Showing 11 changed files with 221 additions and 36 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Component/Clock/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
6.4
---

* Add `DateTime`: an immutable implementation with strict error handling and return types
* Throw `DateMalformedStringException`/`DateInvalidTimeZoneException` when appropriate
* Add `$modifier` argument to the `now()` helper

Expand Down
6 changes: 5 additions & 1 deletion src/Symfony/Component/Clock/Clock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(): DateTime
{
$now = ($this->clock ?? self::get())->now();

if (!$now instanceof DateTime) {
$now = DateTime::createFromInterface($now);
}

return isset($this->timezone) ? $now->setTimezone($this->timezone) : $now;
}

Expand Down
7 changes: 6 additions & 1 deletion src/Symfony/Component/Clock/ClockAwareTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ public function setClock(ClockInterface $clock): void
$this->clock = $clock;
}

/**
* @return DateTime
*/
protected function now(): \DateTimeImmutable
{
return ($this->clock ??= new Clock())->now();
$now = ($this->clock ??= new Clock())->now();

return $now instanceof DateTime ? $now : DateTime::createFromInterface($now);
}
}
130 changes: 130 additions & 0 deletions src/Symfony/Component/Clock/DateTime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*/
final class DateTime 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'];

Check failure on line 50 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedThisPropertyAssignment

src/Symfony/Component/Clock/DateTime.php:50:13: UndefinedThisPropertyAssignment: Instance property Symfony\Component\Clock\DateTime::$date is not defined (see https://psalm.dev/040)
$this->timezone_type = $now['timezone_type'];

Check failure on line 51 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedThisPropertyAssignment

src/Symfony/Component/Clock/DateTime.php:51:13: UndefinedThisPropertyAssignment: Instance property Symfony\Component\Clock\DateTime::$timezone_type is not defined (see https://psalm.dev/040)
$this->timezone = $now['timezone'];

Check failure on line 52 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedThisPropertyAssignment

src/Symfony/Component/Clock/DateTime.php:52:13: UndefinedThisPropertyAssignment: Instance property Symfony\Component\Clock\DateTime::$timezone is not defined (see https://psalm.dev/040)
$this->__wakeup();

return;
}

$this->__unserialize((array) $now);

Check failure on line 58 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/Clock/DateTime.php:58:16: UndefinedMethod: Method Symfony\Component\Clock\DateTime::__unserialize does not exist (see https://psalm.dev/022)
}

/**
* @throws \DateMalformedStringException When $format or $datetime are invalid
*/
public static function createFromFormat(string $format, string $datetime, \DateTimeZone $timezone = null): static

Check failure on line 64 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingImmutableAnnotation

src/Symfony/Component/Clock/DateTime.php:64:5: MissingImmutableAnnotation: DateTimeImmutable::createFromFormat is marked @psalm-external-mutation-free, but Symfony\Component\Clock\DateTime::createFromFormat is not marked @psalm-external-mutation-free (see https://psalm.dev/213)
{
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

Check failure on line 74 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingImmutableAnnotation

src/Symfony/Component/Clock/DateTime.php:74:5: MissingImmutableAnnotation: DateTimeImmutable::createFromMutable is marked @psalm-external-mutation-free, but Symfony\Component\Clock\DateTime::createFromMutable is not marked @psalm-external-mutation-free (see https://psalm.dev/213)
{
return parent::createFromMutable($object);
}

public function add(\DateInterval $interval): static

Check failure on line 79 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingImmutableAnnotation

src/Symfony/Component/Clock/DateTime.php:79:5: MissingImmutableAnnotation: DateTimeImmutable::add is marked @psalm-external-mutation-free, but Symfony\Component\Clock\DateTime::add is not marked @psalm-external-mutation-free (see https://psalm.dev/213)
{
return parent::add($interval);
}

public function sub(\DateInterval $interval): static

Check failure on line 84 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingImmutableAnnotation

src/Symfony/Component/Clock/DateTime.php:84:5: MissingImmutableAnnotation: DateTimeImmutable::sub is marked @psalm-external-mutation-free, but Symfony\Component\Clock\DateTime::sub is not marked @psalm-external-mutation-free (see https://psalm.dev/213)
{
return parent::sub($interval);
}

/**
* @throws \DateMalformedStringException When $modifier is invalid
*/
public function modify(string $modifier): static

Check failure on line 92 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

MissingImmutableAnnotation

src/Symfony/Component/Clock/DateTime.php:92:5: MissingImmutableAnnotation: DateTimeImmutable::modify is marked @psalm-external-mutation-free, but Symfony\Component\Clock\DateTime::modify is not marked @psalm-external-mutation-free (see https://psalm.dev/213)
{
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);

Check failure on line 98 in src/Symfony/Component/Clock/DateTime.php

View workflow job for this annotation

GitHub Actions / Psalm

FalsableReturnStatement

src/Symfony/Component/Clock/DateTime.php:98:16: FalsableReturnStatement: The declared return type 'Symfony\Component\Clock\DateTime' for Symfony\Component\Clock\DateTime::modify does not allow false, but the function returns 'Symfony\Component\Clock\DateTime|false' (see https://psalm.dev/137)
}

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 DateTime object has no timezone.');
}
}
18 changes: 7 additions & 11 deletions src/Symfony/Component/Clock/MockClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*/
final class MockClock implements ClockInterface
{
private \DateTimeImmutable $now;
private DateTime $now;

/**
* @throws \DateMalformedStringException When $now is invalid
Expand All @@ -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 DateTime($now, $timezone ?? new \DateTimeZone('UTC'));
} elseif (!$now instanceof DateTime) {
$now = DateTime::createFromInterface($now);
}

$this->now = null !== $timezone ? $now->setTimezone($timezone) : $now;
}

public function now(): \DateTimeImmutable
public function now(): DateTime
{
return clone $this->now;
}
Expand All @@ -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 = DateTime::createFromInterface(new \DateTimeImmutable($now, $timezone))->setTimezone($timezone);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Clock/MonotonicClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(): DateTime
{
[$s, $us] = hrtime();

Expand All @@ -56,7 +56,7 @@ public function now(): \DateTimeImmutable

$now = '@'.($s + $this->sOffset).'.'.$now;

return (new \DateTimeImmutable($now, $this->timezone))->setTimezone($this->timezone);
return DateTime::createFromInterface(new \DateTimeImmutable($now, $this->timezone))->setTimezone($this->timezone);
}

public function sleep(float|int $seconds): void
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Clock/NativeClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(): DateTime
{
return new \DateTimeImmutable('now', $this->timezone);
return DateTime::createFromInterface(new \DateTimeImmutable('now', $this->timezone));
}

public function sleep(float|int $seconds): void
Expand Down
21 changes: 4 additions & 17 deletions src/Symfony/Component/Clock/Resources/now.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,14 @@
/**
* @throws \DateMalformedStringException When the modifier is invalid
*/
function now(string $modifier = null): \DateTimeImmutable
function now(string $modifier = 'now'): DateTime
{
if (null === $modifier || 'now' === $modifier) {
return Clock::get()->now();
if ('now' !== $modifier) {
return new DateTime($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 DateTime ? $now : DateTime::createFromInterface($now);
}
}
3 changes: 2 additions & 1 deletion src/Symfony/Component/Clock/Tests/ClockAwareTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\ClockAwareTrait;
use Symfony\Component\Clock\DateTime;
use Symfony\Component\Clock\MockClock;

class ClockAwareTraitTest extends TestCase
Expand All @@ -25,7 +26,7 @@ public function testTrait()
}
};

$this->assertInstanceOf(\DateTimeImmutable::class, $sut->now());
$this->assertInstanceOf(DateTime::class, $sut->now());

$clock = new MockClock();
$sut = new $sut();
Expand Down
3 changes: 2 additions & 1 deletion src/Symfony/Component/Clock/Tests/ClockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;
use Symfony\Component\Clock\Clock;
use Symfony\Component\Clock\DateTime;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Clock\NativeClock;
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
Expand All @@ -35,7 +36,7 @@ public function testMockClock()

public function testNativeClock()
{
$this->assertInstanceOf(\DateTimeImmutable::class, now());
$this->assertInstanceOf(DateTime::class, now());
$this->assertInstanceOf(NativeClock::class, Clock::get());
}

Expand Down
60 changes: 60 additions & 0 deletions src/Symfony/Component/Clock/Tests/DateTimeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\DateTime;
use Symfony\Component\Clock\Test\ClockSensitiveTrait;

class DateTimeTest extends TestCase
{
use ClockSensitiveTrait;

public function testDateTime()
{
self::mockTime('2010-01-28 15:00:00');

$date = new DateTime();
$this->assertSame('2010-01-28 15:00:00 UTC', $date->format('Y-m-d H:i:s e'));

$date = new DateTime('+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 DateTime('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 = DateTime::createFromFormat('Y-m-d H:i:s', '2010-01-28 15:00:00');

$this->assertInstanceOf(DateTime::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');
DateTime::createFromFormat('Y-m-d H:i:s', 'Bad Date');
}

public function testModify()
{
$date = new DateTime('2010-01-28 15:00:00');
$date = $date->modify('+1 day');

$this->assertInstanceOf(DateTime::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');
}
}

0 comments on commit 6bd09cf

Please sign in to comment.