Passed
Push — master ( d95ba4...93c67f )
by Mihail
02:28
created

ULID.php (2 issues)

Labels
Severity
1
<?php
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 and 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 the 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 the 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);
118
    }
119
120
    /**
121
     * Decode the date time string into the 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
            [$ts, $ms] = explode('.', $timestamp) + [1 => '000'];
129 1
            return new static("$ts$ms", 1);
0 ignored issues
show
$ts.$ms of type string is incompatible with the type integer expected by parameter $timestamps of Koded\Stdlib\ULID::__construct(). ( Ignorable by Annotation )

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

129
            return new static(/** @scrutinizer ignore-type */ "$ts$ms", 1);
Loading history...
130
        }
131 1
        throw new InvalidArgumentException("Invalid timestamp ($timestamp)", 400);
132
    }
133
134
    /**
135
     * Decode the date time string into the instance of ULID.
136
     * @param string $datetime in format: Y-m-d H:i:s with optional 'v' (milliseconds)
137
     * @return static
138
     */
139 3
    public static function fromDateTime(string $datetime): self
140
    {
141
        try {
142 3
            $dt = (false === str_contains($datetime, '.'))
143 3
                ? DateTime::createFromFormat('Y-m-d H:i:s', $datetime, new DateTimeZone('UTC'))
144 1
                : DateTime::createFromFormat('Y-m-d H:i:s.v', $datetime, new DateTimeZone('UTC'));
145 3
            return new static($dt->getTimestamp() . $dt->format('v'), 1);
0 ignored issues
show
$dt->getTimestamp() . $dt->format('v') of type string is incompatible with the type integer expected by parameter $timestamps of Koded\Stdlib\ULID::__construct(). ( Ignorable by Annotation )

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

145
            return new static(/** @scrutinizer ignore-type */ $dt->getTimestamp() . $dt->format('v'), 1);
Loading history...
146 1
        } catch (Throwable) {
147 1
            throw new InvalidArgumentException("Invalid datetime ($datetime)", 400);
148
        }
149
    }
150
151 6
    public static function valid(string $uuid): bool
152
    {
153 6
        return (bool)preg_match('/^' . static::REGEX . '$/i', $uuid);
154
    }
155
156
    /**
157
     * Creates a single, or a list. of UUID representations of ULID values.
158
     * @return array|string
159
     */
160 3
    public function toUUID(): array|string
161
    {
162 3
        $list = [];
163 3
        foreach ($this->timestamps as $ts) {
164 3
            $timestamp = $this->generateUuidParts($ts);
165 3
            $hex = substr(str_pad(dechex(intval($timestamp)), 12, '0', STR_PAD_LEFT), -12);
166 3
            $list[sprintf('%08s-%04s-%04x-%04x-%012x',
167 3
                substr($hex, 0, 8),
168 3
                substr($hex, 8, 4),
169 3
                ...$this->randomness
170 3
            )] = $ts;
171
        }
172 3
        return (1 === $this->count())
173 2
            ? array_key_first($list)
174 3
            : $list;
175
    }
176
177
    /**
178
     * Creates a single, or a list. of ULID representations.
179
     * @return array|string
180
     */
181 3
    public function toULID(): array|string
182
    {
183 3
        $list = [];
184 3
        foreach ($this->timestamps as $ts) {
185 3
            [$timestamp, $randomness] = $this->generateUlidParts(intval($ts));
186 3
            $list[$timestamp . $randomness] = $ts;
187
        }
188 3
        return (1 === $this->count())
189 2
            ? array_key_first($list)
190 3
            : $list;
191
    }
192
193
    /**
194
     * Returns a single, or a list, of DateTime instances for this ULID.
195
     * @return array|DateTime
196
     */
197 5
    public function toDateTime(): array|DateTime
198
    {
199 5
        $list = [];
200 5
        foreach ($this->timestamps as $timestamp) {
201 5
            $datetime = new DateTime('@' . substr($timestamp, 0, 10), new DateTimeZone('UTC'));
202 5
            if (13 === strlen($timestamp)) {
203 5
                $ms = substr($timestamp, 10);
204 5
                $datetime->modify("+{$ms} milliseconds");
205
            }
206 5
            $list[] = $datetime;
207
        }
208 5
        return (1 === $this->count())
209 5
            ? current($list)
210 5
            : $list;
211
    }
212
213
    /**
214
     * The number of generated timestamps in the ULID instance.
215
     * @return int
216
     */
217 9
    public function count(): int
218
    {
219 9
        return count($this->timestamps);
220
    }
221
222 3
    private function generateUuidParts(int $milliseconds): int
223
    {
224 3
        static $lastTime = 0;
225 3
        $sameTimestamp = $lastTime === $milliseconds;
226 3
        $lastTime = $milliseconds;
227 3
        if ($sameTimestamp) {
228 1
            $this->randomness[2]++;
229
        } else {
230 3
            $this->randomize(false);
231
        }
232 3
        return $lastTime;
233
    }
234
235 3
    private function generateUlidParts(int $milliseconds): array
236
    {
237 3
        static $lastTime = 0;
238 3
        $sameTimestamp = $lastTime === $milliseconds;
239 3
        $lastTime = $milliseconds;
240 3
        $timestamp = $randomness = '';
241
        // Timestamp
242 3
        for ($i = 10; $i > 0; $i--) {
243 3
            $mod = $milliseconds % 32;
244 3
            $timestamp = static::ENCODING[$mod] . $timestamp;
245 3
            $milliseconds = ($milliseconds - $mod) / 32;
246
        }
247
        // Randomness
248 3
        if (count($this->randomness) < 16) {
249 3
            $this->randomize(true);
250
        }
251 3
        if ($sameTimestamp) {
252 1
            for ($i = 15; $i >= 0 && (31 === $this->randomness[$i]); $i--) {
253
                $this->randomness[$i] = 0;
254
            }
255 1
            ++$this->randomness[$i];
256
        }
257 3
        for ($i = 0; $i < 16; ++$i) {
258 3
            $randomness .= static::ENCODING[$this->randomness[$i]];
259
        }
260 3
        return [$timestamp, $randomness];
261
    }
262
263 10
    private function randomize(bool $list): void
264
    {
265 10
        if ($list) {
266 3
            $this->randomness = [];
267 3
            for ($i = 0; $i < 16; $i++) {
268 3
                $this->randomness[] = mt_rand(0, 31);
269
            }
270
        } else {
271 10
            $this->randomness = [
272 10
                mt_rand(0, 0xffff),
273 10
                mt_rand(0, 0xffff),
274 10
                mt_rand(0, 1 << 48)
275 10
            ];
276
        }
277
    }
278
}
279