Issues (21)

ULID.php (3 issues)

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
It seems like $timestamp can also be of type double; however, parameter $timestamps of Koded\Stdlib\ULID::__construct() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

117
        return new static(/** @scrutinizer ignore-type */ $timestamp, 1);
Loading history...
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
Bug Best Practice introduced by
The expression return 1 === $this->coun...ey_first($list) : $list could return the type null which is incompatible with the type-hinted return array|string. Consider adding an additional type-check to rule them out.
Loading history...
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
Bug Best Practice introduced by
The expression return 1 === $this->coun...ey_first($list) : $list could return the type null which is incompatible with the type-hinted return array|string. Consider adding an additional type-check to rule them out.
Loading history...
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