8000 feature #47525 [Uid] Add UuidV7 and UuidV8 (nicolas-grekas) · symfony/symfony@38afb1e · GitHub
[go: up one dir, main page]

Skip to content

Commit 38afb1e

Browse files
feature #47525 [Uid] Add UuidV7 and UuidV8 (nicolas-grekas)
This PR was merged into the 6.2 branch. Discussion ---------- [Uid] Add UuidV7 and UuidV8 | Q | A | ------------- | --- | Branch? | 6.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #47451 | License | MIT | Doc PR | - This PR adds support for UUID v7 and v8 as specified by https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7 UUID v7 are very close conceptually to ULID: they're monotonic, sortable, unguessable, embed a ms-unix-timestamp. Commits ------- 432fdf5 [Uid] Add UuidV7 and UuidV8
2 parents 2a32835 + 432fdf5 commit 38afb1e

File tree

14 files changed

+259
-26
lines changed

14 files changed

+259
-26
lines changed

src/Symfony/Component/Routing/Requirement/Requirement.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ enum Requirement
2525
public const UID_BASE58 = '[1-9A-HJ-NP-Za-km-z]{22}';
2626
public const UID_RFC4122 = '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}';
2727
public const ULID = '[0-7][0-9A-HJKMNP-TV-Z]{25}';
28-
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}';
28+
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}';
2929
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}';
3030
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}';
3131
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}';
3232
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}';
3333
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}';
34+
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}';
35+
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}';
3436
}

src/Symfony/Component/Uid/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.2
55
---
66

7+
* Add `UuidV7` and `UuidV8`
78
* Add `TimeBasedUidInterface` to describe UIDs that embed a timestamp
89
* Add `MaxUuid` and `MaxUlid`
910

src/Symfony/Component/Uid/Factory/UuidFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public function __construct(string|int $defaultClass = UuidV6::class, string|int
4444
$this->nameBasedNamespace = $nameBasedNamespace;
4545
}
4646

47-
public function create(): UuidV6|UuidV4|UuidV1
47+
public function create(): Uuid
4848
{
4949
$class = $this->defaultClass;
5050

src/Symfony/Component/Uid/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ Uid Component
33

44
The UID component provides an object-oriented API to generate and represent UIDs.
55

6+
It provides implementations for UUIDs version 1 and versions 3 to 8,
7+
for ULIDs and for related factories.
8+
69
Resources
710
---------
811

src/Symfony/Component/Uid/Tests/Command/InspectUuidCommandTest.php

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,16 @@ public function testUnknown()
8282
EOF
8383
, $commandTester->getDisplay(true));
8484

85-
$this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-7dba-91e9-33af4c63f7ec']));
85+
$this->assertSame(0, $commandTester->execute(['uuid' => '461cc9b9-2397-adba-91e9-33af4c63f7ec']));
8686
$this->assertSame(<<<EOF
8787
----------------------- --------------------------------------
8888
Label Value
8989
----------------------- --------------------------------------
90-
Version 7
91-
toRfc4122 (canonical) 461cc9b9-2397-7dba-91e9-33af4c63f7ec
92-
toBase58 9f9nftX6kE2K6HpooNEQ83
93-
toBase32 263K4VJ8WQFPX93T9KNX667XZC
94-
toHex 0x461cc9b923977dba91e933af4c63f7ec
90+
Version 10
91+
toRfc4122 (canonical) 461cc9b9-2397-adba-91e9-33af4c63f7ec
92+
toBase58 9f9nftX6nvS6vPZqBckwvj
93+
toBase32 263K4VJ8WQNPX93T9KNX667XZC
94+
toHex 0x461cc9b92397adba91e933af4c63f7ec
9595
----------------------- --------------------------------------
9696
9797
@@ -220,6 +220,50 @@ public function testV6()
220220
----------------------- --------------------------------------
221221
222222
223+
EOF
224+
, $commandTester->getDisplay(true));
225+
}
226+
227+
public function testV7()
228+
{
229+
$commandTester = new CommandTester(new InspectUuidCommand());
230+
231+
$this->assertSame(0, $commandTester->execute(['uuid' => '017f22e2-79b0-7cc3-98c4-dc0c0c07398f']));
232+
$this->assertSame(<<<EOF
233+
----------------------- --------------------------------------
234+
Label Value
235+
----------------------- --------------------------------------
236+
Version 7
237+
toRfc4122 (canonical) 017f22e2-79b0-7cc3-98c4-dc0c0c07398f
238+
toBase58 1BihbxwwQ4NZZpKRH9JDCz
239+
toBase32 01FWHE4YDGFK1SHH6W1G60EECF
240+
toHex 0x017f22e279b07cc398c4dc0c0c07398f
241+
----------------------- --------------------------------------
242+
Time 2022-02-22 19:22:22.000000 UTC
243+
----------------------- --------------------------------------
244+
245+
246+
EOF
247+
, $commandTester->getDisplay(true));
248+
}
249+
250+
public function testV8()
251+
{
252+
$commandTester = new CommandTester(new InspectUuidCommand());
253+
254+
$this->assertSame(0, $commandTester->execute(['uuid' => '017f22e2-79b0-8cc3-98c4-dc0c0c07398f']));
255+
$this->assertSame(<<<EOF
256+
----------------------- --------------------------------------
257+
Label Value
258+
----------------------- --------------------------------------
259+
Version 8
260+
toRfc4122 (canonical) 017f22e2-79b0-8cc3-98c4-dc0c0c07398f
261+
toBase58 1BihbxwwQxWVWWu6QZUPot
262+
toBase32 01FWHE4YDGHK1SHH6W1G60EECF
263+
toHex 0x017f22e279b08cc398c4dc0c0c07398f
264+
----------------------- --------------------------------------
265+
266+
223267
EOF
224268
, $commandTester->getDisplay(true));
225269
}

src/Symfony/Component/Uid/Tests/UuidTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
use Symfony\Component\Uid\UuidV4;
2323
use Symfony\Component\Uid\UuidV5;
2424
use Symfony\Component\Uid\UuidV6;
25+
use Symfony\Component\Uid\UuidV7;
2526

2627
class UuidTest extends TestCase
2728
{
2829
private const A_UUID_V1 = 'd9e7a184-5d5b-11ea-a62a-3499710062d0';
2930
private const A_UUID_V4 = 'd6b3345b-2905-4048-a83c-b5988e765d98';
31+
private const A_UUID_V7 = '017f22e2-79b0-7cc3-98c4-dc0c0c07398f';
3032

3133
/**
3234
* @dataProvider provideInvalidUuids
@@ -69,6 +71,8 @@ public function provideInvalidVariant(): iterable
6971
yield ['8dac64d3-937a-4e7c-fa1d-d5d6c06a61f5'];
7072
yield ['8dac64d3-937a-5e7c-fa1d-d5d6c06a61f5'];
7173
yield ['8dac64d3-937a-6e7c-fa1d-d5d6c06a61f5'];
74+
yield ['8dac64d3-937a-7e7c-fa1d-d5d6c06a61f5'];
75+
yield ['8dac64d3-937a-8e7c-fa1d-d5d6c06a61f5'];
7276
}
7377

7478
public function testConstructorWithValidUuid()
@@ -134,6 +138,28 @@ public function testV6IsSeeded()
134138
$this->assertNotSame(substr($uuidV1, 24), substr($uuidV6, 24));
135139
}
136140

141+
public function testV7()
142+
{
143+
$uuid = Uuid::fromString(self::A_UUID_V7);
144+
145+
$this->assertInstanceOf(UuidV7::class, $uuid);
146+
$this->assertSame(1645557742, $uuid->getDateTime()->getTimeStamp());
147+
148+
$prev = UuidV7::generate();
149+
150+
for ($i = 0; $i < 25; ++$i) {
151+
$uuid = UuidV7::generate();
152+
$now = gmdate('Y-m-d H:i');
153+
$this->assertGreaterThan($prev, $uuid);
154+
$prev = $uuid;
155+
}
156+
157+
$this->assertTrue(Uuid::isValid($uuid));
158+
$uuid = Uuid::fromString($uuid);
159+
$this->assertInstanceOf(UuidV7::class, $uuid);
160+
$this->assertSame($now, $uuid->getDateTime()->format('Y-m-d H:i'));
161+
}
162+
137163
public function testBinary()
138164
{
139165
$uuid = new UuidV4(self::A_UUID_V4);

src/Symfony/Component/Uid/Ulid.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,15 @@ public static function generate(\DateTimeInterface $time = null): string
152152

153153
if ($time > self::$time || (null !== $mtime && $time !== self::$time)) {
154154
randomize:
155-
$r = unpack('nr1/nr2/nr3/nr4/nr', random_bytes(10));
156-
$r['r1'] |= ($r['r'] <<= 4) & 0xF0000;
157-
$r['r2'] |= ($r['r'] <<= 4) & 0xF0000;
158-
$r['r3'] |= ($r['r'] <<= 4) & 0xF0000;
159-
$r['r4'] |= ($r['r'] <<= 4) & 0xF0000;
160-
unset($r['r']);
161-
self::$rand = array_values($r);
155+
$r = unpack('n*', random_bytes(10));
156+
$r[1] |= ($r[5] <<= 4) & 0xF0000;
157+
$r[2] |= ($r[5] <<= 4) & 0xF0000;
158+
$r[3] |= ($r[5] <<= 4) & 0xF0000;
159+
$r[4] |= ($r[5] <<= 4) & 0xF0000;
160+
unset($r[5]);
161+
self::$rand = $r;
162162
self::$time = $time;
163-
} elseif ([0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) {
163+
} elseif ([1 => 0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) {
164164
if (\PHP_INT_SIZE >= 8 || 10 > \strlen($time = self::$time)) {
165165
$time = (string) (1 + $time);
166166
} elseif ('999999999' === $mtime = substr($time, -9)) {
@@ -171,7 +171,7 @@ public static function generate(\DateTimeInterface $time = null): string
171171

172172
goto randomize;
173173
} else {
174-
for ($i = 3; $i >= 0 && 0xFFFFF === self::$rand[$i]; --$i) {
174+
for ($i = 4; $i > 0 && 0xFFFFF === self::$rand[$i]; --$i) {
175175
self::$rand[$i] = 0;
176176
}
177177

@@ -192,10 +192,10 @@ public static function generate(\DateTimeInterface $time = null): string
192192

193193
return strtr(sprintf('%010s%04s%04s%04s%04s',
194194
$time,
195-
base_convert(self::$rand[0], 10, 32),
196195
base_convert(self::$rand[1], 10, 32),
197196
base_convert(self::$rand[2], 10, 32),
198-
base_convert(self::$rand[3], 10, 32)
197+
base_convert(self::$rand[3], 10, 32),
198+
base_convert(self::$rand[4], 10, 32)
199199
), 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ');
200200
}
201201
}

src/Symfony/Component/Uid/Uuid.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public static function fromString(string $uuid): static
8383
UuidV4::TYPE => new UuidV4($uuid),
8484
UuidV5::TYPE => new UuidV5($uuid),
8585
UuidV6::TYPE => new UuidV6($uuid),
86+
UuidV7::TYPE => new UuidV7($uuid),
87+
UuidV8::TYPE => new UuidV8($uuid),
8688
default => new self($uuid),
8789
};
8890
}
@@ -118,6 +120,16 @@ final public static function v6(): UuidV6
118120
return new UuidV6();
119121
}
120122

123+
final public static function v7(): UuidV7
124+
{
125+
return new UuidV7();
126+
}
127+
128+
final public static function v8(string $uuid): UuidV8
129+
{
130+
return new UuidV8($uuid);
131+
}
132+
121133
public static function isValid(string $uuid): bool
122134
{
123135
if (!preg_match('{^[0-9a-f]{8}(?:-[0-9a-f]{4}){2}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$}Di', $uuid)) {

src/Symfony/Component/Uid/UuidV1.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class UuidV1 extends Uuid implements TimeBasedUidInterface
2020
{
2121
protected const TYPE = 1;
2222

23-
private static ?string $clockSeq = null;
23+
private static string $clockSeq;
2424

2525
public function __construct(string $uuid = null)
2626
{
@@ -49,13 +49,13 @@ public static function generate(\DateTimeInterface $time = null, Uuid $node = nu
4949
if ($node) {
5050
// use clock_seq from the node
5151
$seq = substr($node->uid, 19, 4);
52-
} else {
52+
} elseif (!$seq = self::$clockSeq ?? '') {
5353
// generate a static random clock_seq to prevent any collisions with the real one
5454
$seq = substr($uuid, 19, 4);
5555

56-
while (null === self::$clockSeq || $seq === self::$clockSeq) {
56+
do {
5757
self::$clockSeq = sprintf('%04x', random_int(0, 0x3FFF) | 0x8000);
58-
}
58+
} while ($seq === self::$clockSeq);
5959

6060
$seq = self::$clockSeq;
6161
}

src/Symfony/Component/Uid/UuidV7.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Uid;
13+
14+
/**
15+
* A v7 UUID is lexicographically sortable and contains a 48-bit timestamp and 74 extra unique bits.
16+
*
17+
* Within the same millisecond, monotonicity is ensured by incrementing the random part by a random increment.
18+
*
19+
* @author Nicolas Grekas <p@tchwork.com>
20+
*/
21+
class UuidV7 extends Uuid implements TimeBasedUidInterface
22+
{
23+
protected const TYPE = 7;
24+
25+
private static string $time = '';
26+
private static array $rand = [];
27+
private static string $seed;
28+
private static array $seedParts;
29+
private static int $seedIndex = 0;
30+
31+
public function __construct(string $uuid = null)
32+
{
33+
if (null === $uuid) {
34+
$this->uid = static::generate();
35+
} else {
36+
parent::__construct($uuid, true);
37+
}
38+
}
39+
40+
public function getDateTime(): \DateTimeImmutable
41+
{
42+
$time = substr($this->uid, 0, 8).substr($this->uid, 9, 4);
43+
$time = \PHP_INT_SIZE >= 8 ? (string) hexdec($time) : BinaryUtil::toBase(hex2bin($time), BinaryUtil::BASE10);
44+
45+
if (4 > \strlen($time)) {
46+
$time = '000'.$time;
47+
}
48+
49+
return \DateTimeImmutable::createFromFormat('U.v', substr_replace($time, '.', -3, 0));
50+
}
51+
52+
public static function generate(\DateTimeInterface $time = null): string
53+
{
54+
if (null === $mtime = $time) {
55+
$time = microtime(false);
56+
$time = substr($time, 11).substr($time, 2, 3);
57+
} elseif (0 > $time = $time->format('Uv')) {
58+
throw new \InvalidArgumentException('The timestamp must be positive.');
59+
}
60+
61+
if ($time > self::$time || (null !== $mtime && $time !== self::$time)) {
62+
randomize:
63+
self::$rand = unpack('n*', isset(self::$seed) ? random_bytes(10) : self::$seed = random_bytes(16));
64+
self::$rand[1] &= 0x03FF;
65+
self::$time = $time;
66+
} else {
67+
if (!self::$seedIndex) {
68+
$s = unpack('l*', self::$seed = hash('sha512', self::$seed, true));
69+
$s[] = ($s[1] >> 8 & 0xFF0000) | ($s[2] >> 16 & 0xFF00) | ($s[3] >> 24 & 0xFF);
70+
$s[] = ($s[4] >> 8 & 0xFF0000) | ($s[5] >> 16 & 0xFF00) | ($s[6] >> 24 & 0xFF);
71+
$s[] = ($s[7] >> 8 & 0xFF0000) | ($s[8] >> 16 & 0xFF00) | ($s[9] >> 24 & 0xFF);
72+
$s[] = ($s[10] >> 8 & 0xFF0000) | ($s[11] >> 16 & 0xFF00) | ($s[12] >> 24 & 0xFF);
73+
$s[] = ($s[13] >> 8 & 0xFF0000) | ($s[14] >> 16 & 0xFF00) | ($s[15] >> 24 & 0xFF);
74+
self::$seedParts = $s;
75+
self::$seedIndex = 21;
76+
}
77+
78+
self::$rand[5] = 0xFFFF & $carry = self::$rand[5] + (self::$seedParts[self::$seedIndex--] & 0xFFFFFF);
79+
self::$rand[4] = 0xFFFF & $carry = self::$rand[4] + ($carry >> 16);
80+
self::$rand[3] = 0xFFFF & $carry = self::$rand[3] + ($carry >> 16);
81+
self::$rand[2] = 0xFFFF & $carry = self::$rand[2] + ($carry >> 16);
82+
self::$rand[1] += $carry >> 16;
83+
84+
if (0xFC00 & self::$rand[1]) {
85+
if (\PHP_INT_SIZE >= 8 || 10 > \strlen($time = self::$time)) {
86+
$time = (string) (1 + $time);
87+
} elseif ('999999999' === $mtime = substr($time, -9)) {
88+
$time = (1 + substr($time, 0, -9)).'000000000';
89+
} else {
90+
$time = substr_replace($time, str_pad(++$mtime, 9, '0', \STR_PAD_LEFT), -9);
91+
}
92+
93+
goto randomize;
94+
}
95+
96+
$time = self::$time;
97+
}
98+
99+
if (\PHP_INT_SIZE >= 8) {
100+
$time = base_convert($time, 10, 16);
101+
} else {
102+
$time = bin2hex(BinaryUtil::fromBase($time, BinaryUtil::BASE10));
103+
}
104+
105+
return substr_replace(sprintf('%012s-%04x-%04x-%04x%04x%04x',
106+
$time,
107+
0x7000 | (self::$rand[1] << 2) | (self::$rand[2] >> 14),
108+
0x8000 | (self::$rand[2] & 0x3FFF),
109+
self::$rand[3],
110+
self::$rand[4],
111+
self::$rand[5],
112+
), '-', 8, 0);
113+
}
114+
}

0 commit comments

Comments
 (0)
0