8000 [Uid] add support for Ulid · symfony/symfony@8b3f126 · GitHub
[go: up one dir, main page]

Skip to content

Commit 8b3f126

Browse files
[Uid] add support for Ulid
1 parent d108f7b commit 8b3f126

File tree

4 files changed

+326
-0
lines changed

4 files changed

+326
-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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
}
78+
79+
/**
80+
* @group time-sensitive
81+
*/
82+
public function testCompare()
83+
{
84+
$a = new Ulid();
85+
$b = new Ulid();
86+
87+
$this->assertSame(0, $a->compare($a));
88+
$this->assertLessThan(0, $a->compare($b));
89+
$this->assertGreaterThan(0, $b->compare($a));
90+
91+
usleep(1001);
92+
$c = new Ulid();
93+
94+
$this->assertLessThan(0, $b->compare($c));
95+
$this->assertGreaterThan(0, $c->compare($b));
96+
}
97+
}

src/Symfony/Component/Uid/Ulid.php

Lines changed: 224 additions & 0 deletions
10000
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
class Ulid implements \JsonSerializable
18+
{
19+
private static $time = -1;
20+
private static $rand = [];
21+
22+
private $ulid;
23+
24+
public function __construct(string $ulid = null)
25+
{
26+
if (null === $ulid) {
27+
$this->ulid = self::generate();
28+
29+
return;
30+
}
31+
32+
if (!self::isValid($ulid)) {
33+
throw new \InvalidArgumentException(sprintf('Invalid ULID: "%s".', $ulid));
34+
}
35+
36+
$this->ulid = strtr($ulid, 'abcdefghjkmnpqrstvwxyz', 'ABCDEFGHJKMNPQRSTVWXYZ');
37+
}
38+
39+
public static function isValid(string $ulid): bool
40+
{
41+
if (\strlen($ulid) !== 26) {
42+
return false;
43+
}
44+
45+
if (strspn($ulid, '0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz') !== 26) {
46+
return false;
47+
}
48+
49+
return $ulid[0] <= '7';
50+
}
51+
52+
public static function fromBinary(string $ulid): self
53+
{
54+
if (\strlen($ulid) !== 16) {
55+
throw new \InvalidArgumentException('Invalid binary ULID.');
56+
}
57+
58+
$ulid = bin2hex($ulid);
59+
$ulid = sprintf('%02s%04s%04s%04s%04s%04s%04s',
60+
base_convert(substr($ulid, 0, 2), 16, 32),
61+
base_convert(substr($ulid, 2, 5), 16, 32),
62+
base_convert(substr($ulid, 7, 5), 16, 32),
63+
base_convert(substr($ulid, 12, 5), 16, 32),
64+
base_convert(substr($ulid, 17, 5), 16, 32),
65+
base_convert(substr($ulid, 22, 5), 16, 32),
66+
base_convert(substr($ulid, 27, 5), 16, 32)
67+
);
68+
69+
return new self(strtr($ulid, 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ'));
70+
}
71+
72+
public function toBinary()
73+
{
74+
$ulid = strtr($this->ulid, 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
75+
76+
$ulid = sprintf('%02s%05s%05s%05s%05s%05s%05s',
77+
base_convert(substr($ulid, 0, 2), 32, 16),
78+
base_convert(substr($ulid, 2, 4), 32, 16),
79+
base_convert(substr($ulid, 6, 4), 32, 16),
80+
base_convert(substr($ulid, 10, 4), 32, 16),
81+
base_convert(substr($ulid, 14, 4), 32, 16),
82+
base_convert(substr($ulid, 18, 4), 32, 16),
83+
base_convert(substr($ulid, 22, 4), 32, 16)
84+
);
85+
86+
return hex2bin($ulid);
87+
}
88+
89+
public function equals(self $other): bool
90+
{
91+
return $this->ulid === $other->ulid;
92+
}
93+
94+
public function compare(self $other): int
95+
{
96+
return strcmp($this->ulid, $other->ulid);
97+
}
98+
99+
public function getTime(): float
100+
{
101+
$time = strtr(substr($this->ulid, 0, 10), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
102+
103+
if (\PHP_INT_SIZE >= 8) {
104+
return hexdec(base_convert($time, 32, 16)) / 1000;
105+
}
106+
107+
$time = sprintf('%02s%05s%05s',
108+
base_convert(substr($time, 0, 2), 32, 16),
109+
base_convert(substr($time, 2, 4), 32, 16),
110+
base_convert(substr($time, 6, 4), 32, 16)
111+
);
112+
113+
return self::toDecimal(hex2bin($time)) / 1000;
114+
}
115+
116+
public function __toString()
117+
{
118+
return $this->ulid;
119+
}
120+
121+
public function jsonSerialize(): string
122+
{
123+
return $this->ulid;
124+
}
125+
126+
private static function generate(): string
127+
{
128+
$time = microtime(false);
129+
$time = substr($time, 11).substr($time, 2, 3);
130+
131+
if ($time !== self::$time) {
132+
$r = unpack('nr1/nr2/nr3/nr4/nr', random_bytes(10));
133+
$r['r1'] |= ($r['r'] <<= 4) & 0xF0000;
134+
$r['r2'] |= ($r['r'] <<= 4) & 0xF0000;
135+
$r['r3'] |= ($r['r'] <<= 4) & 0xF0000;
136+
$r['r4'] |= ($r['r'] <<= 4) & 0xF0000;
137+
unset($r['r']);
138+
self::$rand = array_values($r);
139+
self::$time = $time;
140+
} elseif ([0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) {
141+
usleep(100);
142+
143+
return self::generate();
144+
} else {
145+
for ($i = 3; $i >= 0 && 0xFFFFF === self::$rand[$i]; --$i) {
146+
self::$rand[$i] = 0;
147+
}
148+
149+
++self::$rand[$i];
150+
}
151+
152+
if (\PHP_INT_SIZE >= 8) {
153+
$time = base_convert($time, 10, 32);
154+
} else {
155+
$time = bin2hex(self::toBytes($time));
156+
$time = sprintf('%s%04s%04s',
157+
base_convert(substr($time, 0, 2), 16, 32),
158+
base_convert(substr($time, 2, 5), 16, 32),
159+
base_convert(substr($time, 7, 5), 16, 32)
160+
);
161+
}
162+
163+
return strtr(sprintf('%010s%04s%04s%04s%04s',
164+
$time,
165+
base_convert(self::$rand[0], 10, 32),
166+
base_convert(self::$rand[1], 10, 32),
167+
base_convert(self::$rand[2], 10, 32),
168+
base_convert(self::$rand[3], 10, 32)
169+
), 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ');
170+
}
171+
172+
private static function toBytes($digits)
173+
{
174+
$bytes = '';
175+
$len = \strlen($digits);
176+
177+
while ($len > $i = strspn($digits, '0')) {
178+
for ($j = 2, $r = 0; $i < $len; $i += $j, $j = 0) {
179+
do {
180+
$r *= 10;
181+
$d = (int) substr($digits, $i, ++$j);
182+
} while ($i + $j < $len && $r + $d < 256);
183+
184+
$j = \strlen((string) $d);
185+
$q = str_pad(($d += $r) >> 8, $j, '0', STR_PAD_LEFT);
186+
$digits = substr_replace($digits, $q, $i, $j);
187+
$r = $d % 256;
188+
}
189+
190+
$bytes .= \chr($r);
191+
}
192+
193+
return strrev($bytes);
194+
}
195+
196+
private static function toDecimal($bytes)
197+
{
198+
$digits = '';
199+
$len = \strlen($bytes);
200+
201+
while ($len > $i = strspn($bytes, "\0")) {
202+
for ($r = 0; $i < $len; $i += $j) {
203+
$j = $d = 0;
204+
do {
205+
$r <<= 8;
206+
$d = ($d << 8) + \ord($bytes[$i + $j]);
207+
} while ($i + ++$j < $len && $r + $d < 10);
208+
209+
if (256 < $d) {
210+
$q = intdiv($d += $r, 10);
211+
$bytes[$i] = \chr($q >> 8);
212+
$bytes[1 + $i] = \chr($q & 0xFF);
213+
} else {
214+
$bytes[$i] = \chr(intdiv($d += $r, 10));
215+
}
216+
$r = $d % 10;
217+
}
218+
219+
$digits .= (string) $r;
220+
}
221+
222+
return strrev($digits);
223+
}
224+
}

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