commit a27d5edc8f3dc4325b71addee907490e9733f81e Author: upstream Date: Fri Sep 4 08:08:19 2026 +0200 Upstream ramsey/uuid state of the touched files before 8d92513899 diff --git a/src/Converter/Time/PhpTimeConverter.php b/src/Converter/Time/PhpTimeConverter.php new file mode 100644 index 0000000..371a66b --- /dev/null +++ b/src/Converter/Time/PhpTimeConverter.php @@ -0,0 +1,170 @@ + + * @license http://opensource.org/licenses/MIT MIT + */ + +declare(strict_types=1); + +namespace Ramsey\Uuid\Converter\Time; + +use Ramsey\Uuid\Converter\TimeConverterInterface; +use Ramsey\Uuid\Math\BrickMathCalculator; +use Ramsey\Uuid\Math\CalculatorInterface; +use Ramsey\Uuid\Type\Hexadecimal; +use Ramsey\Uuid\Type\Integer as IntegerObject; +use Ramsey\Uuid\Type\Time; + +use function count; +use function dechex; +use function explode; +use function is_float; +use function is_int; +use function str_pad; +use function strlen; +use function substr; + +use const STR_PAD_LEFT; +use const STR_PAD_RIGHT; + +/** + * PhpTimeConverter uses built-in PHP functions and standard math operations available to the PHP programming language + * to provide facilities for converting parts of time into representations that may be used in UUIDs + * + * @immutable + */ +class PhpTimeConverter implements TimeConverterInterface +{ + /** + * The number of 100-nanosecond intervals from the Gregorian calendar epoch to the Unix epoch. + */ + private const GREGORIAN_TO_UNIX_INTERVALS = 0x01b21dd213814000; + + /** + * The number of 100-nanosecond intervals in one second. + */ + private const SECOND_INTERVALS = 10_000_000; + + /** + * The number of 100-nanosecond intervals in one microsecond. + */ + private const MICROSECOND_INTERVALS = 10; + + private int $phpPrecision; + private CalculatorInterface $calculator; + private TimeConverterInterface $fallbackConverter; + + public function __construct( + ?CalculatorInterface $calculator = null, + ?TimeConverterInterface $fallbackConverter = null, + ) { + if ($calculator === null) { + $calculator = new BrickMathCalculator(); + } + + if ($fallbackConverter === null) { + $fallbackConverter = new GenericTimeConverter($calculator); + } + + $this->calculator = $calculator; + $this->fallbackConverter = $fallbackConverter; + $this->phpPrecision = (int) ini_get('precision'); + } + + public function calculateTime(string $seconds, string $microseconds): Hexadecimal + { + $seconds = new IntegerObject($seconds); /** @phpstan-ignore possiblyImpure.new */ + $microseconds = new IntegerObject($microseconds); /** @phpstan-ignore possiblyImpure.new */ + + // Calculate the count of 100-nanosecond intervals since the Gregorian calendar epoch + // for the given seconds and microseconds. + $uuidTime = ((int) $seconds->toString() * self::SECOND_INTERVALS) + + ((int) $microseconds->toString() * self::MICROSECOND_INTERVALS) + + self::GREGORIAN_TO_UNIX_INTERVALS; + + // Check to see whether we've overflowed the max/min integer size. + // If so, we will default to a different time converter. + // @phpstan-ignore function.alreadyNarrowedType (the integer value might have overflowed) + if (!is_int($uuidTime)) { + return $this->fallbackConverter->calculateTime( + $seconds->toString(), + $microseconds->toString(), + ); + } + + /** @phpstan-ignore possiblyImpure.new */ + return new Hexadecimal( + str_pad(dechex($uuidTime), 16, '0', STR_PAD_LEFT) + ); + } + + public function convertTime(Hexadecimal $uuidTimestamp): Time + { + $timestamp = $this->calculator->toInteger($uuidTimestamp); + + // Convert the 100-nanosecond intervals into seconds and microseconds. + $splitTime = $this->splitTime( + ($timestamp->toString() - self::GREGORIAN_TO_UNIX_INTERVALS) / self::SECOND_INTERVALS, + ); + + if (count($splitTime) === 0) { + return $this->fallbackConverter->convertTime($uuidTimestamp); + } + + /** @phpstan-ignore possiblyImpure.new */ + return new Time($splitTime['sec'], $splitTime['usec']); + } + + /** + * @param float | int $time The time to split into seconds and microseconds + * + * @return string[] + * + * @pure + */ + private function splitTime(float | int $time): array + { + $split = explode('.', (string) $time, 2); + + // If the $time value is a float but $split only has 1 element, then the float math was rounded up to the next + // second, so we want to return an empty array to allow use of the fallback converter. + if (is_float($time) && count($split) === 1) { + return []; + } + + if (count($split) === 1) { + return ['sec' => $split[0], 'usec' => '0']; + } + + // If the microseconds are less than six characters AND the length of the number is greater than or equal to the + // PHP precision, then it's possible that we lost some precision for the microseconds. Return an empty array so + // that we can choose to use the fallback converter. + if (strlen($split[1]) < 6 && strlen((string) $time) >= $this->phpPrecision) { + return []; + } + + $microseconds = $split[1]; + + // Ensure the microseconds are no longer than 6 digits. If they are, + // truncate the number to the first 6 digits and round up, if needed. + if (strlen($microseconds) > 6) { + $roundingDigit = (int) substr($microseconds, 6, 1); + $microseconds = (int) substr($microseconds, 0, 6); + + if ($roundingDigit >= 5) { + $microseconds++; + } + } + + return [ + 'sec' => $split[0], + 'usec' => str_pad((string) $microseconds, 6, '0', STR_PAD_RIGHT), + ]; + } +} diff --git a/tests/Converter/Time/PhpTimeConverterTest.php b/tests/Converter/Time/PhpTimeConverterTest.php new file mode 100644 index 0000000..2036fdd --- /dev/null +++ b/tests/Converter/Time/PhpTimeConverterTest.php @@ -0,0 +1,196 @@ +plus($seconds->multipliedBy(10000000)) + ->plus($microseconds->multipliedBy(10)) + ->plus(BigInteger::fromBase('01b21dd213814000', 16)); + + $maskLow = BigInteger::fromBase('ffffffff', 16); + $maskMid = BigInteger::fromBase('ffff', 16); + $maskHi = BigInteger::fromBase('0fff', 16); + + $expected = sprintf('%04s', $calculatedTime->shiftedRight(48)->and($maskHi)->toBase(16)); + $expected .= sprintf('%04s', $calculatedTime->shiftedRight(32)->and($maskMid)->toBase(16)); + $expected .= sprintf('%08s', $calculatedTime->and($maskLow)->toBase(16)); + + $converter = new PhpTimeConverter(); + $returned = $converter->calculateTime((string) $seconds, (string) $microseconds); + + $this->assertSame($expected, $returned->toString()); + } + + public function testCalculateTimeThrowsExceptionWhenSecondsIsNotOnlyDigits(): void + { + /** @var Mockery\MockInterface & PhpTimeConverter $converter */ + $converter = Mockery::mock(PhpTimeConverter::class)->makePartial(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Value must be a signed integer or a string containing only digits ' + . '0-9 and, optionally, a sign (+ or -)' + ); + + $converter->calculateTime('12.34', '5678'); + } + + public function testCalculateTimeThrowsExceptionWhenMicrosecondsIsNotOnlyDigits(): void + { + /** @var Mockery\MockInterface & PhpTimeConverter $converter */ + $converter = Mockery::mock(PhpTimeConverter::class)->makePartial(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Value must be a signed integer or a string containing only digits ' + . '0-9 and, optionally, a sign (+ or -)' + ); + + $converter->calculateTime('1234', '56.78'); + } + + /** + * @param numeric-string $unixTimestamp + * @param numeric-string $microseconds + * + * @dataProvider provideConvertTime + */ + public function testConvertTime(Hexadecimal $uuidTimestamp, string $unixTimestamp, string $microseconds): void + { + $calculator = new BrickMathCalculator(); + $fallbackConverter = new GenericTimeConverter($calculator); + $converter = new PhpTimeConverter($calculator, $fallbackConverter); + + $result = $converter->convertTime($uuidTimestamp); + + $this->assertSame($unixTimestamp, $result->getSeconds()->toString()); + $this->assertSame($microseconds, $result->getMicroseconds()->toString()); + } + + /** + * @return array + */ + public function provideConvertTime(): array + { + return [ + [ + 'uuidTimestamp' => new Hexadecimal('1e1c57dff6f8cb0'), + 'unixTimestamp' => '1341368074', + 'microseconds' => '491000', + ], + [ + 'uuidTimestamp' => new Hexadecimal('1ea333764c71df6'), + 'unixTimestamp' => '1578612359', + 'microseconds' => '521023', + ], + [ + 'uuidTimestamp' => new Hexadecimal('fffffffff9785f6'), + 'unixTimestamp' => '103072857659', + 'microseconds' => '999999', + ], + + // This is the last possible time supported by v1 UUIDs. When + // converted to a Unix timestamp, the microseconds are lost. + // 60038-03-11 05:36:10.955161 + [ + 'uuidTimestamp' => new Hexadecimal('fffffffffffffffa'), + 'unixTimestamp' => '1832455114570', + 'microseconds' => '955161', + ], + + // This is the earliest possible date supported by v1 UUIDs: + // 1582-10-15 00:00:00.000000 + [ + 'uuidTimestamp' => new Hexadecimal('000000000000'), + 'unixTimestamp' => '-12219292800', + 'microseconds' => '0', + ], + + // This is the Unix epoch: + // 1970-01-01 00:00:00.000000 + [ + 'uuidTimestamp' => new Hexadecimal('1b21dd213814000'), + 'unixTimestamp' => '0', + 'microseconds' => '0', + ], + ]; + } + + /** + * @param non-empty-string $seconds + * @param non-empty-string $microseconds + * @param non-empty-string $expected + * + * @dataProvider provideCalculateTime + */ + public function testCalculateTime(string $seconds, string $microseconds, string $expected): void + { + $calculator = new BrickMathCalculator(); + $fallbackConverter = new GenericTimeConverter($calculator); + $converter = new PhpTimeConverter($calculator, $fallbackConverter); + + $result = $converter->calculateTime($seconds, $microseconds); + + $this->assertSame($expected, $result->toString()); + } + + /** + * @return array + */ + public function provideCalculateTime(): array + { + return [ + [ + 'seconds' => '-12219146756', + 'microseconds' => '0', + 'expected' => '000001540901e600', + ], + [ + 'seconds' => '103072857659', + 'microseconds' => '999999', + 'expected' => '0fffffffff9785f6', + ], + [ + 'seconds' => '1578612359', + 'microseconds' => '521023', + 'expected' => '01ea333764c71df6', + ], + + // This is the earliest possible date supported by v1 UUIDs: + // 1582-10-15 00:00:00.000000 + [ + 'seconds' => '-12219292800', + 'microseconds' => '0', + 'expected' => '0000000000000000', + ], + + // This is the last possible time supported by v1 UUIDs: + // 60038-03-11 05:36:10.955161 + [ + 'seconds' => '1832455114570', + 'microseconds' => '955161', + 'expected' => 'fffffffffffffffa', + ], + ]; + } +}