From 93f18121c0b0d526bf6e7770ff9e9f0cc9dc25a5 Mon Sep 17 00:00:00 2001 From: Thomas Calvet Date: Sat, 9 Dec 2023 12:12:25 +0100 Subject: [PATCH 1/2] [Uid] Add `UuidV6::fromV1()` and `UuidV7::fromV1()` methods --- src/Symfony/Component/Uid/BinaryUtil.php | 16 +++++++++-- src/Symfony/Component/Uid/CHANGELOG.md | 5 ++++ src/Symfony/Component/Uid/Tests/UuidTest.php | 29 ++++++++++++++++++++ src/Symfony/Component/Uid/UuidV6.php | 7 +++++ src/Symfony/Component/Uid/UuidV7.php | 22 +++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Uid/BinaryUtil.php b/src/Symfony/Component/Uid/BinaryUtil.php index 8fd19d8674af0..fd158345546d3 100644 --- a/src/Symfony/Component/Uid/BinaryUtil.php +++ b/src/Symfony/Component/Uid/BinaryUtil.php @@ -118,8 +118,10 @@ public static function add(string $a, string $b): string /** * @param string $time Count of 100-nanosecond intervals since the UUID epoch 1582-10-15 00:00:00 in hexadecimal + * + * @return string Count of 100-nanosecond intervals since the UUID epoch 1582-10-15 00:00:00 as a numeric string */ - public static function hexToDateTime(string $time): \DateTimeImmutable + public static function hexToNumericString(string $time): string { if (\PHP_INT_SIZE >= 8) { $time = (string) (hexdec($time) - self::TIME_OFFSET_INT); @@ -140,7 +142,17 @@ public static function hexToDateTime(string $time): \DateTimeImmutable $time = '-' === $time[0] ? '-'.str_pad(substr($time, 1), 8, '0', \STR_PAD_LEFT) : str_pad($time, 8, '0', \STR_PAD_LEFT); } - return \DateTimeImmutable::createFromFormat('U.u?', substr_replace($time, '.', -7, 0)); + return $time; + } + + /** + * Sub-microseconds are lost since they are not handled by \DateTimeImmutable. + * + * @param string $time Count of 100-nanosecond intervals since the UUID epoch 1582-10-15 00:00:00 in hexadecimal + */ + public static function hexToDateTime(string $time): \DateTimeImmutable + { + return \DateTimeImmutable::createFromFormat('U.u?', substr_replace(self::hexToNumericString($time), '.', -7, 0)); } /** diff --git a/src/Symfony/Component/Uid/CHANGELOG.md b/src/Symfony/Component/Uid/CHANGELOG.md index b82133751c0a9..1e07caa42a721 100644 --- a/src/Symfony/Component/Uid/CHANGELOG.md +++ b/src/Symfony/Component/Uid/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.1 +--- + + * Add `UuidV6::fromV1()` and `UuidV7::fromV1()` + 6.2 --- diff --git a/src/Symfony/Component/Uid/Tests/UuidTest.php b/src/Symfony/Component/Uid/Tests/UuidTest.php index 5e05b89f6e395..172bc18340950 100644 --- a/src/Symfony/Component/Uid/Tests/UuidTest.php +++ b/src/Symfony/Component/Uid/Tests/UuidTest.php @@ -427,4 +427,33 @@ public function testFromStringBase58Padding() { $this->assertInstanceOf(Uuid::class, Uuid::fromString('111111111u9QRyVM94rdmZ')); } + + public function testV6FromV1() + { + $uuidV1 = new UuidV1('8189d3de-9670-11ee-b9d1-0242ac120002'); + $uuidV6 = UuidV6::fromV1($uuidV1); + + $this->assertEquals($uuidV1->getDateTime(), $uuidV6->getDateTime()); + $this->assertSame($uuidV1->getNode(), $uuidV6->getNode()); + $this->assertEquals($uuidV6, UuidV6::fromV1($uuidV1)); + } + + public function testV7FromV1BeforeUnixEpochThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('UUIDv1 with a timestamp before Unix epoch cannot be converted to UUIDv7'); + + UuidV7::fromV1(new UuidV1('9aba8000-ff00-11b0-b3db-3b3fc83afdfc')); // Timestamp is 1969-01-01 00:00:00.0000000 + } + + public function testV7FromV1() + { + $uuidV1 = new UuidV1('eb248d80-ea4f-11ec-9d2a-839425e6fb88'); + $sameUuidV1100NanosecondsLater = new UuidV1('eb248d81-ea4f-11ec-9d2a-839425e6fb88'); + $uuidV7 = UuidV7::fromV1($uuidV1); + + $this->assertEquals($uuidV1->getDateTime(), $uuidV7->getDateTime()); + $this->assertEquals($uuidV7, UuidV7::fromV1($uuidV1)); + $this->assertNotEquals($uuidV7, UuidV7::fromV1($sameUuidV1100NanosecondsLater)); + } } diff --git a/src/Symfony/Component/Uid/UuidV6.php b/src/Symfony/Component/Uid/UuidV6.php index 1cecf408d9177..bed75bcb58135 100644 --- a/src/Symfony/Component/Uid/UuidV6.php +++ b/src/Symfony/Component/Uid/UuidV6.php @@ -43,6 +43,13 @@ public function getNode(): string return substr($this->uid, 24); } + public static function fromV1(UuidV1 $uuidV1): self + { + $uuidV1 = $uuidV1->toRfc4122(); + + return new self(substr($uuidV1, 15, 3).substr($uuidV1, 9, 4).$uuidV1[0].'-'.substr($uuidV1, 1, 4).'-6'.substr($uuidV1, 5, 3).substr($uuidV1, 18, 6).substr($uuidV1, 24)); + } + public static function generate(\DateTimeInterface $time = null, Uuid $node = null): string { $uuidV1 = UuidV1::generate($time, $node); diff --git a/src/Symfony/Component/Uid/UuidV7.php b/src/Symfony/Component/Uid/UuidV7.php index 88797d37eda67..ce90b2a3da111 100644 --- a/src/Symfony/Component/Uid/UuidV7.php +++ b/src/Symfony/Component/Uid/UuidV7.php @@ -49,6 +49,28 @@ public function getDateTime(): \DateTimeImmutable return \DateTimeImmutable::createFromFormat('U.v', substr_replace($time, '.', -3, 0)); } + /** + * Sub-millisecond timestamp precision is lost since UUIDv7 don't support it. + */ + public static function fromV1(UuidV1 $uuidV1): self + { + $uuidV1 = $uuidV1->toRfc4122(); + $time = '0'.substr($uuidV1, 15, 3).substr($uuidV1, 9, 4).substr($uuidV1, 0, 8); + $time = BinaryUtil::hexToNumericString($time); + if ('-' === $time[0]) { + throw new \InvalidArgumentException('UUIDv1 with a timestamp before Unix epoch cannot be converted to UUIDv7'); + } + + $time = str_pad($time, 5, '0', \STR_PAD_LEFT); + + return new self(substr_replace(sprintf( + '%012s-7%03s-%s', + \PHP_INT_SIZE >= 8 ? dechex(substr($time, 0, -4)) : bin2hex(BinaryUtil::fromBase(substr($time, 0, -4), BinaryUtil::BASE10)), + dechex((int) substr($time, -4, 3)), + substr($uuidV1, 19) + ), '-', 8, 0)); + } + public static function generate(\DateTimeInterface $time = null): string { if (null === $mtime = $time) { From 9ee14372ab0e0059428fea69b6fa59d08fe47c64 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 19 Dec 2023 12:29:43 +0100 Subject: [PATCH 2/2] [Uid] Add `UuidV1::toV6()`, `UuidV1::toV7()` and `UuidV6::toV7()` --- src/Symfony/Component/Uid/CHANGELOG.md | 2 +- src/Symfony/Component/Uid/Tests/UuidTest.php | 37 ++++++++++++++------ src/Symfony/Component/Uid/UuidV1.php | 12 +++++++ src/Symfony/Component/Uid/UuidV6.php | 26 ++++++++++++-- src/Symfony/Component/Uid/UuidV7.php | 22 ------------ 5 files changed, 62 insertions(+), 37 deletions(-) diff --git a/src/Symfony/Component/Uid/CHANGELOG.md b/src/Symfony/Component/Uid/CHANGELOG.md index 1e07caa42a721..dd28dd6a54c49 100644 --- a/src/Symfony/Component/Uid/CHANGELOG.md +++ b/src/Symfony/Component/Uid/CHANGELOG.md @@ -4,7 +4,7 @@ CHANGELOG 7.1 --- - * Add `UuidV6::fromV1()` and `UuidV7::fromV1()` + * Add `UuidV1::toV6()`, `UuidV1::toV7()` and `UuidV6::toV7()` 6.2 --- diff --git a/src/Symfony/Component/Uid/Tests/UuidTest.php b/src/Symfony/Component/Uid/Tests/UuidTest.php index 172bc18340950..297f85cb99993 100644 --- a/src/Symfony/Component/Uid/Tests/UuidTest.php +++ b/src/Symfony/Component/Uid/Tests/UuidTest.php @@ -428,32 +428,47 @@ public function testFromStringBase58Padding() $this->assertInstanceOf(Uuid::class, Uuid::fromString('111111111u9QRyVM94rdmZ')); } - public function testV6FromV1() + public function testV1ToV6() { $uuidV1 = new UuidV1('8189d3de-9670-11ee-b9d1-0242ac120002'); - $uuidV6 = UuidV6::fromV1($uuidV1); + $uuidV6 = $uuidV1->toV6(); $this->assertEquals($uuidV1->getDateTime(), $uuidV6->getDateTime()); $this->assertSame($uuidV1->getNode(), $uuidV6->getNode()); - $this->assertEquals($uuidV6, UuidV6::fromV1($uuidV1)); + $this->assertEquals($uuidV6, $uuidV1->toV6()); } - public function testV7FromV1BeforeUnixEpochThrows() + public function testV1ToV7BeforeUnixEpochThrows() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('UUIDv1 with a timestamp before Unix epoch cannot be converted to UUIDv7'); + $this->expectExceptionMessage('Cannot convert UUID to v7: its timestamp is before the Unix epoch.'); - UuidV7::fromV1(new UuidV1('9aba8000-ff00-11b0-b3db-3b3fc83afdfc')); // Timestamp is 1969-01-01 00:00:00.0000000 + (new UuidV1('9aba8000-ff00-11b0-b3db-3b3fc83afdfc'))->toV7(); // Timestamp is 1969-01-01 00:00:00.0000000 } - public function testV7FromV1() + public function testV1ToV7() { $uuidV1 = new UuidV1('eb248d80-ea4f-11ec-9d2a-839425e6fb88'); $sameUuidV1100NanosecondsLater = new UuidV1('eb248d81-ea4f-11ec-9d2a-839425e6fb88'); - $uuidV7 = UuidV7::fromV1($uuidV1); + $uuidV7 = $uuidV1->toV7(); + $sameUuidV7100NanosecondsLater = $sameUuidV1100NanosecondsLater->toV7(); - $this->assertEquals($uuidV1->getDateTime(), $uuidV7->getDateTime()); - $this->assertEquals($uuidV7, UuidV7::fromV1($uuidV1)); - $this->assertNotEquals($uuidV7, UuidV7::fromV1($sameUuidV1100NanosecondsLater)); + $this->assertSame($uuidV1->getDateTime()->format('Uv'), $uuidV7->getDateTime()->format('Uv')); + $this->assertEquals($uuidV7, $uuidV1->toV7()); + $this->assertNotEquals($uuidV7, $sameUuidV7100NanosecondsLater); + $this->assertSame(hexdec('0'.substr($uuidV7, -2)) + 1, hexdec('0'.substr($sameUuidV7100NanosecondsLater, -2))); + } + + public function testV1ToV7WhenExtraTimeEntropyOverflows() + { + $uuidV1 = new UuidV1('10e7718f-2d4f-11be-bfed-cdd35907e584'); + $sameUuidV1100NanosecondsLater = new UuidV1('10e77190-2d4f-11be-bfed-cdd35907e584'); + $uuidV7 = $uuidV1->toV7(); + $sameUuidV7100NanosecondsLater = $sameUuidV1100NanosecondsLater->toV7(); + + $this->assertSame($uuidV1->getDateTime()->format('Uv'), $uuidV7->getDateTime()->format('Uv')); + $this->assertEquals($uuidV7, $uuidV1->toV7()); + $this->assertNotEquals($uuidV7, $sameUuidV7100NanosecondsLater); + $this->assertSame(hexdec('0'.substr($uuidV7, -2)) + 1, hexdec('0'.substr($sameUuidV7100NanosecondsLater, -2))); } } diff --git a/src/Symfony/Component/Uid/UuidV1.php b/src/Symfony/Component/Uid/UuidV1.php index 8c03792113741..4bb24dacee87d 100644 --- a/src/Symfony/Component/Uid/UuidV1.php +++ b/src/Symfony/Component/Uid/UuidV1.php @@ -41,6 +41,18 @@ public function getNode(): string return uuid_mac($this->uid); } + public function toV6(): UuidV6 + { + $uuid = $this->uid; + + return new UuidV6(substr($uuid, 15, 3).substr($uuid, 9, 4).$uuid[0].'-'.substr($uuid, 1, 4).'-6'.substr($uuid, 5, 3).substr($uuid, 18, 6).substr($uuid, 24)); + } + + public function toV7(): UuidV7 + { + return $this->toV6()->toV7(); + } + public static function generate(\DateTimeInterface $time = null, Uuid $node = null): string { $uuid = !$time || !$node ? uuid_create(static::TYPE) : parent::NIL; diff --git a/src/Symfony/Component/Uid/UuidV6.php b/src/Symfony/Component/Uid/UuidV6.php index bed75bcb58135..cb213ce774208 100644 --- a/src/Symfony/Component/Uid/UuidV6.php +++ b/src/Symfony/Component/Uid/UuidV6.php @@ -43,11 +43,31 @@ public function getNode(): string return substr($this->uid, 24); } - public static function fromV1(UuidV1 $uuidV1): self + public function toV7(): UuidV7 { - $uuidV1 = $uuidV1->toRfc4122(); + $uuid = $this->uid; + $time = BinaryUtil::hexToNumericString('0'.substr($uuid, 0, 8).substr($uuid, 9, 4).substr($uuid, 15, 3)); + if ('-' === $time[0]) { + throw new \InvalidArgumentException('Cannot convert UUID to v7: its timestamp is before the Unix epoch.'); + } + + $ms = \strlen($time) > 4 ? substr($time, 0, -4) : '0'; + $time = dechex(10000 * hexdec(substr($uuid, 20, 3)) + substr($time, -4)); + + if (\strlen($time) > 6) { + $uuid[29] = dechex(hexdec($uuid[29]) ^ hexdec($time[0])); + $time = substr($time, 1); + } - return new self(substr($uuidV1, 15, 3).substr($uuidV1, 9, 4).$uuidV1[0].'-'.substr($uuidV1, 1, 4).'-6'.substr($uuidV1, 5, 3).substr($uuidV1, 18, 6).substr($uuidV1, 24)); + return new UuidV7(substr_replace(sprintf( + '%012s-7%s-%s%s-%s%06s', + \PHP_INT_SIZE >= 8 ? dechex($ms) : bin2hex(BinaryUtil::fromBase($ms, BinaryUtil::BASE10)), + substr($uuid, -6, 3), + $uuid[19], + substr($uuid, -3), + substr($uuid, -12, 6), + $time + ), '-', 8, 0)); } public static function generate(\DateTimeInterface $time = null, Uuid $node = null): string diff --git a/src/Symfony/Component/Uid/UuidV7.php b/src/Symfony/Component/Uid/UuidV7.php index ce90b2a3da111..88797d37eda67 100644 --- a/src/Symfony/Component/Uid/UuidV7.php +++ b/src/Symfony/Component/Uid/UuidV7.php @@ -49,28 +49,6 @@ public function getDateTime(): \DateTimeImmutable return \DateTimeImmutable::createFromFormat('U.v', substr_replace($time, '.', -3, 0)); } - /** - * Sub-millisecond timestamp precision is lost since UUIDv7 don't support it. - */ - public static function fromV1(UuidV1 $uuidV1): self - { - $uuidV1 = $uuidV1->toRfc4122(); - $time = '0'.substr($uuidV1, 15, 3).substr($uuidV1, 9, 4).substr($uuidV1, 0, 8); - $time = BinaryUtil::hexToNumericString($time); - if ('-' === $time[0]) { - throw new \InvalidArgumentException('UUIDv1 with a timestamp before Unix epoch cannot be converted to UUIDv7'); - } - - $time = str_pad($time, 5, '0', \STR_PAD_LEFT); - - return new self(substr_replace(sprintf( - '%012s-7%03s-%s', - \PHP_INT_SIZE >= 8 ? dechex(substr($time, 0, -4)) : bin2hex(BinaryUtil::fromBase(substr($time, 0, -4), BinaryUtil::BASE10)), - dechex((int) substr($time, -4, 3)), - substr($uuidV1, 19) - ), '-', 8, 0)); - } - public static function generate(\DateTimeInterface $time = null): string { if (null === $mtime = $time) {