Skip to content

Commit 20c3559

Browse files
authored
feat(support): add uuid utilities (#1270)
1 parent c2f4e9d commit 20c3559

File tree

7 files changed

+232
-60
lines changed

7 files changed

+232
-60
lines changed

packages/command-bus/src/AsyncCommandMiddleware.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Symfony\Component\Uid\Uuid;
88
use Tempest\Core\Priority;
99
use Tempest\Reflection\ClassReflector;
10+
use Tempest\Support\Random;
1011

1112
#[Priority(Priority::FRAMEWORK)]
1213
final readonly class AsyncCommandMiddleware implements CommandBusMiddleware
@@ -20,7 +21,7 @@ public function __invoke(object $command, CommandBusMiddlewareCallable $next): v
2021
$reflector = new ClassReflector($command);
2122

2223
if ($reflector->hasAttribute(AsyncCommand::class)) {
23-
$this->repository->store(Uuid::v7()->toString(), $command);
24+
$this->repository->store(Random\uuid(), $command);
2425

2526
return;
2627
}

packages/support/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"php": "^8.4",
88
"doctrine/inflector": "^2.0",
99
"tempest/container": "dev-main",
10-
"voku/portable-ascii": "^2.0.3"
10+
"voku/portable-ascii": "^2.0.3",
11+
"symfony/uid": "^7.1"
1112
},
1213
"autoload": {
1314
"psr-4": {

packages/support/src/Random/functions.php

Lines changed: 100 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,67 +2,112 @@
22

33
declare(strict_types=1);
44

5-
namespace Tempest\Support\Random {
6-
use InvalidArgumentException;
7-
8-
use function log;
9-
10-
/**
11-
* Returns a securely generated random string of the given length. The string is
12-
* composed of characters from the given alphabet string.
13-
*
14-
* If the alphabet argument is not specified, the returned string will be composed of
15-
* the alphanumeric characters.
16-
*
17-
* @param int<0, max> $length The length of the string to generate.
18-
*
19-
* @throws InvalidArgumentException If $alphabet length is outside the [2^1, 2^56] range.
20-
*/
21-
function secure_string(int $length, ?string $alphabet = null): string
22-
{
23-
if ($length === 0) {
24-
return '';
25-
}
5+
namespace Tempest\Support\Random;
266

27-
$alphabet ??= '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
28-
$alphabet_size = mb_strlen($alphabet);
29-
$bits = (int) \ceil(log($alphabet_size, 2.0));
7+
use DateTimeInterface as NativeDateTimeInterface;
8+
use InvalidArgumentException;
9+
use Symfony\Component\Uid\Ulid;
10+
use Symfony\Component\Uid\Uuid;
11+
use Tempest\DateTime\DateTime;
12+
use Tempest\DateTime\DateTimeInterface;
3013

31-
if ($bits < 1 || $bits > 56) {
32-
throw new InvalidArgumentException('$alphabet\'s length must be in [2^1, 2^56]');
33-
}
14+
use function log;
15+
16+
/**
17+
* Returns a securely generated random string of the given length. The string is
18+
* composed of characters from the given alphabet string.
19+
*
20+
* If the alphabet argument is not specified, the returned string will be composed of
21+
* the alphanumeric characters.
22+
*
23+
* @param int<0, max> $length The length of the string to generate.
24+
*
25+
* @throws InvalidArgumentException If $alphabet length is outside the [2^1, 2^56] range.
26+
*/
27+
function secure_string(int $length, ?string $alphabet = null): string
28+
{
29+
if ($length === 0) {
30+
return '';
31+
}
32+
33+
$alphabet ??= '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
34+
$alphabet_size = mb_strlen($alphabet);
35+
$bits = (int) \ceil(log($alphabet_size, 2.0));
3436

35-
$ret = '';
36-
while ($length > 0) {
37-
/** @var int<0, max> $urandom_length */
38-
$urandom_length = (int) ceil(((float) (2 * $length * $bits)) / 8.0);
39-
$data = random_bytes($urandom_length);
40-
41-
$unpacked_data = 0;
42-
$unpacked_bits = 0;
43-
for ($i = 0; $i < $urandom_length && $length > 0; ++$i) {
44-
// Unpack 8 bits
45-
/** @var array<int, int> $v */
46-
$v = unpack('C', $data[$i]);
47-
$unpacked_data = ($unpacked_data << 8) | $v[1];
48-
$unpacked_bits += 8;
49-
50-
// While we have enough bits to select a character from the alphabet, keep
51-
// consuming the random data
52-
for (; $unpacked_bits >= $bits && $length > 0; $unpacked_bits -= $bits) {
53-
$index = $unpacked_data & ((1 << $bits) - 1);
54-
$unpacked_data >>= $bits;
55-
// Unfortunately, the alphabet size is not necessarily a power of two.
56-
// Worst case, it is 2^k + 1, which means we need (k+1) bits and we
57-
// have around a 50% chance of missing as k gets larger
58-
if ($index < $alphabet_size) {
59-
$ret .= $alphabet[$index];
60-
--$length;
61-
}
37+
if ($bits < 1 || $bits > 56) {
38+
throw new InvalidArgumentException('$alphabet\'s length must be in [2^1, 2^56]');
39+
}
40+
41+
$ret = '';
42+
while ($length > 0) {
43+
/** @var int<0, max> $urandom_length */
44+
$urandom_length = (int) ceil(((float) (2 * $length * $bits)) / 8.0);
45+
$data = random_bytes($urandom_length);
46+
47+
$unpacked_data = 0;
48+
$unpacked_bits = 0;
49+
for ($i = 0; $i < $urandom_length && $length > 0; ++$i) {
50+
// Unpack 8 bits
51+
/** @var array<int, int> $v */
52+
$v = unpack('C', $data[$i]);
53+
$unpacked_data = ($unpacked_data << 8) | $v[1];
54+
$unpacked_bits += 8;
55+
56+
// While we have enough bits to select a character from the alphabet, keep
57+
// consuming the random data
58+
for (; $unpacked_bits >= $bits && $length > 0; $unpacked_bits -= $bits) {
59+
$index = $unpacked_data & ((1 << $bits) - 1);
60+
$unpacked_data >>= $bits;
61+
// Unfortunately, the alphabet size is not necessarily a power of two.
62+
// Worst case, it is 2^k + 1, which means we need (k+1) bits and we
63+
// have around a 50% chance of missing as k gets larger
64+
if ($index < $alphabet_size) {
65+
$ret .= $alphabet[$index];
66+
--$length;
6267
}
6368
}
6469
}
70+
}
6571

66-
return $ret;
72+
return $ret;
73+
}
74+
75+
/**
76+
* Generates a UUID v7 (time-based) identifier.
77+
*/
78+
function uuid(): string
79+
{
80+
return Uuid::v7()->toString();
81+
}
82+
83+
/**
84+
* Generates a 128-bit universally unique lexicographically sortable identifier.
85+
*/
86+
function ulid(null|DateTimeInterface|NativeDateTimeInterface $time = null): string
87+
{
88+
return Ulid::generate($time ? DateTime::parse($time)->toNativeDateTime() : null);
89+
}
90+
91+
/**
92+
* Determines whether the specified string is a valid UUID.
93+
*/
94+
function is_uuid(?string $uuid): bool
95+
{
96+
if ($uuid === null) {
97+
return false;
6798
}
99+
100+
return Uuid::isValid($uuid);
101+
}
102+
103+
/**
104+
* Determines whether the specified string is a valid ULID.
105+
*/
106+
function is_ulid(?string $ulid): bool
107+
{
108+
if ($ulid === null) {
109+
return false;
110+
}
111+
112+
return Ulid::isValid($ulid);
68113
}

packages/support/src/Str/ManipulatesString.php

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
use Countable;
1010
use Stringable;
1111
use Tempest\Support\Arr\ImmutableArray;
12+
use Tempest\Support\Random;
1213
use Tempest\Support\Regex;
1314

1415
use function Tempest\Support\arr;
15-
use function Tempest\Support\Random\secure_string;
1616
use function Tempest\Support\tap;
1717

1818
/**
@@ -142,7 +142,39 @@ public function singularizeLastWord(): self
142142
*/
143143
public function random(int $length = 16): self
144144
{
145-
return $this->createOrModify(secure_string($length));
145+
return $this->createOrModify(Random\secure_string($length));
146+
}
147+
148+
/**
149+
* Generates a UUID v7 (time-based) identifier.
150+
*/
151+
public function uuid(): self
152+
{
153+
return $this->createOrModify(Random\uuid());
154+
}
155+
156+
/**
157+
* Generates a 128-bit universally unique lexicographically sortable identifier.
158+
*/
159+
public function ulid(): self
160+
{
161+
return $this->createOrModify(Random\ulid());
162+
}
163+
164+
/**
165+
* Determines whether the specified string is a valid UUID.
166+
*/
167+
public function isUuid(): bool
168+
{
169+
return Random\is_uuid($this->value);
170+
}
171+
172+
/**
173+
* Determines whether the instance is a valid ULID.
174+
*/
175+
public function isUlid(): bool
176+
{
177+
return Random\is_ulid($this->value);
146178
}
147179

148180
/**

packages/support/tests/Random/FunctionsTest.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
namespace Tempest\Support\Tests\Random;
66

7+
use DateTimeImmutable;
78
use InvalidArgumentException;
89
use PHPUnit\Framework\TestCase;
10+
use Symfony\Component\Uid\Ulid;
911
use Tempest\Support\Random;
1012

1113
use function Tempest\Support\Str\contains;
@@ -49,4 +51,52 @@ public function test_string_alphabet_min(): void
4951

5052
Random\secure_string(32, 'a');
5153
}
54+
55+
public function test_uuid(): void
56+
{
57+
$this->assertTrue(Random\is_uuid(Random\uuid()));
58+
}
59+
60+
public function test_ulid(): void
61+
{
62+
$this->assertTrue(Random\is_ulid(Random\ulid()));
63+
}
64+
65+
public function test_is_uuid(): void
66+
{
67+
$this->assertTrue(Random\is_uuid(Random\uuid()));
68+
69+
// UUID v1
70+
$this->assertTrue(Random\is_uuid('CB2F46B4-D0C6-11EE-A506-0242AC120002'));
71+
$this->assertTrue(Random\is_uuid('cb2f46b4-d0c6-11ee-a506-0242ac120002'));
72+
73+
// UUID v4
74+
$this->assertTrue(Random\is_uuid('0EC29141-3D58-4187-B664-2D93B7DA0D31'));
75+
$this->assertTrue(Random\is_uuid('0ec29141-3d58-4187-b664-2d93b7da0d31'));
76+
77+
// UUID v7
78+
$this->assertTrue(Random\is_uuid('018DCC19-7E65-7C4B-9B14-9A11DF3E0FDB'));
79+
$this->assertTrue(Random\is_uuid('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb'));
80+
81+
$this->assertFalse(Random\is_uuid(''));
82+
$this->assertFalse(Random\is_uuid('01JVX9G569ETXTZKKCK94T4A6V'));
83+
$this->assertFalse(Random\is_uuid('foo'));
84+
$this->assertFalse(Random\is_uuid(Random\secure_string(26)));
85+
$this->assertFalse(Random\is_uuid(Random\secure_string(36)));
86+
$this->assertFalse(Random\is_uuid(null));
87+
}
88+
89+
public function test_is_ulid(): void
90+
{
91+
$this->assertTrue(Random\is_ulid(Random\ulid()));
92+
93+
$this->assertTrue(Random\is_ulid('01JVX9G569ETXTZKKCK94T4A6V'));
94+
95+
$this->assertFalse(Random\is_ulid(''));
96+
$this->assertFalse(Random\is_ulid('0ec29141-3d58-4187-b664-2d93b7da0d31'));
97+
$this->assertFalse(Random\is_ulid('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb'));
98+
$this->assertFalse(Random\is_ulid('foo'));
99+
$this->assertFalse(Random\is_ulid(Random\secure_string(26)));
100+
$this->assertFalse(Random\is_ulid(null));
101+
}
52102
}

packages/support/tests/Str/ManipulatesStringTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,4 +708,46 @@ public function test_pad_left(string $expected, string $str, int $totalLength, s
708708
{
709709
$this->assertSame($expected, str($str)->padLeft($totalLength, $padString)->toString());
710710
}
711+
712+
public function test_is_uuid(): void
713+
{
714+
$this->assertTrue(str()->uuid()->isUuid());
715+
716+
// UUID v1
717+
$this->assertTrue(str('CB2F46B4-D0C6-11EE-A506-0242AC120002')->isUuid());
718+
$this->assertTrue(str('cb2f46b4-d0c6-11ee-a506-0242ac120002')->isUuid());
719+
720+
// UUID v4
721+
$this->assertTrue(str('0EC29141-3D58-4187-B664-2D93B7DA0D31')->isUuid());
722+
$this->assertTrue(str('0ec29141-3d58-4187-b664-2d93b7da0d31')->isUuid());
723+
724+
// UUID v7
725+
$this->assertTrue(str('018DCC19-7E65-7C4B-9B14-9A11DF3E0FDB')->isUuid());
726+
$this->assertTrue(str('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb')->isUuid());
727+
728+
$this->assertFalse(str('')->isUuid());
729+
$this->assertFalse(str('01JVX9G569ETXTZKKCK94T4A6V')->isUuid());
730+
$this->assertFalse(str('foo')->isUuid());
731+
$this->assertFalse(str()->random()->isUuid());
732+
$this->assertFalse(str(null)->isUuid());
733+
}
734+
735+
public function test_is_ulid(): void
736+
{
737+
$this->assertTrue(str()->ulid()->isUlid());
738+
739+
$this->assertTrue(str('01JVX9G569ETXTZKKCK94T4A6V')->isUlid());
740+
741+
$this->assertFalse(str('')->isUlid());
742+
$this->assertFalse(str('0ec29141-3d58-4187-b664-2d93b7da0d31')->isUlid());
743+
$this->assertFalse(str('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb')->isUlid());
744+
$this->assertFalse(str('foo')->isUlid());
745+
$this->assertFalse(str()->random()->isUlid());
746+
$this->assertFalse(str(null)->isUlid());
747+
}
748+
749+
public function test_uuid(): void
750+
{
751+
$this->assertTrue(str()->uuid()->isUuid());
752+
}
711753
}

packages/validation/src/Rules/Uuid.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tempest\Validation\Rules;
66

77
use Attribute;
8+
use Tempest\Support\Random;
89
use Tempest\Validation\Rule;
910

1011
#[Attribute]
@@ -16,7 +17,7 @@ public function isValid(mixed $value): bool
1617
return false;
1718
}
1819

19-
return boolval(preg_match('/^[a-f\d]{8}(-[a-f\d]{4}){4}[a-f\d]{8}$/i', $value));
20+
return Random\is_uuid($value);
2021
}
2122

2223
public function message(): string

0 commit comments

Comments
 (0)