Skip to content

Commit d95ba43

Browse files
committed
- added ULID class for creating Universally Unique Lexicographically Sortable Identifiers
1 parent 479ee88 commit d95ba43

File tree

2 files changed

+500
-0
lines changed

2 files changed

+500
-0
lines changed

ULID.php

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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+
private function __construct(int $timestamps, int $count)
64+
{
65+
if ($count < 1) {
66+
throw new ArgumentCountError('count must be greater then 0', 400);
67+
}
68+
$this->randomize(false);
69+
for ($i = 0; $i < $count; $i++) {
70+
$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+
public static function generate(int $count = 1): self
81+
{
82+
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+
public static function fromULID(string $ulid): self
91+
{
92+
if (26 !== strlen($ulid)) {
93+
throw new InvalidArgumentException('Invalid ULID, wrong length', 400);
94+
}
95+
if (!preg_match('/^[' . static::ENCODING . ']{26}$/', $ulid)) {
96+
throw new InvalidArgumentException('Invalid ULID, non supported characters', 400);
97+
}
98+
$timestamp = 0;
99+
$chars = substr($ulid, 0, 10);
100+
for ($i = 0; $i < 10; $i++) {
101+
$timestamp = $timestamp * 32 + strpos(static::ENCODING, $chars[$i]);
102+
}
103+
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+
public static function fromUUID(string $ulid): self
112+
{
113+
if (false === static::valid($ulid)) {
114+
throw new InvalidArgumentException('Invalid ULID', 400);
115+
}
116+
$timestamp = hexdec(substr($ulid, 0, 8) . substr($ulid, 9, 4));
117+
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+
public static function fromTimestamp(float $timestamp): self
126+
{
127+
if ($timestamp >= 0 && $timestamp <= PHP_INT_MAX) {
128+
[$ts, $ms] = explode('.', $timestamp) + [1 => '000'];
129+
return new static("$ts$ms", 1);
130+
}
131+
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+
public static function fromDateTime(string $datetime): self
140+
{
141+
try {
142+
$dt = (false === str_contains($datetime, '.'))
143+
? DateTime::createFromFormat('Y-m-d H:i:s', $datetime, new DateTimeZone('UTC'))
144+
: DateTime::createFromFormat('Y-m-d H:i:s.v', $datetime, new DateTimeZone('UTC'));
145+
return new static($dt->getTimestamp() . $dt->format('v'), 1);
146+
} catch (Throwable) {
147+
throw new InvalidArgumentException("Invalid datetime ($datetime)", 400);
148+
}
149+
}
150+
151+
public static function valid(string $uuid): bool
152+
{
153+
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+
public function toUUID(): array|string
161+
{
162+
$list = [];
163+
foreach ($this->timestamps as $ts) {
164+
$timestamp = $this->generateUuidParts($ts);
165+
$hex = substr(str_pad(dechex(intval($timestamp)), 12, '0', STR_PAD_LEFT), -12);
166+
$list[sprintf('%08s-%04s-%04x-%04x-%012x',
167+
substr($hex, 0, 8),
168+
substr($hex, 8, 4),
169+
...$this->randomness
170+
)] = $ts;
171+
}
172+
return (1 === $this->count())
173+
? array_key_first($list)
174+
: $list;
175+
}
176+
177+
/**
178+
* Creates a single, or a list. of ULID representations.
179+
* @return array|string
180+
*/
181+
public function toULID(): array|string
182+
{
183+
$list = [];
184+
foreach ($this->timestamps as $ts) {
185+
[$timestamp, $randomness] = $this->generateUlidParts(intval($ts));
186+
$list[$timestamp . $randomness] = $ts;
187+
}
188+
return (1 === $this->count())
189+
? array_key_first($list)
190+
: $list;
191+
}
192+
193+
/**
194+
* Returns a single, or a list, of DateTime instances for this ULID.
195+
* @return array|DateTime
196+
*/
197+
public function toDateTime(): array|DateTime
198+
{
199+
$list = [];
200+
foreach ($this->timestamps as $timestamp) {
201+
$datetime = new DateTime('@' . substr($timestamp, 0, 10), new DateTimeZone('UTC'));
202+
if (13 === strlen($timestamp)) {
203+
$ms = substr($timestamp, 10);
204+
$datetime->modify("+{$ms} milliseconds");
205+
}
206+
$list[] = $datetime;
207+
}
208+
return (1 === $this->count())
209+
? current($list)
210+
: $list;
211+
}
212+
213+
/**
214+
* The number of generated timestamps in the ULID instance.
215+
* @return int
216+
*/
217+
public function count(): int
218+
{
219+
return count($this->timestamps);
220+
}
221+
222+
private function generateUuidParts(int $milliseconds): int
223+
{
224+
static $lastTime = 0;
225+
$sameTimestamp = $lastTime === $milliseconds;
226+
$lastTime = $milliseconds;
227+
if ($sameTimestamp) {
228+
$this->randomness[2]++;
229+
} else {
230+
$this->randomize(false);
231+
}
232+
return $lastTime;
233+
}
234+
235+
private function generateUlidParts(int $milliseconds): array
236+
{
237+
static $lastTime = 0;
238+
$sameTimestamp = $lastTime === $milliseconds;
239+
$lastTime = $milliseconds;
240+
$timestamp = $randomness = '';
241+
// Timestamp
242+
for ($i = 10; $i > 0; $i--) {
243+
$mod = $milliseconds % 32;
244+
$timestamp = static::ENCODING[$mod] . $timestamp;
245+
$milliseconds = ($milliseconds - $mod) / 32;
246+
}
247+
// Randomness
248+
if (count($this->randomness) < 16) {
249+
$this->randomize(true);
250+
}
251+
if ($sameTimestamp) {
252+
for ($i = 15; $i >= 0 && (31 === $this->randomness[$i]); $i--) {
253+
$this->randomness[$i] = 0;
254+
}
255+
++$this->randomness[$i];
256+
}
257+
for ($i = 0; $i < 16; ++$i) {
258+
$randomness .= static::ENCODING[$this->randomness[$i]];
259+
}
260+
return [$timestamp, $randomness];
261+
}
262+
263+
private function randomize(bool $list): void
264+
{
265+
if ($list) {
266+
$this->randomness = [];
267+
for ($i = 0; $i < 16; $i++) {
268+
$this->randomness[] = mt_rand(0, 31);
269+
}
270+
} else {
271+
$this->randomness = [
272+
mt_rand(0, 0xffff),
273+
mt_rand(0, 0xffff),
274+
mt_rand(0, 1 << 48)
275+
];
276+
}
277+
}
278+
}

0 commit comments

Comments
 (0)