Skip to content

Commit eabe0e5

Browse files
committed
doctrine: add DateImmutableUtcType and DateTimeImmutableUtcType
Same as DateImmutableType and DateTimeImmutableType, except they convert to UTC before storing in the DB, and set UTC when retrieving. This way we store with UTC in the DB, and the default timezone in PHP doesn't affect it. Currently we worked around this via "date_default_timezone_set('UTC')" globally, but that's hacky and we are trying to get away from it. These types need to be registered with doctrine still, and should be handled by the users, with proper namespacing.
1 parent 78140e7 commit eabe0e5

4 files changed

Lines changed: 484 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dbp\Relay\CoreBundle\Doctrine;
6+
7+
use Doctrine\DBAL\Platforms\AbstractPlatform;
8+
use Doctrine\DBAL\Types\DateImmutableType;
9+
use Doctrine\DBAL\Types\Exception\InvalidFormat;
10+
use Doctrine\DBAL\Types\Exception\InvalidType;
11+
12+
/**
13+
* Custom Doctrine type that extends {@see DateImmutableType} to ensure all date values
14+
* are stored and retrieved in UTC timezone.
15+
*
16+
* This type guarantees timezone consistency by:
17+
* - Converting date values to UTC before persisting to the database
18+
* - Parsing and returning date values in UTC when reading from the database
19+
*/
20+
class DateImmutableUtcType extends DateImmutableType
21+
{
22+
private static \DateTimeZone $utc;
23+
24+
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string
25+
{
26+
if ($value instanceof \DateTimeImmutable) {
27+
$value = $value->setTimezone(self::getUtc());
28+
}
29+
30+
return parent::convertToDatabaseValue($value, $platform);
31+
}
32+
33+
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?\DateTimeImmutable
34+
{
35+
if ($value === null) {
36+
return null;
37+
}
38+
39+
if ($value instanceof \DateTimeImmutable) {
40+
return $value->setTimezone(self::getUtc());
41+
}
42+
43+
if ($value instanceof \DateTime) {
44+
throw InvalidType::new(
45+
$value,
46+
static::class,
47+
['null', 'string', \DateTimeImmutable::class],
48+
);
49+
}
50+
51+
$dateTime = \DateTimeImmutable::createFromFormat(
52+
'!'.$platform->getDateFormatString(),
53+
$value,
54+
self::getUtc(),
55+
);
56+
57+
if ($dateTime === false) {
58+
throw InvalidFormat::new(
59+
$value,
60+
static::class,
61+
$platform->getDateFormatString(),
62+
);
63+
}
64+
65+
return $dateTime;
66+
}
67+
68+
private static function getUtc(): \DateTimeZone
69+
{
70+
return self::$utc ??= new \DateTimeZone('UTC');
71+
}
72+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dbp\Relay\CoreBundle\Doctrine;
6+
7+
use Doctrine\DBAL\Platforms\AbstractPlatform;
8+
use Doctrine\DBAL\Types\DateTimeImmutableType;
9+
use Doctrine\DBAL\Types\Exception\InvalidFormat;
10+
use Doctrine\DBAL\Types\Exception\InvalidType;
11+
12+
/**
13+
* Custom Doctrine type that extends {@see DateTimeImmutableType} to ensure all datetime values
14+
* are stored and retrieved in UTC timezone.
15+
*
16+
* This type guarantees timezone consistency by:
17+
* - Converting datetime values to UTC before persisting to the database
18+
* - Parsing and returning datetime values in UTC when reading from the database
19+
*/
20+
class DateTimeImmutableUtcType extends DateTimeImmutableType
21+
{
22+
private static \DateTimeZone $utc;
23+
24+
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string
25+
{
26+
if ($value instanceof \DateTimeImmutable) {
27+
$value = $value->setTimezone(self::getUtc());
28+
}
29+
30+
return parent::convertToDatabaseValue($value, $platform);
31+
}
32+
33+
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?\DateTimeImmutable
34+
{
35+
if ($value === null) {
36+
return null;
37+
}
38+
39+
if ($value instanceof \DateTimeImmutable) {
40+
return $value->setTimezone(self::getUtc());
41+
}
42+
43+
if ($value instanceof \DateTime) {
44+
throw InvalidType::new(
45+
$value,
46+
static::class,
47+
['null', 'string', \DateTimeImmutable::class],
48+
);
49+
}
50+
51+
$format = $platform->getDateTimeFormatString();
52+
53+
$converted = \DateTimeImmutable::createFromFormat($format, $value, self::getUtc());
54+
55+
if ($converted !== false) {
56+
return $converted;
57+
}
58+
59+
try {
60+
return new \DateTimeImmutable($value, self::getUtc());
61+
} catch (\Exception $e) {
62+
throw InvalidFormat::new($value, static::class, $format, $e);
63+
}
64+
}
65+
66+
private static function getUtc(): \DateTimeZone
67+
{
68+
return self::$utc ??= new \DateTimeZone('UTC');
69+
}
70+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Dbp\Relay\CoreBundle\Tests\Doctrine;
6+
7+
use Dbp\Relay\CoreBundle\Doctrine\DateImmutableUtcType;
8+
use Doctrine\DBAL\Platforms\AbstractPlatform;
9+
use Doctrine\DBAL\Types\ConversionException;
10+
use PHPUnit\Framework\MockObject\MockObject;
11+
use PHPUnit\Framework\TestCase;
12+
13+
class DateImmutableUtcTypeTest extends TestCase
14+
{
15+
private AbstractPlatform&MockObject $platform;
16+
private DateImmutableUtcType $type;
17+
18+
protected function setUp(): void
19+
{
20+
$this->platform = $this->createMock(AbstractPlatform::class);
21+
$this->type = new DateImmutableUtcType();
22+
}
23+
24+
public function testConvertsDateTimeImmutableInstanceToDatabaseValue(): void
25+
{
26+
$date = new \DateTimeImmutable('2016-01-01 12:34:56', new \DateTimeZone('UTC'));
27+
28+
$this->platform->expects(self::once())
29+
->method('getDateFormatString')
30+
->willReturn('Y-m-d');
31+
32+
self::assertSame(
33+
'2016-01-01',
34+
$this->type->convertToDatabaseValue($date, $this->platform),
35+
);
36+
}
37+
38+
public function testConvertsDateTimeImmutableInDifferentTimezoneToDatabaseValueInUtc(): void
39+
{
40+
// 2016-01-01 02:00:00 in +05:00 is 2015-12-31 21:00:00 UTC
41+
$date = new \DateTimeImmutable('2016-01-01 02:00:00', new \DateTimeZone('+05:00'));
42+
43+
$this->platform->expects(self::once())
44+
->method('getDateFormatString')
45+
->willReturn('Y-m-d');
46+
47+
self::assertSame(
48+
'2015-12-31',
49+
$this->type->convertToDatabaseValue($date, $this->platform),
50+
);
51+
}
52+
53+
public function testConvertsNullToDatabaseValue(): void
54+
{
55+
self::assertNull($this->type->convertToDatabaseValue(null, $this->platform));
56+
}
57+
58+
public function testDoesNotSupportMutableDateTimeToDatabaseValueConversion(): void
59+
{
60+
$this->expectException(ConversionException::class);
61+
62+
$this->type->convertToDatabaseValue(new \DateTime(), $this->platform);
63+
}
64+
65+
public function testConvertsDateTimeImmutableInstanceToPHPValueInUtc(): void
66+
{
67+
$date = new \DateTimeImmutable('2016-01-01 12:00:00', new \DateTimeZone('+05:00'));
68+
69+
$result = $this->type->convertToPHPValue($date, $this->platform);
70+
71+
self::assertInstanceOf(\DateTimeImmutable::class, $result);
72+
self::assertSame('UTC', $result->getTimezone()->getName());
73+
self::assertSame('2016-01-01 07:00:00', $result->format('Y-m-d H:i:s'));
74+
}
75+
76+
public function testConvertsNullToPHPValue(): void
77+
{
78+
self::assertNull($this->type->convertToPHPValue(null, $this->platform));
79+
}
80+
81+
public function testConvertsDateStringToPHPValue(): void
82+
{
83+
$this->platform->expects(self::once())
84+
->method('getDateFormatString')
85+
->willReturn('Y-m-d');
86+
87+
$date = $this->type->convertToPHPValue('2016-01-01', $this->platform);
88+
89+
self::assertInstanceOf(\DateTimeImmutable::class, $date);
90+
self::assertSame('2016-01-01', $date->format('Y-m-d'));
91+
self::assertSame('UTC', $date->getTimezone()->getName());
92+
}
93+
94+
public function testResetTimeFractionsWhenConvertingToPHPValue(): void
95+
{
96+
$this->platform
97+
->method('getDateFormatString')
98+
->willReturn('Y-m-d');
99+
100+
$date = $this->type->convertToPHPValue('2016-01-01', $this->platform);
101+
102+
self::assertNotNull($date);
103+
self::assertSame('2016-01-01 00:00:00.000000', $date->format('Y-m-d H:i:s.u'));
104+
}
105+
106+
public function testConvertedPHPValueAlwaysHasUtcTimezone(): void
107+
{
108+
$this->platform
109+
->method('getDateFormatString')
110+
->willReturn('Y-m-d');
111+
112+
$date = $this->type->convertToPHPValue('2016-06-15', $this->platform);
113+
114+
self::assertNotNull($date);
115+
self::assertSame('UTC', $date->getTimezone()->getName());
116+
}
117+
118+
public function testThrowsExceptionDuringConversionToPHPValueWithInvalidDateString(): void
119+
{
120+
$this->expectException(ConversionException::class);
121+
122+
$this->platform
123+
->method('getDateFormatString')
124+
->willReturn('Y-m-d');
125+
126+
$this->type->convertToPHPValue('invalid date string', $this->platform);
127+
}
128+
129+
public function testThrowsExceptionWhenConvertingMutableDateTimeToPHPValue(): void
130+
{
131+
$this->expectException(ConversionException::class);
132+
133+
$this->type->convertToPHPValue(new \DateTime(), $this->platform);
134+
}
135+
136+
public function testConvertToDatabaseValuePreservesUtcDate(): void
137+
{
138+
$date = new \DateTimeImmutable('2023-07-15 00:00:00', new \DateTimeZone('UTC'));
139+
140+
$this->platform->expects(self::once())
141+
->method('getDateFormatString')
142+
->willReturn('Y-m-d');
143+
144+
self::assertSame(
145+
'2023-07-15',
146+
$this->type->convertToDatabaseValue($date, $this->platform),
147+
);
148+
}
149+
150+
public function testConvertToPHPValueWithDateTimeImmutableAlreadyInUtc(): void
151+
{
152+
$date = new \DateTimeImmutable('2023-07-15 10:30:00', new \DateTimeZone('UTC'));
153+
154+
$result = $this->type->convertToPHPValue($date, $this->platform);
155+
156+
self::assertInstanceOf(\DateTimeImmutable::class, $result);
157+
self::assertSame('UTC', $result->getTimezone()->getName());
158+
self::assertSame('2023-07-15 10:30:00', $result->format('Y-m-d H:i:s'));
159+
}
160+
161+
public function testRoundTripConversionPreservesDateInUtc(): void
162+
{
163+
$this->platform
164+
->method('getDateFormatString')
165+
->willReturn('Y-m-d');
166+
167+
$original = new \DateTimeImmutable('2023-12-25 00:00:00', new \DateTimeZone('UTC'));
168+
169+
$dbValue = $this->type->convertToDatabaseValue($original, $this->platform);
170+
$phpValue = $this->type->convertToPHPValue($dbValue, $this->platform);
171+
172+
self::assertNotNull($phpValue);
173+
self::assertSame('2023-12-25', $phpValue->format('Y-m-d'));
174+
self::assertSame('UTC', $phpValue->getTimezone()->getName());
175+
}
176+
177+
public function testRoundTripConversionWithNonUtcTimezone(): void
178+
{
179+
$this->platform
180+
->method('getDateFormatString')
181+
->willReturn('Y-m-d');
182+
183+
// 2023-01-01 01:00:00 in +05:00 is 2022-12-31 20:00:00 UTC
184+
$original = new \DateTimeImmutable('2023-01-01 01:00:00', new \DateTimeZone('+05:00'));
185+
186+
$dbValue = $this->type->convertToDatabaseValue($original, $this->platform);
187+
self::assertSame('2022-12-31', $dbValue);
188+
189+
$phpValue = $this->type->convertToPHPValue($dbValue, $this->platform);
190+
191+
self::assertNotNull($phpValue);
192+
self::assertSame('2022-12-31', $phpValue->format('Y-m-d'));
193+
self::assertSame('UTC', $phpValue->getTimezone()->getName());
194+
}
195+
}

0 commit comments

Comments
 (0)