10000 feature #36042 [Uid] add support for Ulid (nicolas-grekas) · symfony/symfony@42c76d7 · GitHub
[go: up one dir, main page]

Skip to content

Commit 42c76d7

Browse files
committed
feature #36042 [Uid] add support for Ulid (nicolas-grekas)
This PR was merged into the 5.1-dev branch. Discussion ---------- [Uid] add support for Ulid | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | - ULIDs are useful alternatives to UUIDs. From https://github.com/ulid/spec: UUID can be suboptimal for many use-cases because: - It isn't the most character efficient way of encoding 128 bits of randomness - UUID v1/v2 is impractical in many environments, as it requires access to a unique, stable MAC address - UUID v3/v5 requires a unique seed and produces randomly distributed IDs, which can cause fragmentation in many data structures - UUID v4 provides no other information than randomness which can cause fragmentation in many data structures Instead, herein is proposed ULID: - 128-bit compatibility with UUID - 1.21e+24 unique ULIDs per millisecond - Lexicographically sortable! - Canonically encoded as a 26 character string, as opposed to the 36 character UUID - Uses Crockford's base32 for better efficiency and readability (5 bits per character) - Case insensitive - No special characters (URL safe) - Monotonic sort order (correctly detects and handles the same millisecond) Commits ------- 59044f9 [Uid] add support for Ulid
2 parents aed93cd + 59044f9 commit 42c76d7

File tree

4 files changed

+285
-0
lines changed

4 files changed

+285
-0
lines changed

src/Symfony/Component/Uid/CHANGELOG.md

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

77
* added support for UUID
8+
* added support for ULID
89
* added the component
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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\Tests\Component\Uid;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Uid\Ulid;
16+
17+
class UlidTest extends TestCase
18+
{
19+
/**
20+
* @group time-sensitive
21+
*/
22+
public function testGenerate()
23+
{
24+
$a = new Ulid();
25+
$b = new Ulid();
26+
27+
$this->assertSame(0, strncmp($a, $b, 20));
28+
$a = base_convert(strtr(substr($a, -6), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv'), 32, 10);
29+
$b = base_convert(strtr(substr($b, -6), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv'), 32, 10);
30+
$this->assertSame(1, $b - $a);
31+
}
32+
33+
public function testWithInvalidUlid()
34+
{
35+
$this->expectException(\InvalidArgumentException::class);
36+
$this->expectExceptionMessage('Invalid ULID: "this is not a ulid".');
37+
38+
new Ulid('this is not a ulid');
39+
}
40+
41+
public function testBinary()
42+
{
43+
$ulid = new Ulid('00000000000000000000000000');
44+
$this->assertSame("\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0", $ulid->toBinary());
45+
46+
$ulid = new Ulid('3zzzzzzzzzzzzzzzzzzzzzzzzz');
47+
$this->assertSame('7fffffffffffffffffffffffffffffff', bin2hex($ulid->toBinary()));
48+
49+
$this->assertTrue($ulid->equals(Ulid::fromBinary(hex2bin('7fffffffffffffffffffffffffffffff'))));
50+
}
51+
52+
/**
53+
* @group time-sensitive
54+
*/
55+
public function testGetTime()
56+
{
57+
$time = microtime(false);
58+
$ulid = new Ulid();
59+
$time = substr($time, 11).substr($time, 1, 4);
60+
61+
$this->assertSame((float) $time, $ulid->getTime());
62+
}
63+
64+
public function testIsValid()
65+
{
66+
$this->assertFalse(Ulid::isValid('not a ulid'));
67+
$this->assertTrue(Ulid::isValid('00000000000000000000000000'));
68+
}
69+
70+
public function testEquals()
71+
{
72+
$a = new Ulid();
73+
$b = new Ulid();
74+
75+
$this->assertTrue($a->equals($a));
76+
$this->assertFalse($a->equals($b));
77+
$this->assertFalse($a->equals((string) $a));
78+
}
79+
80+
/**
81+
* @group time-sensitive
82+
*/
83+
public function testCompare()
84+
{
85+
$a = new Ulid();
86+
$b = new Ulid();
87+
88+
$this->assertSame(0, $a->compare($a));
89+
$this->assertLessThan(0, $a->compare($b));
90+
$this->assertGreaterThan(0, $b->compare($a));
91+
92+
usleep(1001);
93+
$c = new Ulid();
94+
95+
$this->assertLessThan(0, $b->compare($c));
96+
$this->assertGreaterThan(0, $c->compare($b));
97+
}
98+
}

src/Symfony/Component/Uid/Ulid.php

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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+
* @see https://github.com/ulid/spec
16+
*
17+
* @experimental in 5.1
18+
*
19+
* @author Nicolas Grekas <p@tchwork.com>
20+
*/
21+
class Ulid implements \JsonSerializable
22+
{
23+
private static $time = -1;
24+
private static $rand = [];
25+
26+
private $ulid;
27+
28+
public function __construct(string $ulid = null)
29+
{
30+
if (null === $ulid) {
31+
$this->ulid = self::generate();
32+
33+
return;
34+
}
35+
36+
if (!self::isValid($ulid)) {
37+
throw new \InvalidArgumentException(sprintf('Invalid ULID: "%s".', $ulid));
38+
}
39+
40+
$this->ulid = strtr($ulid, 'abcdefghjkmnpqrstvwxyz', 'ABCDEFGHJKMNPQRSTVWXYZ');
41+
}
42+
43+
public static function isValid(string $ulid): bool
44+
{
45+
if (26 !== \strlen($ulid)) {
46+
return false;
47+
}
48+
49+
if (26 !== strspn($ulid, '0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz')) {
50+
return false;
51+
}
52+
53+
return $ulid[0] <= '7';
54+
}
55+
56+
public static function fromBinary(string $ulid): self
57+
{
58+
if (16 !== \strlen($ulid)) {
59+
throw new \InvalidArgumentException('Invalid binary ULID.');
60+
}
61+
62+
$ulid = bin2hex($ulid);
63+
$ulid = sprintf('%02s%04s%04s%04s%04s%04s%04s',
64+
base_convert(substr($ulid, 0, 2), 16, 32),
65+
base_convert(substr($ulid, 2, 5), 16, 32),
66+
base_convert(substr($ulid, 7, 5), 16, 32),
67+
base_convert(substr($ulid, 12, 5), 16, 32),
68+
base_convert(substr($ulid, 17, 5), 16, 32),
69+
base_convert(substr($ulid, 22, 5), 16, 32),
70+
base_convert(substr($ulid, 27, 5), 16, 32)
71+
);
72+
73+
return new self(strtr($ulid, 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ'));
74+
}
75+
76+
public function toBinary()
77+
{
78+
$ulid = strtr($this->ulid, 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
79+
80+
$ulid = sprintf('%02s%05s%05s%05s%05s%05s%05s',
81+
base_convert(substr($ulid, 0, 2), 32, 16),
82+
base_convert(substr($ulid, 2, 4), 32, 16),
83+
base_convert(substr($ulid, 6, 4), 32, 16),
84+
base_convert(substr($ulid, 10, 4), 32, 16),
85+
base_convert(substr($ulid, 14, 4), 32, 16),
86+
base_convert(substr($ulid, 18, 4), 32, 16),
87+
base_convert(substr($ulid, 22, 4), 32, 16)
88+
);
89+
90+
return hex2bin($ulid);
91+
}
92+
93+
/**
94+
* Returns whether the argument is of class Ulid and contains the same value as the current instance.
95+
*/
96+
public function equals($other): bool
97+
{
98+
if (!$other instanceof self) {
99+
return false;
100+
}
101+
102+
return $this->ulid === $other->ulid;
103+
}
104+
105+
public function compare(self $other): int
106+
{
107+
return $this->ulid <=> $other->ulid;
108+
}
109+
110+
public function getTime(): float
111+
{
112+
$time = strtr(substr($this->ulid, 0, 10), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
113+
114+
if (\PHP_INT_SIZE >= 8) {
115+
return hexdec(base_convert($time, 32, 16)) / 1000;
116+
}
117+
118+
$time = sprintf('%02s%05s%05s',
119+
base_convert(substr($time, 0, 2), 32, 16),
120+
base_convert(substr($time, 2, 4), 32, 16),
121+
base_convert(substr($time, 6, 4), 32, 16)
122+
);
123+
124+
return InternalUtil::toDecimal(hex2bin($time)) / 1000;
125+
}
126+
127+
public function __toString(): string
128+
{
129+
return $this->ulid;
130+
}
131+
132+
public function jsonSerialize(): string
133+
{
134+
return $this->ulid;
135+
}
136+
137+
private static function generate(): string
138+
{
139+
$time = microtime(false);
140+
$time = substr($time, 11).substr($time, 2, 3);
141+
142+
if ($time !== self::$time) {
143+
$r = unpack('nr1/nr2/nr3/nr4/nr', random_bytes(10));
144+
$r['r1'] |= ($r['r'] <<= 4) & 0xF0000;
145+
$r['r2'] |= ($r['r'] <<= 4) & 0xF0000;
146+
$r['r3'] |= ($r['r'] <<= 4) & 0xF0000;
147+
$r['r4'] |= ($r['r'] <<= 4) & 0xF0000;
148+
unset($r['r']);
149+
self::$rand = array_values($r);
150+
self::$time = $time;
151+
} elseif ([0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) {
152+
usleep(100);
153+
154+
return self::generate();
155+
} else {
156+
for ($i = 3; $i >= 0 && 0xFFFFF === self::$rand[$i]; --$i) {
157+
self::$rand[$i] = 0;
158+
}
159+
160+
++self::$rand[$i];
161+
}
162+
163+
if (\PHP_INT_SIZE >= 8) {
164+
$time = base_convert($time, 10, 32);
165+
} else {
166+
$time = bin2hex(InternalUtil::toBinary($time));
167+
$time = sprintf('%s%04s%04s',
168+
base_convert(substr($time, 0, 2), 16, 32),
169+
base_convert(substr($time, 2, 5), 16, 32),
170+
base_convert(substr($time, 7, 5), 16, 32)
171+
);
172+
}
173+
174+
return strtr(sprintf('%010s%04s%04s%04s%04s',
175+
$time,
176+
base_convert(self::$rand[0], 10, 32),
177+
base_convert(self::$rand[1], 10, 32),
178+
base_convert(self::$rand[2], 10, 32),
179+
base_convert(self::$rand[3], 10, 32)
180+
), 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ');
181+
}
182+
}

src/Symfony/Component/Uid/composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
"name": "Grégoire Pineau",
1111
"email": "lyrixx@lyrixx.info"
1212
},
13+
{
14+
"name": "Nicolas Grekas",
15+
"email": "p@tchwork.com"
16+
},
1317
{
1418
"name": "Symfony Community",
1519
"homepage": "https://symfony.com/contributors"

0 commit comments

Comments
 (0)
0