diff --git a/src/Symfony/Component/Routing/Requirement/Requirement.php b/src/Symfony/Component/Routing/Requirement/Requirement.php index ccba5ec41d320..54ad86b610182 100644 --- a/src/Symfony/Component/Routing/Requirement/Requirement.php +++ b/src/Symfony/Component/Routing/Requirement/Requirement.php @@ -25,10 +25,12 @@ enum Requirement public const UID_BASE58 = '[1-9A-HJ-NP-Za-km-z]{22}'; public const UID_RFC4122 = '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}'; public const ULID = '[0-7][0-9A-HJKMNP-TV-Z]{25}'; - public const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[13-6][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; + public const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[13-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; public const UUID_V1 = '[0-9a-f]{8}-[0-9a-f]{4}-1[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; public const UUID_V3 = '[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; public const UUID_V4 = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; public const UUID_V5 = '[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; public const UUID_V6 = '[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; + public const UUID_V7 = '[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; + public const UUID_V8 = '[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; } diff --git a/src/Symfony/Component/Uid/CHANGELOG.md b/src/Symfony/Component/Uid/CHANGELOG.md index 7b5142ba5c7d2..b82133751c0a9 100644 --- a/src/Symfony/Component/Uid/CHANGELOG.md +++ b/src/Symfony/Component/Uid/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.2 --- + * Add `UuidV7` and `UuidV8` * Add `TimeBasedUidInterface` to describe UIDs that embed a timestamp * Add `MaxUuid` and `MaxUlid` diff --git a/src/Symfony/Component/Uid/Factory/UuidFactory.php b/src/Symfony/Component/Uid/Factory/UuidFactory.php index aac35386e95f5..0255d51bf37b0 100644 --- a/src/Symfony/Component/Uid/Factory/UuidFactory.php +++ b/src/Symfony/Component/Uid/Factory/UuidFactory.php @@ -44,7 +44,7 @@ public function __construct(string|int $defaultClass = UuidV6::class, string|int $this->nameBasedNamespace = $nameBasedNamespace; } - public function create(): UuidV6|UuidV4|UuidV1 + public function create(): Uuid { $class = $this->defaultClass; diff --git a/src/Symfony/Component/Uid/README.md b/src/Symfony/Component/Uid/README.md index 2ec5b761bc7b1..ce0fb18612d65 100644 --- a/src/Symfony/Component/Uid/README.md +++ b/src/Symfony/Component/Uid/README.md @@ -3,6 +3,9 @@ Uid Component The UID component provides an object-oriented API to generate and represent UIDs. +It provides implementations for UUIDs version 1 and versions 3 to 8, +for ULIDs and for related factories. + Resources --------- diff --git a/src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php b/src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php index f581b37a7a476..c9061b5a861d0 100644 --- a/src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php +++ b/src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php @@ -82,16 +82,16 @@ public function testUnknown() EOF , $commandTester->getDisplay(true)); - $this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-7dba-91e9-33af4c63f7ec'])); + $this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-adba-91e9-33af4c63f7ec'])); $this->assertSame(<<getDisplay(true)); + } + + public function testV7() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'])); + $this->assertSame(<<getDisplay(true)); + } + + public function testV8() + { + $commandTester = new CommandTester(new InspectUuidCommand()); + + $this->assertSame(0, $commandTester->execute(['uuid' => '017f22e2-79b0-8cc3-98c4-dc0c0c07398f'])); + $this->assertSame(<<getDisplay(true)); } diff --git a/src/Symfony/Component/Uid/Tests/UuidTest.php b/src/Symfony/Component/Uid/Tests/UuidTest.php index 93c2340ff9375..3ac925aa4194a 100644 --- a/src/Symfony/Component/Uid/Tests/UuidTest.php +++ b/src/Symfony/Component/Uid/Tests/UuidTest.php @@ -22,11 +22,13 @@ use Symfony\Component\Uid\UuidV4; use Symfony\Component\Uid\UuidV5; use Symfony\Component\Uid\UuidV6; +use Symfony\Component\Uid\UuidV7; class UuidTest extends TestCase { private const A_UUID_V1 = 'd9e7a184-5d5b-11ea-a62a-3499710062d0'; private const A_UUID_V4 = 'd6b3345b-2905-4048-a83c-b5988e765d98'; + private const A_UUID_V7 = '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'; /** * @dataProvider provideInvalidUuids @@ -69,6 +71,8 @@ public function provideInvalidVariant(): iterable yield ['8dac64d3-937a-4e7c-fa1d-d5d6c06a61f5']; yield ['8dac64d3-937a-5e7c-fa1d-d5d6c06a61f5']; yield ['8dac64d3-937a-6e7c-fa1d-d5d6c06a61f5']; + yield ['8dac64d3-937a-7e7c-fa1d-d5d6c06a61f5']; + yield ['8dac64d3-937a-8e7c-fa1d-d5d6c06a61f5']; } public function testConstructorWithValidUuid() @@ -134,6 +138,28 @@ public function testV6IsSeeded() $this->assertNotSame(substr($uuidV1, 24), substr($uuidV6, 24)); } + public function testV7() + { + $uuid = Uuid::fromString(self::A_UUID_V7); + + $this->assertInstanceOf(UuidV7::class, $uuid); + $this->assertSame(1645557742, $uuid->getDateTime()->getTimeStamp()); + + $prev = UuidV7::generate(); + + for ($i = 0; $i < 25; ++$i) { + $uuid = UuidV7::generate(); + $now = gmdate('Y-m-d H:i'); + $this->assertGreaterThan($prev, $uuid); + $prev = $uuid; + } + + $this->assertTrue(Uuid::isValid($uuid)); + $uuid = Uuid::fromString($uuid); + $this->assertInstanceOf(UuidV7::class, $uuid); + $this->assertSame($now, $uuid->getDateTime()->format('Y-m-d H:i')); + } + public function testBinary() { $uuid = new UuidV4(self::A_UUID_V4); diff --git a/src/Symfony/Component/Uid/Ulid.php b/src/Symfony/Component/Uid/Ulid.php index 6a9b21cf4cc5f..81610a00d4210 100644 --- a/src/Symfony/Component/Uid/Ulid.php +++ b/src/Symfony/Component/Uid/Ulid.php @@ -152,15 +152,15 @@ public static function generate(\DateTimeInterface $time = null): string if ($time > self::$time || (null !== $mtime && $time !== self::$time)) { randomize: - $r = unpack('nr1/nr2/nr3/nr4/nr', random_bytes(10)); - $r['r1'] |= ($r['r'] <<= 4) & 0xF0000; - $r['r2'] |= ($r['r'] <<= 4) & 0xF0000; - $r['r3'] |= ($r['r'] <<= 4) & 0xF0000; - $r['r4'] |= ($r['r'] <<= 4) & 0xF0000; - unset($r['r']); - self::$rand = array_values($r); + $r = unpack('n*', random_bytes(10)); + $r[1] |= ($r[5] <<= 4) & 0xF0000; + $r[2] |= ($r[5] <<= 4) & 0xF0000; + $r[3] |= ($r[5] <<= 4) & 0xF0000; + $r[4] |= ($r[5] <<= 4) & 0xF0000; + unset($r[5]); + self::$rand = $r; self::$time = $time; - } elseif ([0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) { + } elseif ([1 => 0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) { if (\PHP_INT_SIZE >= 8 || 10 > \strlen($time = self::$time)) { $time = (string) (1 + $time); } elseif ('999999999' === $mtime = substr($time, -9)) { @@ -171,7 +171,7 @@ public static function generate(\DateTimeInterface $time = null): string goto randomize; } else { - for ($i = 3; $i >= 0 && 0xFFFFF === self::$rand[$i]; --$i) { + for ($i = 4; $i > 0 && 0xFFFFF === self::$rand[$i]; --$i) { self::$rand[$i] = 0; } @@ -192,10 +192,10 @@ public static function generate(\DateTimeInterface $time = null): string return strtr(sprintf('%010s%04s%04s%04s%04s', $time, - base_convert(self::$rand[0], 10, 32), base_convert(self::$rand[1], 10, 32), base_convert(self::$rand[2], 10, 32), - base_convert(self::$rand[3], 10, 32) + base_convert(self::$rand[3], 10, 32), + base_convert(self::$rand[4], 10, 32) ), 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ'); } } diff --git a/src/Symfony/Component/Uid/Uuid.php b/src/Symfony/Component/Uid/Uuid.php index 8f763c6588a4f..da0bfcb39957a 100644 --- a/src/Symfony/Component/Uid/Uuid.php +++ b/src/Symfony/Component/Uid/Uuid.php @@ -83,6 +83,8 @@ public static function fromString(string $uuid): static UuidV4::TYPE => new UuidV4($uuid), UuidV5::TYPE => new UuidV5($uuid), UuidV6::TYPE => new UuidV6($uuid), + UuidV7::TYPE => new UuidV7($uuid), + UuidV8::TYPE => new UuidV8($uuid), default => new self($uuid), }; } @@ -118,6 +120,16 @@ final public static function v6(): UuidV6 return new UuidV6(); } + final public static function v7(): UuidV7 + { + return new UuidV7(); + } + + final public static function v8(string $uuid): UuidV8 + { + return new UuidV8($uuid); + } + public static function isValid(string $uuid): bool { if (!preg_match('{^[0-9a-f]{8}(?:-[0-9a-f]{4}){2}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$}Di', $uuid)) { diff --git a/src/Symfony/Component/Uid/UuidV1.php b/src/Symfony/Component/Uid/UuidV1.php index 045f667b4c526..8c03792113741 100644 --- a/src/Symfony/Component/Uid/UuidV1.php +++ b/src/Symfony/Component/Uid/UuidV1.php @@ -20,7 +20,7 @@ class UuidV1 extends Uuid implements TimeBasedUidInterface { protected const TYPE = 1; - private static ?string $clockSeq = null; + private static string $clockSeq; public function __construct(string $uuid = null) { @@ -49,13 +49,13 @@ public static function generate(\DateTimeInterface $time = null, Uuid $node = nu if ($node) { // use clock_seq from the node $seq = substr($node->uid, 19, 4); - } else { + } elseif (!$seq = self::$clockSeq ?? '') { // generate a static random clock_seq to prevent any collisions with the real one $seq = substr($uuid, 19, 4); - while (null === self::$clockSeq || $seq === self::$clockSeq) { + do { self::$clockSeq = sprintf('%04x', random_int(0, 0x3FFF) | 0x8000); - } + } while ($seq === self::$clockSeq); $seq = self::$clockSeq; } diff --git a/src/Symfony/Component/Uid/UuidV7.php b/src/Symfony/Component/Uid/UuidV7.php new file mode 100644 index 0000000000000..718b35ff9ade7 --- /dev/null +++ b/src/Symfony/Component/Uid/UuidV7.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid; + +/** + * A v7 UUID is lexicographically sortable and contains a 48-bit timestamp and 74 extra unique bits. + * + * Within the same millisecond, monotonicity is ensured by incrementing the random part by a random increment. + * + * @author Nicolas Grekas + */ +class UuidV7 extends Uuid implements TimeBasedUidInterface +{ + protected const TYPE = 7; + + private static string $time = ''; + private static array $rand = []; + private static string $seed; + private static array $seedParts; + private static int $seedIndex = 0; + + public function __construct(string $uuid = null) + { + if (null === $uuid) { + $this->uid = static::generate(); + } else { + parent::__construct($uuid, true); + } + } + + public function getDateTime(): \DateTimeImmutable + { + $time = substr($this->uid, 0, 8).substr($this->uid, 9, 4); + $time = \PHP_INT_SIZE >= 8 ? (string) hexdec($time) : BinaryUtil::toBase(hex2bin($time), BinaryUtil::BASE10); + + if (4 > \strlen($time)) { + $time = '000'.$time; + } + + return \DateTimeImmutable::createFromFormat('U.v', substr_replace($time, '.', -3, 0)); + } + + public static function generate(\DateTimeInterface $time = null): string + { + if (null === $mtime = $time) { + $time = microtime(false); + $time = substr($time, 11).substr($time, 2, 3); + } elseif (0 > $time = $time->format('Uv')) { + throw new \InvalidArgumentException('The timestamp must be positive.'); + } + + if ($time > self::$time || (null !== $mtime && $time !== self::$time)) { + randomize: + self::$rand = unpack('n*', isset(self::$seed) ? random_bytes(10) : self::$seed = random_bytes(16)); + self::$rand[1] &= 0x03FF; + self::$time = $time; + } else { + if (!self::$seedIndex) { + $s = unpack('l*', self::$seed = hash('sha512', self::$seed, true)); + $s[] = ($s[1] >> 8 & 0xFF0000) | ($s[2] >> 16 & 0xFF00) | ($s[3] >> 24 & 0xFF); + $s[] = ($s[4] >> 8 & 0xFF0000) | ($s[5] >> 16 & 0xFF00) | ($s[6] >> 24 & 0xFF); + $s[] = ($s[7] >> 8 & 0xFF0000) | ($s[8] >> 16 & 0xFF00) | ($s[9] >> 24 & 0xFF); + $s[] = ($s[10] >> 8 & 0xFF0000) | ($s[11] >> 16 & 0xFF00) | ($s[12] >> 24 & 0xFF); + $s[] = ($s[13] >> 8 & 0xFF0000) | ($s[14] >> 16 & 0xFF00) | ($s[15] >> 24 & 0xFF); + self::$seedParts = $s; + self::$seedIndex = 21; + } + + self::$rand[5] = 0xFFFF & $carry = self::$rand[5] + (self::$seedParts[self::$seedIndex--] & 0xFFFFFF); + self::$rand[4] = 0xFFFF & $carry = self::$rand[4] + ($carry >> 16); + self::$rand[3] = 0xFFFF & $carry = self::$rand[3] + ($carry >> 16); + self::$rand[2] = 0xFFFF & $carry = self::$rand[2] + ($carry >> 16); + self::$rand[1] += $carry >> 16; + + if (0xFC00 & self::$rand[1]) { + if (\PHP_INT_SIZE >= 8 || 10 > \strlen($time = self::$time)) { + $time = (string) (1 + $time); + } elseif ('999999999' === $mtime = substr($time, -9)) { + $time = (1 + substr($time, 0, -9)).'000000000'; + } else { + $time = substr_replace($time, str_pad(++$mtime, 9, '0', \STR_PAD_LEFT), -9); + } + + goto randomize; + } + + $time = self::$time; + } + + if (\PHP_INT_SIZE >= 8) { + $time = base_convert($time, 10, 16); + } else { + $time = bin2hex(BinaryUtil::fromBase($time, BinaryUtil::BASE10)); + } + + return substr_replace(sprintf('%012s-%04x-%04x-%04x%04x%04x', + $time, + 0x7000 | (self::$rand[1] << 2) | (self::$rand[2] >> 14), + 0x8000 | (self::$rand[2] & 0x3FFF), + self::$rand[3], + self::$rand[4], + self::$rand[5], + ), '-', 8, 0); + } +} diff --git a/src/Symfony/Component/Uid/UuidV8.php b/src/Symfony/Component/Uid/UuidV8.php new file mode 100644 index 0000000000000..c194a6f699eee --- /dev/null +++ b/src/Symfony/Component/Uid/UuidV8.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Uid; + +/** + * A v8 UUID has no explicit requirements except embedding its version + variant bits. + * + * @author Nicolas Grekas + */ +class UuidV8 extends Uuid +{ + protected const TYPE = 8; + + public function __construct(string $uuid) + { + parent::__construct($uuid, true); + } +} diff --git a/src/Symfony/Component/Validator/Constraints/Uuid.php b/src/Symfony/Component/Validator/Constraints/Uuid.php index 82b790578378d..89067fdd47881 100644 --- a/src/Symfony/Component/Validator/Constraints/Uuid.php +++ b/src/Symfony/Component/Validator/Constraints/Uuid.php @@ -51,6 +51,8 @@ class Uuid extends Constraint public const V4_RANDOM = 4; public const V5_SHA1 = 5; public const V6_SORTABLE = 6; + public const V7_SORTABLE = 7; + public const V8_CUSTOM = 8; public const ALL_VERSIONS = [ self::V1_MAC, @@ -59,6 +61,8 @@ class Uuid extends Constraint self::V4_RANDOM, self::V5_SHA1, self::V6_SORTABLE, + self::V7_SORTABLE, + self::V8_CUSTOM, ]; /** diff --git a/src/Symfony/Component/Validator/Constraints/UuidValidator.php b/src/Symfony/Component/Validator/Constraints/UuidValidator.php index af4ae413a9b9c..c4ccfb27dee9d 100644 --- a/src/Symfony/Component/Validator/Constraints/UuidValidator.php +++ b/src/Symfony/Component/Validator/Constraints/UuidValidator.php @@ -35,7 +35,7 @@ class UuidValidator extends ConstraintValidator // Roughly speaking: // x = any hexadecimal character - // M = any allowed version {1..6} + // M = any allowed version {1..8} // N = any allowed variant {8, 9, a, b} public const STRICT_LENGTH = 36; diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UuidValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UuidValidatorTest.php index 347980eecea74..9a1e0183b7f02 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UuidValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UuidValidatorTest.php @@ -82,6 +82,8 @@ public function getValidStrictUuids() ['456daEFb-5AA6-41B5-8DBC-068B05A8B201'], // Version 4 UUID in mixed case ['456daEFb-5AA6-41B5-8DBC-068B05A8B201', [Uuid::V4_RANDOM]], ['1eb01932-4c0b-6570-aa34-d179cdf481ae', [Uuid::V6_SORTABLE]], + ['216fff40-98d9-71e3-a5e2-0800200c9a66', [Uuid::V7_SORTABLE]], + ['216fff40-98d9-81e3-a5e2-0800200c9a66', [Uuid::V8_CUSTOM]], ]; } @@ -159,8 +161,6 @@ public function getInvalidStrictUuids() ['216fff40-98d9-11e3-a5e2-0800200c9a6', Uuid::TOO_SHORT_ERROR], ['216fff40-98d9-11e3-a5e2-0800200c9a666', Uuid::TOO_LONG_ERROR], ['216fff40-98d9-01e3-a5e2-0800200c9a66', Uuid::INVALID_VERSION_ERROR], - ['216fff40-98d9-71e3-a5e2-0800200c9a66', Uuid::INVALID_VERSION_ERROR], - ['216fff40-98d9-81e3-a5e2-0800200c9a66', Uuid::INVALID_VERSION_ERROR], ['216fff40-98d9-91e3-a5e2-0800200c9a66', Uuid::INVALID_VERSION_ERROR], ['216fff40-98d9-a1e3-a5e2-0800200c9a66', Uuid::INVALID_VERSION_ERROR], ['216fff40-98d9-b1e3-a5e2-0800200c9a66', Uuid::INVALID_VERSION_ERROR],