refactor(console, id, config): Dialog mode in Console, consolidated id modul, added config support for ini directives
This commit is contained in:
31
src/Framework/Id/Contracts/IdGeneratorInterface.php
Normal file
31
src/Framework/Id/Contracts/IdGeneratorInterface.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Contracts;
|
||||
|
||||
/**
|
||||
* Common interface for all ID generators
|
||||
*/
|
||||
interface IdGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* Generate a new ID
|
||||
*
|
||||
* @return IdInterface|string Returns either an IdInterface or string representation
|
||||
*/
|
||||
public function generate(): IdInterface|string;
|
||||
|
||||
/**
|
||||
* Generate a batch of IDs
|
||||
*
|
||||
* @param int $count Number of IDs to generate
|
||||
* @return array<int, IdInterface|string>
|
||||
*/
|
||||
public function generateBatch(int $count): array;
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid ID for this generator
|
||||
*/
|
||||
public function isValid(string $value): bool;
|
||||
}
|
||||
26
src/Framework/Id/Contracts/IdInterface.php
Normal file
26
src/Framework/Id/Contracts/IdInterface.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Contracts;
|
||||
|
||||
/**
|
||||
* Common interface for all ID value objects
|
||||
*/
|
||||
interface IdInterface
|
||||
{
|
||||
/**
|
||||
* Get the string representation of the ID
|
||||
*/
|
||||
public function toString(): string;
|
||||
|
||||
/**
|
||||
* Get the string value
|
||||
*/
|
||||
public function getValue(): string;
|
||||
|
||||
/**
|
||||
* Check equality with another ID
|
||||
*/
|
||||
public function equals(self $other): bool;
|
||||
}
|
||||
281
src/Framework/Id/Cuid/Cuid.php
Normal file
281
src/Framework/Id/Cuid/Cuid.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Cuid;
|
||||
|
||||
use App\Framework\Id\Contracts\IdInterface;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Cuid Value Object
|
||||
*
|
||||
* Collision-resistant Unique Identifier
|
||||
* - 25 characters total (c + timestamp + counter + fingerprint + random)
|
||||
* - Base36 encoded (0-9, a-z) - case insensitive
|
||||
* - Optimized for horizontal scaling and collision resistance
|
||||
* - Always starts with 'c' for collision-resistant
|
||||
*/
|
||||
final readonly class Cuid implements IdInterface, Stringable
|
||||
{
|
||||
public const int LENGTH = 25;
|
||||
public const string PREFIX = 'c';
|
||||
|
||||
// Base36 alphabet: 0-9, a-z (lowercase only)
|
||||
public const string ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
// Component lengths
|
||||
public const int TIMESTAMP_LENGTH = 8;
|
||||
public const int COUNTER_LENGTH = 4;
|
||||
public const int FINGERPRINT_LENGTH = 4;
|
||||
public const int RANDOM_LENGTH = 8; // PREFIX(1) + TIMESTAMP(8) + COUNTER(4) + FINGERPRINT(4) + RANDOM(8) = 25
|
||||
|
||||
private string $value;
|
||||
|
||||
private int $timestamp;
|
||||
|
||||
private int $counter;
|
||||
|
||||
private string $fingerprint;
|
||||
|
||||
private string $random;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
throw new InvalidArgumentException('Cuid cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($value) !== self::LENGTH) {
|
||||
throw new InvalidArgumentException('Cuid must be exactly ' . self::LENGTH . ' characters long');
|
||||
}
|
||||
|
||||
if (! str_starts_with($value, self::PREFIX)) {
|
||||
throw new InvalidArgumentException('Cuid must start with "c"');
|
||||
}
|
||||
|
||||
if (! $this->isValidBase36($value)) {
|
||||
throw new InvalidArgumentException('Cuid contains invalid characters');
|
||||
}
|
||||
|
||||
$this->value = $value;
|
||||
$this->parseComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from components
|
||||
*/
|
||||
public static function fromComponents(
|
||||
int $timestamp,
|
||||
int $counter,
|
||||
string $fingerprint,
|
||||
string $random
|
||||
): self {
|
||||
if (strlen($fingerprint) !== self::FINGERPRINT_LENGTH) {
|
||||
throw new InvalidArgumentException('Fingerprint must be exactly ' . self::FINGERPRINT_LENGTH . ' characters');
|
||||
}
|
||||
|
||||
if (strlen($random) !== self::RANDOM_LENGTH) {
|
||||
throw new InvalidArgumentException('Random part must be exactly ' . self::RANDOM_LENGTH . ' characters');
|
||||
}
|
||||
|
||||
// Convert components to Base36
|
||||
$timestampBase36 = self::toBase36($timestamp, self::TIMESTAMP_LENGTH);
|
||||
$counterBase36 = self::toBase36($counter, self::COUNTER_LENGTH);
|
||||
|
||||
$value = self::PREFIX . $timestampBase36 . $counterBase36 . $fingerprint . $random;
|
||||
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from string
|
||||
*/
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string representation
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string value
|
||||
*/
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method for string conversion
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp component (Unix timestamp in milliseconds)
|
||||
*/
|
||||
public function getTimestamp(): int
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp as DateTime
|
||||
*/
|
||||
public function getDateTime(): DateTimeImmutable
|
||||
{
|
||||
// Convert milliseconds to seconds
|
||||
$seconds = intval($this->timestamp / 1000);
|
||||
$microseconds = ($this->timestamp % 1000) * 1000;
|
||||
|
||||
return DateTimeImmutable::createFromFormat('U.u', sprintf('%d.%06d', $seconds, $microseconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the counter component
|
||||
*/
|
||||
public function getCounter(): int
|
||||
{
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fingerprint component
|
||||
*/
|
||||
public function getFingerprint(): string
|
||||
{
|
||||
return $this->fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the random component
|
||||
*/
|
||||
public function getRandom(): string
|
||||
{
|
||||
return $this->random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another Cuid
|
||||
*/
|
||||
public function equals(IdInterface $other): bool
|
||||
{
|
||||
if (! $other instanceof self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another Cuid for sorting
|
||||
*/
|
||||
public function compare(self $other): int
|
||||
{
|
||||
return strcmp($this->value, $other->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this Cuid is older than another
|
||||
*/
|
||||
public function isOlderThan(self $other): bool
|
||||
{
|
||||
return $this->timestamp < $other->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this Cuid is newer than another
|
||||
*/
|
||||
public function isNewerThan(self $other): bool
|
||||
{
|
||||
return $this->timestamp > $other->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age in milliseconds
|
||||
*/
|
||||
public function getAgeInMilliseconds(): int
|
||||
{
|
||||
return intval(microtime(true) * 1000) - $this->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age in seconds
|
||||
*/
|
||||
public function getAgeInSeconds(): float
|
||||
{
|
||||
return $this->getAgeInMilliseconds() / 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Cuid is from the same process/machine (same fingerprint)
|
||||
*/
|
||||
public function isSameProcess(self $other): bool
|
||||
{
|
||||
return $this->fingerprint === $other->fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Base36 characters (lowercase only)
|
||||
*/
|
||||
private function isValidBase36(string $value): bool
|
||||
{
|
||||
$pattern = '/^[' . preg_quote(self::ALPHABET, '/') . ']+$/';
|
||||
|
||||
return preg_match($pattern, $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse components from the Cuid value
|
||||
*/
|
||||
private function parseComponents(): void
|
||||
{
|
||||
$offset = 1; // Skip the 'c' prefix
|
||||
|
||||
// Extract timestamp (8 chars)
|
||||
$timestampBase36 = substr($this->value, $offset, self::TIMESTAMP_LENGTH);
|
||||
$this->timestamp = self::fromBase36($timestampBase36);
|
||||
$offset += self::TIMESTAMP_LENGTH;
|
||||
|
||||
// Extract counter (4 chars)
|
||||
$counterBase36 = substr($this->value, $offset, self::COUNTER_LENGTH);
|
||||
$this->counter = self::fromBase36($counterBase36);
|
||||
$offset += self::COUNTER_LENGTH;
|
||||
|
||||
// Extract fingerprint (4 chars)
|
||||
$this->fingerprint = substr($this->value, $offset, self::FINGERPRINT_LENGTH);
|
||||
$offset += self::FINGERPRINT_LENGTH;
|
||||
|
||||
// Extract random part (8 chars)
|
||||
$this->random = substr($this->value, $offset, self::RANDOM_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert integer to Base36 with padding
|
||||
*/
|
||||
private static function toBase36(int $value, int $length): string
|
||||
{
|
||||
$base36 = base_convert((string)$value, 10, 36);
|
||||
|
||||
return str_pad($base36, $length, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Base36 string to integer
|
||||
*/
|
||||
private static function fromBase36(string $base36): int
|
||||
{
|
||||
return intval(base_convert($base36, 36, 10));
|
||||
}
|
||||
}
|
||||
279
src/Framework/Id/Cuid/CuidGenerator.php
Normal file
279
src/Framework/Id/Cuid/CuidGenerator.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Cuid;
|
||||
|
||||
use App\Framework\Id\Contracts\IdGeneratorInterface;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Cuid Generator Service
|
||||
*
|
||||
* Generates Collision-resistant Unique Identifiers with machine fingerprinting.
|
||||
*/
|
||||
final class CuidGenerator implements IdGeneratorInterface
|
||||
{
|
||||
private int $counter = 0;
|
||||
|
||||
private string $fingerprint;
|
||||
|
||||
public function __construct(
|
||||
private readonly RandomGenerator $randomGenerator,
|
||||
?string $customFingerprint = null
|
||||
) {
|
||||
$this->fingerprint = $customFingerprint ?? $this->generateFingerprint();
|
||||
$this->counter = $this->randomGenerator->int(0, 36 ** Cuid::COUNTER_LENGTH - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new Cuid
|
||||
*/
|
||||
public function generate(): Cuid
|
||||
{
|
||||
$timestamp = $this->getCurrentTimestamp();
|
||||
$counter = $this->getNextCounter();
|
||||
$random = $this->generateRandomPart();
|
||||
|
||||
return Cuid::fromComponents($timestamp, $counter, $this->fingerprint, $random);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Cuid at a specific timestamp (milliseconds)
|
||||
*/
|
||||
public function generateAt(int $timestampMs): Cuid
|
||||
{
|
||||
$counter = $this->getNextCounter();
|
||||
$random = $this->generateRandomPart();
|
||||
|
||||
return Cuid::fromComponents($timestampMs, $counter, $this->fingerprint, $random);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Cuid in the past (useful for testing)
|
||||
*/
|
||||
public function generateInPast(int $millisecondsAgo): Cuid
|
||||
{
|
||||
$timestamp = $this->getCurrentTimestamp() - $millisecondsAgo;
|
||||
|
||||
return $this->generateAt($timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a batch of Cuids with incrementing counters
|
||||
* @return array<int, Cuid>
|
||||
*/
|
||||
public function generateBatch(int $count): array
|
||||
{
|
||||
if ($count <= 0) {
|
||||
throw new InvalidArgumentException('Count must be positive');
|
||||
}
|
||||
|
||||
if ($count > 10000) {
|
||||
throw new InvalidArgumentException('Batch size cannot exceed 10000');
|
||||
}
|
||||
|
||||
$timestamp = $this->getCurrentTimestamp();
|
||||
$cuids = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$counter = $this->getNextCounter();
|
||||
$random = $this->generateRandomPart();
|
||||
|
||||
$cuids[] = Cuid::fromComponents($timestamp, $counter, $this->fingerprint, $random);
|
||||
}
|
||||
|
||||
return $cuids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sequence of Cuids with incrementing timestamps
|
||||
* @return array<int, Cuid>
|
||||
*/
|
||||
public function generateSequence(int $count, int $intervalMs = 1): array
|
||||
{
|
||||
if ($count <= 0) {
|
||||
throw new InvalidArgumentException('Count must be positive');
|
||||
}
|
||||
|
||||
if ($count > 1000) {
|
||||
throw new InvalidArgumentException('Sequence size cannot exceed 1000');
|
||||
}
|
||||
|
||||
if ($intervalMs < 0) {
|
||||
throw new InvalidArgumentException('Interval must be non-negative');
|
||||
}
|
||||
|
||||
$cuids = [];
|
||||
$timestamp = $this->getCurrentTimestamp();
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$currentTimestamp = $timestamp + ($i * $intervalMs);
|
||||
$cuids[] = $this->generateAt($currentTimestamp);
|
||||
}
|
||||
|
||||
return $cuids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Cuid with a specific counter value (for testing)
|
||||
*/
|
||||
public function generateWithCounter(int $counter): Cuid
|
||||
{
|
||||
if ($counter < 0 || $counter >= 36 ** Cuid::COUNTER_LENGTH) {
|
||||
throw new InvalidArgumentException('Counter must be between 0 and ' . (36 ** Cuid::COUNTER_LENGTH - 1));
|
||||
}
|
||||
|
||||
$timestamp = $this->getCurrentTimestamp();
|
||||
$random = $this->generateRandomPart();
|
||||
|
||||
return Cuid::fromComponents($timestamp, $counter, $this->fingerprint, $random);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cuid string and validate it
|
||||
*/
|
||||
public function parse(string $cuidString): Cuid
|
||||
{
|
||||
return Cuid::fromString($cuidString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid Cuid
|
||||
*/
|
||||
public function isValid(string $value): bool
|
||||
{
|
||||
if (strlen($value) !== Cuid::LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! str_starts_with($value, Cuid::PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Cuid::fromString($value);
|
||||
|
||||
return true;
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current fingerprint
|
||||
*/
|
||||
public function getFingerprint(): string
|
||||
{
|
||||
return $this->fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current counter value
|
||||
*/
|
||||
public function getCurrentCounter(): int
|
||||
{
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the counter (useful for testing)
|
||||
*/
|
||||
public function resetCounter(): void
|
||||
{
|
||||
$this->counter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two Cuids could have been generated by the same generator
|
||||
*/
|
||||
public function isSameGenerator(Cuid $cuid): bool
|
||||
{
|
||||
return $cuid->getFingerprint() === $this->fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp in milliseconds
|
||||
*/
|
||||
private function getCurrentTimestamp(): int
|
||||
{
|
||||
return intval(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next counter value with wraparound
|
||||
*/
|
||||
private function getNextCounter(): int
|
||||
{
|
||||
$current = $this->counter;
|
||||
$this->counter = ($this->counter + 1) % (36 ** Cuid::COUNTER_LENGTH);
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random part (8 chars Base36)
|
||||
*/
|
||||
private function generateRandomPart(): string
|
||||
{
|
||||
$random = '';
|
||||
|
||||
for ($i = 0; $i < Cuid::RANDOM_LENGTH; $i++) {
|
||||
$randomIndex = $this->randomGenerator->int(0, 35);
|
||||
$random .= Cuid::ALPHABET[$randomIndex];
|
||||
}
|
||||
|
||||
return $random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate machine fingerprint based on system characteristics
|
||||
*/
|
||||
private function generateFingerprint(): string
|
||||
{
|
||||
// Create a unique fingerprint based on system characteristics
|
||||
$data = [
|
||||
php_uname('n'), // hostname
|
||||
php_uname('s'), // OS
|
||||
php_uname('r'), // release
|
||||
php_uname('m'), // machine type
|
||||
getmypid(), // process ID
|
||||
$_SERVER['SERVER_ADDR'] ?? 'localhost',
|
||||
$_SERVER['SERVER_PORT'] ?? '80',
|
||||
];
|
||||
|
||||
$hash = hash('sha256', implode('|', $data));
|
||||
|
||||
// Convert first 16 chars of hash to Base36
|
||||
$fingerprint = '';
|
||||
for ($i = 0; $i < Cuid::FINGERPRINT_LENGTH; $i++) {
|
||||
$hexPair = substr($hash, $i * 2, 2);
|
||||
$decimal = hexdec($hexPair);
|
||||
$base36Char = base_convert((string)($decimal % 36), 10, 36);
|
||||
$fingerprint .= $base36Char;
|
||||
}
|
||||
|
||||
return $fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create factory method
|
||||
*/
|
||||
public static function create(RandomGenerator $randomGenerator, ?string $customFingerprint = null): self
|
||||
{
|
||||
return new self($randomGenerator, $customFingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create with a deterministic fingerprint (for testing)
|
||||
*/
|
||||
public static function createWithFingerprint(RandomGenerator $randomGenerator, string $fingerprint): self
|
||||
{
|
||||
if (strlen($fingerprint) !== Cuid::FINGERPRINT_LENGTH) {
|
||||
throw new InvalidArgumentException('Fingerprint must be exactly ' . Cuid::FINGERPRINT_LENGTH . ' characters');
|
||||
}
|
||||
|
||||
return new self($randomGenerator, $fingerprint);
|
||||
}
|
||||
}
|
||||
91
src/Framework/Id/IdGeneratorFactory.php
Normal file
91
src/Framework/Id/IdGeneratorFactory.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Id\Contracts\IdGeneratorInterface;
|
||||
use App\Framework\Id\Cuid\CuidGenerator;
|
||||
use App\Framework\Id\Ksuid\KsuidGenerator;
|
||||
use App\Framework\Id\NanoId\NanoId;
|
||||
use App\Framework\Id\NanoId\NanoIdGenerator;
|
||||
use App\Framework\Id\Ulid\UlidGenerator;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
/**
|
||||
* Factory for creating ID generators
|
||||
*/
|
||||
final readonly class IdGeneratorFactory
|
||||
{
|
||||
public function __construct(
|
||||
private RandomGenerator $randomGenerator,
|
||||
private ?Clock $clock = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generator for the specified ID type
|
||||
*
|
||||
* @param IdType|string $type The ID type to create a generator for
|
||||
* @return IdGeneratorInterface
|
||||
*/
|
||||
public function create(IdType|string $type): IdGeneratorInterface
|
||||
{
|
||||
if (is_string($type)) {
|
||||
$type = IdType::fromString($type);
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
IdType::CUID => new CuidGenerator($this->randomGenerator),
|
||||
IdType::KSUID => new KsuidGenerator($this->randomGenerator),
|
||||
IdType::NANOID => new NanoIdGenerator($this->randomGenerator),
|
||||
IdType::ULID => new UlidGenerator($this->clock ?? new SystemClock()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CUID generator
|
||||
*/
|
||||
public function createCuid(?string $customFingerprint = null): CuidGenerator
|
||||
{
|
||||
return new CuidGenerator($this->randomGenerator, $customFingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a KSUID generator
|
||||
*/
|
||||
public function createKsuid(): KsuidGenerator
|
||||
{
|
||||
return new KsuidGenerator($this->randomGenerator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NanoId generator
|
||||
*/
|
||||
public function createNanoId(int $defaultSize = NanoId::DEFAULT_SIZE, string $defaultAlphabet = NanoId::DEFAULT_ALPHABET): NanoIdGenerator
|
||||
{
|
||||
return new NanoIdGenerator($this->randomGenerator, $defaultSize, $defaultAlphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ULID generator
|
||||
*/
|
||||
public function createUlid(?Clock $clock = null): UlidGenerator
|
||||
{
|
||||
return new UlidGenerator($clock ?? $this->clock ?? new SystemClock());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory with default dependencies
|
||||
*/
|
||||
public static function createDefault(): self
|
||||
{
|
||||
return new self(
|
||||
new SecureRandomGenerator(),
|
||||
new SystemClock()
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/Framework/Id/IdType.php
Normal file
56
src/Framework/Id/IdType.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id;
|
||||
|
||||
/**
|
||||
* Enum for ID types supported by the framework
|
||||
*/
|
||||
enum IdType: string
|
||||
{
|
||||
case CUID = 'cuid';
|
||||
case KSUID = 'ksuid';
|
||||
case NANOID = 'nanoid';
|
||||
case ULID = 'ulid';
|
||||
|
||||
/**
|
||||
* Get all available ID types
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string value is a valid ID type
|
||||
*/
|
||||
public static function isValid(string $value): bool
|
||||
{
|
||||
foreach (self::cases() as $case) {
|
||||
if ($case->value === strtolower($value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from string value
|
||||
*/
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
$value = strtolower($value);
|
||||
|
||||
foreach (self::cases() as $case) {
|
||||
if ($case->value === $value) {
|
||||
return $case;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Invalid ID type: {$value}. Valid types are: " . implode(', ', self::all()));
|
||||
}
|
||||
}
|
||||
305
src/Framework/Id/Ksuid/Ksuid.php
Normal file
305
src/Framework/Id/Ksuid/Ksuid.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Ksuid;
|
||||
|
||||
use App\Framework\Id\Contracts\IdInterface;
|
||||
use BcMath\Number;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* KSUID Value Object
|
||||
*
|
||||
* K-Sortable Unique Identifier
|
||||
* - 160-bit (27 characters Base62)
|
||||
* - 32-bit timestamp + 128-bit random payload
|
||||
* - Lexicographically sortable by creation time
|
||||
* - URL-safe, case-sensitive
|
||||
*/
|
||||
final readonly class Ksuid implements IdInterface, Stringable
|
||||
{
|
||||
public const int ENCODED_LENGTH = 27;
|
||||
public const int TIMESTAMP_BYTES = 4;
|
||||
public const int PAYLOAD_BYTES = 16;
|
||||
public const int TOTAL_BYTES = self::TIMESTAMP_BYTES + self::PAYLOAD_BYTES; // 20
|
||||
|
||||
// KSUID epoch: 2014-05-13T16:53:20Z (Unix timestamp 1400000000)
|
||||
public const int EPOCH = 1400000000;
|
||||
|
||||
// Base62 alphabet: 0-9, A-Z, a-z
|
||||
public const string ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
private string $value;
|
||||
|
||||
private int $timestamp;
|
||||
|
||||
private string $payload;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
throw new InvalidArgumentException('KSUID cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($value) !== self::ENCODED_LENGTH) {
|
||||
throw new InvalidArgumentException('KSUID must be exactly ' . self::ENCODED_LENGTH . ' characters long');
|
||||
}
|
||||
|
||||
if (! $this->isValidBase62($value)) {
|
||||
throw new InvalidArgumentException('KSUID contains invalid characters');
|
||||
}
|
||||
|
||||
$this->value = $value;
|
||||
$this->parseComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from raw bytes
|
||||
*/
|
||||
public static function fromBytes(string $bytes): self
|
||||
{
|
||||
if (strlen($bytes) !== self::TOTAL_BYTES) {
|
||||
throw new InvalidArgumentException('KSUID bytes must be exactly ' . self::TOTAL_BYTES . ' bytes');
|
||||
}
|
||||
|
||||
return new self(self::encodeBase62($bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from string
|
||||
*/
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from timestamp and payload
|
||||
*/
|
||||
public static function fromTimestampAndPayload(int $timestamp, string $payload): self
|
||||
{
|
||||
if (strlen($payload) !== self::PAYLOAD_BYTES) {
|
||||
throw new InvalidArgumentException('Payload must be exactly ' . self::PAYLOAD_BYTES . ' bytes');
|
||||
}
|
||||
|
||||
// Convert to KSUID timestamp (subtract epoch)
|
||||
$ksuidTimestamp = $timestamp - self::EPOCH;
|
||||
if ($ksuidTimestamp < 0) {
|
||||
throw new InvalidArgumentException('Timestamp cannot be before KSUID epoch');
|
||||
}
|
||||
|
||||
if ($ksuidTimestamp > 0xFFFFFFFF) {
|
||||
throw new InvalidArgumentException('Timestamp overflow');
|
||||
}
|
||||
|
||||
// Pack timestamp as big-endian 32-bit unsigned integer
|
||||
$timestampBytes = pack('N', $ksuidTimestamp);
|
||||
$bytes = $timestampBytes . $payload;
|
||||
|
||||
return self::fromBytes($bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string representation
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string value
|
||||
*/
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method for string conversion
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp component (Unix timestamp)
|
||||
*/
|
||||
public function getTimestamp(): int
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp as DateTime
|
||||
*/
|
||||
public function getDateTime(): DateTimeImmutable
|
||||
{
|
||||
return DateTimeImmutable::createFromFormat('U', (string)$this->timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw payload bytes
|
||||
*/
|
||||
public function getPayload(): string
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw bytes representation
|
||||
*/
|
||||
public function getBytes(): string
|
||||
{
|
||||
return self::decodeBase62($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another KSUID
|
||||
*/
|
||||
public function equals(IdInterface $other): bool
|
||||
{
|
||||
if (! $other instanceof self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another KSUID for sorting
|
||||
*/
|
||||
public function compare(self $other): int
|
||||
{
|
||||
return strcmp($this->value, $other->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this KSUID is older than another
|
||||
*/
|
||||
public function isOlderThan(self $other): bool
|
||||
{
|
||||
return $this->timestamp < $other->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this KSUID is newer than another
|
||||
*/
|
||||
public function isNewerThan(self $other): bool
|
||||
{
|
||||
return $this->timestamp > $other->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age in seconds
|
||||
*/
|
||||
public function getAgeInSeconds(): int
|
||||
{
|
||||
return time() - $this->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Base62 characters
|
||||
*/
|
||||
private function isValidBase62(string $value): bool
|
||||
{
|
||||
$pattern = '/^[' . preg_quote(self::ALPHABET, '/') . ']+$/';
|
||||
|
||||
return preg_match($pattern, $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timestamp and payload components from the value
|
||||
*/
|
||||
private function parseComponents(): void
|
||||
{
|
||||
$bytes = self::decodeBase62($this->value);
|
||||
|
||||
// Extract timestamp (first 4 bytes)
|
||||
$timestampBytes = substr($bytes, 0, self::TIMESTAMP_BYTES);
|
||||
$ksuidTimestamp = unpack('N', $timestampBytes)[1];
|
||||
$this->timestamp = $ksuidTimestamp + self::EPOCH;
|
||||
|
||||
// Extract payload (remaining 16 bytes)
|
||||
$this->payload = substr($bytes, self::TIMESTAMP_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode bytes to Base62
|
||||
*/
|
||||
private static function encodeBase62(string $bytes): string
|
||||
{
|
||||
// Convert bytes to big integer using BcMath\Number
|
||||
$num = new Number(0);
|
||||
$base = new Number(1);
|
||||
$base256 = new Number(256);
|
||||
|
||||
// Process each byte from right to left
|
||||
for ($i = strlen($bytes) - 1; $i >= 0; $i--) {
|
||||
$byte = new Number(ord($bytes[$i]));
|
||||
$num = $num->add($byte->mul($base));
|
||||
$base = $base->mul($base256);
|
||||
}
|
||||
|
||||
// Convert to Base62
|
||||
$alphabet = self::ALPHABET;
|
||||
$encoded = '';
|
||||
$base62 = new Number(62);
|
||||
$zero = new Number(0);
|
||||
|
||||
if ($num->compare($zero) === 0) {
|
||||
return str_repeat($alphabet[0], self::ENCODED_LENGTH);
|
||||
}
|
||||
|
||||
while ($num->compare($zero) > 0) {
|
||||
[$quotient, $remainder] = $num->divmod($base62);
|
||||
$encoded = $alphabet[(int)$remainder->__toString()] . $encoded;
|
||||
$num = $quotient;
|
||||
}
|
||||
|
||||
// Pad with leading zeros if necessary
|
||||
return str_pad($encoded, self::ENCODED_LENGTH, $alphabet[0], STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Base62 to bytes
|
||||
*/
|
||||
private static function decodeBase62(string $encoded): string
|
||||
{
|
||||
$alphabet = self::ALPHABET;
|
||||
$base62 = new Number(62);
|
||||
$num = new Number(0);
|
||||
$multiplier = new Number(1);
|
||||
|
||||
// Convert from Base62 to big integer
|
||||
for ($i = strlen($encoded) - 1; $i >= 0; $i--) {
|
||||
$char = $encoded[$i];
|
||||
$value = strpos($alphabet, $char);
|
||||
|
||||
if ($value === false) {
|
||||
throw new InvalidArgumentException('Invalid Base62 character: ' . $char);
|
||||
}
|
||||
|
||||
$num = $num->add(new Number($value)->mul($multiplier));
|
||||
$multiplier = $multiplier->mul($base62);
|
||||
}
|
||||
|
||||
// Convert big integer to bytes
|
||||
$bytes = '';
|
||||
$base256 = new Number(256);
|
||||
$zero = new Number(0);
|
||||
|
||||
while ($num->compare($zero) > 0) {
|
||||
[$quotient, $remainder] = $num->divmod($base256);
|
||||
$bytes = chr((int)$remainder->__toString()) . $bytes;
|
||||
$num = $quotient;
|
||||
}
|
||||
|
||||
// Pad with leading zero bytes if necessary
|
||||
return str_pad($bytes, self::TOTAL_BYTES, "\0", STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
209
src/Framework/Id/Ksuid/KsuidGenerator.php
Normal file
209
src/Framework/Id/Ksuid/KsuidGenerator.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Ksuid;
|
||||
|
||||
use App\Framework\Id\Contracts\IdGeneratorInterface;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* KSUID Generator Service
|
||||
*
|
||||
* Generates K-Sortable Unique Identifiers with timestamp ordering.
|
||||
*/
|
||||
final readonly class KsuidGenerator implements IdGeneratorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private RandomGenerator $randomGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new KSUID with current timestamp
|
||||
*/
|
||||
public function generate(): Ksuid
|
||||
{
|
||||
return $this->generateAt(time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a KSUID at a specific Unix timestamp
|
||||
*/
|
||||
public function generateAt(int $timestamp): Ksuid
|
||||
{
|
||||
if ($timestamp < Ksuid::EPOCH) {
|
||||
throw new InvalidArgumentException('Timestamp cannot be before KSUID epoch (2014-05-13)');
|
||||
}
|
||||
|
||||
// Generate 16 random bytes for payload
|
||||
$payload = $this->randomGenerator->bytes(Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
return Ksuid::fromTimestampAndPayload($timestamp, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a KSUID at a specific DateTime
|
||||
*/
|
||||
public function generateAtDateTime(DateTimeImmutable $dateTime): Ksuid
|
||||
{
|
||||
return $this->generateAt($dateTime->getTimestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a KSUID for the past (useful for testing)
|
||||
*/
|
||||
public function generateInPast(int $secondsAgo): Ksuid
|
||||
{
|
||||
$timestamp = time() - $secondsAgo;
|
||||
|
||||
return $this->generateAt($timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a batch of KSUIDs with the same timestamp
|
||||
*/
|
||||
public function generateBatch(int $count, ?int $timestamp = null): array
|
||||
{
|
||||
if ($count <= 0) {
|
||||
throw new InvalidArgumentException('Count must be positive');
|
||||
}
|
||||
|
||||
if ($count > 10000) {
|
||||
throw new InvalidArgumentException('Batch size cannot exceed 10000');
|
||||
}
|
||||
|
||||
$timestamp ??= time();
|
||||
$ksuids = [];
|
||||
$used = [];
|
||||
|
||||
while (count($ksuids) < $count) {
|
||||
$payload = $this->randomGenerator->bytes(Ksuid::PAYLOAD_BYTES);
|
||||
$payloadHex = bin2hex($payload);
|
||||
|
||||
// Ensure uniqueness within batch
|
||||
if (! isset($used[$payloadHex])) {
|
||||
$ksuids[] = Ksuid::fromTimestampAndPayload($timestamp, $payload);
|
||||
$used[$payloadHex] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $ksuids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sequence of KSUIDs with incrementing timestamps
|
||||
*/
|
||||
public function generateSequence(int $count, int $intervalSeconds = 1): array
|
||||
{
|
||||
if ($count <= 0) {
|
||||
throw new InvalidArgumentException('Count must be positive');
|
||||
}
|
||||
|
||||
if ($count > 1000) {
|
||||
throw new InvalidArgumentException('Sequence size cannot exceed 1000');
|
||||
}
|
||||
|
||||
if ($intervalSeconds < 0) {
|
||||
throw new InvalidArgumentException('Interval must be non-negative');
|
||||
}
|
||||
|
||||
$ksuids = [];
|
||||
$timestamp = time();
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$ksuids[] = $this->generateAt($timestamp + ($i * $intervalSeconds));
|
||||
}
|
||||
|
||||
return $ksuids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a KSUID with specific prefix in payload (for testing/debugging)
|
||||
*/
|
||||
public function generateWithPrefix(string $prefix): Ksuid
|
||||
{
|
||||
$maxPrefixLength = Ksuid::PAYLOAD_BYTES - 1; // Reserve at least 1 byte for randomness
|
||||
|
||||
if (strlen($prefix) > $maxPrefixLength) {
|
||||
throw new InvalidArgumentException("Prefix cannot exceed {$maxPrefixLength} bytes");
|
||||
}
|
||||
|
||||
$remainingBytes = Ksuid::PAYLOAD_BYTES - strlen($prefix);
|
||||
$randomSuffix = $this->randomGenerator->bytes($remainingBytes);
|
||||
$payload = $prefix . $randomSuffix;
|
||||
|
||||
return Ksuid::fromTimestampAndPayload(time(), $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a KSUID string and validate it
|
||||
*/
|
||||
public function parse(string $ksuidString): Ksuid
|
||||
{
|
||||
return Ksuid::fromString($ksuidString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid KSUID
|
||||
*/
|
||||
public function isValid(string $value): bool
|
||||
{
|
||||
if (strlen($value) !== Ksuid::ENCODED_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Ksuid::fromString($value);
|
||||
|
||||
return true;
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum possible KSUID for a timestamp
|
||||
*/
|
||||
public function getMinForTimestamp(int $timestamp): Ksuid
|
||||
{
|
||||
$payload = str_repeat("\0", Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
return Ksuid::fromTimestampAndPayload($timestamp, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum possible KSUID for a timestamp
|
||||
*/
|
||||
public function getMaxForTimestamp(int $timestamp): Ksuid
|
||||
{
|
||||
$payload = str_repeat("\xFF", Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
return Ksuid::fromTimestampAndPayload($timestamp, $payload);
|
||||
}
|
||||
|
||||
/**@return array{min: \App\Framework\Id\Ksuid\Ksuid, max: \App\Framework\Id\Ksuid\Ksuid}
|
||||
* Generate KSUIDs for a time range (useful for queries)
|
||||
*/
|
||||
public function generateTimeRange(int $startTimestamp, int $endTimestamp): array
|
||||
{
|
||||
if ($startTimestamp >= $endTimestamp) {
|
||||
throw new InvalidArgumentException('Start timestamp must be before end timestamp');
|
||||
}
|
||||
|
||||
return [
|
||||
'min' => $this->getMinForTimestamp($startTimestamp),
|
||||
'max' => $this->getMaxForTimestamp($endTimestamp),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create factory method
|
||||
*/
|
||||
public static function create(RandomGenerator $randomGenerator): self
|
||||
{
|
||||
return new self($randomGenerator);
|
||||
}
|
||||
}
|
||||
174
src/Framework/Id/NanoId/NanoId.php
Normal file
174
src/Framework/Id/NanoId/NanoId.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\NanoId;
|
||||
|
||||
use App\Framework\Id\Contracts\IdInterface;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* NanoId Value Object
|
||||
*
|
||||
* A URL-safe, unique string ID generator.
|
||||
* Default alphabet: A-Za-z0-9_-
|
||||
* Default size: 21 characters
|
||||
*/
|
||||
final readonly class NanoId implements IdInterface
|
||||
{
|
||||
public const int DEFAULT_SIZE = 21;
|
||||
public const string DEFAULT_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-';
|
||||
|
||||
// URL-safe alphabet without lookalikes
|
||||
public const string SAFE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnpqrstwxyz';
|
||||
|
||||
// Numbers only
|
||||
public const string NUMBERS = '0123456789';
|
||||
|
||||
// Lowercase alphanumeric
|
||||
public const string LOWERCASE_ALPHANUMERIC = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
// Uppercase alphanumeric
|
||||
public const string UPPERCASE_ALPHANUMERIC = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
private string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
throw new InvalidArgumentException('NanoId cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($value) > 255) {
|
||||
throw new InvalidArgumentException('NanoId cannot exceed 255 characters');
|
||||
}
|
||||
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from an existing string
|
||||
*/
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string matches a specific alphabet
|
||||
*/
|
||||
public function matchesAlphabet(string $alphabet): bool
|
||||
{
|
||||
$pattern = '/^[' . preg_quote($alphabet, '/') . ']+$/';
|
||||
|
||||
return preg_match($pattern, $this->value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a valid default NanoId
|
||||
*/
|
||||
public function isDefault(): bool
|
||||
{
|
||||
return $this->matchesAlphabet(self::DEFAULT_ALPHABET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a safe NanoId (no lookalikes)
|
||||
*/
|
||||
public function isSafe(): bool
|
||||
{
|
||||
return $this->matchesAlphabet(self::SAFE_ALPHABET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a numeric NanoId
|
||||
*/
|
||||
public function isNumeric(): bool
|
||||
{
|
||||
return ctype_digit($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the length of the NanoId
|
||||
*/
|
||||
public function getLength(): int
|
||||
{
|
||||
return strlen($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string value
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string value
|
||||
*/
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method for string conversion
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another NanoId
|
||||
*/
|
||||
public function equals(IdInterface $other): bool
|
||||
{
|
||||
if (! $other instanceof self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a prefixed NanoId
|
||||
*/
|
||||
public function withPrefix(string $prefix): self
|
||||
{
|
||||
if (empty($prefix)) {
|
||||
throw new InvalidArgumentException('Prefix cannot be empty');
|
||||
}
|
||||
|
||||
return new self($prefix . $this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a suffixed NanoId
|
||||
*/
|
||||
public function withSuffix(string $suffix): self
|
||||
{
|
||||
if (empty($suffix)) {
|
||||
throw new InvalidArgumentException('Suffix cannot be empty');
|
||||
}
|
||||
|
||||
return new self($this->value . $suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a truncated version of the NanoId
|
||||
*/
|
||||
public function truncate(int $length): self
|
||||
{
|
||||
if ($length <= 0) {
|
||||
throw new InvalidArgumentException('Length must be positive');
|
||||
}
|
||||
|
||||
if ($length >= strlen($this->value)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self(substr($this->value, 0, $length));
|
||||
}
|
||||
}
|
||||
247
src/Framework/Id/NanoId/NanoIdGenerator.php
Normal file
247
src/Framework/Id/NanoId/NanoIdGenerator.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\NanoId;
|
||||
|
||||
use App\Framework\Id\Contracts\IdGeneratorInterface;
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* NanoId Generator Service
|
||||
*
|
||||
* Provides flexible NanoId generation with various presets and configurations.
|
||||
*/
|
||||
final readonly class NanoIdGenerator implements IdGeneratorInterface
|
||||
{
|
||||
private int $defaultSize;
|
||||
|
||||
private string $defaultAlphabet;
|
||||
|
||||
public function __construct(
|
||||
private RandomGenerator $randomGenerator,
|
||||
int $defaultSize = NanoId::DEFAULT_SIZE,
|
||||
string $defaultAlphabet = NanoId::DEFAULT_ALPHABET
|
||||
) {
|
||||
if ($defaultSize <= 0 || $defaultSize > 255) {
|
||||
throw new InvalidArgumentException('Default size must be between 1 and 255');
|
||||
}
|
||||
|
||||
if (empty($defaultAlphabet)) {
|
||||
throw new InvalidArgumentException('Default alphabet cannot be empty');
|
||||
}
|
||||
|
||||
$this->defaultSize = $defaultSize;
|
||||
$this->defaultAlphabet = $defaultAlphabet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a NanoId with default settings
|
||||
*/
|
||||
public function generate(): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($this->defaultSize, $this->defaultAlphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a NanoId with custom size
|
||||
*/
|
||||
public function generateWithSize(int $size): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($size, $this->defaultAlphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a NanoId with custom alphabet
|
||||
*/
|
||||
public function generateWithAlphabet(string $alphabet): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($this->defaultSize, $alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a NanoId with custom size and alphabet
|
||||
*/
|
||||
public function generateCustom(int $size, string $alphabet): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($size, $alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core NanoId generation logic
|
||||
*/
|
||||
private function generateWithCustom(int $size, string $alphabet): NanoId
|
||||
{
|
||||
if ($size <= 0 || $size > 255) {
|
||||
throw new InvalidArgumentException('Size must be between 1 and 255');
|
||||
}
|
||||
|
||||
if (empty($alphabet)) {
|
||||
throw new InvalidArgumentException('Alphabet cannot be empty');
|
||||
}
|
||||
|
||||
$alphabetLength = strlen($alphabet);
|
||||
if ($alphabetLength > 255) {
|
||||
throw new InvalidArgumentException('Alphabet cannot exceed 255 characters');
|
||||
}
|
||||
|
||||
$id = '';
|
||||
$mask = (2 << (int)(log($alphabetLength - 1) / M_LN2)) - 1;
|
||||
$step = (int)ceil(1.6 * $mask * $size / $alphabetLength);
|
||||
|
||||
while (strlen($id) < $size) {
|
||||
$bytes = $this->randomGenerator->bytes($step);
|
||||
|
||||
for ($i = 0; $i < $step && strlen($id) < $size; $i++) {
|
||||
$byte = ord($bytes[$i]) & $mask;
|
||||
|
||||
if ($byte < $alphabetLength) {
|
||||
$id .= $alphabet[$byte];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NanoId::fromString($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a safe NanoId (no lookalikes)
|
||||
*/
|
||||
public function generateSafe(int $size = NanoId::DEFAULT_SIZE): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($size, NanoId::SAFE_ALPHABET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a numeric NanoId
|
||||
*/
|
||||
public function generateNumeric(int $size = 12): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($size, NanoId::NUMBERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a lowercase alphanumeric NanoId
|
||||
*/
|
||||
public function generateLowercase(int $size = NanoId::DEFAULT_SIZE): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($size, NanoId::LOWERCASE_ALPHANUMERIC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an uppercase alphanumeric NanoId
|
||||
*/
|
||||
public function generateUppercase(int $size = NanoId::DEFAULT_SIZE): NanoId
|
||||
{
|
||||
return $this->generateWithCustom($size, NanoId::UPPERCASE_ALPHANUMERIC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a NanoId for a specific entity type with prefix
|
||||
*/
|
||||
public function generateForEntity(string $entityType, int $size = 16): NanoId
|
||||
{
|
||||
$prefix = match($entityType) {
|
||||
'user' => 'usr_',
|
||||
'order' => 'ord_',
|
||||
'product' => 'prd_',
|
||||
'session' => 'ses_',
|
||||
'token' => 'tok_',
|
||||
'transaction' => 'txn_',
|
||||
'invoice' => 'inv_',
|
||||
'customer' => 'cus_',
|
||||
'payment' => 'pay_',
|
||||
'subscription' => 'sub_',
|
||||
default => strtolower(substr($entityType, 0, 3)) . '_'
|
||||
};
|
||||
|
||||
$id = $this->generateWithCustom($size, NanoId::DEFAULT_ALPHABET);
|
||||
|
||||
return $id->withPrefix($prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a time-prefixed NanoId (sortable by creation time)
|
||||
*/
|
||||
public function generateTimePrefixed(int $idSize = 12): NanoId
|
||||
{
|
||||
// Use base36 timestamp for compactness
|
||||
$timestamp = base_convert((string)time(), 10, 36);
|
||||
$id = $this->generateWithCustom($idSize, NanoId::LOWERCASE_ALPHANUMERIC);
|
||||
|
||||
return $id->withPrefix($timestamp . '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a batch of unique NanoIds
|
||||
*/
|
||||
public function generateBatch(int $count, int $size = NanoId::DEFAULT_SIZE): array
|
||||
{
|
||||
if ($count <= 0) {
|
||||
throw new InvalidArgumentException('Count must be positive');
|
||||
}
|
||||
|
||||
if ($count > 10000) {
|
||||
throw new InvalidArgumentException('Batch size cannot exceed 10000');
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
$generated = [];
|
||||
|
||||
while (count($ids) < $count) {
|
||||
$id = $this->generateWithCustom($size, $this->defaultAlphabet);
|
||||
$value = $id->toString();
|
||||
|
||||
// Ensure uniqueness within batch
|
||||
if (! isset($generated[$value])) {
|
||||
$ids[] = $id;
|
||||
$generated[$value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid NanoId for the current configuration
|
||||
*/
|
||||
public function isValid(string $value): bool
|
||||
{
|
||||
if (empty($value) || strlen($value) > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$nanoId = NanoId::fromString($value);
|
||||
|
||||
return $nanoId->matchesAlphabet($this->defaultAlphabet);
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom generator with specific settings
|
||||
*/
|
||||
public static function create(RandomGenerator $randomGenerator, int $size = NanoId::DEFAULT_SIZE, string $alphabet = NanoId::DEFAULT_ALPHABET): self
|
||||
{
|
||||
return new self($randomGenerator, $size, $alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generator for safe IDs (no lookalikes)
|
||||
*/
|
||||
public static function createSafe(RandomGenerator $randomGenerator, int $size = NanoId::DEFAULT_SIZE): self
|
||||
{
|
||||
return new self($randomGenerator, $size, NanoId::SAFE_ALPHABET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generator for numeric IDs
|
||||
*/
|
||||
public static function createNumeric(RandomGenerator $randomGenerator, int $size = 12): self
|
||||
{
|
||||
return new self($randomGenerator, $size, NanoId::NUMBERS);
|
||||
}
|
||||
}
|
||||
56
src/Framework/Id/Ulid/StringConverter.php
Normal file
56
src/Framework/Id/Ulid/StringConverter.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Ulid;
|
||||
|
||||
use App\Framework\Core\Encoding\Base32Alphabet;
|
||||
use App\Framework\Core\Encoding\Base32Encoder;
|
||||
|
||||
/**
|
||||
* ULID String Converter using Crockford's Base32 alphabet
|
||||
*
|
||||
* Migrated to use the framework's Base32Encoder with Crockford alphabet
|
||||
* for consistency and better maintainability.
|
||||
*/
|
||||
final readonly class StringConverter
|
||||
{
|
||||
/**
|
||||
* Encodes a binary string into a Base32 encoded string using Crockford's alphabet
|
||||
*
|
||||
* @param string $binary The binary string to be encoded.
|
||||
* @return string The Base32 encoded string.
|
||||
*/
|
||||
public function encodeBase32(string $binary): string
|
||||
{
|
||||
return Base32Encoder::encodeCrockford($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a Base32-encoded string into its binary representation
|
||||
*
|
||||
* @param string $base32 The Base32-encoded string to decode.
|
||||
* @return string The binary representation of the decoded string.
|
||||
* @throws \InvalidArgumentException If the provided string contains invalid Base32 characters.
|
||||
*/
|
||||
public function decodeBase32(string $base32): string
|
||||
{
|
||||
return Base32Encoder::decodeCrockford($base32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if string is valid Crockford Base32
|
||||
*/
|
||||
public function isValidBase32(string $base32): bool
|
||||
{
|
||||
return Base32Alphabet::CROCKFORD->isValidEncoded($base32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Crockford Base32 alphabet used by ULID
|
||||
*/
|
||||
public function getAlphabet(): string
|
||||
{
|
||||
return Base32Alphabet::CROCKFORD->getAlphabet();
|
||||
}
|
||||
}
|
||||
132
src/Framework/Id/Ulid/Ulid.php
Normal file
132
src/Framework/Id/Ulid/Ulid.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Ulid;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Id\Contracts\IdInterface;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Objekt-Wrapper für ULIDs mit String-/JSON-API.
|
||||
*/
|
||||
final readonly class Ulid implements IdInterface, JsonSerializable
|
||||
{
|
||||
private string $ulid;
|
||||
|
||||
private StringConverter $converter;
|
||||
|
||||
public function __construct(
|
||||
private Clock $clock,
|
||||
?string $ulid = null,
|
||||
bool $isBase32 = false,
|
||||
) {
|
||||
$gen = new UlidGenerator();
|
||||
$validator = new UlidValidator();
|
||||
$this->converter = new StringConverter();
|
||||
|
||||
if ($ulid === null) {
|
||||
|
||||
$this->ulid = $gen->generate($this->clock);
|
||||
|
||||
} else {
|
||||
|
||||
if ($isBase32) {
|
||||
$this->ulid = $this->converter->encodeBase32($ulid);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (! $validator->isValid($ulid)) {
|
||||
throw new InvalidArgumentException("Ungültige ULID: $ulid");
|
||||
}
|
||||
$this->ulid = $ulid;
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromBase32(Clock $clock, string $ulid): self
|
||||
{
|
||||
return new self($clock, $ulid, true);
|
||||
}
|
||||
|
||||
public function toBinary(): string
|
||||
{
|
||||
return $this->converter->decodeBase32($this->ulid);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->ulid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->ulid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->ulid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function equals(IdInterface $other): bool
|
||||
{
|
||||
if (! $other instanceof self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->ulid === $other->ulid;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->ulid;
|
||||
}
|
||||
|
||||
public function getTimestampMs(): int
|
||||
{
|
||||
$parser = new ULIDParser(new ULIDValidator());
|
||||
|
||||
return $parser->getTimestampMs($this->ulid);
|
||||
}
|
||||
|
||||
public function getDateTime(): DateTimeImmutable
|
||||
{
|
||||
$parser = new ULIDParser(new ULIDValidator());
|
||||
|
||||
return $parser->getDateTime($this->ulid, $this->clock);
|
||||
}
|
||||
|
||||
public static function isValid(string $ulid): bool
|
||||
{
|
||||
return new ULIDValidator()->isValid($ulid);
|
||||
}
|
||||
|
||||
public static function fromString(Clock $clock, string $ulid): self
|
||||
{
|
||||
return new self($clock, $ulid);
|
||||
}
|
||||
|
||||
public function __debugInfo(): ?array
|
||||
{
|
||||
return [
|
||||
'ulid' => $this->ulid,
|
||||
'timestamp' => $this->getTimestampMs(),
|
||||
'clock' => $this->clock,
|
||||
'datetime' => $this->getDateTime(),
|
||||
];
|
||||
}
|
||||
}
|
||||
95
src/Framework/Id/Ulid/UlidGenerator.php
Normal file
95
src/Framework/Id/Ulid/UlidGenerator.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Ulid;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Id\Contracts\IdGeneratorInterface;
|
||||
|
||||
/**
|
||||
* ULID Generator - Universally Unique Lexicographically Sortable Identifier
|
||||
*
|
||||
* Generates 26-character, timestamp-based, sortable unique identifiers.
|
||||
* Drop-in replacement for deprecated uniqid() function.
|
||||
*
|
||||
* Usage:
|
||||
* - Production: new UlidGenerator() - uses SystemClock automatically
|
||||
* - Testing: new UlidGenerator($mockClock) - inject mock for deterministic tests
|
||||
*/
|
||||
final readonly class UlidGenerator implements IdGeneratorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ?Clock $clock = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a new ULID
|
||||
*
|
||||
* @return string 26-character ULID (Base32 encoded)
|
||||
*/
|
||||
public function generate(): string
|
||||
{
|
||||
$clock = $this->clock ?? new SystemClock();
|
||||
$stringConverter = new StringConverter();
|
||||
|
||||
// Get timestamp in milliseconds (ULID uses millisecond precision)
|
||||
$timestamp = $clock->now()->getTimestamp() . $clock->now()->getMicrosecond();
|
||||
$time = (int)$timestamp / 1000;
|
||||
|
||||
// Pack timestamp as 48-bit big-endian integer
|
||||
$timeBin = substr(pack('J', $time), 2, 6);
|
||||
|
||||
// 80 bits of cryptographically secure randomness
|
||||
$random = random_bytes(10);
|
||||
|
||||
// Combine timestamp and random bytes
|
||||
$bin = $timeBin . $random;
|
||||
|
||||
return $stringConverter->encodeBase32($bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a ULID with a prefix
|
||||
*
|
||||
* Useful for namespacing/categorizing IDs (e.g., "user_01ARZ3NDEKTSV4...")
|
||||
*
|
||||
* @param string $prefix Prefix to prepend (without separator)
|
||||
* @return string Prefixed ULID
|
||||
*/
|
||||
public function generateWithPrefix(string $prefix): string
|
||||
{
|
||||
return $prefix . '_' . $this->generate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function generateBatch(int $count): array
|
||||
{
|
||||
if ($count <= 0) {
|
||||
throw new \InvalidArgumentException('Count must be positive');
|
||||
}
|
||||
|
||||
if ($count > 10000) {
|
||||
throw new \InvalidArgumentException('Batch size cannot exceed 10000');
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$ids[] = $this->generate();
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function isValid(string $value): bool
|
||||
{
|
||||
return \App\Framework\Id\Ulid\Ulid::isValid($value);
|
||||
}
|
||||
}
|
||||
77
src/Framework/Id/Ulid/UlidParser.php
Normal file
77
src/Framework/Id/Ulid/UlidParser.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Ulid;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Extrahiert Zeitstempel aus einer validen ULID.
|
||||
*/
|
||||
final readonly class UlidParser
|
||||
{
|
||||
public function __construct(
|
||||
private UlidValidator $validator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $ulid
|
||||
* @return int Millisekunden seit Unix-Epoch
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getTimestampMs(string $ulid): int
|
||||
{
|
||||
if (! $this->validator->isValid($ulid)) {
|
||||
throw new InvalidArgumentException("Ungültige ULID: $ulid");
|
||||
}
|
||||
|
||||
// erste 10 Zeichen = 10×5=50 Bit, wir brauchen die ersten 48 Bit
|
||||
$bits = '';
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$bits .= str_pad(decbin($this->charValue($ulid[$i])), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$timeBits = substr($bits, 0, 48);
|
||||
|
||||
return bindec($timeBits);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $ulid
|
||||
* @param Clock $clock
|
||||
* @return DateTimeImmutable
|
||||
*/
|
||||
public function getDateTime(string $ulid, Clock $clock): DateTimeImmutable
|
||||
{
|
||||
$ms = $this->getTimestampMs($ulid);
|
||||
$sec = floor($ms / 1000);
|
||||
$usec = ($ms % 1000) * 1000;
|
||||
|
||||
return $clock->fromString(sprintf("%d %06d", $sec, $usec), 'U u');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Base32 character into its corresponding integer value.
|
||||
*
|
||||
* @param string $char The Base32 character to convert.
|
||||
* @return int The integer value of the specified Base32 character.
|
||||
* @throws InvalidArgumentException If the provided character is not a valid Base32 character.
|
||||
*/
|
||||
private function charValue(string $char): int
|
||||
{
|
||||
static $map = null;
|
||||
if ($map === null) {
|
||||
$alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
$map = array_flip(str_split($alphabet));
|
||||
}
|
||||
$upper = strtoupper($char);
|
||||
if (! isset($map[$upper])) {
|
||||
throw new InvalidArgumentException("Ungültiges Base32-Zeichen: $char");
|
||||
}
|
||||
|
||||
return $map[$upper];
|
||||
}
|
||||
}
|
||||
20
src/Framework/Id/Ulid/UlidValidator.php
Normal file
20
src/Framework/Id/Ulid/UlidValidator.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id\Ulid;
|
||||
|
||||
/**
|
||||
* Validiert ULID-Strings.
|
||||
*/
|
||||
final readonly class UlidValidator
|
||||
{
|
||||
/**
|
||||
* @param string $ulid
|
||||
* @return bool true, wenn $ulid exakt 26 Zeichen Crockford-Base32 sind
|
||||
*/
|
||||
public function isValid(string $ulid): bool
|
||||
{
|
||||
return (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', $ulid);
|
||||
}
|
||||
}
|
||||
121
src/Framework/Id/UnifiedIdGenerator.php
Normal file
121
src/Framework/Id/UnifiedIdGenerator.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Id;
|
||||
|
||||
use App\Framework\Id\Contracts\IdGeneratorInterface;
|
||||
use App\Framework\Id\Contracts\IdInterface;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Unified ID Generator
|
||||
*
|
||||
* A wrapper that can generate any type of ID based on configuration.
|
||||
* Provides a single interface for generating different ID formats.
|
||||
*/
|
||||
final readonly class UnifiedIdGenerator implements IdGeneratorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private IdGeneratorFactory $factory,
|
||||
private IdType $defaultType = IdType::ULID
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new ID using the default type
|
||||
*/
|
||||
public function generate(): IdInterface|string
|
||||
{
|
||||
return $this->generateWithType($this->defaultType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new ID of the specified type
|
||||
*
|
||||
* @param IdType|string $type The ID type to generate
|
||||
* @return IdInterface|string
|
||||
*/
|
||||
public function generateWithType(IdType|string $type): IdInterface|string
|
||||
{
|
||||
$generator = $this->factory->create($type);
|
||||
|
||||
return $generator->generate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a batch of IDs using the default type
|
||||
*
|
||||
* @param int $count Number of IDs to generate
|
||||
* @return array<int, IdInterface|string>
|
||||
*/
|
||||
public function generateBatch(int $count): array
|
||||
{
|
||||
return $this->generateBatchWithType($count, $this->defaultType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a batch of IDs of the specified type
|
||||
*
|
||||
* @param int $count Number of IDs to generate
|
||||
* @param IdType|string $type The ID type to generate
|
||||
* @return array<int, IdInterface|string>
|
||||
*/
|
||||
public function generateBatchWithType(int $count, IdType|string $type): array
|
||||
{
|
||||
$generator = $this->factory->create($type);
|
||||
|
||||
return $generator->generateBatch($count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid ID for the default type
|
||||
*/
|
||||
public function isValid(string $value): bool
|
||||
{
|
||||
return $this->isValidForType($value, $this->defaultType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid ID for the specified type
|
||||
*
|
||||
* @param string $value The ID string to validate
|
||||
* @param IdType|string $type The ID type to validate against
|
||||
*/
|
||||
public function isValidForType(string $value, IdType|string $type): bool
|
||||
{
|
||||
$generator = $this->factory->create($type);
|
||||
|
||||
return $generator->isValid($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default ID type
|
||||
*/
|
||||
public function getDefaultType(): IdType
|
||||
{
|
||||
return $this->defaultType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unified generator with default settings
|
||||
*/
|
||||
public static function createDefault(?IdType $defaultType = null): self
|
||||
{
|
||||
return new self(
|
||||
IdGeneratorFactory::createDefault(),
|
||||
$defaultType ?? IdType::ULID
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unified generator with a custom factory
|
||||
*/
|
||||
public static function create(IdGeneratorFactory $factory, ?IdType $defaultType = null): self
|
||||
{
|
||||
return new self(
|
||||
$factory,
|
||||
$defaultType ?? IdType::ULID
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user