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
-------
59044f914b
[Uid] add support for Ulid
This commit is contained in:
commit
42c76d7683
@ -5,4 +5,5 @@ CHANGELOG
|
||||
-----
|
||||
|
||||
* added support for UUID
|
||||
* added support for ULID
|
||||
* added the component
|
||||
|
98
src/Symfony/Component/Uid/Tests/UlidTest.php
Normal file
98
src/Symfony/Component/Uid/Tests/UlidTest.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Tests\Component\Uid;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
class UlidTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @group time-sensitive
|
||||
*/
|
||||
public function testGenerate()
|
||||
{
|
||||
$a = new Ulid();
|
||||
$b = new Ulid();
|
||||
|
||||
$this->assertSame(0, strncmp($a, $b, 20));
|
||||
$a = base_convert(strtr(substr($a, -6), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv'), 32, 10);
|
||||
$b = base_convert(strtr(substr($b, -6), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv'), 32, 10);
|
||||
$this->assertSame(1, $b - $a);
|
||||
}
|
||||
|
||||
public function testWithInvalidUlid()
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid ULID: "this is not a ulid".');
|
||||
|
||||
new Ulid('this is not a ulid');
|
||||
}
|
||||
|
||||
public function testBinary()
|
||||
{
|
||||
$ulid = new Ulid('00000000000000000000000000');
|
||||
$this->assertSame("\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0", $ulid->toBinary());
|
||||
|
||||
$ulid = new Ulid('3zzzzzzzzzzzzzzzzzzzzzzzzz');
|
||||
$this->assertSame('7fffffffffffffffffffffffffffffff', bin2hex($ulid->toBinary()));
|
||||
|
||||
$this->assertTrue($ulid->equals(Ulid::fromBinary(hex2bin('7fffffffffffffffffffffffffffffff'))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @group time-sensitive
|
||||
*/
|
||||
public function testGetTime()
|
||||
{
|
||||
$time = microtime(false);
|
||||
$ulid = new Ulid();
|
||||
$time = substr($time, 11).substr($time, 1, 4);
|
||||
|
||||
$this->assertSame((float) $time, $ulid->getTime());
|
||||
}
|
||||
|
||||
public function testIsValid()
|
||||
{
|
||||
$this->assertFalse(Ulid::isValid('not a ulid'));
|
||||
$this->assertTrue(Ulid::isValid('00000000000000000000000000'));
|
||||
}
|
||||
|
||||
public function testEquals()
|
||||
{
|
||||
$a = new Ulid();
|
||||
$b = new Ulid();
|
||||
|
||||
$this->assertTrue($a->equals($a));
|
||||
$this->assertFalse($a->equals($b));
|
||||
$this->assertFalse($a->equals((string) $a));
|
||||
}
|
||||
|
||||
/**
|
||||
* @group time-sensitive
|
||||
*/
|
||||
public function testCompare()
|
||||
{
|
||||
$a = new Ulid();
|
||||
$b = new Ulid();
|
||||
|
||||
$this->assertSame(0, $a->compare($a));
|
||||
$this->assertLessThan(0, $a->compare($b));
|
||||
$this->assertGreaterThan(0, $b->compare($a));
|
||||
|
||||
usleep(1001);
|
||||
$c = new Ulid();
|
||||
|
||||
$this->assertLessThan(0, $b->compare($c));
|
||||
$this->assertGreaterThan(0, $c->compare($b));
|
||||
}
|
||||
}
|
182
src/Symfony/Component/Uid/Ulid.php
Normal file
182
src/Symfony/Component/Uid/Ulid.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Uid;
|
||||
|
||||
/**
|
||||
* @see https://github.com/ulid/spec
|
||||
*
|
||||
* @experimental in 5.1
|
||||
*
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*/
|
||||
class Ulid implements \JsonSerializable
|
||||
{
|
||||
private static $time = -1;
|
||||
private static $rand = [];
|
||||
|
||||
private $ulid;
|
||||
|
||||
public function __construct(string $ulid = null)
|
||||
{
|
||||
if (null === $ulid) {
|
||||
$this->ulid = self::generate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::isValid($ulid)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid ULID: "%s".', $ulid));
|
||||
}
|
||||
|
||||
$this->ulid = strtr($ulid, 'abcdefghjkmnpqrstvwxyz', 'ABCDEFGHJKMNPQRSTVWXYZ');
|
||||
}
|
||||
|
||||
public static function isValid(string $ulid): bool
|
||||
{
|
||||
if (26 !== \strlen($ulid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (26 !== strspn($ulid, '0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $ulid[0] <= '7';
|
||||
}
|
||||
|
||||
public static function fromBinary(string $ulid): self
|
||||
{
|
||||
if (16 !== \strlen($ulid)) {
|
||||
throw new \InvalidArgumentException('Invalid binary ULID.');
|
||||
}
|
||||
|
||||
$ulid = bin2hex($ulid);
|
||||
$ulid = sprintf('%02s%04s%04s%04s%04s%04s%04s',
|
||||
base_convert(substr($ulid, 0, 2), 16, 32),
|
||||
base_convert(substr($ulid, 2, 5), 16, 32),
|
||||
base_convert(substr($ulid, 7, 5), 16, 32),
|
||||
base_convert(substr($ulid, 12, 5), 16, 32),
|
||||
base_convert(substr($ulid, 17, 5), 16, 32),
|
||||
base_convert(substr($ulid, 22, 5), 16, 32),
|
||||
base_convert(substr($ulid, 27, 5), 16, 32)
|
||||
);
|
||||
|
||||
return new self(strtr($ulid, 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ'));
|
||||
}
|
||||
|
||||
public function toBinary()
|
||||
{
|
||||
$ulid = strtr($this->ulid, 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
|
||||
|
||||
$ulid = sprintf('%02s%05s%05s%05s%05s%05s%05s',
|
||||
base_convert(substr($ulid, 0, 2), 32, 16),
|
||||
base_convert(substr($ulid, 2, 4), 32, 16),
|
||||
base_convert(substr($ulid, 6, 4), 32, 16),
|
||||
base_convert(substr($ulid, 10, 4), 32, 16),
|
||||
base_convert(substr($ulid, 14, 4), 32, 16),
|
||||
base_convert(substr($ulid, 18, 4), 32, 16),
|
||||
base_convert(substr($ulid, 22, 4), 32, 16)
|
||||
);
|
||||
|
||||
return hex2bin($ulid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the argument is of class Ulid and contains the same value as the current instance.
|
||||
*/
|
||||
public function equals($other): bool
|
||||
{
|
||||
if (!$other instanceof self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->ulid === $other->ulid;
|
||||
}
|
||||
|
||||
public function compare(self $other): int
|
||||
{
|
||||
return $this->ulid <=> $other->ulid;
|
||||
}
|
||||
|
||||
public function getTime(): float
|
||||
{
|
||||
$time = strtr(substr($this->ulid, 0, 10), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
|
||||
|
||||
if (\PHP_INT_SIZE >= 8) {
|
||||
return hexdec(base_convert($time, 32, 16)) / 1000;
|
||||
}
|
||||
|
||||
$time = sprintf('%02s%05s%05s',
|
||||
base_convert(substr($time, 0, 2), 32, 16),
|
||||
base_convert(substr($time, 2, 4), 32, 16),
|
||||
base_convert(substr($time, 6, 4), 32, 16)
|
||||
);
|
||||
|
||||
return InternalUtil::toDecimal(hex2bin($time)) / 1000;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->ulid;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->ulid;
|
||||
}
|
||||
|
||||
private static function generate(): string
|
||||
{
|
||||
$time = microtime(false);
|
||||
$time = substr($time, 11).substr($time, 2, 3);
|
||||
|
||||
if ($time !== self::$time) {
|
||||
$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);
|
||||
self::$time = $time;
|
||||
} elseif ([0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) {
|
||||
usleep(100);
|
||||
|
||||
return self::generate();
|
||||
} else {
|
||||
for ($i = 3; $i >= 0 && 0xFFFFF === self::$rand[$i]; --$i) {
|
||||
self::$rand[$i] = 0;
|
||||
}
|
||||
|
||||
++self::$rand[$i];
|
||||
}
|
||||
|
||||
if (\PHP_INT_SIZE >= 8) {
|
||||
$time = base_convert($time, 10, 32);
|
||||
} else {
|
||||
$time = bin2hex(InternalUtil::toBinary($time));
|
||||
$time = sprintf('%s%04s%04s',
|
||||
base_convert(substr($time, 0, 2), 16, 32),
|
||||
base_convert(substr($time, 2, 5), 16, 32),
|
||||
base_convert(substr($time, 7, 5), 16, 32)
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
), 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ');
|
||||
}
|
||||
}
|
@ -10,6 +10,10 @@
|
||||
"name": "Grégoire Pineau",
|
||||
"email": "lyrixx@lyrixx.info"
|
||||
},
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
|
Reference in New Issue
Block a user