Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/command-bus/src/AsyncCommandMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Symfony\Component\Uid\Uuid;
use Tempest\Core\Priority;
use Tempest\Reflection\ClassReflector;
use Tempest\Support\Random;

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

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

return;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/support/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"php": "^8.4",
"doctrine/inflector": "^2.0",
"tempest/container": "dev-main",
"voku/portable-ascii": "^2.0.3"
"voku/portable-ascii": "^2.0.3",
"symfony/uid": "^7.1"
},
"autoload": {
"psr-4": {
Expand Down
155 changes: 100 additions & 55 deletions packages/support/src/Random/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,112 @@

declare(strict_types=1);

namespace Tempest\Support\Random {
use InvalidArgumentException;

use function log;

/**
* Returns a securely generated random string of the given length. The string is
* composed of characters from the given alphabet string.
*
* If the alphabet argument is not specified, the returned string will be composed of
* the alphanumeric characters.
*
* @param int<0, max> $length The length of the string to generate.
*
* @throws InvalidArgumentException If $alphabet length is outside the [2^1, 2^56] range.
*/
function secure_string(int $length, ?string $alphabet = null): string
{
if ($length === 0) {
return '';
}
namespace Tempest\Support\Random;

$alphabet ??= '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$alphabet_size = mb_strlen($alphabet);
$bits = (int) \ceil(log($alphabet_size, 2.0));
use DateTimeInterface as NativeDateTimeInterface;
use InvalidArgumentException;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
use Tempest\DateTime\DateTime;
use Tempest\DateTime\DateTimeInterface;

if ($bits < 1 || $bits > 56) {
throw new InvalidArgumentException('$alphabet\'s length must be in [2^1, 2^56]');
}
use function log;

/**
* Returns a securely generated random string of the given length. The string is
* composed of characters from the given alphabet string.
*
* If the alphabet argument is not specified, the returned string will be composed of
* the alphanumeric characters.
*
* @param int<0, max> $length The length of the string to generate.
*
* @throws InvalidArgumentException If $alphabet length is outside the [2^1, 2^56] range.
*/
function secure_string(int $length, ?string $alphabet = null): string
{
if ($length === 0) {
return '';
}

$alphabet ??= '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$alphabet_size = mb_strlen($alphabet);
$bits = (int) \ceil(log($alphabet_size, 2.0));

$ret = '';
while ($length > 0) {
/** @var int<0, max> $urandom_length */
$urandom_length = (int) ceil(((float) (2 * $length * $bits)) / 8.0);
$data = random_bytes($urandom_length);

$unpacked_data = 0;
$unpacked_bits = 0;
for ($i = 0; $i < $urandom_length && $length > 0; ++$i) {
// Unpack 8 bits
/** @var array<int, int> $v */
$v = unpack('C', $data[$i]);
$unpacked_data = ($unpacked_data << 8) | $v[1];
$unpacked_bits += 8;

// While we have enough bits to select a character from the alphabet, keep
// consuming the random data
for (; $unpacked_bits >= $bits && $length > 0; $unpacked_bits -= $bits) {
$index = $unpacked_data & ((1 << $bits) - 1);
$unpacked_data >>= $bits;
// Unfortunately, the alphabet size is not necessarily a power of two.
// Worst case, it is 2^k + 1, which means we need (k+1) bits and we
// have around a 50% chance of missing as k gets larger
if ($index < $alphabet_size) {
$ret .= $alphabet[$index];
--$length;
}
if ($bits < 1 || $bits > 56) {
throw new InvalidArgumentException('$alphabet\'s length must be in [2^1, 2^56]');
}

$ret = '';
while ($length > 0) {
/** @var int<0, max> $urandom_length */
$urandom_length = (int) ceil(((float) (2 * $length * $bits)) / 8.0);
$data = random_bytes($urandom_length);

$unpacked_data = 0;
$unpacked_bits = 0;
for ($i = 0; $i < $urandom_length && $length > 0; ++$i) {
// Unpack 8 bits
/** @var array<int, int> $v */
$v = unpack('C', $data[$i]);
$unpacked_data = ($unpacked_data << 8) | $v[1];
$unpacked_bits += 8;

// While we have enough bits to select a character from the alphabet, keep
// consuming the random data
for (; $unpacked_bits >= $bits && $length > 0; $unpacked_bits -= $bits) {
$index = $unpacked_data & ((1 << $bits) - 1);
$unpacked_data >>= $bits;
// Unfortunately, the alphabet size is not necessarily a power of two.
// Worst case, it is 2^k + 1, which means we need (k+1) bits and we
// have around a 50% chance of missing as k gets larger
if ($index < $alphabet_size) {
$ret .= $alphabet[$index];
--$length;
}
}
}
}

return $ret;
return $ret;
}

/**
* Generates a UUID v7 (time-based) identifier.
*/
function uuid(): string
{
return Uuid::v7()->toString();
}

/**
* Generates a 128-bit universally unique lexicographically sortable identifier.
*/
function ulid(null|DateTimeInterface|NativeDateTimeInterface $time = null): string
{
return Ulid::generate($time ? DateTime::parse($time)->toNativeDateTime() : null);
}

/**
* Determines whether the specified string is a valid UUID.
*/
function is_uuid(?string $uuid): bool
{
if ($uuid === null) {
return false;
}

return Uuid::isValid($uuid);
}

/**
* Determines whether the specified string is a valid ULID.
*/
function is_ulid(?string $ulid): bool
{
if ($ulid === null) {
return false;
}

return Ulid::isValid($ulid);
}
36 changes: 34 additions & 2 deletions packages/support/src/Str/ManipulatesString.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
use Countable;
use Stringable;
use Tempest\Support\Arr\ImmutableArray;
use Tempest\Support\Random;
use Tempest\Support\Regex;

use function Tempest\Support\arr;
use function Tempest\Support\Random\secure_string;
use function Tempest\Support\tap;

/**
Expand Down Expand Up @@ -142,7 +142,39 @@ public function singularizeLastWord(): self
*/
public function random(int $length = 16): self
{
return $this->createOrModify(secure_string($length));
return $this->createOrModify(Random\secure_string($length));
}

/**
* Generates a UUID v7 (time-based) identifier.
*/
public function uuid(): self
{
return $this->createOrModify(Random\uuid());
}

/**
* Generates a 128-bit universally unique lexicographically sortable identifier.
*/
public function ulid(): self
{
return $this->createOrModify(Random\ulid());
}

/**
* Determines whether the specified string is a valid UUID.
*/
public function isUuid(): bool
{
return Random\is_uuid($this->value);
}

/**
* Determines whether the instance is a valid ULID.
*/
public function isUlid(): bool
{
return Random\is_ulid($this->value);
}

/**
Expand Down
50 changes: 50 additions & 0 deletions packages/support/tests/Random/FunctionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

namespace Tempest\Support\Tests\Random;

use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Ulid;
use Tempest\Support\Random;

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

Random\secure_string(32, 'a');
}

public function test_uuid(): void
{
$this->assertTrue(Random\is_uuid(Random\uuid()));
}

public function test_ulid(): void
{
$this->assertTrue(Random\is_ulid(Random\ulid()));
}

public function test_is_uuid(): void
{
$this->assertTrue(Random\is_uuid(Random\uuid()));

// UUID v1
$this->assertTrue(Random\is_uuid('CB2F46B4-D0C6-11EE-A506-0242AC120002'));
$this->assertTrue(Random\is_uuid('cb2f46b4-d0c6-11ee-a506-0242ac120002'));

// UUID v4
$this->assertTrue(Random\is_uuid('0EC29141-3D58-4187-B664-2D93B7DA0D31'));
$this->assertTrue(Random\is_uuid('0ec29141-3d58-4187-b664-2d93b7da0d31'));

// UUID v7
$this->assertTrue(Random\is_uuid('018DCC19-7E65-7C4B-9B14-9A11DF3E0FDB'));
$this->assertTrue(Random\is_uuid('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb'));

$this->assertFalse(Random\is_uuid(''));
$this->assertFalse(Random\is_uuid('01JVX9G569ETXTZKKCK94T4A6V'));
$this->assertFalse(Random\is_uuid('foo'));
$this->assertFalse(Random\is_uuid(Random\secure_string(26)));
$this->assertFalse(Random\is_uuid(Random\secure_string(36)));
$this->assertFalse(Random\is_uuid(null));
}

public function test_is_ulid(): void
{
$this->assertTrue(Random\is_ulid(Random\ulid()));

$this->assertTrue(Random\is_ulid('01JVX9G569ETXTZKKCK94T4A6V'));

$this->assertFalse(Random\is_ulid(''));
$this->assertFalse(Random\is_ulid('0ec29141-3d58-4187-b664-2d93b7da0d31'));
$this->assertFalse(Random\is_ulid('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb'));
$this->assertFalse(Random\is_ulid('foo'));
$this->assertFalse(Random\is_ulid(Random\secure_string(26)));
$this->assertFalse(Random\is_ulid(null));
}
}
42 changes: 42 additions & 0 deletions packages/support/tests/Str/ManipulatesStringTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -708,4 +708,46 @@ public function test_pad_left(string $expected, string $str, int $totalLength, s
{
$this->assertSame($expected, str($str)->padLeft($totalLength, $padString)->toString());
}

public function test_is_uuid(): void
{
$this->assertTrue(str()->uuid()->isUuid());

// UUID v1
$this->assertTrue(str('CB2F46B4-D0C6-11EE-A506-0242AC120002')->isUuid());
$this->assertTrue(str('cb2f46b4-d0c6-11ee-a506-0242ac120002')->isUuid());

// UUID v4
$this->assertTrue(str('0EC29141-3D58-4187-B664-2D93B7DA0D31')->isUuid());
$this->assertTrue(str('0ec29141-3d58-4187-b664-2d93b7da0d31')->isUuid());

// UUID v7
$this->assertTrue(str('018DCC19-7E65-7C4B-9B14-9A11DF3E0FDB')->isUuid());
$this->assertTrue(str('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb')->isUuid());

$this->assertFalse(str('')->isUuid());
$this->assertFalse(str('01JVX9G569ETXTZKKCK94T4A6V')->isUuid());
$this->assertFalse(str('foo')->isUuid());
$this->assertFalse(str()->random()->isUuid());
$this->assertFalse(str(null)->isUuid());
}

public function test_is_ulid(): void
{
$this->assertTrue(str()->ulid()->isUlid());

$this->assertTrue(str('01JVX9G569ETXTZKKCK94T4A6V')->isUlid());

$this->assertFalse(str('')->isUlid());
$this->assertFalse(str('0ec29141-3d58-4187-b664-2d93b7da0d31')->isUlid());
$this->assertFalse(str('018dcc19-7e65-7c4b-9b14-9a11df3e0fdb')->isUlid());
$this->assertFalse(str('foo')->isUlid());
$this->assertFalse(str()->random()->isUlid());
$this->assertFalse(str(null)->isUlid());
}

public function test_uuid(): void
{
$this->assertTrue(str()->uuid()->isUuid());
}
}
3 changes: 2 additions & 1 deletion packages/validation/src/Rules/Uuid.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tempest\Validation\Rules;

use Attribute;
use Tempest\Support\Random;
use Tempest\Validation\Rule;

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

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

public function message(): string
Expand Down