diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 1f363f6..2ed7565 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -16,7 +16,7 @@ jobs: actions: read contents: read with: - php_versions: '["8.3","8.4","8.5"]' + php_versions: '["8.2","8.3","8.4","8.5"]' dependency_versions: '["prefer-lowest","prefer-stable"]' php_extensions: "bcmath" coverage: "xdebug" @@ -24,3 +24,5 @@ jobs: phpstan_memory_limit: "1G" psalm_threads: "1" run_analysis: true + run_svg_report: true + artifact_retention_days: 61 diff --git a/captainhook.json b/captainhook.json index c52f64a..782a292 100644 --- a/captainhook.json +++ b/captainhook.json @@ -23,7 +23,7 @@ "options": [] }, { - "action": "composer ic:tests", + "action": "composer ic:ci", "options": [] } ] diff --git a/src/Configuration/ResolvesCustomEpoch.php b/src/Configuration/ResolvesCustomEpoch.php new file mode 100644 index 0000000..b4b7b75 --- /dev/null +++ b/src/Configuration/ResolvesCustomEpoch.php @@ -0,0 +1,34 @@ +customEpoch); + } + + private static function resolveEpochValue(DateTimeInterface|int|string|null $customEpoch): ?int + { + if ($customEpoch === null) { + return null; + } + + if ($customEpoch instanceof DateTimeInterface) { + return (int) $customEpoch->format('Uv'); + } + + if (is_int($customEpoch)) { + return $customEpoch; + } + + $epoch = strtotime($customEpoch); + + return $epoch === false ? null : $epoch * 1000; + } +} diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php index cf5f0fd..3812699 100644 --- a/src/Configuration/SnowflakeConfig.php +++ b/src/Configuration/SnowflakeConfig.php @@ -12,6 +12,8 @@ final readonly class SnowflakeConfig { + use ResolvesCustomEpoch; + private ?Closure $nodeResolver; /** @@ -30,25 +32,6 @@ public function __construct( $this->nodeResolver = $nodeResolver ? $nodeResolver(...) : null; } - public function resolveCustomEpochMs(): ?int - { - if ($this->customEpoch === null) { - return null; - } - - if ($this->customEpoch instanceof DateTimeInterface) { - return (int) $this->customEpoch->format('Uv'); - } - - if (is_int($this->customEpoch)) { - return $this->customEpoch; - } - - $epoch = strtotime($this->customEpoch); - - return $epoch === false ? null : $epoch * 1000; - } - /** * @return array{0:int,1:int} */ diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php index c38ac86..52dc8b3 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -12,6 +12,8 @@ final readonly class SonyflakeConfig { + use ResolvesCustomEpoch; + private ?Closure $machineIdResolver; /** @@ -28,25 +30,6 @@ public function __construct( $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; } - public function resolveCustomEpochMs(): ?int - { - if ($this->customEpoch === null) { - return null; - } - - if ($this->customEpoch instanceof DateTimeInterface) { - return (int) $this->customEpoch->format('Uv'); - } - - if (is_int($this->customEpoch)) { - return $this->customEpoch; - } - - $epoch = strtotime($this->customEpoch); - - return $epoch === false ? null : $epoch * 1000; - } - public function resolveMachineId(): int { if ($this->machineIdResolver === null) { diff --git a/src/IdComparator.php b/src/IdComparator.php index 8ad9c5f..d702f0f 100644 --- a/src/IdComparator.php +++ b/src/IdComparator.php @@ -5,6 +5,7 @@ namespace Infocyph\UID; use Infocyph\UID\Contracts\IdValueInterface; +use Infocyph\UID\Support\UnsignedDecimal; final class IdComparator { @@ -17,7 +18,7 @@ public static function compare(IdValueInterface|string $left, IdValueInterface|s $rightString = $right instanceof IdValueInterface ? $right->toString() : $right; if (preg_match('/^\d+$/', $leftString) && preg_match('/^\d+$/', $rightString)) { - return self::compareUnsignedDecimals($leftString, $rightString); + return UnsignedDecimal::compare($leftString, $rightString); } return strcmp($leftString, $rightString); @@ -35,19 +36,4 @@ public static function sort(array $ids): array return $ids; } - - private static function compareUnsignedDecimals(string $left, string $right): int - { - $left = ltrim($left, '0'); - $right = ltrim($right, '0'); - $left = $left === '' ? '0' : $left; - $right = $right === '' ? '0' : $right; - - $lengthComparison = strlen($left) <=> strlen($right); - if ($lengthComparison !== 0) { - return $lengthComparison; - } - - return strcmp($left, $right); - } } diff --git a/src/KSUID.php b/src/KSUID.php index f798222..88bc86f 100644 --- a/src/KSUID.php +++ b/src/KSUID.php @@ -9,6 +9,7 @@ use Exception; use Infocyph\UID\Contracts\IdAlgorithmInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\BinaryUnpack; final class KSUID implements IdAlgorithmInterface { @@ -60,11 +61,7 @@ public static function parse(string $ksuid): array } $bytes = self::toBytes($ksuid); - $unpackedTimestamp = unpack('N', substr($bytes, 0, 4)); - ($unpackedTimestamp !== false) || throw new Exception('Unable to parse KSUID timestamp'); - $timestampValue = $unpackedTimestamp[1] ?? null; - is_int($timestampValue) || throw new Exception('Unable to parse KSUID timestamp'); - $timestamp = $timestampValue + self::$epoch; + $timestamp = BinaryUnpack::u32(substr($bytes, 0, 4), 'Unable to parse KSUID timestamp') + self::$epoch; $data['time'] = new DateTimeImmutable('@' . $timestamp); $data['payload'] = bin2hex(substr($bytes, 4)); diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 018c4ab..4b8859d 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -5,6 +5,7 @@ namespace Infocyph\UID\Sequence; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Support\FileLock; final readonly class FilesystemSequenceProvider implements SequenceProviderInterface { @@ -40,21 +41,13 @@ public function next(string $type, int $machineId, int $timestamp): int */ private function acquireLock(string $fileLocation) { - ($handle = fopen($fileLocation, 'c+')) || throw new FileLockException( + return FileLock::acquire( + $fileLocation, + $this->waitTime, + $this->maxAttempts, 'Failed to open sequence file: ' . $fileLocation, + 'Unable to acquire sequence lock: ' . $fileLocation, ); - - for ($attempts = 0; $attempts < $this->maxAttempts; $attempts++) { - if (flock($handle, LOCK_EX | LOCK_NB)) { - return $handle; - } - - usleep($this->waitTime); - } - - fclose($handle); - - throw new FileLockException('Unable to acquire sequence lock: ' . $fileLocation); } private function sequenceFileLocation(string $type, int $machineId): string diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 60ef448..fe491d5 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -6,6 +6,7 @@ use Closure; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Support\FileLock; use Psr\SimpleCache\CacheInterface; use Throwable; @@ -79,21 +80,14 @@ public function next(string $type, int $machineId, int $timestamp): int private function acquireLock(string $key) { $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-cache-lock-' . md5($key) . '.lck'; - ($handle = fopen($lockFile, 'c+')) || throw new FileLockException( + + return FileLock::acquire( + $lockFile, + $this->waitTime, + $this->maxAttempts, 'Unable to open sequence cache lock file: ' . $lockFile, + 'Unable to acquire sequence cache lock for key: ' . $key, ); - - for ($attempt = 0; $attempt < $this->maxAttempts; $attempt++) { - if (flock($handle, LOCK_EX | LOCK_NB)) { - return $handle; - } - - usleep($this->waitTime); - } - - fclose($handle); - - throw new FileLockException('Unable to acquire sequence cache lock for key: ' . $key); } private function key(string $type, int $machineId): string diff --git a/src/Snowflake.php b/src/Snowflake.php index 09e8c5a..a76a238 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -14,7 +14,9 @@ use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\EpochGuard; use Infocyph\UID\Support\GetSequence; +use Infocyph\UID\Support\NumericIdCodec; use Infocyph\UID\Support\OutputFormatter; final class Snowflake @@ -46,7 +48,7 @@ final class Snowflake public static function fromBase(string $encoded, int $base): string { try { - return self::fromBytes(BaseEncoder::decodeToBytes($encoded, $base, 8)); + return NumericIdCodec::decimalFromBase($encoded, $base, 8); } catch (\InvalidArgumentException $exception) { throw new SnowflakeException($exception->getMessage(), 0, $exception); } @@ -59,16 +61,11 @@ public static function fromBase(string $encoded, int $base): string */ public static function fromBytes(string $bytes): string { - if (strlen($bytes) !== 8) { - throw new SnowflakeException('Snowflake binary data must be exactly 8 bytes'); - } - - $decimal = '0'; - foreach (str_split(bin2hex($bytes)) as $char) { - $decimal = bcadd(bcmul($decimal, '16'), (string) hexdec($char)); + try { + return NumericIdCodec::decimalFromBytes($bytes, 8); + } catch (\InvalidArgumentException $exception) { + throw new SnowflakeException('Snowflake binary data must be exactly 8 bytes', 0, $exception); } - - return $decimal; } /** @@ -98,11 +95,12 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string public static function generateWithConfig(SnowflakeConfig $config): int|string { [$datacenterId, $workerId] = $config->resolveNode(); + $customEpoch = $config->resolveCustomEpochMs(); return self::generateInternal( $datacenterId, $workerId, - $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(), + $customEpoch ?? self::getStartTimeStamp(), $config->clockBackwardPolicy, $config->outputType, $config->sequenceProvider, @@ -114,7 +112,7 @@ public static function generateWithConfig(SnowflakeConfig $config): int|string */ public static function isValid(string $id): bool { - return preg_match('/^\d+$/', $id) === 1 && $id !== '0'; + return $id !== '' && $id !== '0' && ctype_digit($id); } /** @@ -126,7 +124,10 @@ public static function isValid(string $id): bool */ public static function parse(string $id): array { - return self::parseWithEpoch($id, self::getStartTimeStamp()); + return self::parseWithEpoch( + id: $id, + startTimestamp: self::getStartTimeStamp(), + ); } /** @@ -137,19 +138,20 @@ public static function parse(string $id): array */ public static function parseWithEpoch(string $id, int $startTimestamp): array { - $id = decbin((int) $id); - $time = str_split((string) (bindec(substr($id, 0, -22)) + $startTimestamp), 10); + $binaryId = decbin((int) $id); + $timestamp = (int) bindec(substr($binaryId, 0, -22)) + $startTimestamp; + [$seconds, $fraction] = self::timestampParts($timestamp); return [ 'time' => new DateTimeImmutable( '@' - . $time[0] + . $seconds . '.' - . str_pad($time[1], 6, '0', STR_PAD_LEFT), + . str_pad($fraction, 6, '0', STR_PAD_LEFT), ), - 'sequence' => (int) bindec(substr($id, -12)), - 'worker_id' => (int) bindec(substr($id, -17, 5)), - 'datacenter_id' => (int) bindec(substr($id, -22, 5)), + 'sequence' => (int) bindec(substr($binaryId, -12)), + 'worker_id' => (int) bindec(substr($binaryId, -17, 5)), + 'datacenter_id' => (int) bindec(substr($binaryId, -22, 5)), ]; } @@ -161,15 +163,17 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array */ public static function setStartTimeStamp(string $timeString): void { - $time = strtotime($timeString); - if ($time === false) { - throw new SnowflakeException('Invalid start time format'); - } - $current = time(); - - if ($time > $current) { - throw new SnowflakeException('The start time cannot be in the future'); + try { + $resolved = EpochGuard::resolveStartTime( + $timeString, + 'Invalid start time format', + 'The start time cannot be in the future', + ); + } catch (\InvalidArgumentException $exception) { + throw new SnowflakeException($exception->getMessage(), 0, $exception); } + $time = $resolved['time']; + $current = $resolved['current']; if (($current - $time) > (-1 ^ (-1 << self::$maxTimestampLength))) { throw new SnowflakeException( @@ -200,23 +204,16 @@ public static function toBase(string $id, int $base): string */ public static function toBytes(string $id): string { - if (!self::isValid($id)) { - throw new SnowflakeException('Invalid Snowflake ID string'); - } - - $hex = ''; - $value = $id; - while ($value !== '0') { - $remainder = (int) bcmod($value, '16'); - $hex = dechex($remainder) . $hex; - $value = bcdiv($value, '16', 0); + try { + return NumericIdCodec::bytesFromDecimal( + $id, + 8, + self::isValid(...), + 'Invalid Snowflake ID string', + ); + } catch (\InvalidArgumentException $exception) { + throw new SnowflakeException('Unable to convert Snowflake ID to bytes', 0, $exception); } - - $hex = str_pad($hex, 16, '0', STR_PAD_LEFT); - $bytes = hex2bin($hex); - $bytes !== false || throw new SnowflakeException('Unable to convert Snowflake ID to bytes'); - - return $bytes; } /** @@ -324,6 +321,17 @@ private static function resolveSequenceProvider(?SequenceProviderInterface $prov return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } + /** + * @return array{0:string,1:string} + */ + private static function timestampParts(int $timestamp): array + { + $time = str_split((string) $timestamp, 10); + $time[1] ??= '0'; + + return [$time[0], $time[1]]; + } + private static function waitUntil(int $timestamp): int { do { diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 1555fe2..7b1b1e0 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -13,7 +13,9 @@ use Infocyph\UID\Exceptions\SonyflakeException; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\EpochGuard; use Infocyph\UID\Support\GetSequence; +use Infocyph\UID\Support\NumericIdCodec; use Infocyph\UID\Support\OutputFormatter; final class Sonyflake @@ -37,11 +39,10 @@ final class Sonyflake */ public static function fromBase(string $encoded, int $base): string { - try { - return self::fromBytes(BaseEncoder::decodeToBytes($encoded, $base, 8)); - } catch (\InvalidArgumentException $exception) { - throw new SonyflakeException($exception->getMessage(), 0, $exception); - } + return self::decodeNumeric( + fn(): string => NumericIdCodec::decimalFromBase($encoded, $base, 8), + null, + ); } /** @@ -51,16 +52,10 @@ public static function fromBase(string $encoded, int $base): string */ public static function fromBytes(string $bytes): string { - if (strlen($bytes) !== 8) { - throw new SonyflakeException('Sonyflake binary data must be exactly 8 bytes'); - } - - $decimal = '0'; - foreach (str_split(bin2hex($bytes)) as $char) { - $decimal = bcadd(bcmul($decimal, '16'), (string) hexdec($char)); - } - - return $decimal; + return self::decodeNumeric( + fn(): string => NumericIdCodec::decimalFromBytes($bytes, 8), + 'Sonyflake binary data must be exactly 8 bytes', + ); } /** @@ -124,19 +119,17 @@ public static function parse(string $id): array */ public static function parseWithEpoch(string $id, int $startTimestamp): array { - $id = decbin((int) $id); - $length = self::$maxMachineIdLength + self::$maxSequenceLength; - $time = str_split((string) (bindec(substr($id, 0, strlen($id) - $length)) * 10 + $startTimestamp), 10); + $parts = self::extractParts($id, $startTimestamp); return [ 'time' => new DateTimeImmutable( '@' - . $time[0] + . $parts['seconds'] . '.' - . str_pad($time[1], 6, '0', STR_PAD_LEFT), + . str_pad($parts['fraction'], 6, '0', STR_PAD_LEFT), ), - 'sequence' => (int) bindec(substr($id, -1 * self::$maxSequenceLength)), - 'machine_id' => (int) bindec(substr($id, -1 * $length, self::$maxMachineIdLength)), + 'sequence' => $parts['sequence'], + 'machine_id' => $parts['machine_id'], ]; } @@ -148,15 +141,17 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array */ public static function setStartTimeStamp(string $timeString): void { - $time = strtotime($timeString); - if ($time === false) { - throw new SonyflakeException('Invalid start time format'); - } - $current = time(); - - if ($time > $current) { - throw new SonyflakeException('The start time cannot be in the future'); + try { + $resolved = EpochGuard::resolveStartTime( + $timeString, + 'Invalid start time format', + 'The start time cannot be in the future', + ); + } catch (\InvalidArgumentException $exception) { + throw new SonyflakeException($exception->getMessage(), 0, $exception); } + $time = $resolved['time']; + $current = $resolved['current']; self::ensureEffectiveRuntime(floor(($current - $time) / 10) | 0); self::$startTime = $time * 1000; @@ -179,23 +174,28 @@ public static function toBase(string $id, int $base): string */ public static function toBytes(string $id): string { - if (!self::isValid($id)) { - throw new SonyflakeException('Invalid Sonyflake ID string'); - } + return self::decodeNumeric( + fn(): string => NumericIdCodec::bytesFromDecimal( + $id, + 8, + self::isValid(...), + 'Invalid Sonyflake ID string', + ), + 'Unable to convert Sonyflake ID to bytes', + ); + } - $hex = ''; - $value = $id; - while ($value !== '0') { - $remainder = (int) bcmod($value, '16'); - $hex = dechex($remainder) . $hex; - $value = bcdiv($value, '16', 0); + /** + * @param callable():string $operation + * @throws SonyflakeException + */ + private static function decodeNumeric(callable $operation, ?string $customMessage): string + { + try { + return $operation(); + } catch (\InvalidArgumentException $exception) { + throw new SonyflakeException($customMessage ?? $exception->getMessage(), 0, $exception); } - - $hex = str_pad($hex, 16, '0', STR_PAD_LEFT); - $bytes = hex2bin($hex); - $bytes !== false || throw new SonyflakeException('Unable to convert Sonyflake ID to bytes'); - - return $bytes; } /** @@ -219,6 +219,25 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void } } + /** + * @return array{seconds:string,fraction:string,sequence:int,machine_id:int} + */ + private static function extractParts(string $id, int $startTimestamp): array + { + $binary = decbin((int) $id); + $tailBitLength = self::$maxMachineIdLength + self::$maxSequenceLength; + $elapsed = bindec(substr($binary, 0, strlen($binary) - $tailBitLength)); + $timestamp = (string) ($startTimestamp + ($elapsed * 10)); + $timeParts = str_split($timestamp, 10); + + return [ + 'seconds' => $timeParts[0], + 'fraction' => $timeParts[1] ?? '0', + 'sequence' => (int) bindec(substr($binary, -1 * self::$maxSequenceLength)), + 'machine_id' => (int) bindec(substr($binary, -1 * $tailBitLength, self::$maxMachineIdLength)), + ]; + } + /** * @throws SonyflakeException|FileLockException */ diff --git a/src/Support/BinaryUnpack.php b/src/Support/BinaryUnpack.php new file mode 100644 index 0000000..ec4e1e0 --- /dev/null +++ b/src/Support/BinaryUnpack.php @@ -0,0 +1,45 @@ +|false $unpacked + * @throws \Exception + */ + private static function value(array|false $unpacked, string $error): int + { + ($unpacked !== false) || throw new \Exception($error); + $value = $unpacked[1] ?? null; + is_int($value) || throw new \Exception($error); + + return $value; + } +} diff --git a/src/Support/DecimalBytes.php b/src/Support/DecimalBytes.php new file mode 100644 index 0000000..bdaf535 --- /dev/null +++ b/src/Support/DecimalBytes.php @@ -0,0 +1,52 @@ + ($byteLength * 2)) { + throw new \InvalidArgumentException('Decimal value exceeds target byte length'); + } + + $hex = str_pad($hex, $byteLength * 2, '0', STR_PAD_LEFT); + $bytes = hex2bin($hex); + if ($bytes === false) { + throw new \InvalidArgumentException('Unable to convert decimal value to bytes'); + } + + return $bytes; + } +} diff --git a/src/Support/EpochGuard.php b/src/Support/EpochGuard.php new file mode 100644 index 0000000..ae28bcd --- /dev/null +++ b/src/Support/EpochGuard.php @@ -0,0 +1,30 @@ + $current) { + throw new \InvalidArgumentException($futureMessage); + } + + return ['time' => $time, 'current' => $current]; + } +} diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php new file mode 100644 index 0000000..2afdbb0 --- /dev/null +++ b/src/Support/FileLock.php @@ -0,0 +1,36 @@ + strlen($right); - if ($lengthComparison !== 0) { - return $lengthComparison; - } - - return strcmp($left, $right); - } - /** * @throws UIDException */ private static function toBinary64(string $decimal): string { - $hex = ''; - $value = $decimal; - - while ($value !== '0') { - $remainder = (int) bcmod($value, '16'); - $hex = dechex($remainder) . $hex; - $value = bcdiv($value, '16', 0); + try { + return DecimalBytes::toFixedBytes($decimal, 8); + } catch (\InvalidArgumentException $exception) { + throw new UIDException('Unable to convert numeric ID to binary', 0, $exception); } - - $hex = str_pad($hex, 16, '0', STR_PAD_LEFT); - $binary = hex2bin($hex); - $binary !== false || throw new UIDException('Unable to convert numeric ID to binary'); - - return $binary; } /** @@ -66,7 +42,7 @@ private static function toBinary64(string $decimal): string */ private static function toInt(string $decimal): int { - if (self::compareUnsignedDecimals($decimal, (string) PHP_INT_MAX) === 1) { + if (UnsignedDecimal::compare($decimal, (string) PHP_INT_MAX) === 1) { throw new UIDException('Numeric ID exceeds PHP_INT_MAX; use string or binary output'); } diff --git a/src/Support/UnsignedDecimal.php b/src/Support/UnsignedDecimal.php new file mode 100644 index 0000000..5a43018 --- /dev/null +++ b/src/Support/UnsignedDecimal.php @@ -0,0 +1,28 @@ + strlen($right); + if ($lengthComparison !== 0) { + return $lengthComparison; + } + + return strcmp($left, $right); + } + + public static function normalize(string $value): string + { + $normalized = ltrim($value, '0'); + + return $normalized === '' ? '0' : $normalized; + } +} diff --git a/src/TBSL.php b/src/TBSL.php index 55335f6..97c99bc 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -12,7 +12,9 @@ use Infocyph\UID\Exceptions\UIDException; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\DecimalBytes; use Infocyph\UID\Support\GetSequence; +use Infocyph\UID\Support\UnsignedDecimal; final class TBSL { @@ -194,15 +196,11 @@ private static function generateInternal( private static function hexToDecimal(string $hex): int { - $decimal = '0'; - foreach (str_split(strtolower($hex)) as $char) { - $decimal = bcadd( - bcmul($decimal, '16'), - (string) hexdec($char), - ); - } + $bytes = hex2bin(strtolower($hex)); + $bytes !== false || throw new UIDException('Unable to convert TBSL hex to bytes'); + $decimal = DecimalBytes::fromBytes($bytes); - if (bccomp($decimal, (string) PHP_INT_MAX) === 1) { + if (UnsignedDecimal::compare($decimal, (string) PHP_INT_MAX) === 1) { throw new UIDException('TBSL integer output exceeds PHP_INT_MAX; use string or binary output'); } diff --git a/src/ULID.php b/src/ULID.php index 2a1338a..4a61c6e 100644 --- a/src/ULID.php +++ b/src/ULID.php @@ -10,6 +10,7 @@ use Infocyph\UID\Enums\UlidGenerationMode; use Infocyph\UID\Exceptions\ULIDException; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\DecimalBytes; final class ULID { @@ -51,12 +52,7 @@ public static function fromBytes(string $bytes): string throw new ULIDException('ULID binary data must be exactly 16 bytes'); } - $decimal = '0'; - $hexChars = str_split(bin2hex($bytes)); - foreach ($hexChars as $char) { - $value = hexdec($char); - $decimal = bcadd(bcmul($decimal, '16'), (string) $value); - } + $decimal = DecimalBytes::fromBytes($bytes); $encoded = str_repeat('0', 26); $chars = str_split($encoded); @@ -186,19 +182,11 @@ public static function toBytes(string $ulid): string throw new ULIDException('Invalid ULID string'); } - $decimal = self::decodeToDecimal($ulid); - $hex = ''; - while ($decimal !== '0') { - $remainder = (int) bcmod($decimal, '16'); - $hex = dechex($remainder) . $hex; - $decimal = bcdiv($decimal, '16', 0); + try { + return DecimalBytes::toFixedBytes(self::decodeToDecimal($ulid), 16); + } catch (\InvalidArgumentException $exception) { + throw new ULIDException('Unable to convert ULID to bytes', 0, $exception); } - - $hex = str_pad($hex, 32, '0', STR_PAD_LEFT); - $bytes = hex2bin($hex); - $bytes !== false || throw new ULIDException('Unable to convert ULID to bytes'); - - return $bytes; } /** diff --git a/src/UUID.php b/src/UUID.php index 09946b2..d899e95 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -332,13 +332,7 @@ public static function v1(?string $node = null): string */ public static function v3(string $namespace, string $string): string { - $namespace = self::nsResolve($namespace); - if (!$namespace) { - throw new UUIDException('Invalid NameSpace!'); - } - $hash = md5(hex2bin($namespace) . $string); - - return self::output(3, $hash); + return self::nameBased($namespace, $string, 3, 'md5'); } /** @@ -363,13 +357,7 @@ public static function v4(): string */ public static function v5(string $namespace, string $string): string { - $namespace = self::nsResolve($namespace); - if (!$namespace) { - throw new UUIDException('Invalid NameSpace!'); - } - $hash = sha1(hex2bin($namespace) . $string); - - return self::output(5, $hash); + return self::nameBased($namespace, $string, 5, 'sha1'); } /** @@ -600,6 +588,24 @@ private static function incrementHexCounter(string $hex): ?string return null; } + /** + * @throws UUIDException + */ + private static function nameBased(string $namespace, string $string, int $version, string $algorithm): string + { + $resolvedNamespace = self::nsResolve($namespace); + if (!$resolvedNamespace) { + throw new UUIDException('Invalid NameSpace!'); + } + + $binaryNamespace = hex2bin($resolvedNamespace); + if ($binaryNamespace === false) { + throw new UUIDException('Invalid NameSpace!'); + } + + return self::output($version, hash($algorithm, $binaryNamespace . $string)); + } + /** * @return array{0: int, 1: string} * @throws Exception diff --git a/src/Value/AbstractParsedIdValue.php b/src/Value/AbstractParsedIdValue.php new file mode 100644 index 0000000..7bb633c --- /dev/null +++ b/src/Value/AbstractParsedIdValue.php @@ -0,0 +1,40 @@ +parsed = $this->initializeComparableValue( + $value, + $this->validator(), + $this->parser(), + $this->invalidMessage(), + ); + } + + abstract protected function invalidMessage(): string; + + /** + * @return callable(string):TParsed + */ + abstract protected function parser(): callable; + + /** + * @return callable(string):bool + */ + abstract protected function validator(): callable; +} diff --git a/src/Value/ComparableIdValue.php b/src/Value/ComparableIdValue.php new file mode 100644 index 0000000..adf3890 --- /dev/null +++ b/src/Value/ComparableIdValue.php @@ -0,0 +1,58 @@ +toString(); + } + + public function compare(IdValueInterface|string $other): int + { + $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other; + + return IdComparator::compare($this->value, $otherValue); + } + + public function getVersion(): ?int + { + return null; + } + + public function isSortable(): bool + { + return true; + } + + public function toString(): string + { + return $this->value; + } + + /** + * @template T of array + * @param callable(string):bool $validator + * @param callable(string):T $parser + * @return T + */ + protected function initializeComparableValue( + string $value, + callable $validator, + callable $parser, + string $invalidMessage, + ): array { + $validator($value) || throw new \InvalidArgumentException($invalidMessage); + $this->value = $value; + + return $parser($value); + } +} diff --git a/src/Value/SnowflakeValue.php b/src/Value/SnowflakeValue.php index 856b408..6b50e0d 100644 --- a/src/Value/SnowflakeValue.php +++ b/src/Value/SnowflakeValue.php @@ -5,38 +5,13 @@ namespace Infocyph\UID\Value; use DateTimeImmutable; -use Infocyph\UID\Contracts\IdValueInterface; -use Infocyph\UID\IdComparator; use Infocyph\UID\Snowflake; -final readonly class SnowflakeValue implements IdValueInterface +/** + * @extends AbstractParsedIdValue + */ +final readonly class SnowflakeValue extends AbstractParsedIdValue { - /** - * @var array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} - */ - private array $parsed; - - private string $value; - - public function __construct(string $value) - { - Snowflake::isValid($value) || throw new \InvalidArgumentException('Invalid Snowflake ID string'); - $this->value = $value; - $this->parsed = Snowflake::parse($value); - } - - public function __toString(): string - { - return $this->toString(); - } - - public function compare(IdValueInterface|string $other): int - { - $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other; - - return IdComparator::compare($this->value, $otherValue); - } - public function getDatacenterId(): int { return $this->parsed['datacenter_id']; @@ -52,23 +27,23 @@ public function getTimestamp(): DateTimeImmutable return $this->parsed['time']; } - public function getVersion(): ?int + public function getWorkerId(): int { - return null; + return $this->parsed['worker_id']; } - public function getWorkerId(): int + protected function invalidMessage(): string { - return $this->parsed['worker_id']; + return 'Invalid Snowflake ID string'; } - public function isSortable(): bool + protected function parser(): callable { - return true; + return Snowflake::parse(...); } - public function toString(): string + protected function validator(): callable { - return $this->value; + return Snowflake::isValid(...); } } diff --git a/src/Value/SonyflakeValue.php b/src/Value/SonyflakeValue.php index b0127e6..c30da79 100644 --- a/src/Value/SonyflakeValue.php +++ b/src/Value/SonyflakeValue.php @@ -5,38 +5,13 @@ namespace Infocyph\UID\Value; use DateTimeImmutable; -use Infocyph\UID\Contracts\IdValueInterface; -use Infocyph\UID\IdComparator; use Infocyph\UID\Sonyflake; -final readonly class SonyflakeValue implements IdValueInterface +/** + * @extends AbstractParsedIdValue + */ +final readonly class SonyflakeValue extends AbstractParsedIdValue { - /** - * @var array{time: DateTimeImmutable, sequence: int, machine_id: int} - */ - private array $parsed; - - private string $value; - - public function __construct(string $value) - { - Sonyflake::isValid($value) || throw new \InvalidArgumentException('Invalid Sonyflake ID string'); - $this->value = $value; - $this->parsed = Sonyflake::parse($value); - } - - public function __toString(): string - { - return $this->toString(); - } - - public function compare(IdValueInterface|string $other): int - { - $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other; - - return IdComparator::compare($this->value, $otherValue); - } - public function getMachineId(): int { return $this->parsed['machine_id']; @@ -47,18 +22,18 @@ public function getTimestamp(): DateTimeImmutable return $this->parsed['time']; } - public function getVersion(): ?int + protected function invalidMessage(): string { - return null; + return 'Invalid Sonyflake ID string'; } - public function isSortable(): bool + protected function parser(): callable { - return true; + return Sonyflake::parse(...); } - public function toString(): string + protected function validator(): callable { - return $this->value; + return Sonyflake::isValid(...); } } diff --git a/src/Value/TbslValue.php b/src/Value/TbslValue.php index 51ecf0b..95eeb4d 100644 --- a/src/Value/TbslValue.php +++ b/src/Value/TbslValue.php @@ -5,37 +5,13 @@ namespace Infocyph\UID\Value; use DateTimeImmutable; -use Infocyph\UID\Contracts\IdValueInterface; use Infocyph\UID\TBSL; -final readonly class TbslValue implements IdValueInterface +/** + * @extends AbstractParsedIdValue + */ +final readonly class TbslValue extends AbstractParsedIdValue { - /** - * @var array{isValid: bool, time: DateTimeImmutable|null, machineId: int|null} - */ - private array $parsed; - - private string $value; - - public function __construct(string $value) - { - TBSL::isValid($value) || throw new \InvalidArgumentException('Invalid TBSL string'); - $this->value = $value; - $this->parsed = TBSL::parse($value); - } - - public function __toString(): string - { - return $this->toString(); - } - - public function compare(IdValueInterface|string $other): int - { - $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other; - - return strcmp($this->value, $otherValue); - } - public function getMachineId(): ?int { return $this->parsed['machineId']; @@ -46,18 +22,18 @@ public function getTimestamp(): ?DateTimeImmutable return $this->parsed['time']; } - public function getVersion(): ?int + protected function invalidMessage(): string { - return null; + return 'Invalid TBSL string'; } - public function isSortable(): bool + protected function parser(): callable { - return true; + return TBSL::parse(...); } - public function toString(): string + protected function validator(): callable { - return $this->value; + return TBSL::isValid(...); } } diff --git a/src/XID.php b/src/XID.php index 9ecf330..431e652 100644 --- a/src/XID.php +++ b/src/XID.php @@ -8,6 +8,7 @@ use Exception; use Infocyph\UID\Contracts\IdAlgorithmInterface; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\BinaryUnpack; final class XID implements IdAlgorithmInterface { @@ -63,23 +64,11 @@ public static function parse(string $xid): array } $bytes = self::toBytes($xid); - $unpackedTimestamp = unpack('N', substr($bytes, 0, 4)); - ($unpackedTimestamp !== false) || throw new Exception('Unable to parse XID timestamp'); - $timestamp = $unpackedTimestamp[1] ?? null; - is_int($timestamp) || throw new Exception('Unable to parse XID timestamp'); + $timestamp = BinaryUnpack::u32(substr($bytes, 0, 4), 'Unable to parse XID timestamp'); $data['time'] = new DateTimeImmutable('@' . $timestamp); $data['machine'] = bin2hex(substr($bytes, 4, 3)); - $unpackedPid = unpack('n', substr($bytes, 7, 2)); - ($unpackedPid !== false) || throw new Exception('Unable to parse XID pid'); - $pid = $unpackedPid[1] ?? null; - is_int($pid) || throw new Exception('Unable to parse XID pid'); - $data['pid'] = $pid; - $counterBytes = substr($bytes, 9, 3); - $unpackedCounter = unpack('N', chr(0) . $counterBytes); - ($unpackedCounter !== false) || throw new Exception('Unable to parse XID counter'); - $counter = $unpackedCounter[1] ?? null; - is_int($counter) || throw new Exception('Unable to parse XID counter'); - $data['counter'] = $counter; + $data['pid'] = BinaryUnpack::u16(substr($bytes, 7, 2), 'Unable to parse XID pid'); + $data['counter'] = BinaryUnpack::u24(substr($bytes, 9, 3), 'Unable to parse XID counter'); return $data; } diff --git a/src/functions.php b/src/functions.php index 755ed45..9806a73 100644 --- a/src/functions.php +++ b/src/functions.php @@ -18,6 +18,46 @@ use Infocyph\UID\UUID; use Infocyph\UID\XID; +if (!function_exists('__uid_base_call')) { + function __uid_base_call(string $family, string $method, string $value, int $base): string + { + /** @var array> $operations */ + $operations = [ + 'toBase' => [ + 'uuid' => UUID::toBase(...), + 'ulid' => ULID::toBase(...), + 'snowflake' => Snowflake::toBase(...), + 'sonyflake' => Sonyflake::toBase(...), + 'tbsl' => TBSL::toBase(...), + ], + 'fromBase' => [ + 'uuid' => UUID::fromBase(...), + 'ulid' => ULID::fromBase(...), + 'snowflake' => Snowflake::fromBase(...), + 'sonyflake' => Sonyflake::fromBase(...), + 'tbsl' => TBSL::fromBase(...), + ], + ]; + + $familyHandlers = $operations[$method] ?? throw new InvalidArgumentException('Unsupported base operation'); + $handler = $familyHandlers[$family] ?? throw new InvalidArgumentException('Unsupported ID family'); + + return $handler($value, $base); + } +} + +if (!function_exists('__uid_is_valid')) { + function __uid_is_valid(string $family, string $id): bool + { + return match ($family) { + 'snowflake' => Snowflake::isValid($id), + 'sonyflake' => Sonyflake::isValid($id), + 'tbsl' => TBSL::isValid($id), + default => throw new InvalidArgumentException('Unsupported ID family for validation'), + }; + } +} + if (!function_exists('uuid1')) { /** * Generates a version 1 UUID @@ -204,61 +244,30 @@ function uuid_compact(string $uuid): string } if (!function_exists('uuid_urn')) { - /** - * Converts UUID to URN format. - * - * @throws Exception - */ function uuid_urn(string $uuid): string { return UUID::toUrn($uuid); } } - if (!function_exists('uuid_braces')) { - /** - * Converts UUID to brace format. - * - * @throws Exception - */ function uuid_braces(string $uuid): string { return UUID::toBraces($uuid); } } - if (!function_exists('uuid_to_base')) { - /** - * Encodes UUID into base16/base32/base36/base58/base62. - * - * @throws Exception - */ function uuid_to_base(string $uuid, int $base): string { return UUID::toBase($uuid, $base); } } - if (!function_exists('uuid_from_base')) { - /** - * Decodes UUID from base16/base32/base36/base58/base62. - * - * @throws Exception - */ function uuid_from_base(string $encoded, int $base): string { return UUID::fromBase($encoded, $base); } } - if (!function_exists('guid')) { - /** - * Generates a GUID (Globally Unique Identifier) string. - * - * @param bool $trim Whether to trim the curly braces from the GUID string. Default is true. - * @return string The generated GUID string. - * @throws Exception - */ function guid(bool $trim = true): string { return UUID::guid($trim); @@ -266,200 +275,120 @@ function guid(bool $trim = true): string } if (!function_exists('ulid')) { - /** - * Generates ULID. - * - * @throws Exception - */ function ulid(?DateTimeInterface $dateTime = null): string { return ULID::generate($dateTime); } } - if (!function_exists('ulid_monotonic')) { - /** - * Generates monotonic ULID. - * - * @throws Exception - */ function ulid_monotonic(?DateTimeInterface $dateTime = null): string { return ULID::generate($dateTime, UlidGenerationMode::MONOTONIC); } } - if (!function_exists('ulid_random')) { - /** - * Generates strict-random ULID. - * - * @throws Exception - */ function ulid_random(?DateTimeInterface $dateTime = null): string { return ULID::generate($dateTime, UlidGenerationMode::RANDOM); } } - if (!function_exists('ulid_to_base')) { - /** - * Encodes ULID into base16/base32/base36/base58/base62. - * - * @throws Exception - */ function ulid_to_base(string $ulid, int $base): string { - return ULID::toBase($ulid, $base); + return __uid_base_call('ulid', 'toBase', $ulid, $base); } } - if (!function_exists('ulid_from_base')) { - /** - * Decodes ULID from base16/base32/base36/base58/base62. - * - * @throws Exception - */ function ulid_from_base(string $encoded, int $base): string { - return ULID::fromBase($encoded, $base); + return __uid_base_call('ulid', 'fromBase', $encoded, $base); } } if (!function_exists('snowflake')) { - /** - * Generates Snowflake ID. - * - * @throws SnowflakeException|FileLockException - */ + /** @throws SnowflakeException|FileLockException */ function snowflake(int $datacenter = 0, int $workerId = 0): string { return Snowflake::generate($datacenter, $workerId); } } +if (!function_exists('sonyflake')) { + /** @throws SonyflakeException|FileLockException */ + function sonyflake(int $machineId = 0): string + { + return Sonyflake::generate($machineId); + } +} +if (!function_exists('tbsl')) { + function tbsl(int $machineId = 0, bool $sequenced = false): string + { + return TBSL::generate($machineId, $sequenced); + } +} if (!function_exists('snowflake_is_valid')) { - /** - * Checks whether Snowflake ID is valid. - */ function snowflake_is_valid(string $id): bool { return Snowflake::isValid($id); } } - -if (!function_exists('snowflake_to_base')) { - /** - * Encodes Snowflake into base16/base32/base36/base58/base62. - * - * @throws Exception - */ - function snowflake_to_base(string $id, int $base): string +if (!function_exists('sonyflake_is_valid')) { + function sonyflake_is_valid(string $id): bool { - return Snowflake::toBase($id, $base); + return __uid_is_valid('sonyflake', $id); } } - -if (!function_exists('snowflake_from_base')) { - /** - * Decodes Snowflake from base16/base32/base36/base58/base62. - * - * @throws Exception - */ - function snowflake_from_base(string $encoded, int $base): string +if (!function_exists('tbsl_is_valid')) { + function tbsl_is_valid(string $id): bool { - return Snowflake::fromBase($encoded, $base); + if ($id === '') { + return false; + } + + return __uid_is_valid('tbsl', $id); } } -if (!function_exists('sonyflake')) { - /** - * Generates Sonyflake ID. - * - * @throws SonyflakeException|FileLockException - */ - function sonyflake(int $machineId = 0): string +if (!function_exists('snowflake_to_base')) { + function snowflake_to_base(string $id, int $base): string { - return Sonyflake::generate($machineId); + return Snowflake::toBase($id, $base); } } - -if (!function_exists('sonyflake_is_valid')) { - /** - * Checks whether Sonyflake ID is valid. - */ - function sonyflake_is_valid(string $id): bool +if (!function_exists('snowflake_from_base')) { + function snowflake_from_base(string $encoded, int $base): string { - return Sonyflake::isValid($id); + return Snowflake::fromBase($encoded, $base); } } if (!function_exists('sonyflake_to_base')) { - /** - * Encodes Sonyflake into base16/base32/base36/base58/base62. - * - * @throws Exception - */ function sonyflake_to_base(string $id, int $base): string { - return Sonyflake::toBase($id, $base); + return __uid_base_call('sonyflake', 'toBase', $id, $base); } } - if (!function_exists('sonyflake_from_base')) { - /** - * Decodes Sonyflake from base16/base32/base36/base58/base62. - * - * @throws Exception - */ function sonyflake_from_base(string $encoded, int $base): string { - return Sonyflake::fromBase($encoded, $base); - } -} - -if (!function_exists('tbsl')) { - /** - * Generates TBSL ID. - * - * @throws Exception - */ - function tbsl(int $machineId = 0, bool $sequenced = false): string - { - return TBSL::generate($machineId, $sequenced); - } -} - -if (!function_exists('tbsl_is_valid')) { - /** - * Checks whether TBSL ID is valid. - */ - function tbsl_is_valid(string $id): bool - { - return TBSL::isValid($id); + return __uid_base_call('sonyflake', 'fromBase', $encoded, $base); } } if (!function_exists('tbsl_to_base')) { - /** - * Encodes TBSL into base16/base32/base36/base58/base62. - * - * @throws Exception - */ function tbsl_to_base(string $id, int $base): string { - return TBSL::toBase($id, $base); + $family = 'tbsl'; + + return __uid_base_call($family, 'toBase', $id, $base); } } - if (!function_exists('tbsl_from_base')) { - /** - * Decodes TBSL from base16/base32/base36/base58/base62. - * - * @throws Exception - */ function tbsl_from_base(string $encoded, int $base): string { - return TBSL::fromBase($encoded, $base); + $method = 'fromBase'; + + return __uid_base_call('tbsl', $method, $encoded, $base); } } diff --git a/tests/ArchTest.php b/tests/ArchTest.php deleted file mode 100644 index 609b6b6..0000000 --- a/tests/ArchTest.php +++ /dev/null @@ -1,9 +0,0 @@ -each->not()->toBeUsed(); -}); - -test('No echo statements', function () { - expect(['echo', 'print'])->each->not()->toBeUsed(); -});