diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7f0d7..5043c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +* Add `Uuid::fromDateTime()` to create version 1 UUIDs from instances of + `\DateTimeInterface`. + ### Changed +* Add `fromDateTime()` method to `UuidFactoryInterface`. + ### Deprecated ### Removed diff --git a/src/Exception/TimeSourceException.php b/src/Exception/TimeSourceException.php new file mode 100644 index 0000000..accd37f --- /dev/null +++ b/src/Exception/TimeSourceException.php @@ -0,0 +1,24 @@ + + * @license http://opensource.org/licenses/MIT MIT + */ + +declare(strict_types=1); + +namespace Ramsey\Uuid\Exception; + +use RuntimeException as PhpRuntimeException; + +/** + * Thrown to indicate that the source of time encountered an error + */ +class TimeSourceException extends PhpRuntimeException +{ +} diff --git a/src/FeatureSet.php b/src/FeatureSet.php index 1e822b2..ed82a23 100644 --- a/src/FeatureSet.php +++ b/src/FeatureSet.php @@ -214,6 +214,14 @@ class FeatureSet return $this->randomGenerator; } + /** + * Returns the time converter configured for this environment + */ + public function getTimeConverter(): TimeConverterInterface + { + return $this->timeConverter; + } + /** * Returns the time generator configured for this environment */ diff --git a/src/Generator/DefaultTimeGenerator.php b/src/Generator/DefaultTimeGenerator.php index 4fdf662..66c23bc 100644 --- a/src/Generator/DefaultTimeGenerator.php +++ b/src/Generator/DefaultTimeGenerator.php @@ -17,6 +17,7 @@ namespace Ramsey\Uuid\Generator; use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\Exception\InvalidArgumentException; use Ramsey\Uuid\Exception\RandomSourceException; +use Ramsey\Uuid\Exception\TimeSourceException; use Ramsey\Uuid\Provider\NodeProviderInterface; use Ramsey\Uuid\Provider\TimeProviderInterface; use Throwable; @@ -75,12 +76,23 @@ class DefaultTimeGenerator implements TimeGeneratorInterface } } + $time = $this->timeProvider->getTime(); + $uuidTime = $this->timeConverter->calculateTime( - $this->timeProvider->getTime()->getSeconds()->toString(), - $this->timeProvider->getTime()->getMicroSeconds()->toString() + $time->getSeconds()->toString(), + $time->getMicroSeconds()->toString() ); - $timeBytes = (string) hex2bin(str_pad($uuidTime->toString(), 16, '0', STR_PAD_LEFT)); + $timeHex = str_pad($uuidTime->toString(), 16, '0', STR_PAD_LEFT); + + if (strlen($timeHex) !== 16) { + throw new TimeSourceException(sprintf( + 'The generated time of \'%s\' is larger than expected', + $timeHex + )); + } + + $timeBytes = (string) hex2bin($timeHex); return $timeBytes[4] . $timeBytes[5] . $timeBytes[6] . $timeBytes[7] . $timeBytes[2] . $timeBytes[3] diff --git a/src/Uuid.php b/src/Uuid.php index 62d3e47..9dc484a 100644 --- a/src/Uuid.php +++ b/src/Uuid.php @@ -14,6 +14,7 @@ declare(strict_types=1); namespace Ramsey\Uuid; +use DateTimeInterface; use Ramsey\Uuid\Codec\CodecInterface; use Ramsey\Uuid\Converter\NumberConverterInterface; use Ramsey\Uuid\Converter\TimeConverterInterface; @@ -368,6 +369,27 @@ class Uuid implements UuidInterface return self::getFactory()->fromString($uuid); } + /** + * Creates a UUID from a DateTimeInterface instance + * + * @param DateTimeInterface $dateTime The date and time + * @param Hexadecimal|null $node A 48-bit number representing the hardware + * address + * @param int|null $clockSeq A 14-bit number used to help avoid duplicates + * that could arise when the clock is set backwards in time or if the + * node ID changes + * + * @return UuidInterface A UuidInterface instance that represents a + * version 1 UUID created from a DateTimeInterface instance + */ + public static function fromDateTime( + DateTimeInterface $dateTime, + ?Hexadecimal $node = null, + ?int $clockSeq = null + ): UuidInterface { + return self::getFactory()->fromDateTime($dateTime, $node, $clockSeq); + } + /** * Creates a UUID from a 128-bit integer string * diff --git a/src/UuidFactory.php b/src/UuidFactory.php index 25f7527..b71e17f 100644 --- a/src/UuidFactory.php +++ b/src/UuidFactory.php @@ -14,15 +14,20 @@ declare(strict_types=1); namespace Ramsey\Uuid; +use DateTimeInterface; use Ramsey\Uuid\Builder\UuidBuilderInterface; use Ramsey\Uuid\Codec\CodecInterface; use Ramsey\Uuid\Converter\NumberConverterInterface; +use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\Generator\DceSecurityGeneratorInterface; +use Ramsey\Uuid\Generator\DefaultTimeGenerator; use Ramsey\Uuid\Generator\RandomGeneratorInterface; use Ramsey\Uuid\Generator\TimeGeneratorInterface; use Ramsey\Uuid\Provider\NodeProviderInterface; +use Ramsey\Uuid\Provider\Time\FixedTimeProvider; use Ramsey\Uuid\Type\Hexadecimal; use Ramsey\Uuid\Type\IntegerValue; +use Ramsey\Uuid\Type\Time; use Ramsey\Uuid\Validator\ValidatorInterface; class UuidFactory implements UuidFactoryInterface @@ -52,6 +57,11 @@ class UuidFactory implements UuidFactoryInterface */ private $randomGenerator; + /** + * @var TimeConverterInterface + */ + private $timeConverter; + /** * @var TimeGeneratorInterface */ @@ -79,6 +89,7 @@ class UuidFactory implements UuidFactoryInterface $this->nodeProvider = $features->getNodeProvider(); $this->numberConverter = $features->getNumberConverter(); $this->randomGenerator = $features->getRandomGenerator(); + $this->timeConverter = $features->getTimeConverter(); $this->timeGenerator = $features->getTimeGenerator(); $this->uuidBuilder = $features->getBuilder(); $this->validator = $features->getValidator(); @@ -234,6 +245,28 @@ class UuidFactory implements UuidFactoryInterface return $this->fromString($hex); } + public function fromDateTime( + DateTimeInterface $dateTime, + ?Hexadecimal $node = null, + ?int $clockSeq = null + ): UuidInterface { + $timeProvider = new FixedTimeProvider( + new Time($dateTime->getTimestamp(), $dateTime->format('u')) + ); + + $timeGenerator = new DefaultTimeGenerator( + $this->nodeProvider, + $this->timeConverter, + $timeProvider + ); + + $nodeHex = $node ? $node->toString() : null; + + $bytes = $timeGenerator->generate($nodeHex, $clockSeq); + + return $this->uuidFromBytesAndVersion($bytes, 1); + } + /** * @inheritDoc */ diff --git a/src/UuidFactoryInterface.php b/src/UuidFactoryInterface.php index 88339f9..c969d9b 100644 --- a/src/UuidFactoryInterface.php +++ b/src/UuidFactoryInterface.php @@ -14,6 +14,7 @@ declare(strict_types=1); namespace Ramsey\Uuid; +use DateTimeInterface; use Ramsey\Uuid\Type\Hexadecimal; use Ramsey\Uuid\Type\IntegerValue; use Ramsey\Uuid\Validator\ValidatorInterface; @@ -139,4 +140,23 @@ interface UuidFactoryInterface * @psalm-pure */ public function fromInteger(string $integer): UuidInterface; + + /** + * Creates a UUID from a DateTimeInterface instance + * + * @param DateTimeInterface $dateTime The date and time + * @param Hexadecimal|null $node A 48-bit number representing the hardware + * address + * @param int|null $clockSeq A 14-bit number used to help avoid duplicates + * that could arise when the clock is set backwards in time or if the + * node ID changes + * + * @return UuidInterface A UuidInterface instance that represents a + * version 1 UUID created from a DateTimeInterface instance + */ + public function fromDateTime( + DateTimeInterface $dateTime, + ?Hexadecimal $node = null, + ?int $clockSeq = null + ): UuidInterface; } diff --git a/tests/Converter/Time/GenericTimeConverterTest.php b/tests/Converter/Time/GenericTimeConverterTest.php index 61fcd33..19de659 100644 --- a/tests/Converter/Time/GenericTimeConverterTest.php +++ b/tests/Converter/Time/GenericTimeConverterTest.php @@ -54,13 +54,28 @@ class GenericTimeConverterTest extends TestCase 'expected' => '0000000000000000', ], - // This is the last possible time supported by v1 UUIDs: + // This is the last possible time supported by the GenericTimeConverter: // 60038-03-11 05:36:10.955161 + // When a UUID is created from this time, however, the highest 4 bits + // are replaced with the version (1), so we lose fidelity and cannot + // accurately decompose the date from the UUID. [ 'seconds' => '1832455114570', 'microseconds' => '955161', 'expected' => 'fffffffffffffffa', ], + + // This is technically the last possible time supported by v1 UUIDs: + // 5236-03-31 21:21:00.684697 + // All dates above this will lose fidelity, since the highest 4 bits + // are replaced with the UUID version (1). As a result, we cannot + // accurately decompose the date from UUIDs created from dates + // greater than this one. + [ + 'seconds' => '103072857660', + 'microseconds' => '684697', + 'expected' => '0ffffffffffffffa', + ], ]; } diff --git a/tests/FeatureSetTest.php b/tests/FeatureSetTest.php index 95e2d9a..394da44 100644 --- a/tests/FeatureSetTest.php +++ b/tests/FeatureSetTest.php @@ -6,6 +6,7 @@ namespace Ramsey\Uuid\Test; use Mockery; use Ramsey\Uuid\Builder\FallbackBuilder; +use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\FeatureSet; use Ramsey\Uuid\Guid\GuidBuilder; use Ramsey\Uuid\Validator\ValidatorInterface; @@ -35,4 +36,11 @@ class FeatureSetTest extends TestCase $this->assertSame($validator, $featureSet->getValidator()); } + + public function testGetTimeConverter(): void + { + $featureSet = new FeatureSet(); + + $this->assertInstanceOf(TimeConverterInterface::class, $featureSet->getTimeConverter()); + } } diff --git a/tests/Generator/DefaultTimeGeneratorTest.php b/tests/Generator/DefaultTimeGeneratorTest.php index 8d8cf52..bac066c 100644 --- a/tests/Generator/DefaultTimeGeneratorTest.php +++ b/tests/Generator/DefaultTimeGeneratorTest.php @@ -12,8 +12,11 @@ use PHPUnit\Framework\MockObject\MockObject; use Ramsey\Uuid\BinaryUtils; use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\Exception\RandomSourceException; +use Ramsey\Uuid\Exception\TimeSourceException; +use Ramsey\Uuid\FeatureSet; use Ramsey\Uuid\Generator\DefaultTimeGenerator; use Ramsey\Uuid\Provider\NodeProviderInterface; +use Ramsey\Uuid\Provider\Time\FixedTimeProvider; use Ramsey\Uuid\Provider\TimeProviderInterface; use Ramsey\Uuid\Test\TestCase; use Ramsey\Uuid\Type\Hexadecimal; @@ -189,4 +192,22 @@ class DefaultTimeGeneratorTest extends TestCase $defaultTimeGenerator->generate($this->nodeId); } + + public function testDefaultTimeGeneratorThrowsExceptionForLargeGeneratedValue(): void + { + $timeProvider = new FixedTimeProvider(new Time('1832455114570', '955162')); + $featureSet = new FeatureSet(); + $timeGenerator = new DefaultTimeGenerator( + $featureSet->getNodeProvider(), + $featureSet->getTimeConverter(), + $timeProvider + ); + + $this->expectException(TimeSourceException::class); + $this->expectExceptionMessage( + 'The generated time of \'10000000000000004\' is larger than expected' + ); + + $timeGenerator->generate(); + } } diff --git a/tests/UuidFactoryTest.php b/tests/UuidFactoryTest.php index bd434ce..40154ef 100644 --- a/tests/UuidFactoryTest.php +++ b/tests/UuidFactoryTest.php @@ -4,16 +4,22 @@ declare(strict_types=1); namespace Ramsey\Uuid\Test; +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; use Mockery; use PHPUnit\Framework\MockObject\MockObject; use Ramsey\Uuid\Builder\UuidBuilderInterface; use Ramsey\Uuid\Codec\CodecInterface; use Ramsey\Uuid\Converter\NumberConverterInterface; +use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\FeatureSet; use Ramsey\Uuid\Generator\DceSecurityGeneratorInterface; use Ramsey\Uuid\Generator\RandomGeneratorInterface; use Ramsey\Uuid\Generator\TimeGeneratorInterface; use Ramsey\Uuid\Provider\NodeProviderInterface; +use Ramsey\Uuid\Rfc4122\UuidV1; +use Ramsey\Uuid\Type\Hexadecimal; use Ramsey\Uuid\UuidFactory; use Ramsey\Uuid\Validator\ValidatorInterface; @@ -55,6 +61,7 @@ class UuidFactoryTest extends TestCase $codec = Mockery::mock(CodecInterface::class); $nodeProvider = Mockery::mock(NodeProviderInterface::class); $randomGenerator = Mockery::mock(RandomGeneratorInterface::class); + $timeConverter = Mockery::mock(TimeConverterInterface::class); $timeGenerator = Mockery::mock(TimeGeneratorInterface::class); $dceSecurityGenerator = Mockery::mock(DceSecurityGeneratorInterface::class); $numberConverter = Mockery::mock(NumberConverterInterface::class); @@ -65,6 +72,7 @@ class UuidFactoryTest extends TestCase 'getCodec' => $codec, 'getNodeProvider' => $nodeProvider, 'getRandomGenerator' => $randomGenerator, + 'getTimeConverter' => $timeConverter, 'getTimeGenerator' => $timeGenerator, 'getDceSecurityGenerator' => $dceSecurityGenerator, 'getNumberConverter' => $numberConverter, @@ -122,4 +130,75 @@ class UuidFactoryTest extends TestCase $uuidFactory->setUuidBuilder($uuidBuilder); $this->assertEquals($uuidBuilder, $uuidFactory->getUuidBuilder()); } + + /** + * @dataProvider provideDateTime + */ + public function testFromDateTime( + DateTimeInterface $dateTime, + ?Hexadecimal $node, + ?int $clockSeq, + string $expectedUuidFormat, + string $expectedTime + ): void { + $factory = new UuidFactory(); + + /** @var UuidV1 $uuid */ + $uuid = $factory->fromDateTime($dateTime, $node, $clockSeq); + + $this->assertInstanceOf(UuidV1::class, $uuid); + $this->assertStringMatchesFormat($expectedUuidFormat, $uuid->toString()); + $this->assertSame($expectedTime, $uuid->getDateTime()->format('U.u')); + } + + /** + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingTraversableTypeHintSpecification + */ + public function provideDateTime(): array + { + return [ + [ + new DateTimeImmutable('2012-07-04 02:14:34.491000'), + null, + null, + 'ff6f8cb0-c57d-11e1-%s', + '1341368074.491000', + ], + [ + new DateTimeImmutable('1582-10-16 16:34:04'), + new Hexadecimal('0800200c9a66'), + 15137, + '0901e600-0154-1000-%cb21-0800200c9a66', + '-12219146756.000000', + ], + [ + new DateTime('5236-03-31 21:20:59.999999'), + new Hexadecimal('00007ffffffe'), + 1641, + 'ff9785f6-ffff-1fff-%c669-00007ffffffe', + '103072857659.999999', + ], + [ + new DateTime('1582-10-15 00:00:00'), + new Hexadecimal('00007ffffffe'), + 1641, + '00000000-0000-1000-%c669-00007ffffffe', + '-12219292800.000000', + ], + [ + new DateTimeImmutable('@103072857660.684697'), + new Hexadecimal('0'), + 0, + 'fffffffa-ffff-1fff-%c000-000000000000', + '103072857660.684697', + ], + [ + new DateTimeImmutable('5236-03-31 21:21:00.684697'), + null, + null, + 'fffffffa-ffff-1fff-%s', + '103072857660.684697', + ], + ]; + } } diff --git a/tests/UuidTest.php b/tests/UuidTest.php index 2b974f2..d95f837 100644 --- a/tests/UuidTest.php +++ b/tests/UuidTest.php @@ -33,6 +33,7 @@ use Ramsey\Uuid\Rfc4122\UuidV2; use Ramsey\Uuid\Rfc4122\UuidV3; use Ramsey\Uuid\Rfc4122\UuidV4; use Ramsey\Uuid\Rfc4122\UuidV5; +use Ramsey\Uuid\Type\Hexadecimal; use Ramsey\Uuid\Type\Time; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidFactory; @@ -961,6 +962,17 @@ class UuidTest extends TestCase $this->assertTrue($uuid->equals($fromIntegerUuid)); } + public function testFromDateTime(): void + { + /** @var UuidV1 $uuid */ + $uuid = Uuid::fromString('ff6f8cb0-c57d-11e1-8b21-0800200c9a66'); + $dateTime = $uuid->getDateTime(); + + $fromDateTimeUuid = Uuid::fromDateTime($dateTime, new Hexadecimal('0800200c9a66'), 2849); + + $this->assertTrue($uuid->equals($fromDateTimeUuid)); + } + /** * This test ensures that Ramsey\Uuid passes the same test cases * as the Python UUID library. diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 7619958..fb34980 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -70,7 +70,8 @@ - + + getFactory getFactory getFactory getFactory