Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/TimeZoneOffset.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ public function getOffset(Instant $pointInTime): int
#[Override]
public function toNativeDateTimeZone(): DateTimeZone
{
return new DateTimeZone($this->getId());
$id = $this->getId();

if ($id === 'Z') {
return clone self::z();
}

return new DateTimeZone($id);
}

private static function z(): DateTimeZone
{
/** @var DateTimeZone|null $z */
static $z = null;

return $z ??= new DateTimeZone('Z');
}
}
87 changes: 47 additions & 40 deletions src/ZonedDateTime.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Brick\DateTime\Parser\DateTimeParser;
use Brick\DateTime\Parser\DateTimeParseResult;
use Brick\DateTime\Parser\IsoParsers;
use Brick\DateTime\Utility\Math;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
Expand Down Expand Up @@ -76,21 +77,36 @@ private function __construct(
*/
public static function of(LocalDateTime $dateTime, TimeZone $timeZone): ZonedDateTime
{
$dtz = $timeZone->toNativeDateTimeZone();
$dt = new DateTime((string) $dateTime->withNano(0), $dtz);

$instant = Instant::of($dt->getTimestamp(), $dateTime->getNano());
$localEpochSecond = $dateTime->getDate()->toEpochDay() * LocalTime::SECONDS_PER_DAY
+ $dateTime->getTime()->toSecondOfDay();

if ($timeZone instanceof TimeZoneOffset) {
$timeZoneOffset = $timeZone;
} else {
$timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dt->getOffset());
// A fixed offset has no DST gap or overlap.
$instant = Instant::of($localEpochSecond - $timeZone->getTotalSeconds(), $dateTime->getNano());

return new ZonedDateTime($dateTime, $timeZone, $timeZone, $instant);
}

$text = (string) $dateTime->withNano(0);

// DateTime requires an explicit sign for years beyond 9999.
if ($dateTime->getYear() > 9999) {
$text = '+' . $text;
}

// The time can be affected if the date-time is not valid for the given time-zone due to a DST transition,
// so we have to re-compute the local date-time from the DateTime object.
// DateTime does not support nanos of seconds, so we just copy the nanos back from the original date-time.
$dateTime = LocalDateTime::parse($dt->format('Y-m-d\TH:i:s'))->withNano($dateTime->getNano());
$dt = new DateTime($text, $timeZone->toNativeDateTimeZone());

$epochSecond = $dt->getTimestamp();
$timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dt->getOffset());

// Shifted forward in a DST gap.
$shift = $epochSecond + $timeZoneOffset->getTotalSeconds() - $localEpochSecond;

if ($shift !== 0) {
$dateTime = $dateTime->plusSeconds($shift);
}

$instant = Instant::of($epochSecond, $dateTime->getNano());

return new ZonedDateTime($dateTime, $timeZoneOffset, $timeZone, $instant);
}
Expand All @@ -102,23 +118,25 @@ public static function of(LocalDateTime $dateTime, TimeZone $timeZone): ZonedDat
*/
public static function ofInstant(Instant $instant, TimeZone $timeZone): ZonedDateTime
{
$dateTimeZone = $timeZone->toNativeDateTimeZone();

// We need to pass a DateTimeZone to avoid a PHP warning...
$dateTime = new DateTime('@' . $instant->getEpochSecond(), $dateTimeZone);

// ... but this DateTimeZone is ignored because of the timestamp, so we set it again.
$dateTime->setTimezone($dateTimeZone);

$localDateTime = LocalDateTime::parse($dateTime->format('Y-m-d\TH:i:s'));
$localDateTime = $localDateTime->withNano($instant->getNano());

if ($timeZone instanceof TimeZoneOffset) {
$timeZoneOffset = $timeZone;
} else {
$timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dateTime->getOffset());
$timeZoneOffset = TimeZoneOffset::ofTotalSeconds($timeZone->getOffset($instant));
}

// Split before adding the offset, to avoid overflowing.
$epochSecond = $instant->getEpochSecond();
$epochDay = Math::floorDiv($epochSecond, LocalTime::SECONDS_PER_DAY);
$secondOfDay = Math::floorMod($epochSecond, LocalTime::SECONDS_PER_DAY) + $timeZoneOffset->getTotalSeconds();

$epochDay += Math::floorDiv($secondOfDay, LocalTime::SECONDS_PER_DAY);
$secondOfDay = Math::floorMod($secondOfDay, LocalTime::SECONDS_PER_DAY);

$localDateTime = new LocalDateTime(
LocalDate::ofEpochDay($epochDay),
LocalTime::ofSecondOfDay($secondOfDay, $instant->getNano()),
);

return new ZonedDateTime($localDateTime, $timeZoneOffset, $timeZone, $instant);
}

Expand Down Expand Up @@ -698,29 +716,18 @@ public function isPast(?Clock $clock = null): bool
*/
public function toNativeDateTime(): DateTime
{
$second = $this->localDateTime->getSecond();

// round down to the microsecond
$nano = $this->localDateTime->getNano();
$nano = 1000 * intdiv($nano, 1000);

$dateTime = (string) $this->localDateTime->withNano($nano);
$dateTimeZone = $this->timeZone->toNativeDateTimeZone();

$format = 'Y-m-d\TH:i';
$micro = intdiv($this->instant->getNano(), 1000);

if ($second !== 0 || $nano !== 0) {
$format .= ':s';
// From the instant, as the local date-time is ambiguous in a DST overlap.
$timestamp = $this->instant->getEpochSecond() . '.' . str_pad((string) $micro, 6, '0', STR_PAD_LEFT);

if ($nano !== 0) {
$format .= '.u';
}
}

$nativeDateTime = DateTime::createFromFormat($format, $dateTime, $dateTimeZone);
$nativeDateTime = DateTime::createFromFormat('U.u', $timestamp);

assert($nativeDateTime !== false);

$nativeDateTime->setTimezone($this->timeZone->toNativeDateTimeZone());

return $nativeDateTime;
}

Expand Down
7 changes: 7 additions & 0 deletions tests/LocalDateTimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,10 @@ public static function providerAtTimeZone(): array
['1960-04-30T06:00:00.123456', '+02:00', -305236800, 123456000],
['2008-01-02T12:34:56', 'Europe/Paris', 1199273696, 0],
['2008-01-02T12:34:56.123', 'America/Los_Angeles', 1199306096, 123000000],
['10000-01-01T00:00', 'Z', 253402300800, 0],
['999999-12-31T23:59:59.999999999', 'Z', 31494784780799, 999999999],
['-999999-01-01T00:00', 'Z', -31619087596800, 0],
['10000-07-01T12:00:00.123', 'Europe/Paris', 253418061600, 123000000],
];
}

Expand Down Expand Up @@ -1180,6 +1184,9 @@ public static function providerToNativeDateTime(): array
['2011-07-31T23:59:59', '2011-07-31T23:59:59.000000+0000'],
['2011-07-31T23:59:59.02', '2011-07-31T23:59:59.020000+0000'],
['2011-07-31T23:59:59.000123456', '2011-07-31T23:59:59.000123+0000'],
['-0001-01-01T00:00', '-0001-01-01T00:00:00.000000+0000'],
['10000-01-01T00:00', '10000-01-01T00:00:00.000000+0000'],
['999999-12-31T23:59:59.999999999', '999999-12-31T23:59:59.999999+0000'],
];
}

Expand Down
12 changes: 12 additions & 0 deletions tests/TimeZoneOffsetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,18 @@ public function testToNativeDateTimeZone(int $totalSeconds, string $string): voi
self::assertSame($totalSeconds, $dateTimeZone->getOffset(new DateTimeImmutable()));
}

public function testToNativeDateTimeZoneReturnsNewInstance(): void
{
$timeZoneOffset = TimeZoneOffset::utc();

$first = $timeZoneOffset->toNativeDateTimeZone();
$second = $timeZoneOffset->toNativeDateTimeZone();

self::assertNotSame($first, $second);
self::assertSame('Z', $first->getName());
self::assertSame('Z', $second->getName());
}

public static function providerToNativeDateTimeZone(): iterable
{
yield from [
Expand Down
127 changes: 127 additions & 0 deletions tests/ZonedDateTimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Brick\DateTime\Tests;

use Brick\DateTime\Clock\FixedClock;
use Brick\DateTime\DateTimeException;
use Brick\DateTime\DayOfWeek;
use Brick\DateTime\Duration;
use Brick\DateTime\Instant;
Expand All @@ -26,6 +27,8 @@
use function json_encode;

use const JSON_THROW_ON_ERROR;
use const PHP_INT_MAX;
use const PHP_INT_MIN;
use const PHP_VERSION_ID;

/**
Expand Down Expand Up @@ -100,6 +103,20 @@ public static function providerOf(): array
['2011-06-30T12:34:56.123456789', '+17:00', '+17:00', 0, 1309376096, 123456789],
['2011-07-31T12:34:56.123456789', '+18:00', '+18:00', 0, 1312050896, 123456789],

// WITH OFFSET FROM UTC, EDGE CASES

['2020-01-01T00:00:00.000000000', '+05:30:45', '+05:30:45', 0, 1577816955, 0],
['0000-01-01T00:00:00.000000000', '+00:00', '+00:00', 0, -62167219200, 0],
['9999-12-31T23:59:59.999999999', '+18:00', '+18:00', 0, 253402235999, 999999999],
['10000-02-29T12:00:00.000000000', '-01:00', '-01:00', 0, 253407445200, 0],
['-999999-01-01T00:00:00.000000000', '-18:00', '-18:00', 0, -31619087532000, 0],
['-999999-01-01T00:00:00.000000000', '+00:00', '+00:00', 0, -31619087596800, 0],
['-0001-12-31T23:59:59.999999999', '+01:00', '+01:00', 0, -62167222801, 999999999],
['10000-01-01T00:00:00.000000000', '+00:00', '+00:00', 0, 253402300800, 0],
['10000-01-01T00:00:00.000000000', '-18:00', '-18:00', 0, 253402365600, 0],
['999999-12-31T23:59:59.999999999', '+00:00', '+00:00', 0, 31494784780799, 999999999],
['999999-12-31T23:59:59.999999999', '+18:00', '+18:00', 0, 31494784715999, 999999999],

// WITH REGION, NORMAL: NOT WITHIN A DST TRANSITION
// The region is resolved to an offset without ambiguity.
// The date-time is resolved to an instant without ambiguity.
Expand Down Expand Up @@ -326,9 +343,49 @@ public static function providerOf(): array
['2014-10-05T02:59:59.999999999', 'Australia/Sydney', '+11:00', 3600, 1412441999, 999999999],
['2014-10-05T03:00:00.000000000', 'Australia/Sydney', '+11:00', 0, 1412438400, 0],
['2014-10-05T03:59:59.999999999', 'Australia/Sydney', '+11:00', 0, 1412441999, 999999999],

// WITH REGION, EDGE CASES

// Pacific/Apia: at midnight (-10:00) on 2011-12-30, clocks jumped forward 24 hours (+14:00), skipping that day
['2011-12-29T23:59:59.999999999', 'Pacific/Apia', '-10:00', 0, 1325239199, 999999999],
['2011-12-30T12:00:00.000000000', 'Pacific/Apia', '+14:00', 86400, 1325282400, 0],
// Australia/Lord_Howe: 30-minute DST, gap from 2:00 AM to 2:30 AM, overlap from 1:30 AM to 2:00 AM
['2013-10-06T02:15:00.000000000', 'Australia/Lord_Howe', '+11:00', 1800, 1380987900, 0],
['2014-04-06T01:45:00.000000000', 'Australia/Lord_Howe', '+10:30', 0, 1396710900, 0],
// America/Sao_Paulo: gap at midnight, from 0:00 AM to 1:00 AM
['2008-10-19T00:30:00.000000000', 'America/Sao_Paulo', '-02:00', 3600, 1224387000, 0],
// Local mean time, with an offset in seconds
['1900-01-01T00:00:00.000000000', 'Europe/Paris', '+00:09:21', 0, -2208989361, 0],
['-0001-12-31T23:59:59.999999999', 'America/New_York', '-04:56:02', 0, -62167201439, 999999999],
['-999999-01-01T00:00:00.000000000', 'Europe/Paris', '+00:09:21', 0, -31619087597361, 0],
// Years 9999 to 10000, and up to the max
['9999-12-31T23:59:59.999999999', 'Europe/Paris', '+01:00', 0, 253402297199, 999999999],
['10000-01-01T00:00:00.000000000', 'Europe/Paris', '+01:00', 0, 253402297200, 0],
['999999-12-31T23:59:59.999999999', 'Europe/Paris', '+01:00', 0, 31494784777199, 999999999],

// Europe/Paris beyond year 9999: DST transitions on the last Sunday of March and October at 01:00 UTC.

['10000-07-01T12:00:00.123456789', 'Europe/Paris', '+02:00', 0, 253418061600, 123456789],
['10000-03-26T01:59:59.999999999', 'Europe/Paris', '+01:00', 0, 253409648399, 999999999],
['10000-03-26T02:00:00.000000000', 'Europe/Paris', '+02:00', 3600, 253409648400, 0],
['10000-03-26T02:59:59.999999999', 'Europe/Paris', '+02:00', 3600, 253409651999, 999999999],
['10000-03-26T03:00:00.000000000', 'Europe/Paris', '+02:00', 0, 253409648400, 0],
['10000-10-29T01:59:59.999999999', 'Europe/Paris', '+02:00', 0, 253428393599, 999999999],
['10000-10-29T02:00:00.000000000', 'Europe/Paris', '+01:00', 0, 253428397200, 0],
['10000-10-29T02:59:59.999999999', 'Europe/Paris', '+01:00', 0, 253428400799, 999999999],
['10000-10-29T03:00:00.000000000', 'Europe/Paris', '+01:00', 0, 253428400800, 0],
];
}

public function testOfWithOffsetUsesOffsetAsTimeZone(): void
{
$offset = TimeZoneOffset::of(2);
$zonedDateTime = ZonedDateTime::of(LocalDateTime::of(2020, 1, 1), $offset);

self::assertSame($offset, $zonedDateTime->getTimeZone());
self::assertSame($offset, $zonedDateTime->getTimeZoneOffset());
}

#[DataProvider('providerOfInstant')]
public function testOfInstant(string $formattedDatetime, string $timeZone): void
{
Expand All @@ -347,6 +404,53 @@ public static function providerOfInstant(): array
];
}

#[DataProvider('providerOfInstantEdgeCases')]
public function testOfInstantEdgeCases(int $epochSecond, int $nano, string $timeZone, string $expected): void
{
$zonedDateTime = ZonedDateTime::ofInstant(Instant::of($epochSecond, $nano), TimeZone::parse($timeZone));

self::assertSame($expected, (string) $zonedDateTime);
self::assertSame($epochSecond, $zonedDateTime->getEpochSecond());
self::assertSame($nano, $zonedDateTime->getNano());
}

public static function providerOfInstantEdgeCases(): array
{
return [
[-1, 999999999, 'Z', '1969-12-31T23:59:59.999999999Z'],
[0, 0, '+05:30:45', '1970-01-01T05:30:45+05:30:45'],
[0, 0, '-18:00', '1969-12-31T06:00:00-18:00'],
[1711846799, 0, 'Europe/Paris', '2024-03-31T01:59:59+01:00[Europe/Paris]'],
[1711846800, 0, 'Europe/Paris', '2024-03-31T03:00:00+02:00[Europe/Paris]'],
[1729989000, 0, 'Europe/Paris', '2024-10-27T02:30:00+02:00[Europe/Paris]'],
[1729992600, 0, 'Europe/Paris', '2024-10-27T02:30:00+01:00[Europe/Paris]'],
[-62167219200, 0, 'Z', '0000-01-01T00:00:00Z'],
[253402300800, 0, 'Europe/Paris', '10000-01-01T01:00:00+01:00[Europe/Paris]'],
[31494784780799, 999999999, 'Z', '999999-12-31T23:59:59.999999999Z'],
[-31619087596800, 0, '+18:00', '-999999-01-01T18:00:00+18:00'],
];
}

#[DataProvider('providerOfInstantOutOfRangeThrowsException')]
public function testOfInstantOutOfRangeThrowsException(int $epochSecond, string $timeZone): void
{
$this->expectException(DateTimeException::class);

ZonedDateTime::ofInstant(Instant::of($epochSecond), TimeZone::parse($timeZone));
}

public static function providerOfInstantOutOfRangeThrowsException(): array
{
return [
[PHP_INT_MIN, 'Z'],
[PHP_INT_MIN, '-18:00'],
[PHP_INT_MAX, '+18:00'],
[PHP_INT_MAX, 'Europe/Paris'],
[31494784780800, 'Z'],
[-31619087596801, '-18:00'],
];
}

/**
* @param string $text The string to parse.
* @param string $date The expected date string.
Expand Down Expand Up @@ -1059,9 +1163,32 @@ public static function providerToNativeDateTime(): array
['2011-07-31T23:59:59.02-05:30', '2011-07-31T23:59:59.020000-0530'],
['2011-07-31T23:59:59+01:00[Europe/London]', '2011-07-31T23:59:59.000000+0100'],
['2011-07-31T23:59:59.000123456-07:00', '2011-07-31T23:59:59.000123-0700'],
['-999999-01-01T00:00Z', '-999999-01-01T00:00:00.000000+0000'],
['-0001-12-31T23:59:59.5+01:00', '-0001-12-31T23:59:59.500000+0100'],
['10000-01-01T00:00Z', '10000-01-01T00:00:00.000000+0000'],
['10000-07-01T12:00+02:00[Europe/Paris]', '10000-07-01T12:00:00.000000+0200'],
['999999-12-31T23:59:59.999999999Z', '999999-12-31T23:59:59.999999+0000'],
];
}

public function testToNativeDateTimeDuringOverlap(): void
{
$timeZone = TimeZone::parse('Europe/Paris');

// 2024-10-27T02:30 occurs twice in Europe/Paris: first at +02:00, then at +01:00
$first = ZonedDateTime::ofInstant(Instant::of(1729989000), $timeZone)->toNativeDateTime();
$second = ZonedDateTime::ofInstant(Instant::of(1729992600), $timeZone)->toNativeDateTime();

self::assertSame('2024-10-27T02:30:00+02:00', $first->format('Y-m-d\TH:i:sP'));
self::assertSame('2024-10-27T02:30:00+01:00', $second->format('Y-m-d\TH:i:sP'));

self::assertSame(1729989000, $first->getTimestamp());
self::assertSame(1729992600, $second->getTimestamp());

self::assertSame('Europe/Paris', $first->getTimezone()->getName());
self::assertSame('Europe/Paris', $second->getTimezone()->getName());
}

#[DataProvider('providerToString')]
public function testJsonSerialize(string $localDateTime, string $timeZone, string $expectedString): void
{
Expand Down
Loading