diff --git a/src/Symfony/Component/Uid/CHANGELOG.md b/src/Symfony/Component/Uid/CHANGELOG.md index b1bd12e138bd7..70a35a92916ff 100644 --- a/src/Symfony/Component/Uid/CHANGELOG.md +++ b/src/Symfony/Component/Uid/CHANGELOG.md @@ -5,4 +5,5 @@ CHANGELOG ----- * added support for UUID + * added support for ULID * added the component diff --git a/src/Symfony/Component/Uid/Tests/UlidTest.php b/src/Symfony/Component/Uid/Tests/UlidTest.php new file mode 100644 index 0000000000000..c609998d942d6 --- /dev/null +++ b/src/Symfony/Component/Uid/Tests/UlidTest.php @@ -0,0 +1,98 @@ + + * + * 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)); + } +} diff --git a/src/Symfony/Component/Uid/Ulid.php b/src/Symfony/Component/Uid/Ulid.php new file mode 100644 index 0000000000000..69576f6a92ae5 --- /dev/null +++ b/src/Symfony/Component/Uid/Ulid.php @@ -0,0 +1,182 @@ + + * + * 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
+ */ +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'); + } +} diff --git a/src/Symfony/Component/Uid/composer.json b/src/Symfony/Component/Uid/composer.json index e8cc48e899699..d455e367f0164 100644 --- a/src/Symfony/Component/Uid/composer.json +++ b/src/Symfony/Component/Uid/composer.json @@ -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"