1 | <?php declare(strict_types=1); |
||
2 | |||
3 | /* |
||
4 | * This file is part of the Koded package. |
||
5 | * |
||
6 | * (c) Mihail Binev <[email protected]> |
||
7 | * |
||
8 | * Please view the LICENSE distributed with this source code |
||
9 | * for the full copyright and license information. |
||
10 | */ |
||
11 | |||
12 | namespace Koded\Stdlib; |
||
13 | |||
14 | use ArgumentCountError; |
||
15 | use Countable; |
||
16 | use DateTime; |
||
17 | use DateTimeZone; |
||
18 | use InvalidArgumentException; |
||
19 | use Throwable; |
||
20 | use function array_key_first; |
||
21 | use function count; |
||
22 | use function current; |
||
23 | use function dechex; |
||
24 | use function intval; |
||
25 | use function microtime; |
||
26 | use function mt_rand; |
||
27 | use function preg_match; |
||
28 | use function sprintf; |
||
29 | use function str_contains; |
||
30 | use function str_pad; |
||
31 | use function strlen; |
||
32 | use function strpos; |
||
33 | use function substr; |
||
34 | |||
35 | /** |
||
36 | * Class ULID generates Universally Unique Lexicographically Sortable Identifiers |
||
37 | * that are sortable, has monotonic sort order (correctly detects and handles the |
||
38 | * same millisecond), uses Crockford's base32 for better readability |
||
39 | * and will work until 10.889AD among other things. |
||
40 | * |
||
41 | * ULID: |
||
42 | * |
||
43 | * 01GXDATSFG 43B0Y7R64G172FVH |
||
44 | * |--------| |--------------| |
||
45 | * Timestamp Randomness |
||
46 | * 48bits 80bits |
||
47 | * |
||
48 | * ULID as UUID: |
||
49 | * |
||
50 | * 01875aad-65f0-a911-bc1d-77989d5c99bb |
||
51 | * |-----------| |--------------------| |
||
52 | * Timestamp Randomness |
||
53 | * |
||
54 | */ |
||
55 | class ULID implements Countable |
||
56 | { |
||
57 | public const REGEX = '[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}'; |
||
58 | private const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford's base32 |
||
59 | |||
60 | protected array $timestamps = []; |
||
61 | private array $randomness = []; |
||
62 | |||
63 | 11 | private function __construct(int $timestamps, int $count) |
|
64 | { |
||
65 | 11 | if ($count < 1) { |
|
66 | 1 | throw new ArgumentCountError('count must be greater then 0', 400); |
|
67 | } |
||
68 | 10 | $this->randomize(false); |
|
69 | 10 | for ($i = 0; $i < $count; ++$i) { |
|
70 | 10 | $this->timestamps[$i] = $timestamps; |
|
71 | } |
||
72 | } |
||
73 | |||
74 | /** |
||
75 | * Creates an instance of ULID with number |
||
76 | * of timestamps defined by the count argument. |
||
77 | * @param int $count |
||
78 | * @return static |
||
79 | */ |
||
80 | 5 | public static function generate(int $count = 1): self |
|
81 | { |
||
82 | 5 | return new static((int)(microtime(true) * 1000), $count); |
|
83 | } |
||
84 | |||
85 | /** |
||
86 | * Decode the ULID string into an instance of ULID. |
||
87 | * @param string $ulid |
||
88 | * @return static |
||
89 | */ |
||
90 | 4 | public static function fromULID(string $ulid): self |
|
91 | { |
||
92 | 4 | if (26 !== strlen($ulid)) { |
|
93 | 1 | throw new InvalidArgumentException('Invalid ULID, wrong length', 400); |
|
94 | } |
||
95 | 3 | if (!preg_match('/^[' . static::ENCODING . ']{26}$/', $ulid)) { |
|
96 | 1 | throw new InvalidArgumentException('Invalid ULID, non supported characters', 400); |
|
97 | } |
||
98 | 2 | $timestamp = 0; |
|
99 | 2 | $chars = substr($ulid, 0, 10); |
|
100 | 2 | for ($i = 0; $i < 10; ++$i) { |
|
101 | 2 | $timestamp = $timestamp * 32 + strpos(static::ENCODING, $chars[$i]); |
|
102 | } |
||
103 | 2 | return new static ($timestamp, 1); |
|
104 | } |
||
105 | |||
106 | /** |
||
107 | * Decode the ULID string in UUID format into an instance of ULID. |
||
108 | * @param string $ulid UUID representation of ULID value |
||
109 | * @return static |
||
110 | */ |
||
111 | 4 | public static function fromUUID(string $ulid): self |
|
112 | { |
||
113 | 4 | if (false === static::valid($ulid)) { |
|
114 | 1 | throw new InvalidArgumentException('Invalid ULID', 400); |
|
115 | } |
||
116 | 3 | $timestamp = hexdec(substr($ulid, 0, 8) . substr($ulid, 9, 4)); |
|
117 | 3 | return new static($timestamp, 1); |
|
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||
118 | } |
||
119 | |||
120 | /** |
||
121 | * Decode the date time string into an instance of ULID. |
||
122 | * @param float $timestamp UNIX timestamp with or without the milliseconds part |
||
123 | * @return static |
||
124 | */ |
||
125 | 2 | public static function fromTimestamp(float $timestamp): self |
|
126 | { |
||
127 | 2 | if ($timestamp <= 0 || $timestamp >= PHP_INT_MAX) { |
|
128 | 1 | throw new InvalidArgumentException("Invalid timestamp ($timestamp)", 400); |
|
129 | } |
||
130 | 1 | $timestamp = (string)$timestamp; |
|
131 | 1 | if (str_contains($timestamp, '.')) { |
|
132 | 1 | $timestamp = (string)($timestamp * 1000); |
|
133 | } |
||
134 | 1 | if (strlen($timestamp) >= 13) { |
|
135 | 1 | $timestamp = substr($timestamp, 0, 13); |
|
136 | } |
||
137 | 1 | return new static(intval($timestamp), 1); |
|
138 | } |
||
139 | |||
140 | /** |
||
141 | * Decode the date time string into an instance of ULID. |
||
142 | * @param string $datetime in format: Y-m-d H:i:s with optional 'v' (milliseconds) |
||
143 | * @return static |
||
144 | */ |
||
145 | 3 | public static function fromDateTime(string $datetime): self |
|
146 | { |
||
147 | try { |
||
148 | 3 | $dt = (str_contains($datetime, '.')) |
|
149 | 1 | ? DateTime::createFromFormat('Y-m-d H:i:s.v', $datetime, new DateTimeZone('UTC')) |
|
150 | 3 | : DateTime::createFromFormat('Y-m-d H:i:s', $datetime, new DateTimeZone('UTC')); |
|
151 | 3 | return new static(intval($dt->getTimestamp() . $dt->format('v')), 1); |
|
152 | 1 | } catch (Throwable) { |
|
153 | 1 | throw new InvalidArgumentException("Invalid datetime ($datetime)", 400); |
|
154 | } |
||
155 | } |
||
156 | |||
157 | 6 | public static function valid(string $uuid): bool |
|
158 | { |
||
159 | 6 | return (bool)preg_match('/^' . static::REGEX . '$/i', $uuid); |
|
160 | } |
||
161 | |||
162 | /** |
||
163 | * Creates a single, or a list, of UUID values. |
||
164 | * @return array|string |
||
165 | */ |
||
166 | 3 | public function toUUID(): array|string |
|
167 | { |
||
168 | 3 | $list = []; |
|
169 | 3 | foreach ($this->timestamps as $ts) { |
|
170 | 3 | $timestamp = $this->generateUuidParts($ts); |
|
171 | 3 | $hex = substr(str_pad(dechex(intval($timestamp)), 12, '0', STR_PAD_LEFT), -12); |
|
172 | 3 | $list[sprintf('%08s-%04s-%04x-%04x-%012x', |
|
173 | 3 | substr($hex, 0, 8), |
|
174 | 3 | substr($hex, 8, 4), |
|
175 | 3 | ...$this->randomness |
|
176 | 3 | )] = $ts; |
|
177 | } |
||
178 | 3 | return (1 === $this->count()) |
|
0 ignored issues
–
show
|
|||
179 | 2 | ? array_key_first($list) |
|
180 | 3 | : $list; |
|
181 | } |
||
182 | |||
183 | /** |
||
184 | * Creates a single, or a list of ULID values. |
||
185 | * @return array|string |
||
186 | */ |
||
187 | 3 | public function toULID(): array|string |
|
188 | { |
||
189 | 3 | $list = []; |
|
190 | 3 | foreach ($this->timestamps as $ts) { |
|
191 | 3 | [$timestamp, $randomness] = $this->generateUlidParts(intval($ts)); |
|
192 | 3 | $list[$timestamp . $randomness] = $ts; |
|
193 | } |
||
194 | 3 | return (1 === $this->count()) |
|
0 ignored issues
–
show
|
|||
195 | 2 | ? array_key_first($list) |
|
196 | 3 | : $list; |
|
197 | } |
||
198 | |||
199 | /** |
||
200 | * Returns a single, or a list, of DateTime instances. |
||
201 | * @return array|DateTime |
||
202 | */ |
||
203 | 5 | public function toDateTime(): array|DateTime |
|
204 | { |
||
205 | 5 | $list = []; |
|
206 | 5 | foreach ($this->timestamps as $timestamp) { |
|
207 | 5 | $timestamp = (string)$timestamp; |
|
208 | 5 | $datetime = new DateTime('@' . substr($timestamp, 0, 10), new DateTimeZone('UTC')); |
|
209 | 5 | if (strlen($timestamp) >= 13) { |
|
210 | 5 | $ms = substr($timestamp, 10, 3); |
|
211 | 5 | $datetime->modify("+{$ms} milliseconds"); |
|
212 | } |
||
213 | 5 | $list[] = $datetime; |
|
214 | } |
||
215 | 5 | return (1 === $this->count()) |
|
216 | 5 | ? current($list) |
|
217 | 5 | : $list; |
|
218 | } |
||
219 | |||
220 | /** |
||
221 | * The number of generated timestamps in the ULID instance. |
||
222 | * @return int |
||
223 | */ |
||
224 | 9 | public function count(): int |
|
225 | { |
||
226 | 9 | return count($this->timestamps); |
|
227 | } |
||
228 | |||
229 | 3 | private function generateUuidParts(int $milliseconds): int |
|
230 | { |
||
231 | 3 | static $lastTime = 0; |
|
232 | 3 | $sameTimestamp = $lastTime === $milliseconds; |
|
233 | 3 | $lastTime = $milliseconds; |
|
234 | 3 | if ($sameTimestamp) { |
|
235 | 1 | ++$this->randomness[2]; |
|
236 | } else { |
||
237 | 3 | $this->randomize(false); |
|
238 | } |
||
239 | 3 | return $lastTime; |
|
240 | } |
||
241 | |||
242 | 3 | private function generateUlidParts(int $milliseconds): array |
|
243 | { |
||
244 | 3 | static $lastTime = 0; |
|
245 | 3 | $sameTimestamp = $lastTime === $milliseconds; |
|
246 | 3 | $lastTime = $milliseconds; |
|
247 | 3 | $timestamp = $randomness = ''; |
|
248 | // Timestamp |
||
249 | 3 | for ($i = 10; $i > 0; $i--) { |
|
250 | 3 | $mod = $milliseconds % 32; |
|
251 | 3 | $timestamp = static::ENCODING[$mod] . $timestamp; |
|
252 | 3 | $milliseconds = ($milliseconds - $mod) / 32; |
|
253 | } |
||
254 | // Randomness |
||
255 | 3 | if (count($this->randomness) < 16) { |
|
256 | 3 | $this->randomize(true); |
|
257 | } |
||
258 | 3 | if ($sameTimestamp) { |
|
259 | 1 | for ($i = 15; $i >= 0 && (31 === $this->randomness[$i]); $i--) { |
|
260 | $this->randomness[$i] = 0; |
||
261 | } |
||
262 | 1 | ++$this->randomness[$i]; |
|
263 | } |
||
264 | 3 | for ($i = 0; $i < 16; ++$i) { |
|
265 | 3 | $randomness .= static::ENCODING[$this->randomness[$i]]; |
|
266 | } |
||
267 | 3 | return [$timestamp, $randomness]; |
|
268 | } |
||
269 | |||
270 | 10 | private function randomize(bool $list): void |
|
271 | { |
||
272 | 10 | if ($list) { |
|
273 | 3 | $this->randomness = []; |
|
274 | 3 | for ($i = 0; $i < 16; ++$i) { |
|
275 | 3 | $this->randomness[] = mt_rand(0, 31); |
|
276 | } |
||
277 | } else { |
||
278 | 10 | $this->randomness = [ |
|
279 | 10 | mt_rand(0, 0xffff), |
|
280 | 10 | mt_rand(0, 0xffff), |
|
281 | 10 | mt_rand(0, 1 << 48) |
|
282 | 10 | ]; |
|
283 | } |
||
284 | } |
||
285 | } |
||
286 |