diff --git a/src/Nonstandard/UuidV6.php b/src/Nonstandard/UuidV6.php new file mode 100644 index 0000000..98d0848 --- /dev/null +++ b/src/Nonstandard/UuidV6.php @@ -0,0 +1,130 @@ + + * @license http://opensource.org/licenses/MIT MIT + */ + +declare(strict_types=1); + +namespace Ramsey\Uuid\Nonstandard; + +use DateTimeImmutable; +use DateTimeInterface; +use Ramsey\Uuid\Codec\CodecInterface; +use Ramsey\Uuid\Converter\NumberConverterInterface; +use Ramsey\Uuid\Converter\TimeConverterInterface; +use Ramsey\Uuid\Exception\DateTimeException; +use Ramsey\Uuid\Exception\InvalidArgumentException; +use Ramsey\Uuid\Rfc4122\FieldsInterface as Rfc4122FieldsInterface; +use Ramsey\Uuid\Rfc4122\UuidInterface; +use Ramsey\Uuid\Rfc4122\UuidV1; +use Ramsey\Uuid\Uuid; +use Throwable; + +use function str_pad; + +use const STR_PAD_LEFT; + +/** + * Ordered-time, or version 6, UUIDs include timestamp, clock sequence, and node + * values that are combined into a 128-bit unsigned integer + * + * @link https://github.com/uuid6/uuid6-ietf-draft UUID version 6 IETF draft + * @link http://gh.peabody.io/uuidv6/ "Version 6" UUIDs + * + * @psalm-immutable + */ +final class UuidV6 extends Uuid implements UuidInterface +{ + /** + * Creates a version 6 (time-based) UUID + * + * @param Rfc4122FieldsInterface $fields The fields from which to construct a UUID + * @param NumberConverterInterface $numberConverter The number converter to use + * for converting hex values to/from integers + * @param CodecInterface $codec The codec to use when encoding or decoding + * UUID strings + * @param TimeConverterInterface $timeConverter The time converter to use + * for converting timestamps extracted from a UUID to unix timestamps + */ + public function __construct( + Rfc4122FieldsInterface $fields, + NumberConverterInterface $numberConverter, + CodecInterface $codec, + TimeConverterInterface $timeConverter + ) { + if ($fields->getVersion() !== Uuid::UUID_TYPE_PEABODY) { + throw new InvalidArgumentException( + 'Fields used to create a UuidV6 must represent a ' + . 'version 6 (ordered-time) UUID' + ); + } + + parent::__construct($fields, $numberConverter, $codec, $timeConverter); + } + + /** + * Returns a DateTimeInterface object representing the timestamp associated + * with the UUID + * + * @return DateTimeImmutable A PHP DateTimeImmutable instance representing + * the timestamp of a version 6 UUID + */ + public function getDateTime(): DateTimeInterface + { + $time = $this->timeConverter->convertTime($this->fields->getTimestamp()); + + try { + return new DateTimeImmutable( + '@' + . $time->getSeconds()->toString() + . '.' + . str_pad($time->getMicroSeconds()->toString(), 6, '0', STR_PAD_LEFT) + ); + } catch (Throwable $e) { + throw new DateTimeException($e->getMessage(), (int) $e->getCode(), $e); + } + } + + /** + * Converts this UUID into an instance of a version 1 UUID + */ + public function toUuidV1(): UuidV1 + { + $hex = $this->getHex()->toString(); + $hex = substr($hex, 7, 5) + . substr($hex, 13, 3) + . substr($hex, 3, 4) + . '1' . substr($hex, 0, 3) + . substr($hex, 16); + + /** @var UuidV1 $uuid */ + $uuid = Uuid::fromBytes((string) hex2bin($hex)); + + return $uuid; + } + + /** + * Converts a version 1 UUID into an instance of a version 6 UUID + */ + public static function fromUuidV1(UuidV1 $uuidV1): UuidV6 + { + $hex = $uuidV1->getHex()->toString(); + $hex = substr($hex, 13, 3) + . substr($hex, 8, 4) + . substr($hex, 0, 5) + . '6' . substr($hex, 5, 3) + . substr($hex, 16); + + /** @var UuidV6 $uuid */ + $uuid = Uuid::fromBytes((string) hex2bin($hex)); + + return $uuid; + } +} diff --git a/src/Rfc4122/Fields.php b/src/Rfc4122/Fields.php index 9d7a589..d313eb0 100644 --- a/src/Rfc4122/Fields.php +++ b/src/Rfc4122/Fields.php @@ -125,12 +125,21 @@ final class Fields implements FieldsInterface public function getTimestamp(): Hexadecimal { - return new Hexadecimal(sprintf( + $timestamp = sprintf( '%03x%04s%08s', hexdec($this->getTimeHiAndVersion()->toString()) & 0x0fff, $this->getTimeMid()->toString(), $this->getTimeLow()->toString() - )); + ); + + // Put the timestamp into the correct order, if this is a v6 UUID. + if ($this->getVersion() === Uuid::UUID_TYPE_PEABODY) { + $timestamp = substr($timestamp, 7) + . substr($timestamp, 3, 4) + . substr($timestamp, 0, 3); + } + + return new Hexadecimal($timestamp); } public function getVersion(): ?int diff --git a/src/Rfc4122/UuidBuilder.php b/src/Rfc4122/UuidBuilder.php index 94d9b6e..b705ac6 100644 --- a/src/Rfc4122/UuidBuilder.php +++ b/src/Rfc4122/UuidBuilder.php @@ -20,6 +20,7 @@ use Ramsey\Uuid\Converter\NumberConverterInterface; use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\Exception\UnableToBuildUuidException; use Ramsey\Uuid\Exception\UnsupportedOperationException; +use Ramsey\Uuid\Nonstandard\UuidV6; use Ramsey\Uuid\Rfc4122\UuidInterface as Rfc4122UuidInterface; use Ramsey\Uuid\UuidInterface; use Throwable; @@ -85,6 +86,8 @@ class UuidBuilder implements UuidBuilderInterface return new UuidV4($fields, $this->numberConverter, $codec, $this->timeConverter); case 5: return new UuidV5($fields, $this->numberConverter, $codec, $this->timeConverter); + case 6: + return new UuidV6($fields, $this->numberConverter, $codec, $this->timeConverter); } throw new UnsupportedOperationException( diff --git a/src/Rfc4122/VersionTrait.php b/src/Rfc4122/VersionTrait.php index 11db712..d150f68 100644 --- a/src/Rfc4122/VersionTrait.php +++ b/src/Rfc4122/VersionTrait.php @@ -46,6 +46,7 @@ trait VersionTrait case 3: case 4: case 5: + case 6: return true; } diff --git a/src/Uuid.php b/src/Uuid.php index 7046d5d..2a3e628 100644 --- a/src/Uuid.php +++ b/src/Uuid.php @@ -156,6 +156,17 @@ class Uuid implements UuidInterface */ public const UUID_TYPE_HASH_SHA1 = 5; + /** + * Version 6 (ordered-time) UUID + * + * This is named `UUID_TYPE_PEABODY`, since the specification is still in + * draft form, and the primary author/editor's name is Brad Peabody. + * + * @link https://github.com/uuid6/uuid6-ietf-draft UUID version 6 IETF draft + * @link http://gh.peabody.io/uuidv6/ "Version 6" UUIDs + */ + public const UUID_TYPE_PEABODY = 6; + /** * DCE Security principal domain * @@ -523,4 +534,22 @@ class Uuid implements UuidInterface { return self::getFactory()->uuid5($ns, $name); } + + /** + * Returns a version 6 (ordered-time) UUID from a host ID, sequence number, + * and the current time + * + * @param int|string $node A 48-bit number representing the hardware address; + * this number may be represented as an integer or a hexadecimal string + * @param int $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 6 UUID + */ + public static function uuid6($node = null, ?int $clockSeq = null): UuidInterface + { + return self::getFactory()->uuid6($node, $clockSeq); + } } diff --git a/src/UuidFactory.php b/src/UuidFactory.php index 19a8a7c..46e70e1 100644 --- a/src/UuidFactory.php +++ b/src/UuidFactory.php @@ -351,6 +351,27 @@ class UuidFactory implements UuidFactoryInterface return $this->uuidFromNsAndName($ns, $name, 5, 'sha1'); } + /** + * @inheritDoc + */ + public function uuid6($node = null, ?int $clockSeq = null): UuidInterface + { + $bytes = $this->timeGenerator->generate($node, $clockSeq); + + // Rearrange the bytes, according to the UUID version 6 specification. + $v6 = $bytes[6] . $bytes[7] . $bytes[4] . $bytes[5] + . $bytes[0] . $bytes[1] . $bytes[2] . $bytes[3]; + $v6 = bin2hex($v6); + + // Drop the first four bits, while adding an empty four bits for the + // version field. This allows us to reconstruct the correct time from + // the bytes of this UUID. + $v6Bytes = hex2bin(substr($v6, 1, 12) . '0' . substr($v6, -3)); + $v6Bytes .= substr($bytes, 8); + + return $this->uuidFromBytesAndVersion($v6Bytes, 6); + } + /** * Returns a Uuid created from the provided byte string * diff --git a/src/UuidFactoryInterface.php b/src/UuidFactoryInterface.php index 5bcffda..03505e1 100644 --- a/src/UuidFactoryInterface.php +++ b/src/UuidFactoryInterface.php @@ -105,6 +105,21 @@ interface UuidFactoryInterface */ public function uuid5($ns, string $name): UuidInterface; + /** + * Returns a version 6 (ordered-time) UUID from a host ID, sequence number, + * and the current time + * + * @param int|string $node A 48-bit number representing the hardware address; + * this number may be represented as an integer or a hexadecimal string + * @param int $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 6 UUID + */ + public function uuid6($node = null, ?int $clockSeq = null): UuidInterface; + /** * Creates a UUID from a byte string * diff --git a/tests/Guid/FieldsTest.php b/tests/Guid/FieldsTest.php index 3e39c59..2df25f6 100644 --- a/tests/Guid/FieldsTest.php +++ b/tests/Guid/FieldsTest.php @@ -89,7 +89,6 @@ class FieldsTest extends TestCase // representations, which are never in GUID byte order. return [ ['b08c6fff7dc5e1018b210800200c9a66'], - ['b08c6fff7dc5e1618b210800200c9a66'], ['b08c6fff7dc5e1719b210800200c9a66'], ['b08c6fff7dc5e181ab210800200c9a66'], ['b08c6fff7dc5e191bb210800200c9a66'], diff --git a/tests/Nonstandard/UuidV6Test.php b/tests/Nonstandard/UuidV6Test.php new file mode 100644 index 0000000..558b092 --- /dev/null +++ b/tests/Nonstandard/UuidV6Test.php @@ -0,0 +1,160 @@ + $version, + ]); + + $numberConverter = Mockery::mock(NumberConverterInterface::class); + $codec = Mockery::mock(CodecInterface::class); + $timeConverter = Mockery::mock(TimeConverterInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Fields used to create a UuidV6 must represent a ' + . 'version 6 (ordered-time) UUID' + ); + + new UuidV6($fields, $numberConverter, $codec, $timeConverter); + } + + /** + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingTraversableTypeHintSpecification + */ + public function provideTestVersions(): array + { + return [ + ['version' => 0], + ['version' => 1], + ['version' => 2], + ['version' => 3], + ['version' => 4], + ['version' => 5], + ['version' => 7], + ['version' => 8], + ['version' => 9], + ]; + } + + /** + * @dataProvider provideUuidV6WithOddMicroseconds + */ + public function testGetDateTimeProperlyHandlesLongMicroseconds(string $uuid, string $expected): void + { + /** @var UuidV6 $object */ + $object = Uuid::fromString($uuid); + + $date = $object->getDateTime(); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame($expected, $date->format('U.u')); + } + + /** + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingTraversableTypeHintSpecification + */ + public function provideUuidV6WithOddMicroseconds(): array + { + return [ + [ + 'uuid' => '1b21dd21-4814-6000-9669-00007ffffffe', + 'expected' => '1.677722', + ], + [ + 'uuid' => '1b21dd21-3714-6000-9669-00007ffffffe', + 'expected' => '0.104858', + ], + [ + 'uuid' => '1b21dd21-3713-6000-9669-00007ffffffe', + 'expected' => '0.105267', + ], + [ + 'uuid' => '1b21dd21-2e8a-6980-8d4f-acde48001122', + 'expected' => '-1.000000', + ], + ]; + } + + /** + * @dataProvider provideUuidV1UuidV6Equivalents + */ + public function testToUuidV1(string $uuidv6, string $uuidv1): void + { + /** @var UuidV6 $uuid6 */ + $uuid6 = Uuid::fromString($uuidv6); + $uuid1 = $uuid6->toUuidV1(); + + $this->assertSame($uuidv6, $uuid6->toString()); + $this->assertSame($uuidv1, $uuid1->toString()); + + $this->assertSame( + $uuid6->getDateTime()->format('U.u'), + $uuid1->getDateTime()->format('U.u') + ); + } + + /** + * @dataProvider provideUuidV1UuidV6Equivalents + */ + public function testFromUuidV1(string $uuidv6, string $uuidv1): void + { + /** @var UuidV1 $uuid1 */ + $uuid1 = Uuid::fromString($uuidv1); + $uuid6 = UuidV6::fromUuidV1($uuid1); + + $this->assertSame($uuidv1, $uuid1->toString()); + $this->assertSame($uuidv6, $uuid6->toString()); + + $this->assertSame( + $uuid1->getDateTime()->format('U.u'), + $uuid6->getDateTime()->format('U.u') + ); + } + + /** + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingTraversableTypeHintSpecification + */ + public function provideUuidV1UuidV6Equivalents(): array + { + return [ + [ + 'uuidv6' => '1b21dd21-4814-6000-9669-00007ffffffe', + 'uuidv1' => '14814000-1dd2-11b2-9669-00007ffffffe', + ], + [ + 'uuidv6' => '1b21dd21-3714-6000-9669-00007ffffffe', + 'uuidv1' => '13714000-1dd2-11b2-9669-00007ffffffe', + ], + [ + 'uuidv6' => '1b21dd21-3713-6000-9669-00007ffffffe', + 'uuidv1' => '13713000-1dd2-11b2-9669-00007ffffffe', + ], + [ + 'uuidv6' => '1b21dd21-2e8a-6980-8d4f-acde48001122', + 'uuidv1' => '12e8a980-1dd2-11b2-8d4f-acde48001122', + ], + ]; + } +} diff --git a/tests/Rfc4122/FieldsTest.php b/tests/Rfc4122/FieldsTest.php index 0c57396..c1ae3e9 100644 --- a/tests/Rfc4122/FieldsTest.php +++ b/tests/Rfc4122/FieldsTest.php @@ -84,7 +84,6 @@ class FieldsTest extends TestCase { return [ ['ff6f8cb0-c57d-01e1-8b21-0800200c9a66'], - ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66'], ['ff6f8cb0-c57d-71e1-9b21-0800200c9a66'], ['ff6f8cb0-c57d-81e1-ab21-0800200c9a66'], ['ff6f8cb0-c57d-91e1-bb21-0800200c9a66'], @@ -165,6 +164,18 @@ class FieldsTest extends TestCase ['ff6f8cb0-c57d-51e1-8b21-0800200c9a66', 'getVersion', 5], ['ff6f8cb0-c57d-51e1-8b21-0800200c9a66', 'isNil', false], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getClockSeq', '0b21'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getClockSeqHiAndReserved', '8b'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getClockSeqLow', '21'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getNode', '0800200c9a66'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getTimeHiAndVersion', '61e1'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getTimeLow', 'ff6f8cb0'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getTimeMid', 'c57d'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getTimestamp', 'ff6f8cb0c57d1e1'], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getVariant', 2], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'getVersion', 6], + ['ff6f8cb0-c57d-61e1-8b21-0800200c9a66', 'isNil', false], + ['00000000-0000-0000-0000-000000000000', 'getClockSeq', '0000'], ['00000000-0000-0000-0000-000000000000', 'getClockSeqHiAndReserved', '00'], ['00000000-0000-0000-0000-000000000000', 'getClockSeqLow', '00'], diff --git a/tests/Rfc4122/UuidBuilderTest.php b/tests/Rfc4122/UuidBuilderTest.php index ddcd055..089cd05 100644 --- a/tests/Rfc4122/UuidBuilderTest.php +++ b/tests/Rfc4122/UuidBuilderTest.php @@ -9,6 +9,7 @@ use Ramsey\Uuid\Converter\Number\GenericNumberConverter; use Ramsey\Uuid\Converter\Time\GenericTimeConverter; use Ramsey\Uuid\Exception\UnableToBuildUuidException; use Ramsey\Uuid\Math\BrickMathCalculator; +use Ramsey\Uuid\Nonstandard\UuidV6; use Ramsey\Uuid\Rfc4122\Fields; use Ramsey\Uuid\Rfc4122\UuidBuilder; use Ramsey\Uuid\Rfc4122\UuidV1; @@ -78,6 +79,11 @@ class UuidBuilderTest extends TestCase 'expectedClass' => UuidV5::class, 'expectedVersion' => 5, ], + [ + 'uuid' => 'ff6f8cb0-c57d-61e1-9b21-0800200c9a66', + 'expectedClass' => UuidV6::class, + 'expectedVersion' => 6, + ], ]; } diff --git a/tests/UuidTest.php b/tests/UuidTest.php index 4332c43..80d3e0c 100644 --- a/tests/UuidTest.php +++ b/tests/UuidTest.php @@ -25,6 +25,7 @@ use Ramsey\Uuid\Generator\RandomGeneratorFactory; use Ramsey\Uuid\Generator\RandomGeneratorInterface; use Ramsey\Uuid\Guid\Guid; use Ramsey\Uuid\Nonstandard\Uuid as NonstandardUuid; +use Ramsey\Uuid\Nonstandard\UuidV6; use Ramsey\Uuid\Provider\Time\FixedTimeProvider; use Ramsey\Uuid\Rfc4122\FieldsInterface; use Ramsey\Uuid\Rfc4122\NilUuid; @@ -541,6 +542,84 @@ class UuidTest extends TestCase $this->assertEquals(1, $uuid->getVersion()); } + public function testUuid6(): void + { + $uuid = Uuid::uuid6(); + $this->assertInstanceOf(UuidV6::class, $uuid); + $this->assertInstanceOf(DateTimeInterface::class, $uuid->getDateTime()); + $this->assertEquals(2, $uuid->getVariant()); + $this->assertEquals(6, $uuid->getVersion()); + } + + public function testUuid6WithNodeAndClockSequence(): void + { + $uuid = Uuid::uuid6('0800200c9a66', 0x1669); + $this->assertInstanceOf(UuidV6::class, $uuid); + $this->assertInstanceOf(DateTimeInterface::class, $uuid->getDateTime()); + $this->assertSame(2, $uuid->getVariant()); + $this->assertSame(6, $uuid->getVersion()); + $this->assertSame('1669', $uuid->getClockSequenceHex()); + $this->assertSame('0800200c9a66', $uuid->getNodeHex()); + $this->assertSame('9669-0800200c9a66', substr($uuid->toString(), 19)); + } + + public function testUuid6WithHexadecimalNode(): void + { + $uuid = Uuid::uuid6('7160355e'); + + $this->assertInstanceOf(UuidV6::class, $uuid); + $this->assertInstanceOf(DateTimeInterface::class, $uuid->getDateTime()); + $this->assertSame(2, $uuid->getVariant()); + $this->assertSame(6, $uuid->getVersion()); + $this->assertSame('00007160355e', $uuid->getNodeHex()); + } + + public function testUuid6WithMixedCaseHexadecimalNode(): void + { + $uuid = Uuid::uuid6('71B0aD5e'); + + $this->assertInstanceOf(Uuid::class, $uuid); + $this->assertInstanceOf(DateTimeInterface::class, $uuid->getDateTime()); + $this->assertSame(2, $uuid->getVariant()); + $this->assertSame(6, $uuid->getVersion()); + $this->assertSame('000071b0ad5e', $uuid->getNodeHex()); + } + + public function testUuid6WithOutOfBoundsNode(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid node value'); + + Uuid::uuid6('9223372036854775808'); + } + + public function testUuid6WithNonHexadecimalNode(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid node value'); + + Uuid::uuid6('db77e160355g'); + } + + public function testUuid6WithNon48bitNumber(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid node value'); + + Uuid::uuid6('db77e160355ef'); + } + + public function testUuid6WithRandomNode(): void + { + Uuid::setFactory(new UuidFactory(new FeatureSet(false, false, false, true))); + + $uuid = Uuid::uuid6(); + $this->assertInstanceOf(UuidV6::class, $uuid); + $this->assertInstanceOf(DateTimeInterface::class, $uuid->getDateTime()); + $this->assertEquals(2, $uuid->getVariant()); + $this->assertEquals(6, $uuid->getVersion()); + } + /** * Tests known version-3 UUIDs * @@ -1470,6 +1549,12 @@ class UuidTest extends TestCase $this->assertEquals($uuid->getVersion(), Uuid::UUID_TYPE_HASH_SHA1); } + public function testUuidVersionConstantForVersion6(): void + { + $uuid = Uuid::fromString('886313e1-3b8a-6372-9b90-0c9aee199e5d'); + $this->assertEquals($uuid->getVersion(), Uuid::UUID_TYPE_PEABODY); + } + public function testGetDateTimeThrowsExceptionWhenDateTimeCannotParseDate(): void { $numberConverter = new BigNumberConverter();