Ulid.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Uid;
  11. /**
  12. * A ULID is lexicographically sortable and contains a 48-bit timestamp and 80-bit of crypto-random entropy.
  13. *
  14. * @see https://github.com/ulid/spec
  15. *
  16. * @author Nicolas Grekas <p@tchwork.com>
  17. */
  18. class Ulid extends AbstractUid implements TimeBasedUidInterface
  19. {
  20. protected const NIL = '00000000000000000000000000';
  21. protected const MAX = '7ZZZZZZZZZZZZZZZZZZZZZZZZZ';
  22. private static string $time = '';
  23. private static array $rand = [];
  24. public function __construct(?string $ulid = null)
  25. {
  26. if (null === $ulid) {
  27. $this->uid = static::generate();
  28. } elseif (self::NIL === $ulid) {
  29. $this->uid = $ulid;
  30. } elseif (self::MAX === strtr($ulid, 'z', 'Z')) {
  31. $this->uid = $ulid;
  32. } else {
  33. if (!self::isValid($ulid)) {
  34. throw new \InvalidArgumentException(\sprintf('Invalid ULID: "%s".', $ulid));
  35. }
  36. $this->uid = strtoupper($ulid);
  37. }
  38. }
  39. public static function isValid(string $ulid): bool
  40. {
  41. if (26 !== \strlen($ulid)) {
  42. return false;
  43. }
  44. if (26 !== strspn($ulid, '0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz')) {
  45. return false;
  46. }
  47. return $ulid[0] <= '7';
  48. }
  49. public static function fromString(string $ulid): static
  50. {
  51. if (36 === \strlen($ulid) && preg_match('{^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$}Di', $ulid)) {
  52. $ulid = uuid_parse($ulid);
  53. } elseif (22 === \strlen($ulid) && 22 === strspn($ulid, BinaryUtil::BASE58[''])) {
  54. $ulid = str_pad(BinaryUtil::fromBase($ulid, BinaryUtil::BASE58), 16, "\0", \STR_PAD_LEFT);
  55. }
  56. if (16 !== \strlen($ulid)) {
  57. return match (strtr($ulid, 'z', 'Z')) {
  58. self::NIL => new NilUlid(),
  59. self::MAX => new MaxUlid(),
  60. default => new static($ulid),
  61. };
  62. }
  63. $ulid = bin2hex($ulid);
  64. $ulid = \sprintf('%02s%04s%04s%04s%04s%04s%04s',
  65. base_convert(substr($ulid, 0, 2), 16, 32),
  66. base_convert(substr($ulid, 2, 5), 16, 32),
  67. base_convert(substr($ulid, 7, 5), 16, 32),
  68. base_convert(substr($ulid, 12, 5), 16, 32),
  69. base_convert(substr($ulid, 17, 5), 16, 32),
  70. base_convert(substr($ulid, 22, 5), 16, 32),
  71. base_convert(substr($ulid, 27, 5), 16, 32)
  72. );
  73. if (self::NIL === $ulid) {
  74. return new NilUlid();
  75. }
  76. if (self::MAX === $ulid = strtr($ulid, 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ')) {
  77. return new MaxUlid();
  78. }
  79. $u = new static(self::NIL);
  80. $u->uid = $ulid;
  81. return $u;
  82. }
  83. public function toBinary(): string
  84. {
  85. $ulid = strtr($this->uid, 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
  86. $ulid = \sprintf('%02s%05s%05s%05s%05s%05s%05s',
  87. base_convert(substr($ulid, 0, 2), 32, 16),
  88. base_convert(substr($ulid, 2, 4), 32, 16),
  89. base_convert(substr($ulid, 6, 4), 32, 16),
  90. base_convert(substr($ulid, 10, 4), 32, 16),
  91. base_convert(substr($ulid, 14, 4), 32, 16),
  92. base_convert(substr($ulid, 18, 4), 32, 16),
  93. base_convert(substr($ulid, 22, 4), 32, 16)
  94. );
  95. return hex2bin($ulid);
  96. }
  97. /**
  98. * Returns the identifier as a base32 case insensitive string.
  99. *
  100. * @see https://tools.ietf.org/html/rfc4648#section-6
  101. *
  102. * @example 09EJ0S614A9FXVG9C5537Q9ZE1 (len=26)
  103. */
  104. public function toBase32(): string
  105. {
  106. return $this->uid;
  107. }
  108. public function getDateTime(): \DateTimeImmutable
  109. {
  110. $time = strtr(substr($this->uid, 0, 10), 'ABCDEFGHJKMNPQRSTVWXYZ', 'abcdefghijklmnopqrstuv');
  111. if (\PHP_INT_SIZE >= 8) {
  112. $time = (string) hexdec(base_convert($time, 32, 16));
  113. } else {
  114. $time = \sprintf('%02s%05s%05s',
  115. base_convert(substr($time, 0, 2), 32, 16),
  116. base_convert(substr($time, 2, 4), 32, 16),
  117. base_convert(substr($time, 6, 4), 32, 16)
  118. );
  119. $time = BinaryUtil::toBase(hex2bin($time), BinaryUtil::BASE10);
  120. }
  121. if (4 > \strlen($time)) {
  122. $time = '000'.$time;
  123. }
  124. return \DateTimeImmutable::createFromFormat('U.u', substr_replace($time, '.', -3, 0));
  125. }
  126. public static function generate(?\DateTimeInterface $time = null): string
  127. {
  128. if (null === $mtime = $time) {
  129. $time = microtime(false);
  130. $time = substr($time, 11).substr($time, 2, 3);
  131. } elseif (0 > $time = $time->format('Uv')) {
  132. throw new \InvalidArgumentException('The timestamp must be positive.');
  133. }
  134. if ($time > self::$time || (null !== $mtime && $time !== self::$time)) {
  135. randomize:
  136. $r = unpack('n*', random_bytes(10));
  137. $r[1] |= ($r[5] <<= 4) & 0xF0000;
  138. $r[2] |= ($r[5] <<= 4) & 0xF0000;
  139. $r[3] |= ($r[5] <<= 4) & 0xF0000;
  140. $r[4] |= ($r[5] <<= 4) & 0xF0000;
  141. unset($r[5]);
  142. self::$rand = $r;
  143. self::$time = $time;
  144. } elseif ([1 => 0xFFFFF, 0xFFFFF, 0xFFFFF, 0xFFFFF] === self::$rand) {
  145. if (\PHP_INT_SIZE >= 8 || 10 > \strlen($time = self::$time)) {
  146. $time = (string) (1 + $time);
  147. } elseif ('999999999' === $mtime = substr($time, -9)) {
  148. $time = (1 + substr($time, 0, -9)).'000000000';
  149. } else {
  150. $time = substr_replace($time, str_pad(++$mtime, 9, '0', \STR_PAD_LEFT), -9);
  151. }
  152. goto randomize;
  153. } else {
  154. for ($i = 4; $i > 0 && 0xFFFFF === self::$rand[$i]; --$i) {
  155. self::$rand[$i] = 0;
  156. }
  157. ++self::$rand[$i];
  158. $time = self::$time;
  159. }
  160. if (\PHP_INT_SIZE >= 8) {
  161. $time = base_convert($time, 10, 32);
  162. } else {
  163. $time = str_pad(bin2hex(BinaryUtil::fromBase($time, BinaryUtil::BASE10)), 12, '0', \STR_PAD_LEFT);
  164. $time = \sprintf('%s%04s%04s',
  165. base_convert(substr($time, 0, 2), 16, 32),
  166. base_convert(substr($time, 2, 5), 16, 32),
  167. base_convert(substr($time, 7, 5), 16, 32)
  168. );
  169. }
  170. return strtr(\sprintf('%010s%04s%04s%04s%04s',
  171. $time,
  172. base_convert(self::$rand[1], 10, 32),
  173. base_convert(self::$rand[2], 10, 32),
  174. base_convert(self::$rand[3], 10, 32),
  175. base_convert(self::$rand[4], 10, 32)
  176. ), 'abcdefghijklmnopqrstuv', 'ABCDEFGHJKMNPQRSTVWXYZ');
  177. }
  178. }