From e991c3eaf7499edeec6577fee8b3397ba3f045e0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 21 Apr 2026 16:51:54 +0600 Subject: [PATCH 1/6] code update --- .gitattributes | 18 +- .github/CODEOWNERS | 2 +- .github/dependabot.yml | 4 +- .github/scripts/composer-audit-guard.php | 85 ++ .github/scripts/phpstan-sarif.php | 178 +++++ .github/scripts/syntax.php | 109 +++ .github/workflows/build.yml | 108 ++- .gitignore | 24 +- .readthedocs.yaml | 17 + CODE_OF_CONDUCT.md | 126 +++ README.md | 14 +- benchmarks/HotspotBench.php | 45 ++ benchmarks/SequenceProviderBench.php | 32 + captainhook.json | 2 +- composer.json | 111 ++- docs/compatibility-matrix.md | 27 + docs/db-storage.md | 24 + docs/framework-integration.md | 20 + pest.xml | 22 + phpbench.json | 26 + phpcs.xml.dist | 66 ++ phpstan.neon.dist | 16 + phpunit.xml | 35 +- pint.json | 137 +++- psalm.xml | 39 + rector.php | 18 +- src/Configuration/SnowflakeConfig.php | 66 ++ src/Configuration/SonyflakeConfig.php | 58 ++ src/Configuration/TBSLConfig.php | 38 + src/Contracts/IdValueInterface.php | 23 + src/DbStorage.php | 50 ++ src/DeterministicId.php | 21 + src/Enums/ClockBackwardPolicy.php | 12 + src/Enums/IdOutputType.php | 14 + src/Enums/UlidGenerationMode.php | 12 + src/Exceptions/FileLockException.php | 6 +- src/Exceptions/SnowflakeException.php | 6 +- src/Exceptions/SonyflakeException.php | 6 +- src/Exceptions/UIDException.php | 7 + src/Exceptions/ULIDException.php | 6 +- src/Exceptions/UUIDException.php | 6 +- src/GetSequence.php | 151 ++-- src/Id.php | 396 ++++++++++ src/IdComparator.php | 53 ++ src/KSUID.php | 84 ++ src/OpaqueId.php | 52 ++ src/RandomId.php | 127 ++- src/Sequence/CallbackSequenceProvider.php | 34 + src/Sequence/FilesystemSequenceProvider.php | 95 +++ src/Sequence/InMemorySequenceProvider.php | 36 + .../PsrSimpleCacheSequenceProvider.php | 83 ++ src/Sequence/SequenceProviderInterface.php | 13 + src/Snowflake.php | 252 +++++- src/Sonyflake.php | 225 +++++- src/Support/BaseEncoder.php | 88 +++ src/Support/OutputFormatter.php | 75 ++ src/TBSL.php | 203 ++++- src/ULID.php | 250 +++++- src/UUID.php | 725 ++++++++++++++---- src/Value/SnowflakeValue.php | 74 ++ src/Value/SonyflakeValue.php | 64 ++ src/Value/TbslValue.php | 63 ++ src/Value/UlidValue.php | 56 ++ src/Value/UuidValue.php | 77 ++ src/XID.php | 119 +++ src/functions.php | 373 ++++++++- tests/AdditionalIdsTest.php | 45 ++ tests/IdFactoryTest.php | 72 ++ tests/RandomIdTest.php | 34 +- tests/SequenceProviderTest.php | 106 +++ tests/SnowflakeTest.php | 108 ++- tests/SonyflakeTest.php | 103 ++- tests/TBSLTest.php | 46 +- tests/ULIDTest.php | 54 +- tests/UUIDTest.php | 96 ++- 75 files changed, 5488 insertions(+), 580 deletions(-) create mode 100644 .github/scripts/composer-audit-guard.php create mode 100644 .github/scripts/phpstan-sarif.php create mode 100644 .github/scripts/syntax.php create mode 100644 .readthedocs.yaml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 benchmarks/HotspotBench.php create mode 100644 benchmarks/SequenceProviderBench.php create mode 100644 docs/compatibility-matrix.md create mode 100644 docs/db-storage.md create mode 100644 docs/framework-integration.md create mode 100644 pest.xml create mode 100644 phpbench.json create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon.dist create mode 100644 psalm.xml create mode 100644 src/Configuration/SnowflakeConfig.php create mode 100644 src/Configuration/SonyflakeConfig.php create mode 100644 src/Configuration/TBSLConfig.php create mode 100644 src/Contracts/IdValueInterface.php create mode 100644 src/DbStorage.php create mode 100644 src/DeterministicId.php create mode 100644 src/Enums/ClockBackwardPolicy.php create mode 100644 src/Enums/IdOutputType.php create mode 100644 src/Enums/UlidGenerationMode.php create mode 100644 src/Exceptions/UIDException.php create mode 100644 src/Id.php create mode 100644 src/IdComparator.php create mode 100644 src/KSUID.php create mode 100644 src/OpaqueId.php create mode 100644 src/Sequence/CallbackSequenceProvider.php create mode 100644 src/Sequence/FilesystemSequenceProvider.php create mode 100644 src/Sequence/InMemorySequenceProvider.php create mode 100644 src/Sequence/PsrSimpleCacheSequenceProvider.php create mode 100644 src/Sequence/SequenceProviderInterface.php create mode 100644 src/Support/BaseEncoder.php create mode 100644 src/Support/OutputFormatter.php create mode 100644 src/Value/SnowflakeValue.php create mode 100644 src/Value/SonyflakeValue.php create mode 100644 src/Value/TbslValue.php create mode 100644 src/Value/UlidValue.php create mode 100644 src/Value/UuidValue.php create mode 100644 src/XID.php create mode 100644 tests/AdditionalIdsTest.php create mode 100644 tests/IdFactoryTest.php create mode 100644 tests/SequenceProviderTest.php diff --git a/.gitattributes b/.gitattributes index 84e409a..2755315 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,21 @@ +.github export-ignore +benchmarks export-ignore +docs export-ignore +examples export-ignore +tests export-ignore + .editorconfig export-ignore +.gitattributes export-ignore .gitignore export-ignore -tests export-ignore -docs export-ignore -.github export-ignore .readthedocs.yaml export-ignore captainhook.json export-ignore +pest.xml export-ignore +phpbench.json export-ignore +phpcs.xml.dist export-ignore +phpstan.neon.dist export-ignore phpunit.xml export-ignore pint.json export-ignore +psalm.xml export-ignore rector.php export-ignore -.gitattributes export-ignore -* text eol=lf +* text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f32cc39..931aef9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @abmmhasan \ No newline at end of file +* @abmmhasan diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7445f3..e9d271d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: "composer" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "composer" + directory: "/" schedule: interval: "weekly" diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php new file mode 100644 index 0000000..a1b1cdb --- /dev/null +++ b/.github/scripts/composer-audit-guard.php @@ -0,0 +1,85 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], +]; + +$process = proc_open($command, $descriptorSpec, $pipes); + +if (! \is_resource($process)) { + fwrite(STDERR, "Failed to start composer audit process.\n"); + exit(1); +} + +fclose($pipes[0]); +$stdout = stream_get_contents($pipes[1]) ?: ''; +$stderr = stream_get_contents($pipes[2]) ?: ''; +fclose($pipes[1]); +fclose($pipes[2]); + +$exitCode = proc_close($process); + +/** @var array|null $decoded */ +$decoded = json_decode($stdout, true); + +if (! \is_array($decoded)) { + fwrite(STDERR, "Unable to parse composer audit JSON output.\n"); + if (trim($stdout) !== '') { + fwrite(STDERR, $stdout . "\n"); + } + if (trim($stderr) !== '') { + fwrite(STDERR, $stderr . "\n"); + } + + exit($exitCode !== 0 ? $exitCode : 1); +} + +$advisories = $decoded['advisories'] ?? []; +$abandoned = $decoded['abandoned'] ?? []; + +$advisoryCount = 0; + +if (\is_array($advisories)) { + foreach ($advisories as $entries) { + if (\is_array($entries)) { + $advisoryCount += \count($entries); + } + } +} + +$abandonedPackages = []; + +if (\is_array($abandoned)) { + foreach ($abandoned as $package => $replacement) { + if (\is_string($package) && $package !== '') { + $abandonedPackages[$package] = $replacement; + } + } +} + +echo sprintf( + "Composer audit summary: %d advisories, %d abandoned packages.\n", + $advisoryCount, + \count($abandonedPackages), +); + +if ($abandonedPackages !== []) { + fwrite(STDERR, "Warning: abandoned packages detected (non-blocking):\n"); + foreach ($abandonedPackages as $package => $replacement) { + $target = \is_string($replacement) && $replacement !== '' ? $replacement : 'none'; + fwrite(STDERR, sprintf(" - %s (replacement: %s)\n", $package, $target)); + } +} + +if ($advisoryCount > 0) { + fwrite(STDERR, "Security vulnerabilities detected by composer audit.\n"); + exit(1); +} + +exit(0); diff --git a/.github/scripts/phpstan-sarif.php b/.github/scripts/phpstan-sarif.php new file mode 100644 index 0000000..2b01b26 --- /dev/null +++ b/.github/scripts/phpstan-sarif.php @@ -0,0 +1,178 @@ + [sarif-output] + */ + +$argv = $_SERVER['argv'] ?? []; +$input = $argv[1] ?? ''; +$output = $argv[2] ?? 'phpstan-results.sarif'; + +if (! is_string($input) || $input === '') { + fwrite(STDERR, "Error: missing input file.\n"); + fwrite(STDERR, "Usage: php .github/scripts/phpstan-sarif.php [sarif-output]\n"); + exit(2); +} + +if (! is_file($input) || ! is_readable($input)) { + fwrite(STDERR, "Error: input file not found or unreadable: {$input}\n"); + exit(2); +} + +$raw = file_get_contents($input); +if ($raw === false) { + fwrite(STDERR, "Error: failed to read input file: {$input}\n"); + exit(2); +} + +$decoded = json_decode($raw, true); +if (! is_array($decoded)) { + fwrite(STDERR, "Error: input is not valid JSON.\n"); + exit(2); +} + +/** + * @return non-empty-string + */ +function normalizeUri(string $path): string +{ + $normalized = str_replace('\\', '/', $path); + $cwd = getcwd(); + + if (is_string($cwd) && $cwd !== '') { + $cwd = rtrim(str_replace('\\', '/', $cwd), '/'); + + if (preg_match('/^[A-Za-z]:\//', $normalized) === 1) { + if (stripos($normalized, $cwd . '/') === 0) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } elseif (str_starts_with($normalized, '/')) { + if (str_starts_with($normalized, $cwd . '/')) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } + } + + $normalized = ltrim($normalized, './'); + + return $normalized === '' ? 'unknown.php' : $normalized; +} + +$results = []; +$rules = []; + +$globalErrors = $decoded['errors'] ?? []; +if (is_array($globalErrors)) { + foreach ($globalErrors as $error) { + if (! is_string($error) || $error === '') { + continue; + } + + $ruleId = 'phpstan.internal'; + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $error, + ], + ]; + } +} + +$files = $decoded['files'] ?? []; +if (is_array($files)) { + foreach ($files as $filePath => $fileData) { + if (! is_string($filePath) || ! is_array($fileData)) { + continue; + } + + $messages = $fileData['messages'] ?? []; + if (! is_array($messages)) { + continue; + } + + foreach ($messages as $messageData) { + if (! is_array($messageData)) { + continue; + } + + $messageText = (string) ($messageData['message'] ?? 'PHPStan issue'); + $line = (int) ($messageData['line'] ?? 1); + $identifier = (string) ($messageData['identifier'] ?? ''); + $ruleId = $identifier !== '' ? $identifier : 'phpstan.issue'; + + if ($line < 1) { + $line = 1; + } + + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $messageText, + ], + 'locations' => [[ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => normalizeUri($filePath), + ], + 'region' => [ + 'startLine' => $line, + ], + ], + ]], + ]; + } + } +} + +$ruleDescriptors = []; +$ruleIds = array_keys($rules); +sort($ruleIds); + +foreach ($ruleIds as $ruleId) { + $ruleDescriptors[] = [ + 'id' => $ruleId, + 'name' => $ruleId, + 'shortDescription' => [ + 'text' => $ruleId, + ], + ]; +} + +$sarif = [ + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'version' => '2.1.0', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPStan', + 'informationUri' => 'https://phpstan.org/', + 'rules' => $ruleDescriptors, + ], + ], + 'results' => $results, + ]], +]; + +$encoded = json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +if (! is_string($encoded)) { + fwrite(STDERR, "Error: failed to encode SARIF JSON.\n"); + exit(2); +} + +$written = file_put_contents($output, $encoded . PHP_EOL); +if ($written === false) { + fwrite(STDERR, "Error: failed to write output file: {$output}\n"); + exit(2); +} + +fwrite(STDOUT, sprintf("SARIF generated: %s (%d findings)\n", $output, count($results))); +exit(0); diff --git a/.github/scripts/syntax.php b/.github/scripts/syntax.php new file mode 100644 index 0000000..043bf53 --- /dev/null +++ b/.github/scripts/syntax.php @@ -0,0 +1,109 @@ +isFile()) { + continue; + } + + $filename = $entry->getFilename(); + if (! str_ends_with($filename, '.php')) { + continue; + } + + $files[] = $entry->getPathname(); + } +} + +$files = array_values(array_unique($files)); +sort($files); + +if ($files === []) { + fwrite(STDOUT, "No PHP files found.\n"); + exit(0); +} + +$failed = []; + +foreach ($files as $file) { + $command = [PHP_BINARY, '-d', 'display_errors=1', '-l', $file]; + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes); + + if (! is_resource($process)) { + $failed[] = [$file, 'Could not start PHP lint process']; + continue; + } + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + $output = trim((string) $stdout . "\n" . (string) $stderr); + $failed[] = [$file, $output !== '' ? $output : 'Unknown lint failure']; + } +} + +if ($failed === []) { + fwrite(STDOUT, sprintf("Syntax OK: %d PHP files checked.\n", count($files))); + exit(0); +} + +fwrite(STDERR, sprintf("Syntax errors in %d file(s):\n", count($failed))); + +foreach ($failed as [$file, $error]) { + fwrite(STDERR, "- {$file}\n{$error}\n"); +} + +exit(1); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e6ad57..6795626 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,39 +4,125 @@ on: schedule: - cron: '0 0 * * 0' push: - branches: [ '*' ] + branches: [ "main", "master" ] pull_request: - branches: [ "main", "master", "develop" ] + branches: [ "main", "master", "develop", "development" ] jobs: + prepare: + name: Prepare CI matrix + runs-on: ubuntu-latest + outputs: + php_versions: ${{ steps.matrix.outputs.php_versions }} + dependency_versions: ${{ steps.matrix.outputs.dependency_versions }} + steps: + - name: Define shared matrix values + id: matrix + run: | + echo 'php_versions=["8.2","8.3","8.4","8.5"]' >> "$GITHUB_OUTPUT" + echo 'dependency_versions=["prefer-lowest","prefer-stable"]' >> "$GITHUB_OUTPUT" + run: + needs: prepare runs-on: ${{ matrix.operating-system }} strategy: matrix: operating-system: [ ubuntu-latest ] - php-versions: [ '8.2' , '8.3', '8.4' ] - dependency-version: [ prefer-lowest, prefer-stable ] + php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} + dependency-version: ${{ fromJson(needs.prepare.outputs.dependency_versions) }} - name: PHP ${{ matrix.php-versions }} - ${{ matrix.operating-system }} - ${{ matrix.dependency-version }} + name: Code Analysis - PHP ${{ matrix.php-versions }} - ${{ matrix.dependency-version }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Install PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} tools: composer:v2 coverage: xdebug - - name: Validate composer.json and composer.lock + - name: Check PHP Version + run: php -v + + - name: Validate Composer run: composer validate --strict + - name: Resolve dependencies (${{ matrix.dependency-version }}) + run: composer update --no-interaction --prefer-dist --no-progress --${{ matrix.dependency-version }} + + - name: Test + run: | + composer test:syntax + composer test:code + composer test:lint + composer test:sniff + composer test:refactor + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer test:static + fi + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer test:security + fi + + analyze: + needs: prepare + name: Security Analysis - PHP ${{ matrix.php-versions }} + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + coverage: xdebug + - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Package Audit - run: composer audit + - name: Composer Audit (Release Guard) + run: composer release:audit - - name: Test - run: composer tests + - name: Quality Gate (PHPStan) + run: composer test:static + + - name: Security Gate (Psalm) + run: composer test:security + + - name: Run PHPStan (Code Scanning) + run: | + php ./vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true + php .github/scripts/phpstan-sarif.php phpstan-results.json phpstan-results.sarif + continue-on-error: true + + - name: Upload PHPStan Results + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: phpstan-results.sarif + category: "phpstan-${{ matrix.php-versions }}" + if: always() && hashFiles('phpstan-results.sarif') != '' + + # Run Psalm (Deep Taint Analysis) + - name: Run Psalm Security Scan + run: | + php ./vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --report=psalm-results.sarif || true + continue-on-error: true + + - name: Upload Psalm Results + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: psalm-results.sarif + category: "psalm-${{ matrix.php-versions }}" + if: always() && hashFiles('psalm-results.sarif') != '' diff --git a/.gitignore b/.gitignore index f45ef09..6991d72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,20 @@ -vendor -example .idea -example.php -test.php +.psalm-cache +.phpunit.cache +.vscode +.windsurf +.codex +*~ +*.patch +*.txt +!docs/requirements.txt +AI_CONTEXT.md composer.lock +example +example.php git-story_media -*~ -diffs.txt -index.php +patch.php +test.php +var +vendor +d2utmp* diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..a807878 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +python: + install: + - requirements: docs/requirements.txt + +sphinx: + configuration: docs/conf.py + +formats: + - pdf + - epub diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..62d44a8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,126 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +infocyph@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, +available at: +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html + +Community Impact Guidelines were inspired by Mozilla's code of conduct +enforcement ladder: +https://github.com/mozilla/diversity + +For answers to common questions about this code of conduct, see: +https://www.contributor-covenant.org/faq diff --git a/README.md b/README.md index 53a14ec..f45bcdf 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ An AIO Unique ID generator written in PHP. Supports (references available at the ## Prerequisites -Language: PHP 8/+ +Language: PHP 8.2+ ## Installation @@ -59,6 +59,8 @@ Language: PHP 8/+ composer require infocyph/uid ``` +Global helper functions are available via Composer autoload. + ## Usage ### UUID (Universal Unique Identifier) @@ -228,7 +230,7 @@ _Note: Sending false in only parameter will return the string enclosed with Brac - Parse any UUID string: ```php -\Infocyph\UID\UUID::parse($uuid); // returns ['isValid', 'version', 'time', 'node'] +\Infocyph\UID\UUID::parse($uuid); // returns ['isValid', 'version', 'variant', 'time', 'node', 'tail'] ``` @@ -369,6 +371,8 @@ These IDs are unique & can't be backtracked. ## Benchmark +Additional hotspot benchmarks (same-ms bursts, parser speed, and provider overhead) are available in [`benchmarks/`](benchmarks/). + | Type | Generation time (ms) | |:---------------------------|:---------------------------------------------------------------------------------:| | UUID v1 (random node) | 0.00411 (ramsey/Uuid: 0.18753) | @@ -395,11 +399,13 @@ Having trouble? Create an issue! ## References -- UUID (RFC4122): https://tools.ietf.org/html/rfc4122 -- UUID (Drafts/Proposals): https://datatracker.ietf.org/doc/draft-ietf-uuidrev-rfc4122bis +- UUID (RFC4122/RFC9562): https://datatracker.ietf.org/doc/html/rfc9562 - ULID: https://github.com/ulid/spec - Snowflake ID: https://github.com/twitter-archive/snowflake/tree/snowflake-2010 - Sonyflake ID: https://github.com/sony/sonyflake - TBSL ID: https://github.com/infocyph/UID/blob/main/TBSL.md - NanoID: https://github.com/ai/nanoid - Cuid2: https://github.com/paralleldrive/cuid2 +- Compatibility matrix: [docs/compatibility-matrix.md](docs/compatibility-matrix.md) +- DB storage notes: [docs/db-storage.md](docs/db-storage.md) +- Framework integration notes: [docs/framework-integration.md](docs/framework-integration.md) diff --git a/benchmarks/HotspotBench.php b/benchmarks/HotspotBench.php new file mode 100644 index 0000000..a5f8edd --- /dev/null +++ b/benchmarks/HotspotBench.php @@ -0,0 +1,45 @@ +=8.0", - "ext-bcmath": "*" - }, - "config": { - "sort-packages": true, - "optimize-autoloader": true, - "allow-plugins": { - "pestphp/pest-plugin": true - } - }, "replace": { "abmmhasan/uuid": "*" }, + "require": { + "php": ">=8.2", + "ext-bcmath": "*", + "psr/simple-cache": "^3.0" + }, "require-dev": { - "captainhook/captainhook": "^5.24", - "laravel/pint": "^1.20", - "pestphp/pest": "^3.7", - "rector/rector": "^2.0", - "symfony/var-dumper": "^7.2" + "captainhook/captainhook": "^5.29.2", + "laravel/pint": "^1.29", + "pestphp/pest": "^4.6.3", + "pestphp/pest-plugin-drift": "^4.1", + "phpbench/phpbench": "^1.6.1", + "phpstan/phpstan": "^2.1.50", + "rector/rector": "^2.4.2", + "squizlabs/php_codesniffer": "^4.0.1", + "symfony/var-dumper": "^7.3 || ^8.0.8", + "tomasvotruba/cognitive-complexity": "^1.1", + "vimeo/psalm": "^6.16.1" }, "scripts": { - "test:code": "pest --parallel --processes=10", - "test:refactor": "rector process --dry-run", - "test:lint": "pint --test", - "test:hook": [ - "captainhook hook:post-checkout", - "captainhook hook:pre-commit", - "captainhook hook:post-commit", - "captainhook hook:post-merge", - "captainhook hook:post-rewrite", - "captainhook hook:pre-push" - ], - "tests": [ + "test:syntax": "@php .github/scripts/syntax.php src tests benchmarks examples", + "test:code": "@php vendor/bin/pest", + "test:lint": "@php vendor/bin/pint --test", + "test:sniff": "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=full", + "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", + "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --no-cache", + "test:refactor": "@php vendor/bin/rector process --dry-run --debug", + "test:bench": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "test:details": [ + "@test:syntax", "@test:code", "@test:lint", + "@test:sniff", + "@test:static", + "@test:security", "@test:refactor" ], - "git:hook": "captainhook install --only-enabled -nf", - "test": "pest", - "refactor": "rector process", - "lint": "pint", - "post-autoload-dump": "@git:hook" + "test:all": [ + "@test:syntax", + "@php vendor/bin/pest --parallel --processes=10", + "@php vendor/bin/pint --test", + "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", + "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", + "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --threads=1 --no-progress --no-cache", + "@php vendor/bin/rector process --dry-run --debug" + ], + "release:audit": "@php .github/scripts/composer-audit-guard.php", + "release:guard": [ + "@composer validate --strict", + "@release:audit", + "@tests" + ], + "process:lint": "@php vendor/bin/pint", + "process:sniff:fix": "@php vendor/bin/phpcbf --standard=phpcs.xml.dist --runtime-set ignore_errors_on_exit 1", + "process:refactor": "@php vendor/bin/rector process", + "process:all": [ + "@process:refactor", + "@process:lint", + "@process:sniff:fix" + ], + "bench:run": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "bench:quick": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate --revs=10 --iterations=3 --warmup=1", + "bench:chart": "@php vendor/bin/phpbench run --config=phpbench.json --report=chart", + "tests": "@test:all", + "process": "@process:all", + "benchmark": "@bench:run", + "post-autoload-dump": "captainhook install --only-enabled -nf" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "sort-packages": true, + "optimize-autoloader": true, + "classmap-authoritative": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } } } diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md new file mode 100644 index 0000000..21ab268 --- /dev/null +++ b/docs/compatibility-matrix.md @@ -0,0 +1,27 @@ +# Compatibility Matrix + +## UUID support + +- `v1`, `v3`, `v4`, `v5`: RFC 4122 / RFC 9562 compatible layouts. +- `v6`, `v7`: RFC 9562 time-ordered UUIDs. +- `v8`: custom payload strategy inside RFC 9562 v8 envelope. +- `guid()`: Microsoft-compatible GUID text formatting helper. + +## Non-UUID families + +- `ULID`: Crockford base32 ULID (monotonic + random generation modes). +- `Snowflake`: 64-bit Twitter-style ID (41/5/5/12 layout). +- `Sonyflake`: 64-bit Sonyflake-style ID (39/16/8 layout). +- `TBSL`: project-specific time-based sortable hex identifier. +- `NanoID`, `CUID2`: random ID families for URL-safe usage. + +## Binary and alternate representations + +- UUID / ULID / TBSL: `toBytes()` / `fromBytes()`. +- UUID / ULID / TBSL / Snowflake / Sonyflake: base encodings via `toBase()` / `fromBase()` where applicable. + +## Runtime + +- Minimum PHP: `8.2`. +- Required extension: `ext-bcmath`. +- Optional sequence backends: filesystem (default), in-memory, PSR-16 simple cache, callback. diff --git a/docs/db-storage.md b/docs/db-storage.md new file mode 100644 index 0000000..789d5c5 --- /dev/null +++ b/docs/db-storage.md @@ -0,0 +1,24 @@ +# Sortable DB Helpers + +## UUID + +- MySQL: prefer `BINARY(16)` for compact indexes. +- PostgreSQL: prefer native `UUID` type. +- Ordering: use UUIDv7 when insertion locality matters. + +## ULID + +- MySQL: `CHAR(26)` (human-friendly) or `BINARY(16)` (compact). +- PostgreSQL: `CHAR(26)` or `BYTEA`. +- Ordering: canonical ULID text preserves chronological order. + +## Snowflake / Sonyflake + +- MySQL: `BIGINT UNSIGNED`. +- PostgreSQL: `BIGINT` if in range, otherwise `NUMERIC(20,0)`. +- Ordering: numeric sort is time sort. + +## TBSL + +- Store as fixed-length uppercase hex: `CHAR(20)`. +- If compactness matters, store bytes in `BINARY(10)` via `toBytes()`. diff --git a/docs/framework-integration.md b/docs/framework-integration.md new file mode 100644 index 0000000..46a2dad --- /dev/null +++ b/docs/framework-integration.md @@ -0,0 +1,20 @@ +# Framework Integration Notes + +## Laravel + +- Helper functions are available automatically via Composer autoload. + +- Prefer UUIDv7 / ULID for ordered primary keys. +- Keep IDs as strings at app boundary; convert to binary for DB only when needed. + +## Symfony + +- Helper functions are available automatically via Composer autoload. + +- Use `Id` factory in services to centralize generation strategy. + +## Generic PHP apps + +- Use `Id::nanoId()` for URL-safe public IDs. +- Use configuration objects (`SnowflakeConfig`, `SonyflakeConfig`, `TBSLConfig`) for policy/output tuning. +- Use value objects (`UuidValue`, `UlidValue`, etc.) for safer domain modeling. diff --git a/pest.xml b/pest.xml new file mode 100644 index 0000000..d5d12d8 --- /dev/null +++ b/pest.xml @@ -0,0 +1,22 @@ + + + + + ./tests + + + + + ./src + + + + + + + diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..fff7e05 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,26 @@ +{ + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.file_pattern": "*Bench.php", + "runner.attributes": true, + "runner.annotations": false, + "runner.progress": "dots", + "runner.retry_threshold": 8, + "report.generators": { + "chart": { + "title": "Benchmark Chart", + "description": "Console bar chart grouped by benchmark subject", + "generator": "component", + "components": [ + { + "component": "bar_chart_aggregate", + "x_partition": ["subject_name"], + "bar_partition": ["benchmark_name"], + "y_expr": "mode(partition['result_time_avg'])", + "y_axes_label": "yValue as time precision 1" + } + ] + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..1cf0c6c --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,66 @@ + + + Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. + + + + + + + ./src + ./tests + + */vendor/* + */.git/* + */.idea/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..650fbdf --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,16 @@ +includes: + - vendor/tomasvotruba/cognitive-complexity/config/extension.neon + +parameters: + customRulesetUsed: true + level: max + paths: + - src + parallel: + maximumNumberOfProcesses: 2 + cognitive_complexity: + class: 80 + function: 12 + dependency_tree: 80 + dependency_tree_types: [] + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml index 8f842bd..d5d12d8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,17 +1,22 @@ - - - - ./tests - - - - - - ./src - - + + + + ./tests + + + + + ./src + + + + + + diff --git a/pint.json b/pint.json index e44f7b4..6f546dc 100644 --- a/pint.json +++ b/pint.json @@ -1,10 +1,129 @@ { - "preset": "psr12", - "exclude": [ - "tests" - ], - "notPath": [ - "rector.php", - "test.php" - ] -} \ No newline at end of file + "preset": "per", + "exclude": [ + "tests" + ], + "notPath": [ + "rector.php" + ], + "rules": { + "ordered_imports": { + "imports_order": [ + "class", + "function", + "const" + ], + "sort_algorithm": "alpha" + }, + "no_unused_imports": true, + "class_attributes_separation": { + "elements": { + "trait_import": "none", + "case": "one", + "const": "one", + "property": "one", + "method": "one" + } + }, + "ordered_class_elements": { + "order": [ + "use_trait", + "case", + "constant_public", + "constant_protected", + "constant_private", + "constant", + "property_public_static", + "property_protected_static", + "property_private_static", + "property_static", + "property_public_readonly", + "property_protected_readonly", + "property_private_readonly", + "property_public_abstract", + "property_protected_abstract", + "property_public", + "property_protected", + "property_private", + "property", + "construct", + "destruct", + "magic", + "phpunit", + "method_public_abstract_static", + "method_protected_abstract_static", + "method_private_abstract_static", + "method_public_abstract", + "method_protected_abstract", + "method_private_abstract", + "method_abstract", + "method_public_static", + "method_public", + "method_protected_static", + "method_protected", + "method_private_static", + "method_private", + "method_static", + "method" + ], + "sort_algorithm": "alpha" + }, + "blank_line_after_opening_tag": true, + "no_alias_functions": true, + "multiline_whitespace_before_semicolons": true, + "no_trailing_whitespace": true, + "blank_line_before_statement": { + "statements": [ + "break", + "continue", + "declare", + "return", + "throw", + "try" + ] + }, + "phpdoc_align": { + "align": "left" + }, + "binary_operator_spaces": { + "default": "single_space" + }, + "concat_space": { + "spacing": "one" + }, + "cast_spaces": true, + "unary_operator_spaces": true, + "ternary_operator_spaces": true, + "array_indentation": true, + "trim_array_spaces": true, + "method_argument_space": { + "on_multiline": "ensure_fully_multiline" + }, + "trailing_comma_in_multiline": { + "elements": [ + "arrays", + "arguments", + "parameters", + "match" + ] + }, + "single_quote": true, + "single_line_empty_body": true, + "no_multiple_statements_per_line": true, + "no_extra_blank_lines": true, + "no_whitespace_in_blank_line": true, + "single_blank_line_at_eof": true, + "statement_indentation": true, + "control_structure_braces": true, + "control_structure_continuation_position": true, + "declare_parentheses": true, + "declare_strict_types": true, + "lowercase_keywords": true, + "constant_case": true, + "lowercase_static_reference": true, + "native_function_casing": true, + "nullable_type_declaration_for_default_null_value": true, + "no_superfluous_phpdoc_tags": true, + "phpdoc_trim": true + } +} diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..0cbbcd3 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rector.php b/rector.php index 310abde..e30ddf8 100644 --- a/rector.php +++ b/rector.php @@ -3,14 +3,12 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; -use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector; +use Rector\ValueObject\PhpVersion; -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->paths([ - __DIR__ . '/src' - ]); - $rectorConfig->sets([ - constant("Rector\Set\ValueObject\LevelSetList::UP_TO_PHP_84") - ]); -}; +return RectorConfig::configure() + ->withPaths([__DIR__ . '/src']) + ->withPreparedSets(deadCode: true) + ->withPhpVersion( + constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), + ) + ->withPhpSets(); diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php new file mode 100644 index 0000000..cf5f0fd --- /dev/null +++ b/src/Configuration/SnowflakeConfig.php @@ -0,0 +1,66 @@ +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} + */ + public function resolveNode(): array + { + if ($this->nodeResolver === null) { + return [$this->datacenterId, $this->workerId]; + } + + /** @var array{0:int,1:int} $resolved */ + $resolved = ($this->nodeResolver)(); + + return $resolved; + } +} diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php new file mode 100644 index 0000000..c38ac86 --- /dev/null +++ b/src/Configuration/SonyflakeConfig.php @@ -0,0 +1,58 @@ +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) { + return $this->machineId; + } + + return (int) ($this->machineIdResolver)(); + } +} diff --git a/src/Configuration/TBSLConfig.php b/src/Configuration/TBSLConfig.php new file mode 100644 index 0000000..7d2ced1 --- /dev/null +++ b/src/Configuration/TBSLConfig.php @@ -0,0 +1,38 @@ +machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; + } + + public function resolveMachineId(): int + { + if ($this->machineIdResolver === null) { + return $this->machineId; + } + + return (int) ($this->machineIdResolver)(); + } +} diff --git a/src/Contracts/IdValueInterface.php b/src/Contracts/IdValueInterface.php new file mode 100644 index 0000000..22cdfa1 --- /dev/null +++ b/src/Contracts/IdValueInterface.php @@ -0,0 +1,23 @@ + 'Use BIGINT UNSIGNED for numeric operations and compact indexing.', + 'postgres' => 'Use BIGINT when value fits signed range, otherwise NUMERIC(20,0).', + 'ordering' => 'Snowflake IDs are time-sortable by numeric order.', + ]; + } + + /** + * ULID storage recommendations. + * + * @return array{mysql:string, postgres:string, ordering:string} + */ + public static function ulid(): array + { + return [ + 'mysql' => 'Use CHAR(26) for readability or BINARY(16) when compactness/performance is primary.', + 'postgres' => 'Use CHAR(26) or BYTEA depending on interoperability needs.', + 'ordering' => 'ULID lexical order preserves chronological order in canonical 26-char text.', + ]; + } + + /** + * UUID storage recommendations. + * + * @return array{mysql:string, postgres:string, ordering:string} + */ + public static function uuid(): array + { + return [ + 'mysql' => 'Use BINARY(16) for compact storage; keep generated columns for textual debugging if needed.', + 'postgres' => 'Use native UUID type. It is space-efficient and indexed well.', + 'ordering' => 'UUIDv7 provides better temporal locality for B-Tree indexes than v4.', + ]; + } +} diff --git a/src/DeterministicId.php b/src/DeterministicId.php new file mode 100644 index 0000000..b8f0bde --- /dev/null +++ b/src/DeterministicId.php @@ -0,0 +1,21 @@ += self::$lockTimeout) { - fclose($handle); - ($handle = fopen(self::$fileLocation, "c+")) || throw new RuntimeException( - 'Failed to reopen file after stale lock reset: ' . self::$fileLocation, - ); - - // Acquire exclusive lock after reopening - flock($handle, LOCK_EX); - return $handle; - } + self::$sequenceProvider = $provider; + } - // If max attempts are reached, return false - if (++$attempts >= self::$maxAttempts) { - fclose($handle); - return false; - } - } + /** + * Use the default filesystem-backed sequence provider. + */ + public static function useFilesystemSequenceProvider( + ?string $baseDirectory = null, + int $waitTime = 100, + int $maxAttempts = 10, + ): void { + self::$sequenceProvider = new FilesystemSequenceProvider($baseDirectory, $waitTime, $maxAttempts); + } - return $handle; + /** + * Use in-memory sequence provider (process-local). + */ + public static function useInMemorySequenceProvider(): void + { + self::$sequenceProvider = new InMemorySequenceProvider(); } /** - * Reads the current sequence from the file, increments it, and writes it back. + * Use a user-supplied callback to resolve sequences. * - * @param resource $handle The file handle. - * @param int $dateTime The timestamp for sequence tracking. - * @return int The updated sequence number. + * @param callable(string, int, int):int $callback */ - private static function updateSequence($handle, int $dateTime): int + public static function useSequenceCallback(callable $callback): void { - $sequence = 0; - $line = stream_get_contents($handle); - - if ($line !== false && trim($line) !== '') { - [$lastTimestamp, $lastSequence] = explode(',', trim($line)); - $lastTimestamp = (int) $lastTimestamp; - - if ($lastTimestamp === $dateTime) { - $sequence = (int) $lastSequence; - } - } - - // Increment sequence - $sequence++; + self::$sequenceProvider = new CallbackSequenceProvider($callback); + } - // Move pointer to the beginning and write updated values - rewind($handle); - fwrite($handle, "$dateTime,$sequence"); - ftruncate($handle, ftell($handle)); + /** + * Use PSR-16 simple cache-backed sequence provider. + */ + public static function useSimpleCacheSequenceProvider( + CacheInterface $cache, + string $prefix = 'uid:seq:', + int $waitTime = 100, + int $maxAttempts = 10, + ): void { + self::$sequenceProvider = new PsrSimpleCacheSequenceProvider($cache, $prefix, $waitTime, $maxAttempts); + } - return $sequence; + /** + * Generates a sequence number based on provider strategy. + * + * @param int $dateTime The current time. + * @param int $machineId The machine ID. + * @param string $type The type identifier. + */ + private static function sequence( + int $dateTime, + int $machineId, + string $type, + ?SequenceProviderInterface $provider = null, + ): int { + $provider ??= self::$sequenceProvider ??= new FilesystemSequenceProvider(); + + return $provider->next($type, $machineId, $dateTime); } } diff --git a/src/Id.php b/src/Id.php new file mode 100644 index 0000000..4fb4688 --- /dev/null +++ b/src/Id.php @@ -0,0 +1,396 @@ +toString() : $left; + $rightString = $right instanceof IdValueInterface ? $right->toString() : $right; + + if (preg_match('/^\d+$/', $leftString) && preg_match('/^\d+$/', $rightString)) { + return self::compareUnsignedDecimals($leftString, $rightString); + } + + return strcmp($leftString, $rightString); + } + + /** + * Sorts IDs in ascending order. + * + * @param array $ids + * @return array + */ + public static function sort(array $ids): array + { + usort($ids, self::compare(...)); + + 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 new file mode 100644 index 0000000..3b69098 --- /dev/null +++ b/src/KSUID.php @@ -0,0 +1,84 @@ +getTimestamp() - self::$epoch; + $timestamp = max(0, $timestamp); + + $timeBytes = pack('N', $timestamp); + $payload = random_bytes(16); + $bytes = $timeBytes . $payload; + + return str_pad(BaseEncoder::encodeBytes($bytes, 62), 27, '0', STR_PAD_LEFT); + } + + public static function isValid(string $ksuid): bool + { + return preg_match('/^[0-9A-Za-z]{27}$/', $ksuid) === 1; + } + + /** + * @return array{isValid: bool, time: DateTimeImmutable|null, payload: string|null} + * @throws Exception + */ + public static function parse(string $ksuid): array + { + $data = ['isValid' => self::isValid($ksuid), 'time' => null, 'payload' => null]; + if (!$data['isValid']) { + return $data; + } + + $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; + $data['time'] = new DateTimeImmutable('@' . $timestamp); + $data['payload'] = bin2hex(substr($bytes, 4)); + + return $data; + } + + /** + * @throws Exception + */ + public static function toBytes(string $ksuid): string + { + if (!self::isValid($ksuid)) { + throw new Exception('Invalid KSUID string'); + } + + return BaseEncoder::decodeToBytes($ksuid, 62, 20); + } +} diff --git a/src/OpaqueId.php b/src/OpaqueId.php new file mode 100644 index 0000000..fbe0ca8 --- /dev/null +++ b/src/OpaqueId.php @@ -0,0 +1,52 @@ + 32) && throw new InvalidArgumentException( + 'maxLength must be between 4 and 32', + ); + + self::$cuid2counter ??= random_int(0, PHP_INT_MAX); + $hash = hash_init('sha3-512'); + hash_update($hash, (new DateTimeImmutable('now'))->format('Uv')); + hash_update($hash, (string) self::$cuid2counter++); + hash_update($hash, bin2hex(random_bytes($maxLength))); + hash_update($hash, self::cuid2Fingerprint()); + $encoded = self::hexToBase36(hash_final($hash)); + + return substr(str_pad($encoded, $maxLength, '0'), 0, $maxLength); + } + + /** + * Checks whether a CUID2 string is valid. + */ + public static function isCuid2(string $id): bool + { + return preg_match('/^[0-9a-z]{4,32}$/', $id) === 1; + } + + /** + * Checks whether a NanoID string is valid. + * + * @param int|null $size Optional exact size to validate against. + */ + public static function isNanoId(string $id, ?int $size = null): bool + { + if ($size !== null && strlen($id) !== $size) { + return false; + } + + return preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1; + } + /** * Generates Nano ID of specified size. * @@ -19,34 +69,41 @@ final class RandomId */ public static function nanoId(int $size = 21): string { + ($size < 1) && throw new InvalidArgumentException('size must be greater than 0'); + return substr( str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(random_bytes($size))), 0, - $size + $size, ); } /** - * Generates a CUID-2 string with a specified maximum length. + * Parses CUID2 information. * - * @param int $maxLength The maximum length of the CUID-2 string (default: 24). - * @return string The generated CUID-2 string. - * @throws InvalidArgumentException|Exception + * @return array{isValid: bool, length: int} */ - public static function cuid2(int $maxLength = 24): string + public static function parseCuid2(string $id): array { - ($maxLength < 4 || $maxLength > 32) && throw new InvalidArgumentException( - 'maxLength must be between 4 and 32' - ); + return [ + 'isValid' => self::isCuid2($id), + 'length' => strlen($id), + ]; + } - self::$cuid2counter ??= (int)(random_int(PHP_INT_MIN, PHP_INT_MAX) * 476782367); - $hash = hash_init('sha3-512'); - hash_update($hash, (new DateTimeImmutable('now'))->format('Uv')); - hash_update($hash, (string)self::$cuid2counter++); - hash_update($hash, bin2hex(random_bytes($maxLength))); - hash_update($hash, self::cuid2Fingerprint()); - $hash = hash_final($hash); - return substr(base_convert($hash, 16, 36), 0, $maxLength - 1); + /** + * Parses NanoID information. + * + * @param int|null $size Optional expected size. + * @return array{isValid: bool, length: int, alphabet: string} + */ + public static function parseNanoId(string $id, ?int $size = null): array + { + return [ + 'isValid' => self::isNanoId($id, $size), + 'length' => strlen($id), + 'alphabet' => 'base64url', + ]; } /** @@ -59,8 +116,40 @@ private static function cuid2Fingerprint(): string { $hash = hash_init('sha3-512'); hash_update($hash, gethostname() ?: substr(str_shuffle('abcdefghjkmnpqrstvwxyz0123456789'), 0, 32)); - hash_update($hash, (string)random_int(1, 32768)); + hash_update($hash, (string) random_int(1, 32768)); hash_update($hash, bin2hex(random_bytes(32))); - return bin2hex(hash_final($hash)); + + return hash_final($hash); + } + + /** + * Converts a base16 string to base36 using BCMath for large integer safety. + * + * @param string $hex A hexadecimal string. + */ + private static function hexToBase36(string $hex): string + { + $hex = strtolower(ltrim($hex, '0')); + if ($hex === '') { + return '0'; + } + + $decimal = '0'; + $hexChars = '0123456789abcdef'; + foreach (str_split($hex) as $char) { + ($value = strpos($hexChars, $char)) !== false || throw new InvalidArgumentException( + 'Invalid hexadecimal string provided', + ); + $decimal = bcadd(bcmul($decimal, '16'), (string) $value); + } + + $encoded = ''; + while (bccomp($decimal, '0') === 1) { + $remainder = (int) bcmod($decimal, '36'); + $encoded = self::$cuid2Alphabet[$remainder] . $encoded; + $decimal = bcdiv($decimal, '36', 0); + } + + return $encoded; } } diff --git a/src/Sequence/CallbackSequenceProvider.php b/src/Sequence/CallbackSequenceProvider.php new file mode 100644 index 0000000..76f6c8d --- /dev/null +++ b/src/Sequence/CallbackSequenceProvider.php @@ -0,0 +1,34 @@ +callback = $callback(...); + } + + /** + * @throws FileLockException + */ + public function next(string $type, int $machineId, int $timestamp): int + { + $value = ($this->callback)($type, $machineId, $timestamp); + if ($value < 0) { + throw new FileLockException('Custom sequence callback must return a non-negative integer'); + } + + return $value; + } +} diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php new file mode 100644 index 0000000..018c4ab --- /dev/null +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -0,0 +1,95 @@ +baseDirectory = $baseDirectory ?: sys_get_temp_dir(); + } + + /** + * @throws FileLockException + */ + public function next(string $type, int $machineId, int $timestamp): int + { + $fileLocation = $this->sequenceFileLocation($type, $machineId); + $handle = $this->acquireLock($fileLocation); + + try { + return $this->updateSequence($handle, $timestamp); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + /** + * @return resource + * @throws FileLockException + */ + private function acquireLock(string $fileLocation) + { + ($handle = fopen($fileLocation, 'c+')) || throw new FileLockException( + 'Failed to open sequence file: ' . $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 + { + return $this->baseDirectory . DIRECTORY_SEPARATOR . "uid-$type-$machineId.seq"; + } + + /** + * @param resource $handle + * @throws FileLockException + */ + private function updateSequence($handle, int $timestamp): int + { + $sequence = 0; + $line = stream_get_contents($handle); + + if ($line !== false && trim($line) !== '') { + [$lastTimestamp, $lastSequence] = explode(',', trim($line)); + $lastTimestamp = (int) $lastTimestamp; + + if ($lastTimestamp === $timestamp) { + $sequence = (int) $lastSequence; + } + } + + $sequence++; + + rewind($handle); + fwrite($handle, "$timestamp,$sequence"); + $position = ftell($handle); + ($position !== false && $position >= 0) || throw new FileLockException( + 'Unable to determine sequence file write position', + ); + ftruncate($handle, $position); + + return $sequence; + } +} diff --git a/src/Sequence/InMemorySequenceProvider.php b/src/Sequence/InMemorySequenceProvider.php new file mode 100644 index 0000000..59b82a4 --- /dev/null +++ b/src/Sequence/InMemorySequenceProvider.php @@ -0,0 +1,36 @@ + + */ + private array $state = []; + + public function next(string $type, int $machineId, int $timestamp): int + { + $key = $this->key($type, $machineId); + $last = $this->state[$key] ?? null; + + $sequence = 1; + if ($last !== null && $last['timestamp'] === $timestamp) { + $sequence = $last['sequence'] + 1; + } + + $this->state[$key] = [ + 'timestamp' => $timestamp, + 'sequence' => $sequence, + ]; + + return $sequence; + } + + private function key(string $type, int $machineId): string + { + return $type . ':' . $machineId; + } +} diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php new file mode 100644 index 0000000..cdda9de --- /dev/null +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -0,0 +1,83 @@ +key($type, $machineId); + $lock = $this->acquireLock($key); + + try { + $state = $this->cache->get($key); + $sequence = 1; + if ( + is_array($state) + && ($state['timestamp'] ?? null) === $timestamp + && isset($state['sequence']) + && is_int($state['sequence']) + ) { + $sequence = $state['sequence'] + 1; + } + + $this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence]); + + return $sequence; + } catch (Throwable $exception) { + throw new FileLockException( + 'Failed to read/write sequence state from PSR cache for key: ' . $key, + 0, + $exception, + ); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + /** + * @return resource + * @throws FileLockException + */ + 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( + 'Unable to open sequence cache lock file: ' . $lockFile, + ); + + 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 + { + return $this->prefix . $type . ':' . $machineId; + } +} diff --git a/src/Sequence/SequenceProviderInterface.php b/src/Sequence/SequenceProviderInterface.php new file mode 100644 index 0000000..b0bb119 --- /dev/null +++ b/src/Sequence/SequenceProviderInterface.php @@ -0,0 +1,13 @@ +getMessage(), 0, $exception); + } + } + + /** + * Converts 8-byte Snowflake binary data to decimal string. + * + * @throws SnowflakeException + */ + 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)); + } + + return $decimal; + } + /** * Generates a unique snowflake ID. * @@ -27,59 +74,75 @@ final class Snowflake */ public static function generate(int $datacenter = 0, int $workerId = 0): string { - $maxDataCenter = -1 ^ (-1 << self::$maxDatacenterLength); - $maxWorkId = -1 ^ (-1 << self::$maxWorkIdLength); - - if ($datacenter > $maxDataCenter || $datacenter < 0) { - throw new SnowflakeException("Invalid datacenter ID, must be between 0 ~ $maxDataCenter."); - } - - if ($workerId > $maxWorkId || $workerId < 0) { - throw new SnowflakeException("Invalid worker ID, must be between 0 ~ $maxWorkId."); - } + return (string) self::generateInternal( + $datacenter, + $workerId, + self::getStartTimeStamp(), + ClockBackwardPolicy::WAIT, + IdOutputType::STRING, + ); + } - $currentTime = (int)(new DateTimeImmutable('now'))->format('Uv'); - while (($sequence = self::sequence( - $currentTime, - $datacenter . $workerId, - 'snowflake', - self::$maxSequenceLength - )) > (-1 ^ (-1 << self::$maxSequenceLength))) { - ++$currentTime; - } + /** + * Generates Snowflake using configuration object. + * + * @throws SnowflakeException|FileLockException + */ + public static function generateWithConfig(SnowflakeConfig $config): int|string + { + [$datacenterId, $workerId] = $config->resolveNode(); - $workerLeftMoveLength = self::$maxSequenceLength; - $datacenterLeftMoveLength = self::$maxWorkIdLength + $workerLeftMoveLength; - $timestampLeftMoveLength = self::$maxDatacenterLength + $datacenterLeftMoveLength; + return self::generateInternal( + $datacenterId, + $workerId, + $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(), + $config->clockBackwardPolicy, + $config->outputType, + $config->sequenceProvider, + ); + } - return (string)((($currentTime - self::getStartTimeStamp()) << $timestampLeftMoveLength) - | ($datacenter << $datacenterLeftMoveLength) - | ($workerId << $workerLeftMoveLength) - | ($sequence)); + /** + * Checks whether a Snowflake ID string has a valid numeric shape. + */ + public static function isValid(string $id): bool + { + return preg_match('/^\d+$/', $id) === 1 && $id !== '0'; } /** * Parse the given ID into components. * * @param string $id The ID to parse. - * @return array + * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} * @throws Exception */ public static function parse(string $id): array { - $id = decbin((int)$id); - $time = str_split(bindec(substr($id, 0, -22)) + self::getStartTimeStamp(), 10); + return self::parseWithEpoch($id, self::getStartTimeStamp()); + } + + /** + * Parse Snowflake ID using a custom epoch in milliseconds. + * + * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} + * @throws Exception + */ + public static function parseWithEpoch(string $id, int $startTimestamp): array + { + $id = decbin((int) $id); + $time = str_split((string) (bindec(substr($id, 0, -22)) + $startTimestamp), 10); return [ 'time' => new DateTimeImmutable( '@' . $time[0] . '.' - . str_pad($time[1], 6, '0', STR_PAD_LEFT) + . str_pad($time[1], 6, '0', STR_PAD_LEFT), ), - 'sequence' => bindec(substr($id, -12)), - 'worker_id' => bindec(substr($id, -17, 5)), - 'datacenter_id' => bindec(substr($id, -22, 5)), + 'sequence' => (int) bindec(substr($id, -12)), + 'worker_id' => (int) bindec(substr($id, -17, 5)), + 'datacenter_id' => (int) bindec(substr($id, -22, 5)), ]; } @@ -101,10 +164,9 @@ public static function setStartTimeStamp(string $timeString): void if (($current - $time) > (-1 ^ (-1 << self::$maxTimestampLength))) { throw new SnowflakeException( sprintf( - 'The current microtime - start_time is not allowed to exceed -1 ^ (-1 << %d), - You can reset the start time to fix this', - self::$maxTimestampLength - ) + 'The current microtime - start_time is not allowed to exceed -1 ^ (-1 << %d),\n You can reset the start time to fix this', + self::$maxTimestampLength, + ), ); } @@ -112,12 +174,118 @@ public static function setStartTimeStamp(string $timeString): void } /** - * Retrieves the start timestamp. + * Encodes Snowflake bytes into one of bases: 16, 32, 36, 58, 62. * - * @return float|int The start timestamp in milliseconds. + * @throws SnowflakeException + */ + public static function toBase(string $id, int $base): string + { + return BaseEncoder::encodeBytes(self::toBytes($id), $base); + } + + /** + * Converts a Snowflake decimal string to 8-byte binary representation. + * + * @throws SnowflakeException + */ + 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); + } + + $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; + } + + /** + * @throws SnowflakeException + */ + private static function assertNodeIds(int $datacenter, int $workerId): void + { + $maxDataCenter = -1 ^ (-1 << self::$maxDatacenterLength); + $maxWorkId = -1 ^ (-1 << self::$maxWorkIdLength); + + if ($datacenter > $maxDataCenter || $datacenter < 0) { + throw new SnowflakeException("Invalid datacenter ID, must be between 0 ~ $maxDataCenter."); + } + + if ($workerId > $maxWorkId || $workerId < 0) { + throw new SnowflakeException("Invalid worker ID, must be between 0 ~ $maxWorkId."); + } + } + + /** + * @throws SnowflakeException|FileLockException */ - private static function getStartTimeStamp(): float|int + private static function generateInternal( + int $datacenter, + int $workerId, + int $startTimestamp, + ClockBackwardPolicy $clockBackwardPolicy, + IdOutputType $outputType, + ?SequenceProviderInterface $sequenceProvider = null, + ): int|string { + self::assertNodeIds($datacenter, $workerId); + + $currentTime = (int) (new DateTimeImmutable('now'))->format('Uv'); + if ($currentTime < self::$lastTimestamp) { + if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { + throw new SnowflakeException('Clock moved backwards while generating Snowflake ID'); + } + + $currentTime = self::waitUntil(self::$lastTimestamp); + } + + $sequenceKey = ($datacenter << self::$maxWorkIdLength) | $workerId; + while (($sequence = self::sequence( + $currentTime, + $sequenceKey, + 'snowflake', + $sequenceProvider, + )) > (-1 ^ (-1 << self::$maxSequenceLength))) { + ++$currentTime; + } + self::$lastTimestamp = $currentTime; + + $workerLeftMoveLength = self::$maxSequenceLength; + $datacenterLeftMoveLength = self::$maxWorkIdLength + $workerLeftMoveLength; + $timestampLeftMoveLength = self::$maxDatacenterLength + $datacenterLeftMoveLength; + + $id = (string) ((($currentTime - $startTimestamp) << $timestampLeftMoveLength) + | ($datacenter << $datacenterLeftMoveLength) + | ($workerId << $workerLeftMoveLength) + | ($sequence)); + + return OutputFormatter::formatNumeric($id, $outputType); + } + + /** + * Retrieves the start timestamp. + */ + private static function getStartTimeStamp(): int { return self::$startTime ??= (strtotime('2020-01-01 00:00:00') * 1000); } + + private static function waitUntil(int $timestamp): int + { + do { + usleep(1000); + $now = (int) (new DateTimeImmutable('now'))->format('Uv'); + } while ($now < $timestamp); + + return $now; + } } diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 9a8814b..543da2a 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -1,21 +1,67 @@ getMessage(), 0, $exception); + } + } + + /** + * Converts 8-byte Sonyflake binary data to decimal string. + * + * @throws SonyflakeException + */ + 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; + } + /** * Generates a unique identifier using the SonyFlake algorithm. * @@ -25,43 +71,61 @@ final class Sonyflake */ public static function generate(int $machineId = 0): string { - $maxMachineID = -1 ^ (-1 << self::$maxMachineIdLength); - if ($machineId < 0 || $machineId > $maxMachineID) { - throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maxMachineID."); - } - $now = (int)(new DateTimeImmutable('now'))->format('Uv'); - $elapsedTime = self::elapsedTime(); - while (($sequence = self::sequence( - $now, + return (string) self::generateInternal( $machineId, - 'sonyflake', - self::$maxSequenceLength - )) > (-1 ^ (-1 << self::$maxSequenceLength))) { - $nextMillisecond = self::elapsedTime(); - while ($nextMillisecond === $elapsedTime) { - ++$nextMillisecond; - } - $elapsedTime = $nextMillisecond; - } - self::ensureEffectiveRuntime($elapsedTime); + self::getStartTimeStamp(), + ClockBackwardPolicy::WAIT, + IdOutputType::STRING, + ); + } - return (string)($elapsedTime << (self::$maxMachineIdLength + self::$maxSequenceLength) - | ($machineId << self::$maxSequenceLength) - | ($sequence)); + /** + * Generates Sonyflake using configuration object. + * + * @throws SonyflakeException|FileLockException + */ + public static function generateWithConfig(SonyflakeConfig $config): int|string + { + return self::generateInternal( + $config->resolveMachineId(), + $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(), + $config->clockBackwardPolicy, + $config->outputType, + $config->sequenceProvider, + ); + } + + /** + * Checks whether a Sonyflake ID string has a valid numeric shape. + */ + public static function isValid(string $id): bool + { + return preg_match('/^\d+$/', $id) === 1 && $id !== '0'; } /** * Parse the given ID into components. * * @param string $id The ID to parse. - * @return array + * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} * @throws Exception */ public static function parse(string $id): array { - $id = decbin((int)$id); + return self::parseWithEpoch($id, self::getStartTimeStamp()); + } + + /** + * Parse Sonyflake using custom epoch in milliseconds. + * + * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} + * @throws Exception + */ + public static function parseWithEpoch(string $id, int $startTimestamp): array + { + $id = decbin((int) $id); $length = self::$maxMachineIdLength + self::$maxSequenceLength; - $time = str_split(bindec(substr($id, 0, strlen($id) - $length)) * 10 + self::getStartTimeStamp(), 10); + $time = str_split((string) (bindec(substr($id, 0, strlen($id) - $length)) * 10 + $startTimestamp), 10); return [ 'time' => new DateTimeImmutable( @@ -70,8 +134,8 @@ public static function parse(string $id): array . '.' . str_pad($time[1], 6, '0', STR_PAD_LEFT), ), - 'sequence' => bindec(substr($id, -1 * self::$maxSequenceLength)), - 'machine_id' => bindec(substr($id, -1 * $length, self::$maxMachineIdLength)), + 'sequence' => (int) bindec(substr($id, -1 * self::$maxSequenceLength)), + 'machine_id' => (int) bindec(substr($id, -1 * $length, self::$maxMachineIdLength)), ]; } @@ -79,7 +143,6 @@ public static function parse(string $id): array * Sets the start timestamp for the SonyFlake algorithm. * * @param string $timeString The start time in string format. - * @return void * @throws SonyflakeException */ public static function setStartTimeStamp(string $timeString): void @@ -96,20 +159,53 @@ public static function setStartTimeStamp(string $timeString): void } /** - * Retrieves the start timestamp. + * Encodes Sonyflake bytes into one of bases: 16, 32, 36, 58, 62. * - * @return float|int The start timestamp in milliseconds. + * @throws SonyflakeException */ - private static function getStartTimeStamp(): float|int + public static function toBase(string $id, int $base): string { - return self::$startTime ??= (strtotime('2020-01-01 00:00:00') * 1000); + return BaseEncoder::encodeBytes(self::toBytes($id), $base); + } + + /** + * Converts a Sonyflake decimal string to 8-byte binary representation. + * + * @throws SonyflakeException + */ + public static function toBytes(string $id): string + { + if (!self::isValid($id)) { + throw new SonyflakeException('Invalid Sonyflake ID string'); + } + + $hex = ''; + $value = $id; + while ($value !== '0') { + $remainder = (int) bcmod($value, '16'); + $hex = dechex($remainder) . $hex; + $value = bcdiv($value, '16', 0); + } + + $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; + } + + /** + * Calculates the elapsed time in 10ms units. + */ + private static function elapsedTime(int $startTimestamp): int + { + return floor(((new DateTimeImmutable('now'))->format('Uv') - $startTimestamp) / 10) | 0; } /** * Ensures that the elapsed time does not exceed the maximum life cycle of the algorithm. * * @param int $elapsedTime The elapsed time in milliseconds. - * @return void * @throws SonyflakeException If the elapsed time exceeds the maximum life cycle. */ private static function ensureEffectiveRuntime(int $elapsedTime): void @@ -120,12 +216,65 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void } /** - * Calculates the elapsed time in milliseconds. - * - * @return int unit: 10ms. + * @throws SonyflakeException|FileLockException */ - private static function elapsedTime(): int + private static function generateInternal( + int $machineId, + int $startTimestamp, + ClockBackwardPolicy $clockBackwardPolicy, + IdOutputType $outputType, + ?SequenceProviderInterface $sequenceProvider = null, + ): int|string { + $maxMachineID = -1 ^ (-1 << self::$maxMachineIdLength); + if ($machineId < 0 || $machineId > $maxMachineID) { + throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maxMachineID."); + } + + $elapsedTime = self::elapsedTime($startTimestamp); + + if ($elapsedTime < self::$lastElapsedTime) { + if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { + throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); + } + + $elapsedTime = self::waitUntilElapsed(self::$lastElapsedTime, $startTimestamp); + } + + while (($sequence = self::sequence( + $elapsedTime, + $machineId, + 'sonyflake', + $sequenceProvider, + )) > (-1 ^ (-1 << self::$maxSequenceLength))) { + $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp); + } + self::$lastElapsedTime = $elapsedTime; + + self::ensureEffectiveRuntime($elapsedTime); + + $id = (string) ($elapsedTime << (self::$maxMachineIdLength + self::$maxSequenceLength) + | ($machineId << self::$maxSequenceLength) + | ($sequence)); + + return OutputFormatter::formatNumeric($id, $outputType); + } + + /** + * Retrieves the start timestamp. + */ + private static function getStartTimeStamp(): int { - return floor(((new DateTimeImmutable('now'))->format('Uv') - self::getStartTimeStamp()) / 10) | 0; + return self::$startTime ??= (strtotime('2020-01-01 00:00:00') * 1000); + } + + private static function waitUntilElapsed(int $elapsedTime, int $startTimestamp): int + { + $next = self::elapsedTime($startTimestamp); + while ($next <= $elapsedTime) { + usleep(1000); + $next = self::elapsedTime($startTimestamp); + } + + return $next; } } diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php new file mode 100644 index 0000000..47366d7 --- /dev/null +++ b/src/Support/BaseEncoder.php @@ -0,0 +1,88 @@ + '0123456789abcdef', + 32 => '0123456789abcdefghijklmnopqrstuv', + 36 => '0123456789abcdefghijklmnopqrstuvwxyz', + 58 => '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', + 62 => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', + ]; + + /** + * Decodes one of supported bases (16/32/36/58/62) into bytes. + */ + public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string + { + $alphabet = self::alphabet($base); + $decimal = '0'; + + foreach (str_split($encoded) as $char) { + $index = strpos($alphabet, $char); + $index !== false || throw new InvalidArgumentException('Invalid character for base ' . $base); + $decimal = bcadd(bcmul($decimal, (string) $base), (string) $index); + } + + $hex = ''; + while ($decimal !== '0') { + $remainder = (int) bcmod($decimal, '16'); + $hex = dechex($remainder) . $hex; + $decimal = bcdiv($decimal, '16', 0); + } + + $hex = str_pad($hex, $bytesLength * 2, '0', STR_PAD_LEFT); + $bytes = hex2bin($hex); + if ($bytes === false || strlen($bytes) !== $bytesLength) { + throw new InvalidArgumentException('Invalid encoded value for target byte length'); + } + + return $bytes; + } + + /** + * Encodes bytes into one of supported bases (16/32/36/58/62). + */ + public static function encodeBytes(string $bytes, int $base): string + { + $alphabet = self::alphabet($base); + $decimal = self::bytesToDecimal($bytes); + + if ($decimal === '0') { + return '0'; + } + + $encoded = ''; + while ($decimal !== '0') { + $remainder = (int) bcmod($decimal, (string) $base); + $encoded = $alphabet[$remainder] . $encoded; + $decimal = bcdiv($decimal, (string) $base, 0); + } + + return $encoded; + } + + private static function alphabet(int $base): string + { + return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); + } + + private static function bytesToDecimal(string $bytes): string + { + $decimal = '0'; + foreach (str_split(bin2hex($bytes)) as $char) { + $decimal = bcadd( + bcmul($decimal, '16'), + (string) hexdec($char), + ); + } + + return $decimal; + } +} diff --git a/src/Support/OutputFormatter.php b/src/Support/OutputFormatter.php new file mode 100644 index 0000000..1b5486c --- /dev/null +++ b/src/Support/OutputFormatter.php @@ -0,0 +1,75 @@ + $decimal, + IdOutputType::INT => self::toInt($decimal), + IdOutputType::BINARY => self::toBinary64($decimal), + }; + } + + 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); + } + + /** + * @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); + } + + $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; + } + + /** + * @throws UIDException + */ + private static function toInt(string $decimal): int + { + if (self::compareUnsignedDecimals($decimal, (string) PHP_INT_MAX) === 1) { + throw new UIDException('Numeric ID exceeds PHP_INT_MAX; use string or binary output'); + } + + return (int) $decimal; + } +} diff --git a/src/TBSL.php b/src/TBSL.php index 5d94deb..1ee127c 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -1,68 +1,101 @@ dechex(self::sequence($timeSequence, $machineId, 'tbsl', self::$maxSequenceLength)), - default => bin2hex(random_bytes(3)), - }; + return self::generateInternal( + $config->resolveMachineId(), + $config->sequenced, + $config->clockBackwardPolicy, + $config->outputType, + $config->sequenceProvider, + ); + } + + /** + * Checks whether a TBSL string is valid. + */ + public static function isValid(string $tbsl): bool + { + return (bool) preg_match('/^[0-9A-F]{20}$/', $tbsl); } /** * Parses a TBSL string and returns an array with its components. * * @param string $tbsl The TBSL string to parse. - * @return array ['isValid' => bool, 'time' => DateTimeImmutable|null, 'machineId' => int|null] + * @return array{isValid: bool, time: DateTimeImmutable|null, machineId: int|null} * @throws Exception */ public static function parse(string $tbsl): array { $data = [ - 'isValid' => (bool)preg_match('/^[0-9A-F]{20}$/', $tbsl), + 'isValid' => self::isValid($tbsl), 'time' => null, 'machineId' => null, ]; @@ -73,8 +106,124 @@ public static function parse(string $tbsl): array $storeData = base_convert(substr($tbsl, 0, 15), 16, 10); $data['time'] = new DateTimeImmutable('@' . substr($storeData, 0, 10) . '.' . substr($storeData, 10, 6)); - $data['machineId'] = (int)substr($storeData, -2); + $data['machineId'] = (int) substr($storeData, -2); return $data; } + + /** + * Encodes TBSL bytes into one of bases: 16, 32, 36, 58, 62. + * + * @throws Exception + */ + public static function toBase(string $tbsl, int $base): string + { + return BaseEncoder::encodeBytes(self::toBytes($tbsl), $base); + } + + /** + * Converts a TBSL string to 10-byte binary representation. + * + * @throws Exception + */ + public static function toBytes(string $tbsl): string + { + if (!self::isValid($tbsl)) { + throw new Exception('Invalid TBSL string'); + } + + $bytes = hex2bin($tbsl); + $bytes !== false || throw new Exception('Unable to convert TBSL to bytes'); + + return $bytes; + } + + private static function formatOutput(string $id, IdOutputType $outputType): int|string + { + return match ($outputType) { + IdOutputType::STRING => $id, + IdOutputType::BINARY => self::toBytes($id), + IdOutputType::INT => self::hexToDecimal($id), + }; + } + + /** + * @throws Exception + */ + private static function generateInternal( + int $machineId, + bool $sequenced, + ClockBackwardPolicy $clockBackwardPolicy, + IdOutputType $outputType, + ?SequenceProviderInterface $sequenceProvider = null, + ): int|string { + [$micro, $seconds] = explode(' ', microtime()); + $timeSequence = (int) ($seconds . substr($micro, 2, 6)); + + if ($timeSequence < self::$lastTimeSequence) { + if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { + throw new UIDException('Clock moved backwards while generating TBSL ID'); + } + + $timeSequence = self::waitUntilNextTimeSequence(self::$lastTimeSequence); + } + self::$lastTimeSequence = $timeSequence; + + $storeData = base_convert($timeSequence . sprintf('%02d', $machineId), 10, 16); + $id = strtoupper(sprintf( + '%015s%05s', + $storeData, + substr(self::sequencedGenerate($machineId, $sequenced, $timeSequence, $sequenceProvider), 0, 5), + )); + + return self::formatOutput($id, $outputType); + } + + private static function hexToDecimal(string $hex): int + { + $decimal = '0'; + foreach (str_split(strtolower($hex)) as $char) { + $decimal = bcadd( + bcmul($decimal, '16'), + (string) hexdec($char), + ); + } + + if (bccomp($decimal, (string) PHP_INT_MAX) === 1) { + throw new UIDException('TBSL integer output exceeds PHP_INT_MAX; use string or binary output'); + } + + return (int) $decimal; + } + + /** + * Generates a sequence or random bytes based on the sequencing flag. + * + * @param int $machineId Machine identifier. + * @param bool $enableSequence Whether to enable sequence. + * @param int $timeSequence The timestamp sequence. + * @return string Hexadecimal sequence. + * @throws Exception + */ + private static function sequencedGenerate( + int $machineId, + bool $enableSequence, + int $timeSequence, + ?SequenceProviderInterface $sequenceProvider = null, + ): string { + return match ($enableSequence) { + true => dechex(self::sequence($timeSequence, $machineId, 'tbsl', $sequenceProvider)), + default => bin2hex(random_bytes(3)), + }; + } + + private static function waitUntilNextTimeSequence(int $last): int + { + do { + [$micro, $seconds] = explode(' ', microtime()); + $candidate = (int) ($seconds . substr($micro, 2, 6)); + } while ($candidate <= $last); + + return $candidate; + } } diff --git a/src/ULID.php b/src/ULID.php index 5d3d375..2a1338a 100644 --- a/src/ULID.php +++ b/src/ULID.php @@ -1,61 +1,127 @@ */ private static array $lastRandChars = []; + private static int $randomLength = 16; + + private static int $timeLength = 10; /** - * Generates a ULID (Universally Unique Lexicographically Sortable Identifier). + * Decodes one of bases: 16, 32, 36, 58, 62 into canonical ULID. * - * @param DateTimeInterface|null $dateTime - * @return string - * @throws Exception + * @throws ULIDException */ - public static function generate(?DateTimeInterface $dateTime = null): string + public static function fromBase(string $encoded, int $base): string { - $time = (int)($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); + try { + return self::fromBytes(BaseEncoder::decodeToBytes($encoded, $base, 16)); + } catch (\InvalidArgumentException $exception) { + throw new ULIDException($exception->getMessage(), 0, $exception); + } + } - $isDuplicate = $time === self::$lastGenTime; - self::$lastGenTime = $time; + /** + * Converts 16-byte ULID binary data to canonical ULID string. + * + * @throws ULIDException + */ + public static function fromBytes(string $bytes): string + { + if (strlen($bytes) !== 16) { + throw new ULIDException('ULID binary data must be exactly 16 bytes'); + } - // Generate time characters - $timeChars = ''; - for ($i = self::$timeLength - 1; $i >= 0; $i--) { - $mod = $time % self::$encodingLength; - $timeChars = self::$encodingChars[$mod] . $timeChars; - $time = ($time - $mod) / self::$encodingLength; + $decimal = '0'; + $hexChars = str_split(bin2hex($bytes)); + foreach ($hexChars as $char) { + $value = hexdec($char); + $decimal = bcadd(bcmul($decimal, '16'), (string) $value); } - // Generate random characters - $randChars = ''; - if (!$isDuplicate) { - for ($i = 0; $i < self::$randomLength; $i++) { - self::$lastRandChars[$i] = random_int(0, 31); - } - } else { - for ($i = self::$randomLength - 1; $i >= 0 && self::$lastRandChars[$i] === 31; $i--) { - self::$lastRandChars[$i] = 0; - } - self::$lastRandChars[$i]++; + $encoded = str_repeat('0', 26); + $chars = str_split($encoded); + for ($index = 25; $index >= 0; --$index) { + $remainder = (int) bcmod($decimal, '32'); + $chars[$index] = self::$encodingChars[$remainder]; + $decimal = bcdiv($decimal, '32', 0); } - for ($i = 0; $i < self::$randomLength; $i++) { - $randChars .= self::$encodingChars[self::$lastRandChars[$i]]; + + $ulid = implode('', $chars); + self::isValid($ulid) || throw new ULIDException('Converted bytes produced invalid ULID'); + + return $ulid; + } + + /** + * Generates a ULID (Universally Unique Lexicographically Sortable Identifier). + * + * @throws Exception + */ + public static function generate( + ?DateTimeInterface $dateTime = null, + UlidGenerationMode $mode = UlidGenerationMode::MONOTONIC, + ): string { + $time = (int) ($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); + + $isMonotonic = $mode === UlidGenerationMode::MONOTONIC; + $isDuplicate = $isMonotonic && $time === self::$lastGenTime; + self::$lastGenTime = $time; + + $timeChars = self::encodeTime($time); + if (!$isMonotonic || !$isDuplicate || count(self::$lastRandChars) !== self::$randomLength) { + self::resetRandomState(); + } elseif (!self::incrementRandomState()) { + if ($dateTime !== null) { + throw new ULIDException('Monotonic ULID overflow for the provided timestamp'); + } + + $time = self::waitForNextMillisecond(self::$lastGenTime); + self::$lastGenTime = $time; + $timeChars = self::encodeTime($time); + self::resetRandomState(); } - return $timeChars . $randChars; + return $timeChars . self::randomCharsFromState(); + } + + /** + * Generates ULID in monotonic mode. + * + * @throws Exception + */ + public static function generateMonotonic(?DateTimeInterface $dateTime = null): string + { + return self::generate($dateTime, UlidGenerationMode::MONOTONIC); + } + + /** + * Generates ULID in strict-random mode. + * + * @throws Exception + */ + public static function generateRandom(?DateTimeInterface $dateTime = null): string + { + return self::generate($dateTime, UlidGenerationMode::RANDOM); } /** @@ -79,7 +145,8 @@ public static function getTime(string $ulid): DateTimeImmutable $time += ($encodingIndex * self::$encodingLength ** $index); } - $time = str_split($time, self::$timeLength); + $time = str_split((string) $time, max(1, self::$timeLength)); + $time[1] ??= '0'; if ($time[0] > (time() + (86400 * 365 * 10))) { throw new ULIDException('Invalid ULID string: timestamp too large'); @@ -92,10 +159,127 @@ public static function getTime(string $ulid): DateTimeImmutable * Check if ULID is valid * * @param string $ulid The ULID to be checked - * @return bool */ public static function isValid(string $ulid): bool { - return (bool)preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/', $ulid); + return (bool) preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/', $ulid); + } + + /** + * Encodes ULID bytes into one of bases: 16, 32, 36, 58, 62. + * + * @throws ULIDException + */ + public static function toBase(string $ulid, int $base): string + { + return BaseEncoder::encodeBytes(self::toBytes($ulid), $base); + } + + /** + * Converts a ULID string to 16-byte binary representation. + * + * @throws ULIDException + */ + public static function toBytes(string $ulid): string + { + if (!self::isValid($ulid)) { + 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); + } + + $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; + } + + /** + * Decodes ULID base32 text to an arbitrary precision decimal string. + * + * @throws ULIDException + */ + private static function decodeToDecimal(string $ulid): string + { + $decimal = '0'; + foreach (str_split($ulid) as $char) { + $index = strpos(self::$encodingChars, $char); + if ($index === false) { + throw new ULIDException('Invalid ULID character'); + } + + $decimal = bcadd(bcmul($decimal, '32'), (string) $index); + } + + return $decimal; + } + + /** + * Encodes the ULID millisecond timestamp to Crockford base32. + * + * @param int $time Timestamp in milliseconds. + */ + private static function encodeTime(int $time): string + { + $timeChars = ''; + for ($i = self::$timeLength - 1; $i >= 0; $i--) { + $mod = $time % self::$encodingLength; + $timeChars = self::$encodingChars[$mod] . $timeChars; + $time = ($time - $mod) / self::$encodingLength; + } + + return $timeChars; + } + + private static function incrementRandomState(): bool + { + for ($index = self::$randomLength - 1; $index >= 0; --$index) { + if (self::$lastRandChars[$index] < 31) { + self::$lastRandChars[$index]++; + + return true; + } + + self::$lastRandChars[$index] = 0; + } + + return false; + } + + private static function randomCharsFromState(): string + { + $randChars = ''; + for ($index = 0; $index < self::$randomLength; $index++) { + $randChars .= self::$encodingChars[self::$lastRandChars[$index]]; + } + + return $randChars; + } + + /** + * @throws Exception + */ + private static function resetRandomState(): void + { + for ($index = 0; $index < self::$randomLength; $index++) { + self::$lastRandChars[$index] = random_int(0, 31); + } + } + + private static function waitForNextMillisecond(int $lastTimestamp): int + { + do { + usleep(1000); + $next = (int) (new DateTimeImmutable('now'))->format('Uv'); + } while ($next <= $lastTimestamp); + + return $next; } } diff --git a/src/UUID.php b/src/UUID.php index 99a089b..d6f9be3 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -1,62 +1,325 @@ */ private static array $nsList = [ 'dns' => 0, 'url' => 1, 'oid' => 2, - 'x500' => 4 + 'x500' => 4, ]; - private static array $unixTs = [ + + /** @var array */ + private static array $randomLength = [ + 6 => 2, + 7 => 4, + 8 => 1, + ]; + + private static int $secondIntervals = 10_000_000; + + private static int $secondIntervals78 = 10_000; + + /** @var array */ + private static array $subSec = [ 1 => 0, 6 => 0, 7 => 0, 8 => 0, ]; - private static int $unixTsMs = 0; - private static array $subSec = [ + + private static int $timeOffset = 0x01b21dd213814000; + + /** @var array */ + private static array $unixTs = [ 1 => 0, 6 => 0, 7 => 0, 8 => 0, ]; - private static int $secondIntervals = 10_000_000; - private static int $secondIntervals78 = 10_000; - private static int $timeOffset = 0x01b21dd213814000; - private static array $randomLength = [ - 6 => 2, - 7 => 4, - 8 => 1 - ]; + + /** @var array{timestamp: int, tail: string}|null */ + private static ?array $v7DefaultState = null; + + /** @var array */ + private static array $v7NodeState = []; + + /** + * Converts a UUID to compact (32 hex chars, no dashes) format. + * + * @throws UUIDException + */ + public static function compact(string $uuid): string + { + return self::normalizeInputToHex($uuid); + } + + /** + * Decodes one of bases: 16, 32, 36, 58, 62 into canonical UUID. + * + * @throws UUIDException + */ + public static function fromBase(string $encoded, int $base): string + { + try { + return self::fromBytes(BaseEncoder::decodeToBytes($encoded, $base, 16)); + } catch (\InvalidArgumentException $exception) { + throw new UUIDException($exception->getMessage(), 0, $exception); + } + } + + /** + * Converts 16-byte binary UUID data to canonical UUID string. + * + * @throws UUIDException + */ + public static function fromBytes(string $bytes): string + { + if (strlen($bytes) !== 16) { + throw new UUIDException('UUID binary data must be exactly 16 bytes'); + } + + return self::canonicalFromHex(bin2hex($bytes)); + } + + /** + * Generate unique node. + * + * @return string The generated node. + * @throws Exception + */ + public static function getNode(): string + { + return bin2hex(random_bytes(6)); + } + + /** + * 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 + */ + public static function guid(bool $trim = true): string + { + if (function_exists('com_create_guid') === true) { + $data = com_create_guid(); + if (!is_string($data)) { + throw new UUIDException('Failed to generate GUID'); + } + + return $trim ? trim($data, '{}') : $data; + } + + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + $data = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + + return $trim ? $data : "\{$data\}"; + } + + /** + * Checks whether the given UUID string is MAX. + */ + public static function isMax(string $uuid): bool + { + try { + return self::normalize($uuid) === self::max(); + } catch (UUIDException) { + return false; + } + } + + /** + * Checks whether the given UUID string is NIL. + */ + public static function isNil(string $uuid): bool + { + try { + return self::normalize($uuid) === self::nil(); + } catch (UUIDException) { + return false; + } + } + + /** + * Check if UUID is valid (validates version 1-9 & NIL) + * + * @param string $uuid The UUID to be checked + */ + public static function isValid(string $uuid): bool + { + return (bool) preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-\d[0-9a-f]{3}-[089a-e][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid); + } + + /** + * Converts a UUID to lowercase canonical format. + * + * @throws UUIDException + */ + public static function lowercase(string $uuid): string + { + return strtolower(self::normalize($uuid)); + } + + /** + * Returns the MAX UUID. + */ + public static function max(): string + { + return 'ffffffff-ffff-ffff-ffff-ffffffffffff'; + } + + /** + * Returns the NIL UUID. + */ + public static function nil(): string + { + return '00000000-0000-0000-0000-000000000000'; + } + + /** + * Normalizes a UUID to lowercase canonical format. + * + * @throws UUIDException + */ + public static function normalize(string $uuid): string + { + return self::canonicalFromHex(self::normalizeInputToHex($uuid)); + } + + /** + * Parses a UUID string and returns an array with information about the UUID. + * + * @param string $uuid The UUID string to parse. + * @return array{isValid: bool, version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null} + * @throws Exception + */ + public static function parse(string $uuid): array + { + $uuid = trim($uuid, '{}'); + $data = [ + 'isValid' => self::isValid($uuid), + 'version' => null, + 'variant' => null, + 'time' => null, + 'node' => null, + 'tail' => null, + ]; + + if (!$data['isValid']) { + return $data; + } + + $uuidData = explode('-', $uuid); + if (count($uuidData) !== 5) { + return $data; + } + $variantN = hexdec($uuidData[3][0]); + $data['version'] = (int) $uuidData[2][0]; + $data['time'] = in_array($data['version'], [1, 6, 7, 8]) ? self::getTime($uuidData, $data['version']) : null; + $data['tail'] = $uuidData[4]; + $data['node'] = in_array($data['version'], [7, 8], true) ? null : $uuidData[4]; + $data['variant'] = match (true) { + $variantN <= 7 => 'NCS', + $variantN >= 8 && $variantN <= 11 => 'DCE 1.1, ISO/IEC 11578:1996', + $variantN === 12 || $variantN === 13 => 'Microsoft GUID', + $variantN === 14 => 'Reserved', + default => 'Unknown', + }; + + return $data; + } + + /** + * Encodes UUID bytes into one of bases: 16, 32, 36, 58, 62. + * + * @throws UUIDException + */ + public static function toBase(string $uuid, int $base): string + { + try { + return BaseEncoder::encodeBytes(self::toBytes($uuid), $base); + } catch (\InvalidArgumentException $exception) { + throw new UUIDException($exception->getMessage(), 0, $exception); + } + } + + /** + * Converts a UUID to brace format. + * + * @throws UUIDException + */ + public static function toBraces(string $uuid): string + { + return '{' . self::normalize($uuid) . '}'; + } + + /** + * Converts a UUID string to 16-byte binary representation. + * + * @throws UUIDException + */ + public static function toBytes(string $uuid): string + { + $bytes = hex2bin(self::normalizeInputToHex($uuid)); + $bytes !== false || throw new UUIDException('Unable to convert UUID to bytes'); + + return $bytes; + } + + /** + * Converts a UUID to URN format. + * + * @throws UUIDException + */ + public static function toUrn(string $uuid): string + { + return 'urn:uuid:' . self::normalize($uuid); + } + + /** + * Converts a UUID to uppercase canonical format. + * + * @throws UUIDException + */ + public static function uppercase(string $uuid): string + { + return strtoupper(self::normalize($uuid)); + } /** * Generates a version 1 UUID. * * @param string|null $node The node identifier. Defaults to null. - * @return string * @throws Exception */ - public static function v1(string $node = null): string + public static function v1(?string $node = null): string { [$unixTs, $subSec] = self::getUnixTimeSubSec(); - $time = str_pad(dechex((int)($unixTs . $subSec) + self::$timeOffset), 16, '0', STR_PAD_LEFT); + $time = str_pad(dechex((int) ($unixTs . $subSec) + self::$timeOffset), 16, '0', STR_PAD_LEFT); + return sprintf( '%08s-%04s-1%03s-%04x-%012s', substr($time, -8), substr($time, -12, 4), substr($time, -15, 3), random_int(0, 0x3fff) & 0x3fff | 0x8000, - $node ?? self::getNode() + $node ?? self::getNode(), ); } @@ -65,7 +328,6 @@ public static function v1(string $node = null): string * * @param string $namespace The namespace to use for the hash generation. * @param string $string The string to generate the hash for. - * @return string * @throws UUIDException */ public static function v3(string $namespace, string $string): string @@ -75,6 +337,7 @@ public static function v3(string $namespace, string $string): string throw new UUIDException('Invalid NameSpace!'); } $hash = md5(hex2bin($namespace) . $string); + return self::output(3, $hash); } @@ -87,6 +350,7 @@ public static function v3(string $namespace, string $string): string public static function v4(): string { $string = bin2hex(random_bytes(16)); + return self::output(4, $string); } @@ -95,7 +359,6 @@ public static function v4(): string * * @param string $namespace The namespace to use for the hash generation. * @param string $string The string to generate the hash for. - * @return string * @throws UUIDException */ public static function v5(string $namespace, string $string): string @@ -105,6 +368,7 @@ public static function v5(string $namespace, string $string): string throw new UUIDException('Invalid NameSpace!'); } $hash = sha1(hex2bin($namespace) . $string); + return self::output(5, $hash); } @@ -112,20 +376,22 @@ public static function v5(string $namespace, string $string): string * Generates a Version 6 UUID. * * @param string|null $node The node identifier. Defaults to null. - * @return string * @throws Exception */ - public static function v6(string $node = null): string + public static function v6(?string $node = null): string { [$unixTs, $subSec] = self::getUnixTimeSubSec(6); + $unixTs = (int) $unixTs; + $subSec = (int) $subSec; $timestamp = $unixTs * self::$secondIntervals + $subSec; $timeHex = str_pad(dechex($timestamp + self::$timeOffset), 15, '0', STR_PAD_LEFT); $string = substr_replace( substr($timeHex, -15), '6', -3, - 0 + 0, ) . self::prepareNode(6, $node); + return self::output(6, $string); } @@ -134,19 +400,23 @@ public static function v6(string $node = null): string * * @param DateTimeInterface|null $dateTime An optional DateTimeInterface object to create the UUID. * @param string|null $node The node identifier. Defaults to null. - * @return string * @throws Exception */ - public static function v7(?DateTimeInterface $dateTime = null, string $node = null): string + public static function v7(?DateTimeInterface $dateTime = null, ?string $node = null): string { - $unixTsMs = ($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); - if ($unixTsMs <= self::$unixTsMs) { - $unixTsMs = self::$unixTsMs + 1; + $unixTsMs = (int) ($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); + $isExplicitTimestamp = $dateTime !== null; + + if ($node === null) { + [$unixTsMs, $tail] = self::nextV7DefaultState($unixTsMs, $isExplicitTimestamp); + } else { + [$unixTsMs, $randomPart] = self::nextV7NodeState($node, $unixTsMs, $isExplicitTimestamp); + $tail = $randomPart . $node; } - self::$unixTsMs = $unixTsMs; $string = substr(str_pad(dechex($unixTsMs), 12, '0', STR_PAD_LEFT), -12) - . self::prepareNode(7, $node); + . $tail; + return self::output(7, $string); } @@ -154,122 +424,43 @@ public static function v7(?DateTimeInterface $dateTime = null, string $node = nu * Generates a version 8 UUID. * * @param string|null $node The node identifier. Defaults to null. - * @return string * @throws Exception */ - public static function v8(string $node = null): string + public static function v8(?string $node = null): string { [$unixTs, $subSec] = self::getUnixTimeSubSec(8); + $unixTs = (int) $unixTs; + $subSec = (int) $subSec; $unixTsMs = $unixTs * 1000 + intdiv($subSec, self::$secondIntervals78); $subSec = intdiv(($subSec % self::$secondIntervals78) << 14, self::$secondIntervals78); $subSecA = $subSec >> 2; - $string = substr(str_pad(dechex($unixTsMs), 12, '0', STR_PAD_LEFT), -12) . - '8' . str_pad(dechex($subSecA), 3, '0', STR_PAD_LEFT) . - bin2hex(chr(ord(random_bytes(1)) & 0x0f | ($subSec & 0x03) << 4)) . - self::prepareNode(8, $node); - return self::output(8, $string); - } - - /** - * 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 - */ - public static function guid(bool $trim = true): string - { - if (function_exists('com_create_guid') === true) { - $data = com_create_guid(); - return $trim ? trim($data, '{}') : $data; - } - - $data = random_bytes(16); - $data[6] = chr(ord($data[6]) & 0x0f | 0x40); - $data[8] = chr(ord($data[8]) & 0x3f | 0x80); - $data = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - return $trim ? $data : "\{$data\}"; - } - - /** - * Generate unique node. - * - * @return string The generated node. - * @throws Exception - */ - public static function getNode(): string - { - return bin2hex(random_bytes(6)); - } - - /** - * Parses a UUID string and returns an array with information about the UUID. - * - * @param string $uuid The UUID string to parse. - * @return array ['isValid', 'version', 'time', 'node'] - * @throws Exception - */ - public static function parse(string $uuid): array - { - $uuid = trim($uuid, '{}'); - $data = [ - 'isValid' => self::isValid($uuid), - 'version' => null, - 'variant' => null, - 'time' => null, - 'node' => null - ]; - - if (!$data['isValid']) { - return $data; - } - - $uuidData = explode('-', $uuid); - $variantN = hexdec($uuidData[3][0]); - $data['version'] = (int)$uuidData[2][0]; - $data['time'] = in_array($data['version'], [1, 6, 7, 8]) ? self::getTime($uuidData, $data['version']) : null; - $data['node'] = $uuidData[4]; - $data['variant'] = match (true) { - $variantN <= 7 => 'NCS', - $variantN >= 8 && $variantN <= 11 => 'DCE 1.1, ISO/IEC 11578:1996', - $variantN === 12 || $variantN === 13 => 'Microsoft GUID', - $variantN === 14 => 'Reserved', - default => 'Unknown' - }; - return $data; - } + $string = substr(str_pad(dechex($unixTsMs), 12, '0', STR_PAD_LEFT), -12) + . '8' . str_pad(dechex($subSecA), 3, '0', STR_PAD_LEFT) + . bin2hex(chr(ord(random_bytes(1)) & 0x0f | ($subSec & 0x03) << 4)) + . self::prepareNode(8, $node); - /** - * Generates a random node string based on the given version and node. - * - * @param int $version The version of the node. - * @param string|null $node The node identifier. Defaults to null. - * @return string The generated node string. - * @throws Exception - */ - private static function prepareNode(int $version, string $node = null): string - { - if (!$node) { - return bin2hex(random_bytes(self::$randomLength[$version] + 6)); - } - return bin2hex(random_bytes(self::$randomLength[$version])) . $node; + return self::output(8, $string); } /** - * Check if UUID is valid (validates version 1-9 & NIL) - * - * @param string $uuid The UUID to be checked - * @return bool + * Converts 32-char hex UUID data to canonical dashed format. */ - private static function isValid(string $uuid): bool + private static function canonicalFromHex(string $hex): string { - return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-\d[0-9a-f]{3}-[089a-e][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid); + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12), + ); } /** * Retrieves the time from the UUID. * - * @param array $uuid The UUID array to extract time from. + * @param array{0: string, 1: string, 2: string, 3: string, 4: string} $uuid The UUID array to extract time from. * @param int $version The version of the UUID. * @return DateTimeInterface The DateTimeImmutable object representing the extracted time. * @throws UUIDException|Exception @@ -277,38 +468,44 @@ private static function isValid(string $uuid): bool private static function getTime(array $uuid, int $version): DateTimeInterface { $timestamp = match ($version) { - 1 => substr($uuid[2], -3) . $uuid[1] . $uuid[0], - 6, 8 => $uuid[0] . $uuid[1] . substr($uuid[2], -3), - 7 => sprintf('%011s%04s', $uuid[0], $uuid[1]), - default => throw new UUIDException('Invalid version (applicable: 1, 6, 7, 8)') + 1 => substr((string) $uuid[2], -3) . $uuid[1] . $uuid[0], + 6, 8 => $uuid[0] . $uuid[1] . substr((string) $uuid[2], -3), + 7 => $uuid[0] . $uuid[1], + default => throw new UUIDException('Invalid version (applicable: 1, 6, 7, 8)'), }; switch ($version) { case 7: - $time = str_split(base_convert($timestamp, 16, 10), 10); + $unixTsMs = (int) hexdec($timestamp); + $time = [ + (string) intdiv($unixTsMs, 1000), + str_pad((string) (($unixTsMs % 1000) * 1000), 6, '0', STR_PAD_LEFT), + ]; + break; case 8: $unixTs = hexdec(substr('0' . $timestamp, 0, 13)); $subSec = -( -( - (hexdec(substr('0' . $timestamp, 13)) << 2) + - (hexdec($uuid[3][0]) & 0x03) + (hexdec(substr('0' . $timestamp, 13)) << 2) + + (hexdec((string) $uuid[3][0]) & 0x03) ) * self::$secondIntervals78 >> 14 ); - $time = str_split((string)($unixTs * self::$secondIntervals78 + $subSec), 10); + $time = str_split((string) ($unixTs * self::$secondIntervals78 + $subSec), 10); $time[1] = substr($time[1], 0, 6); + break; default: - $timestamp = base_convert($timestamp, 16, 10); - $epochNanoseconds = bcsub($timestamp, self::$timeOffset); - $time = explode('.', bcdiv($epochNanoseconds, self::$secondIntervals, 6)); + $timestamp = self::hexToDecimal($timestamp); + $epochNanoseconds = bcsub($timestamp, (string) self::$timeOffset); + $time = explode('.', bcdiv($epochNanoseconds, (string) self::$secondIntervals, 6)); } return new DateTimeImmutable( '@' . $time[0] . '.' - . str_pad($time[1], 6, '0', STR_PAD_LEFT) + . str_pad($time[1], 6, '0', STR_PAD_LEFT), ); } @@ -316,20 +513,20 @@ private static function getTime(array $uuid, int $version): DateTimeInterface * Retrieves the Unix timestamp and sub-second component of the current time. * * @param int $version The version of the UUID. Defaults to 1. - * @return array An array containing the Unix timestamp and sub-second component. + * @return array{0: int|string, 1: int|string} An array containing the Unix timestamp and sub-second component. */ private static function getUnixTimeSubSec(int $version = 1): array { $timestamp = microtime(); - $unixTs = substr($timestamp, 11); - $subSec = substr($timestamp, 2, 7); + $unixTs = (int) substr($timestamp, 11); + $subSec = (int) substr($timestamp, 2, 7); if ($version === 1) { - return [$unixTs, $subSec]; + return [(string) $unixTs, str_pad((string) $subSec, 7, '0', STR_PAD_LEFT)]; } if ( - self::$unixTs[$version] > $unixTs || - (self::$unixTs[$version] === $unixTs && - self::$subSec[$version] >= $subSec) + self::$unixTs[$version] > $unixTs + || (self::$unixTs[$version] === $unixTs + && self::$subSec[$version] >= $subSec) ) { $unixTs = self::$unixTs[$version]; $subSec = self::$subSec[$version]; @@ -342,9 +539,196 @@ private static function getUnixTimeSubSec(int $version = 1): array } self::$unixTs[$version] = $unixTs; self::$subSec[$version] = $subSec; + return [$unixTs, $subSec]; } + /** + * @return numeric-string + */ + private static function hexToDecimal(string $hex): string + { + $decimal = '0'; + $hex = strtolower(ltrim($hex, '0')); + if ($hex === '') { + return '0'; + } + + foreach (str_split($hex) as $char) { + $decimal = bcadd( + bcmul($decimal, '16'), + (string) hexdec($char), + ); + } + + return $decimal; + } + + /** + * Increments a hexadecimal counter string by one. + * + * @return string|null The incremented value or null if overflow occurred. + */ + private static function incrementHexCounter(string $hex): ?string + { + $chars = str_split(strtolower($hex)); + $hexChars = '0123456789abcdef'; + $nextNibbles = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; + for ($index = count($chars) - 1; $index >= 0; --$index) { + $value = strpos($hexChars, $chars[$index]); + if ($value === false) { + return null; + } + + if ($value === 15) { + $chars[$index] = '0'; + + continue; + } + + $nextNibble = $nextNibbles[$value] ?? null; + if ($nextNibble === null) { + return null; + } + $chars[$index] = $nextNibble; + + return implode('', $chars); + } + + return null; + } + + /** + * @return array{0: int, 1: string} + * @throws Exception + */ + private static function nextV7DefaultState(int $unixTsMs, bool $isExplicitTimestamp): array + { + $state = self::$v7DefaultState; + + if ($state === null || $unixTsMs > $state['timestamp']) { + $tail = self::randomV7Tail(); + self::$v7DefaultState = ['timestamp' => $unixTsMs, 'tail' => $tail]; + + return [$unixTsMs, $tail]; + } + + if ($isExplicitTimestamp && $state['timestamp'] !== $unixTsMs) { + $tail = self::randomV7Tail(); + self::$v7DefaultState = ['timestamp' => $unixTsMs, 'tail' => $tail]; + + return [$unixTsMs, $tail]; + } + + $unixTsMs = $state['timestamp']; + $tail = self::incrementHexCounter($state['tail']); + if ($tail === null) { + if ($isExplicitTimestamp) { + throw new UUIDException('Monotonic UUID v7 overflow for the provided timestamp'); + } + + $unixTsMs = self::nextV7Timestamp($state['timestamp']); + $tail = self::randomV7Tail(); + } + + self::$v7DefaultState = ['timestamp' => $unixTsMs, 'tail' => $tail]; + + return [$unixTsMs, $tail]; + } + + /** + * @return array{0: int, 1: string} + * @throws Exception + */ + private static function nextV7NodeState(string $node, int $unixTsMs, bool $isExplicitTimestamp): array + { + $stateKey = 'node:' . $node; + $state = self::$v7NodeState[$stateKey] ?? null; + + if ($state === null || $unixTsMs > $state['timestamp']) { + $randomPart = self::randomV7NodePart(); + self::$v7NodeState[$stateKey] = ['timestamp' => $unixTsMs, 'random' => $randomPart]; + + return [$unixTsMs, $randomPart]; + } + + if ($isExplicitTimestamp && $state['timestamp'] !== $unixTsMs) { + $randomPart = self::randomV7NodePart(); + self::$v7NodeState[$stateKey] = ['timestamp' => $unixTsMs, 'random' => $randomPart]; + + return [$unixTsMs, $randomPart]; + } + + $unixTsMs = $state['timestamp']; + $randomPart = self::incrementHexCounter($state['random']); + if ($randomPart === null) { + if ($isExplicitTimestamp) { + throw new UUIDException('Monotonic UUID v7 overflow for the provided timestamp'); + } + + $unixTsMs = self::nextV7Timestamp($state['timestamp']); + $randomPart = self::randomV7NodePart(); + } + + self::$v7NodeState[$stateKey] = ['timestamp' => $unixTsMs, 'random' => $randomPart]; + + return [$unixTsMs, $randomPart]; + } + + /** + * Waits until the system clock moves to the next millisecond. + */ + private static function nextV7Timestamp(int $lastTimestamp): int + { + do { + usleep(1000); + $next = (int) (new DateTimeImmutable('now'))->format('Uv'); + } while ($next <= $lastTimestamp); + + return $next; + } + + /** + * Normalizes possible UUID input variants to 32-char lowercase hex. + * + * @throws UUIDException + */ + private static function normalizeInputToHex(string $uuid): string + { + $uuid = trim($uuid); + if (stripos($uuid, 'urn:uuid:') === 0) { + $uuid = substr($uuid, 9); + } + + $uuid = trim($uuid, '{}'); + $hex = str_replace('-', '', $uuid); + + if (!preg_match('/^[0-9a-f]{32}$/i', $hex)) { + throw new UUIDException('Invalid UUID format'); + } + + return strtolower($hex); + } + + /** + * Resolves the given namespace. + * + * @param string $namespace The namespace to be resolved. + * @return string The resolved namespace or false if it cannot be resolved. + */ + private static function nsResolve(string $namespace): string + { + if (self::isValid($namespace)) { + return str_replace('-', '', $namespace); + } + $namespace = str_replace(['namespace', 'ns', '_'], '', strtolower($namespace)); + if (isset(self::$nsList[$namespace])) { + return '6ba7b81' . self::$nsList[$namespace] . '9dad11d180b400c04fd430c8'; + } + + return ''; + } + /** * Generates a formatted string based on the given version and string. * @@ -355,31 +739,60 @@ private static function getUnixTimeSubSec(int $version = 1): array private static function output(int $version, string $id): string { $string = str_split($id, 4); + return sprintf( "%08s-%04s-$version%03s-%04x-%012s", $string[0] . $string[1], $string[2], substr($string[3], 1, 3), hexdec($string[4]) & 0x3fff | 0x8000, - $string[5] . $string[6] . $string[7] + $string[5] . $string[6] . $string[7], ); } /** - * Resolves the given namespace. + * Generates a random node string based on the given version and node. * - * @param string $namespace The namespace to be resolved. - * @return string The resolved namespace or false if it cannot be resolved. + * @param int $version The version of the node. + * @param string|null $node The node identifier. Defaults to null. + * @return string The generated node string. + * @throws Exception */ - private static function nsResolve(string $namespace): string + private static function prepareNode(int $version, ?string $node = null): string { - if (self::isValid($namespace)) { - return str_replace('-', '', $namespace); + if (!$node) { + return bin2hex(random_bytes(self::randomLengthFor($version) + 6)); } - $namespace = str_replace(['namespace', 'ns', '_'], '', strtolower($namespace)); - if (isset(self::$nsList[$namespace])) { - return "6ba7b81" . self::$nsList[$namespace] . "9dad11d180b400c04fd430c8"; + + return bin2hex(random_bytes(self::randomLengthFor($version))) . $node; + } + + /** + * @return int<1, max> + */ + private static function randomLengthFor(int $version): int + { + $length = self::$randomLength[$version] ?? throw new UUIDException('Unsupported UUID version for random length'); + if ($length < 1) { + throw new UUIDException('Random length must be greater than zero'); } - return ''; + + return $length; + } + + /** + * @throws Exception + */ + private static function randomV7NodePart(): string + { + return bin2hex(random_bytes(self::randomLengthFor(7))); + } + + /** + * @throws Exception + */ + private static function randomV7Tail(): string + { + return bin2hex(random_bytes(self::randomLengthFor(7) + 6)); } } diff --git a/src/Value/SnowflakeValue.php b/src/Value/SnowflakeValue.php new file mode 100644 index 0000000..856b408 --- /dev/null +++ b/src/Value/SnowflakeValue.php @@ -0,0 +1,74 @@ +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']; + } + + public function getMachineId(): string + { + return $this->parsed['datacenter_id'] . ':' . $this->parsed['worker_id']; + } + + public function getTimestamp(): DateTimeImmutable + { + return $this->parsed['time']; + } + + public function getVersion(): ?int + { + return null; + } + + public function getWorkerId(): int + { + return $this->parsed['worker_id']; + } + + public function isSortable(): bool + { + return true; + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/src/Value/SonyflakeValue.php b/src/Value/SonyflakeValue.php new file mode 100644 index 0000000..b0127e6 --- /dev/null +++ b/src/Value/SonyflakeValue.php @@ -0,0 +1,64 @@ +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']; + } + + public function getTimestamp(): DateTimeImmutable + { + return $this->parsed['time']; + } + + public function getVersion(): ?int + { + return null; + } + + public function isSortable(): bool + { + return true; + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/src/Value/TbslValue.php b/src/Value/TbslValue.php new file mode 100644 index 0000000..51ecf0b --- /dev/null +++ b/src/Value/TbslValue.php @@ -0,0 +1,63 @@ +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']; + } + + public function getTimestamp(): ?DateTimeImmutable + { + return $this->parsed['time']; + } + + public function getVersion(): ?int + { + return null; + } + + public function isSortable(): bool + { + return true; + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/src/Value/UlidValue.php b/src/Value/UlidValue.php new file mode 100644 index 0000000..eec3257 --- /dev/null +++ b/src/Value/UlidValue.php @@ -0,0 +1,56 @@ +value = $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|string|null + { + return null; + } + + public function getTimestamp(): \DateTimeImmutable + { + return ULID::getTime($this->value); + } + + public function getVersion(): ?int + { + return null; + } + + public function isSortable(): bool + { + return true; + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/src/Value/UuidValue.php b/src/Value/UuidValue.php new file mode 100644 index 0000000..1084c12 --- /dev/null +++ b/src/Value/UuidValue.php @@ -0,0 +1,77 @@ +value = UUID::normalize($value); + $this->parsed = UUID::parse($this->value); + } + + public function __toString(): string + { + return $this->toString(); + } + + public function compare(IdValueInterface|string $other): int + { + $otherValue = $other instanceof IdValueInterface ? $other->toString() : UUID::normalize($other); + + return strcmp($this->value, $otherValue); + } + + public function getMachineId(): ?string + { + return $this->parsed['node']; + } + + public function getTimestamp(): ?DateTimeImmutable + { + $time = $this->parsed['time']; + if ($time === null) { + return null; + } + + if ($time instanceof DateTimeImmutable) { + return $time; + } + + try { + return new DateTimeImmutable($time->format(DateTimeInterface::RFC3339_EXTENDED)); + } catch (Throwable) { + return DateTimeImmutable::createFromInterface($time); + } + } + + public function getVersion(): ?int + { + return $this->parsed['version']; + } + + public function isSortable(): bool + { + return in_array($this->getVersion(), [6, 7, 8], true); + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/src/XID.php b/src/XID.php new file mode 100644 index 0000000..7aa7e27 --- /dev/null +++ b/src/XID.php @@ -0,0 +1,119 @@ + self::isValid($xid), 'time' => null, 'machine' => null, 'pid' => null, 'counter' => null]; + if (!$data['isValid']) { + return $data; + } + + $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'); + $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; + + return $data; + } + + /** + * @throws Exception + */ + public static function toBytes(string $xid): string + { + if (!self::isValid($xid)) { + throw new Exception('Invalid XID string'); + } + + return BaseEncoder::decodeToBytes($xid, 32, 12); + } + + private static function counterBytes(): string + { + self::$counter ??= random_int(0, 0xFFFFFF); + self::$counter = (self::$counter + 1) & 0xFFFFFF; + + return substr(pack('N', self::$counter), 1, 3); + } + + private static function machine(): string + { + return self::$machine ??= substr(hash('sha1', gethostname() ?: 'localhost', true), 0, 3); + } + + private static function pidBytes(): string + { + if (self::$pid !== null) { + return self::$pid; + } + + return self::$pid = pack('n', getmypid() % 0x10000); + } +} diff --git a/src/functions.php b/src/functions.php index fd7770e..e6bcd12 100644 --- a/src/functions.php +++ b/src/functions.php @@ -1,13 +1,21 @@ toBeTrue() + ->and($id)->toHaveLength(27) + ->and($parsed['isValid'])->toBeTrue() + ->and($parsed['time'])->not()->toBeNull(); +}); + +test('XID generation and parsing', function () { + $id = XID::generate(); + $parsed = XID::parse($id); + + expect(XID::isValid($id))->toBeTrue() + ->and($id)->toHaveLength(20) + ->and($parsed['isValid'])->toBeTrue() + ->and($parsed['time'])->not()->toBeNull(); +}); + +test('Opaque and deterministic IDs', function () { + $opaque = OpaqueId::random(14); + $det1 = DeterministicId::fromPayload('payload', 20, 'ns'); + $det2 = DeterministicId::fromPayload('payload', 20, 'ns'); + + expect($opaque)->toHaveLength(14) + ->and($det1)->toHaveLength(20) + ->and($det1)->toBe($det2); +}); + +test('IdComparator sorts numeric and lexical values', function () { + $sortedNumeric = IdComparator::sort(['10', '2', '1']); + $sortedLexical = IdComparator::sort(['b', 'a', 'c']); + + expect($sortedNumeric)->toBe(['1', '2', '10']) + ->and($sortedLexical)->toBe(['a', 'b', 'c']); +}); diff --git a/tests/IdFactoryTest.php b/tests/IdFactoryTest.php new file mode 100644 index 0000000..9931f33 --- /dev/null +++ b/tests/IdFactoryTest.php @@ -0,0 +1,72 @@ +toBeString()->toHaveLength(27) + ->and($xid)->toBeString()->toHaveLength(20) + ->and($uuid1)->toBeString()->toHaveLength(36) + ->and($uuid3)->toBeString()->toHaveLength(36) + ->and($uuid4)->toBeString()->toHaveLength(36) + ->and($uuid5)->toBeString()->toHaveLength(36) + ->and($uuid6)->toBeString()->toHaveLength(36) + ->and($uuid)->toBeString()->toHaveLength(36) + ->and($uuid8)->toBeString()->toHaveLength(36) + ->and($ulid)->toBeString()->toHaveLength(26) + ->and((string)$snowflake)->toBeString()->not()->toBeEmpty() + ->and((string)$sonyflake)->toBeString()->not()->toBeEmpty() + ->and((string)$tbsl)->toBeString()->toHaveLength(20); +}); + +test('Id factory value objects', function () { + $uuidValue = Id::uuid7Value(); + expect($uuidValue)->toBeInstanceOf(UuidValue::class) + ->and($uuidValue->toString())->toHaveLength(36) + ->and($uuidValue->getVersion())->toBe(7); +}); + +test('Id factory random strategy', function () { + $nano = Id::nanoId(10); + $cuid2 = Id::cuid2(24); + $opaque = Id::opaque(10); + $deterministic = Id::deterministic('payload', 16, 'ns'); + + expect($nano)->toHaveLength(10) + ->and($cuid2)->toHaveLength(24) + ->and(Id::nanoIdIsValid($nano, 10))->toBeTrue() + ->and(Id::cuid2IsValid($cuid2))->toBeTrue() + ->and($opaque)->toHaveLength(10) + ->and($deterministic)->toHaveLength(16); +}); + +test('configuration objects apply output modes', function () { + $snowflake = Id::snowflake(new SnowflakeConfig(outputType: IdOutputType::INT)); + $sonyflake = Id::sonyflake(new SonyflakeConfig(outputType: IdOutputType::INT)); + $tbsl = Id::tbsl(new TBSLConfig(outputType: IdOutputType::BINARY)); + + expect($snowflake)->toBeInt() + ->and($sonyflake)->toBeInt() + ->and($tbsl)->toBeString() + ->and(strlen($tbsl))->toBe(10); +}); diff --git a/tests/RandomIdTest.php b/tests/RandomIdTest.php index 0182862..383fedd 100644 --- a/tests/RandomIdTest.php +++ b/tests/RandomIdTest.php @@ -4,10 +4,42 @@ test('CUID2', function () { $string = RandomId::cuid2(); - expect($string)->toBeString()->not()->toBeEmpty(); + expect($string) + ->toBeString() + ->not()->toBeEmpty() + ->toHaveLength(24) + ->toMatch('/^[0-9a-z]+$/'); +}); + +test('CUID2 custom length', function () { + $string = RandomId::cuid2(32); + expect($string)->toHaveLength(32)->toMatch('/^[0-9a-z]+$/'); }); test('nanoId', function () { $string = RandomId::nanoId(); expect($string)->toBeString()->not()->toBeEmpty()->toHaveLength(21); }); + +test('global helper functions for NanoID and CUID2', function () { + expect(nanoid(10))->toHaveLength(10) + ->and(cuid2(24))->toHaveLength(24); +}); + +test('NanoID and CUID2 validation and parse', function () { + $nano = RandomId::nanoId(12); + $cuid = RandomId::cuid2(24); + + $nanoParsed = RandomId::parseNanoId($nano, 12); + $cuidParsed = RandomId::parseCuid2($cuid); + + expect(RandomId::isNanoId($nano, 12))->toBeTrue() + ->and($nanoParsed['isValid'])->toBeTrue() + ->and($nanoParsed['length'])->toBe(12) + ->and($nanoParsed['alphabet'])->toBe('base64url') + ->and(RandomId::isCuid2($cuid))->toBeTrue() + ->and($cuidParsed['isValid'])->toBeTrue() + ->and($cuidParsed['length'])->toBe(24) + ->and(nanoid_is_valid($nano, 12))->toBeTrue() + ->and(cuid2_is_valid($cuid))->toBeTrue(); +}); diff --git a/tests/SequenceProviderTest.php b/tests/SequenceProviderTest.php new file mode 100644 index 0000000..211a082 --- /dev/null +++ b/tests/SequenceProviderTest.php @@ -0,0 +1,106 @@ +toBeGreaterThan((int)$id1); +}); + +test('custom callback sequence provider works', function () { + $counter = 0; + Snowflake::useSequenceCallback(function () use (&$counter): int { + $counter++; + return $counter; + }); + + $id1 = Snowflake::generate(0, 0); + $id2 = Snowflake::generate(0, 0); + + expect($id1)->not()->toBe($id2); +}); + +test('psr-16 sequence provider works', function () { + $cache = new class implements CacheInterface { + /** @var array */ + private array $store = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->store[$key] ?? $default; + } + + public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool + { + unset($ttl); + $this->store[$key] = $value; + return true; + } + + public function delete(string $key): bool + { + unset($this->store[$key]); + return true; + } + + public function clear(): bool + { + $this->store = []; + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $values = []; + foreach ($keys as $key) { + $values[$key] = $this->get($key, $default); + } + + return $values; + } + + public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set((string)$key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete((string)$key); + } + + return true; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->store); + } + }; + + Snowflake::setSequenceProvider(new PsrSimpleCacheSequenceProvider($cache)); + + $id1 = Snowflake::generate(2, 3); + $id2 = Snowflake::generate(2, 3); + + expect((int)$id2)->toBeGreaterThan((int)$id1); +}); diff --git a/tests/SnowflakeTest.php b/tests/SnowflakeTest.php index 0ff3b18..77faaf8 100644 --- a/tests/SnowflakeTest.php +++ b/tests/SnowflakeTest.php @@ -1,30 +1,36 @@ getTimestamp())->toBeBetween(time() - 1, time()) + expect($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt) ->and($parsed['worker_id'])->toBe(0) ->and($parsed['datacenter_id'])->toBe(0); }); test('Snowflake ID Uniqueness', function () { - $id1 = Snowflake::generate(); - usleep(10); - $id2 = Snowflake::generate(); + $ids = []; + for ($i = 0; $i < 100; $i++) { + $ids[] = Snowflake::generate(); + } - expect($id1)->not->toBe($id2); + expect(count(array_unique($ids)))->toBe(count($ids)); }); test('Snowflake Sequential Order', function () { - $id1 = Snowflake::generate(); - usleep(10); - $id2 = Snowflake::generate(); - - expect((int) $id2)->toBeGreaterThan((int) $id1); + $previous = (int) Snowflake::generate(); + for ($i = 0; $i < 100; $i++) { + $current = (int) Snowflake::generate(); + expect($current)->toBeGreaterThan($previous); + $previous = $current; + } }); test('Snowflake Datacenter and Worker Differentiation', function () { @@ -45,10 +51,90 @@ for ($i = 0; $i <= $maxSeq; $i++) { $id1 = Snowflake::generate(); } - usleep(10); $id2 = Snowflake::generate(); expect((int) $id2)->toBeGreaterThan((int) $id1); }); +test('Snowflake uses distinct sequence files per worker combination', function () { + $sequenceKeyA = (1 << 5) | 2; + $sequenceKeyB = (3 << 5) | 4; + + $fileA = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "uid-snowflake-$sequenceKeyA.seq"; + $fileB = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "uid-snowflake-$sequenceKeyB.seq"; + + if (file_exists($fileA)) { + unlink($fileA); + } + if (file_exists($fileB)) { + unlink($fileB); + } + + Snowflake::generate(1, 2); + Snowflake::generate(3, 4); + + expect(file_exists($fileA))->toBeTrue() + ->and(file_exists($fileB))->toBeTrue(); + + if (file_exists($fileA)) { + unlink($fileA); + } + if (file_exists($fileB)) { + unlink($fileB); + } +}); + +test('Snowflake sequence key does not collide for ambiguous decimal concatenations', function () { + $sequenceKeyA = (1 << 5) | 23; + $sequenceKeyB = (12 << 5) | 3; + $fileA = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "uid-snowflake-$sequenceKeyA.seq"; + $fileB = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "uid-snowflake-$sequenceKeyB.seq"; + + if (file_exists($fileA)) { + unlink($fileA); + } + if (file_exists($fileB)) { + unlink($fileB); + } + + Snowflake::generate(1, 23); + Snowflake::generate(12, 3); + + expect(file_exists($fileA))->toBeTrue() + ->and(file_exists($fileB))->toBeTrue(); + + if (file_exists($fileA)) { + unlink($fileA); + } + if (file_exists($fileB)) { + unlink($fileB); + } +}); + +test('Snowflake validation helper', function () { + $id = Snowflake::generate(); + + expect(Snowflake::isValid($id))->toBeTrue() + ->and(Snowflake::isValid('abc'))->toBeFalse() + ->and(Snowflake::isValid('0'))->toBeFalse(); +}); + +test('Snowflake bytes and base conversion roundtrip', function () { + $id = Snowflake::generate(); + $bytes = Snowflake::toBytes($id); + $encoded = Snowflake::toBase($id, 36); + + expect(strlen($bytes))->toBe(8) + ->and(Snowflake::fromBytes($bytes))->toBe($id) + ->and(Snowflake::fromBase($encoded, 36))->toBe($id); +}); + +test('Snowflake config supports output modes', function () { + $intId = Snowflake::generateWithConfig(new SnowflakeConfig(outputType: IdOutputType::INT)); + $binaryId = Snowflake::generateWithConfig(new SnowflakeConfig(outputType: IdOutputType::BINARY)); + + expect($intId)->toBeInt() + ->and($binaryId)->toBeString() + ->and(strlen($binaryId))->toBe(8); +}); diff --git a/tests/SonyflakeTest.php b/tests/SonyflakeTest.php index c8725b3..15d730f 100644 --- a/tests/SonyflakeTest.php +++ b/tests/SonyflakeTest.php @@ -1,30 +1,44 @@ getTimestamp())->toBeBetween(time() - 1, time()) + expect($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt) ->and($parsed['machine_id'])->toBe(0); }); test('Sonyflake ID Uniqueness', function () { - $id1 = Sonyflake::generate(); - usleep(10); - $id2 = Sonyflake::generate(); + $ids = []; + for ($i = 0; $i < 100; $i++) { + $ids[] = Sonyflake::generate(); + } - expect($id1)->not->toBe($id2); -})->skip(); + expect(count(array_unique($ids)))->toBe(count($ids)); +}); test('Sonyflake Sequential Order', function () { - $id1 = Sonyflake::generate(); - usleep(10); - $id2 = Sonyflake::generate(); - - expect((int) $id2)->toBeGreaterThan((int) $id1); -})->skip(); + $previous = (int) Sonyflake::generate(); + for ($i = 0; $i < 100; $i++) { + $current = (int) Sonyflake::generate(); + expect($current)->toBeGreaterThan($previous); + $previous = $current; + } +}); test('Sonyflake Machine ID Differentiation', function () { $id1 = Sonyflake::generate(1); @@ -37,15 +51,62 @@ }); test('Sonyflake Max Sequence Handling', function () { - $maxSeq = (-1 ^ (-1 << 8)); + $firstTimestamp = null; + $attempts = 0; - $id1 = Sonyflake::generate(); - for ($i = 0; $i <= $maxSeq; $i++) { - $id1 = Sonyflake::generate(); - } - usleep(10); - $id2 = Sonyflake::generate(); + Sonyflake::useSequenceCallback(function (string $type, int $machineId, int $timestamp) use ( + &$firstTimestamp, + &$attempts + ): int { + unset($type, $machineId); + $attempts++; + + if ($firstTimestamp === null) { + $firstTimestamp = $timestamp; + return 256; + } + + if ($timestamp === $firstTimestamp) { + if ($attempts > 64) { + throw new \RuntimeException('Sonyflake did not advance timestamp after sequence overflow'); + } - expect((int) $id2)->toBeGreaterThan((int) $id1); -})->skip(); + return 256; + } + return 1; + }); + + $id = Sonyflake::generate(); + $parsed = Sonyflake::parse($id); + + expect($parsed['sequence'])->toBe(1) + ->and($attempts)->toBeGreaterThan(1); +}); + +test('Sonyflake validation helper', function () { + $id = Sonyflake::generate(); + + expect(Sonyflake::isValid($id))->toBeTrue() + ->and(Sonyflake::isValid('abc'))->toBeFalse() + ->and(Sonyflake::isValid('0'))->toBeFalse(); +}); + +test('Sonyflake bytes and base conversion roundtrip', function () { + $id = Sonyflake::generate(); + $bytes = Sonyflake::toBytes($id); + $encoded = Sonyflake::toBase($id, 58); + + expect(strlen($bytes))->toBe(8) + ->and(Sonyflake::fromBytes($bytes))->toBe($id) + ->and(Sonyflake::fromBase($encoded, 58))->toBe($id); +}); + +test('Sonyflake config supports output modes', function () { + $intId = Sonyflake::generateWithConfig(new SonyflakeConfig(outputType: IdOutputType::INT)); + $binaryId = Sonyflake::generateWithConfig(new SonyflakeConfig(outputType: IdOutputType::BINARY)); + + expect($intId)->toBeInt() + ->and($binaryId)->toBeString() + ->and(strlen($binaryId))->toBe(8); +}); diff --git a/tests/TBSLTest.php b/tests/TBSLTest.php index 8441a60..d1028a7 100644 --- a/tests/TBSLTest.php +++ b/tests/TBSLTest.php @@ -1,29 +1,31 @@ toBeTrue() - ->and($parsed['time']->getTimestamp())->toBeBetween(time() - 1, time()) + ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt) ->and($parsed['machineId'])->toBe(0); }); test('TBSL ID Uniqueness', function () { - $id1 = TBSL::generate(); - usleep(10); // Ensure slight time difference - $id2 = TBSL::generate(); + $id1 = TBSL::generate(0, true); + $id2 = TBSL::generate(0, true); expect($id1)->not->toBe($id2); }); test('TBSL Sequential Order', function () { - $id1 = TBSL::generate(); - usleep(10); - $id2 = TBSL::generate(); + $id1 = TBSL::generate(0, true); + $id2 = TBSL::generate(0, true); - expect(hexdec($id2))->toBeGreaterThan(hexdec($id1)); + expect(strcmp($id2, $id1))->toBeGreaterThan(0); }); test('TBSL Machine ID Differentiation', function () { @@ -36,3 +38,31 @@ expect($parsed1['machineId'])->not->toBe($parsed2['machineId']); }); +test('TBSL bytes conversion roundtrip', function () { + $id = TBSL::generate(); + $bytes = TBSL::toBytes($id); + + expect(strlen($bytes))->toBe(10) + ->and(TBSL::fromBytes($bytes))->toBe($id); +}); + +test('TBSL validation helper', function () { + $id = TBSL::generate(); + + expect(TBSL::isValid($id))->toBeTrue() + ->and(TBSL::isValid('ZZZZ'))->toBeFalse(); +}); + +test('TBSL base conversion roundtrip', function () { + $id = TBSL::generate(); + $encoded = TBSL::toBase($id, 62); + + expect(TBSL::fromBase($encoded, 62))->toBe($id); +}); + +test('TBSL config supports output mode', function () { + $binary = TBSL::generateWithConfig(new TBSLConfig(outputType: IdOutputType::BINARY)); + + expect($binary)->toBeString() + ->and(strlen($binary))->toBe(10); +}); diff --git a/tests/ULIDTest.php b/tests/ULIDTest.php index e39bc35..d616deb 100644 --- a/tests/ULIDTest.php +++ b/tests/ULIDTest.php @@ -1,10 +1,62 @@ toBeString() ->and(ULID::isValid($ulid))->toBeTrue() - ->and(ULID::getTime($ulid)->getTimestamp())->toBeBetween(time() - 1, time()); + ->and(ULID::getTime($ulid)->getTimestamp())->toBeBetween($startedAt, $finishedAt); +}); + +test('Monotonic overflow on fixed timestamp throws ULIDException', function () { + $dateTime = DateTimeImmutable::createFromFormat('U.u', '1700000000.123000'); + expect($dateTime)->not()->toBeFalse(); + + $class = new ReflectionClass(ULID::class); + $lastGenTime = $class->getProperty('lastGenTime'); + $lastRandChars = $class->getProperty('lastRandChars'); + $lastGenTime->setAccessible(true); + $lastRandChars->setAccessible(true); + + $previousTime = $lastGenTime->getValue(null); + $previousChars = $lastRandChars->getValue(null); + + try { + $lastGenTime->setValue(null, (int)$dateTime->format('Uv')); + $lastRandChars->setValue(null, array_fill(0, 16, 31)); + expect(fn() => ULID::generate($dateTime))->toThrow(ULIDException::class); + } finally { + $lastGenTime->setValue(null, $previousTime); + $lastRandChars->setValue(null, $previousChars); + } +}); + +test('ULID bytes conversion roundtrip', function () { + $ulid = ULID::generate(); + $bytes = ULID::toBytes($ulid); + + expect(strlen($bytes))->toBe(16) + ->and(ULID::fromBytes($bytes))->toBe($ulid); +}); + +test('ULID random mode does not depend on monotonic state', function () { + $fixed = DateTimeImmutable::createFromFormat('U.u', '1700000000.123000'); + expect($fixed)->not()->toBeFalse(); + + $id1 = ULID::generate($fixed, UlidGenerationMode::RANDOM); + $id2 = ULID::generate($fixed, UlidGenerationMode::RANDOM); + + expect($id1)->not()->toBe($id2); +}); + +test('ULID base conversion roundtrip', function () { + $ulid = ULID::generate(); + $encoded = ULID::toBase($ulid, 62); + + expect(ULID::fromBase($encoded, 62))->toBe($ulid); }); diff --git a/tests/UUIDTest.php b/tests/UUIDTest.php index e560bd8..72abfdf 100644 --- a/tests/UUIDTest.php +++ b/tests/UUIDTest.php @@ -3,14 +3,16 @@ use Infocyph\UID\UUID; test('UUID v1', function () { + $startedAt = time() - 1; $uid = UUID::v1(); + $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); expect($parsed['isValid'])->toBeTrue() ->and($parsed['version'])->toBe(1) ->and($parsed['time'])->not()->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull() - ->and($parsed['time']->getTimestamp())->toBeBetween(time() - 1, time()); + ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); }); $ns = UUID::v4(); @@ -46,36 +48,44 @@ }); test('UUID v6', function () { + $startedAt = time() - 1; $uid = UUID::v6(); + $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); expect($parsed['isValid'])->toBeTrue() ->and($parsed['version'])->toBe(6) ->and($parsed['time'])->not()->toBeNull() ->and($parsed['node'])->toBeString()->not()->toBeNull() - ->and($parsed['time']->getTimestamp())->toBeBetween(time() - 1, time()); + ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); }); test('UUID v7', function () { + $startedAt = time() - 1; $uid = UUID::v7(); + $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); expect($parsed['isValid'])->toBeTrue() ->and($parsed['version'])->toBe(7) ->and($parsed['time'])->not()->toBeNull() - ->and($parsed['node'])->toBeString()->not()->toBeNull() - ->and($parsed['time']->getTimestamp())->toBeBetween(time() - 1, time()); + ->and($parsed['node'])->toBeNull() + ->and($parsed['tail'])->toBeString()->not()->toBeNull() + ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); }); test('UUID v8', function () { + $startedAt = time() - 1; $uid = UUID::v8(); + $finishedAt = time() + 1; expect($uid)->toBeString(); $parsed = UUID::parse($uid); expect($parsed['isValid'])->toBeTrue() ->and($parsed['version'])->toBe(8) ->and($parsed['time'])->not()->toBeNull() - ->and($parsed['node'])->toBeString()->not()->toBeNull() - ->and($parsed['time']->getTimestamp())->toBeBetween(time() - 1, time()); + ->and($parsed['node'])->toBeNull() + ->and($parsed['tail'])->toBeString()->not()->toBeNull() + ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); }); test('GUID', function () { @@ -88,3 +98,77 @@ ->and($parsed['node'])->toBeString()->not()->toBeNull(); }); +test('UUID v7 does not move timestamp forward for monotonicity', function () { + $fixedTime = DateTimeImmutable::createFromFormat('U.u', '1700000000.123000'); + expect($fixedTime)->not()->toBeFalse(); + + $uid1 = UUID::v7($fixedTime); + $uid2 = UUID::v7($fixedTime); + + $time1 = (int)UUID::parse($uid1)['time']->format('Uv'); + $time2 = (int)UUID::parse($uid2)['time']->format('Uv'); + $expected = (int)$fixedTime->format('Uv'); + + expect($time1)->toBe($expected) + ->and($time2)->toBe($expected) + ->and($uid1)->not()->toBe($uid2); +}); + +test('UUID nil and max helpers', function () { + expect(UUID::nil())->toBe('00000000-0000-0000-0000-000000000000') + ->and(UUID::max())->toBe('ffffffff-ffff-ffff-ffff-ffffffffffff') + ->and(UUID::isNil(UUID::nil()))->toBeTrue() + ->and(UUID::isMax(UUID::max()))->toBeTrue() + ->and(UUID::isNil(UUID::max()))->toBeFalse() + ->and(UUID::isMax(UUID::nil()))->toBeFalse(); +}); + +test('UUID canonical transformation helpers', function () { + $uuid = UUID::v4(); + $upperBraced = '{' . strtoupper($uuid) . '}'; + + expect(UUID::normalize($upperBraced))->toBe(strtolower($uuid)) + ->and(UUID::compact($uuid))->toHaveLength(32) + ->and(UUID::toUrn($uuid))->toBe('urn:uuid:' . strtolower($uuid)) + ->and(UUID::toBraces($uuid))->toBe('{' . strtolower($uuid) . '}') + ->and(UUID::lowercase(strtoupper($uuid)))->toBe(strtolower($uuid)) + ->and(UUID::uppercase($uuid))->toBe(strtoupper($uuid)); +}); + +test('UUID bytes conversion roundtrip', function () { + $uuid = UUID::v4(); + $bytes = UUID::toBytes($uuid); + + expect(strlen($bytes))->toBe(16) + ->and(UUID::fromBytes($bytes))->toBe(strtolower($uuid)); +}); + +test('global UUID helper transformations', function () { + $uuid = UUID::v4(); + + expect(uuid_nil())->toBe(UUID::nil()) + ->and(uuid_max())->toBe(UUID::max()) + ->and(uuid_is_nil(UUID::nil()))->toBeTrue() + ->and(uuid_is_max(UUID::max()))->toBeTrue() + ->and(uuid_normalize(strtoupper($uuid)))->toBe(strtolower($uuid)) + ->and(uuid_compact($uuid))->toHaveLength(32) + ->and(uuid_urn($uuid))->toBe('urn:uuid:' . strtolower($uuid)) + ->and(uuid_braces($uuid))->toBe('{' . strtolower($uuid) . '}'); +}); + +test('UUID base conversion roundtrip', function () { + $uuid = UUID::v4(); + $encoded = UUID::toBase($uuid, 58); + + expect(UUID::fromBase($encoded, 58))->toBe(strtolower($uuid)); +}); + +test('UUID single-file API covers generate, parse, and byte conversion', function () { + $uuid = UUID::v7(); + $parsed = UUID::parse($uuid); + $bytes = UUID::toBytes($uuid); + + expect(UUID::isValid($uuid))->toBeTrue() + ->and($parsed['version'])->toBe(7) + ->and(UUID::fromBytes($bytes))->toBe(strtolower($uuid)); +}); From 4089f8cae62c00f8a433670e7ac624dfb668e372 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 21 Apr 2026 23:51:10 +0600 Subject: [PATCH 2/6] fix --- .github/workflows/build.yml | 43 +++++----- README.md | 2 +- SECURITY.md | 54 ++++++++++-- docs/framework-integration.md | 1 + src/OpaqueId.php | 5 ++ .../PsrSimpleCacheSequenceProvider.php | 64 +++++++++++--- src/Snowflake.php | 3 + src/Sonyflake.php | 3 + src/TBSL.php | 12 +++ src/UUID.php | 22 ++++- tests/AdditionalIdsTest.php | 5 ++ tests/SequenceProviderTest.php | 83 +++++++++++++++++++ tests/SnowflakeTest.php | 5 ++ tests/SonyflakeTest.php | 5 ++ tests/TBSLTest.php | 5 ++ tests/ULIDTest.php | 2 - tests/UUIDTest.php | 24 ++++++ 17 files changed, 292 insertions(+), 46 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6795626..8537f7d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: "Security & Standards" on: schedule: - - cron: '0 0 * * 0' + - cron: "0 0 * * 0" push: branches: [ "main", "master" ] pull_request: @@ -27,7 +27,7 @@ jobs: runs-on: ${{ matrix.operating-system }} strategy: matrix: - operating-system: [ ubuntu-latest ] + operating-system: [ "ubuntu-latest" ] php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} dependency-version: ${{ fromJson(needs.prepare.outputs.dependency_versions) }} @@ -54,16 +54,10 @@ jobs: - name: Test run: | - composer test:syntax - composer test:code - composer test:lint - composer test:sniff - composer test:refactor if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:static - fi - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:security + composer ic:ci + else + composer ic:ci --prefer-lowest fi analyze: @@ -92,19 +86,25 @@ jobs: - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Composer Audit (Release Guard) - run: composer release:audit + - name: Composer Audit + run: composer ic:release:audit - name: Quality Gate (PHPStan) - run: composer test:static + run: composer ic:test:static - name: Security Gate (Psalm) - run: composer test:security + run: composer ic:test:security - name: Run PHPStan (Code Scanning) run: | - php ./vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true - php .github/scripts/phpstan-sarif.php phpstan-results.json phpstan-results.sarif + VENDOR_DIR="$(composer config vendor-dir)" + PHPSTAN_CONFIG="$VENDOR_DIR/infocyph/phpforge/phpstan.neon.dist" + if [ -f "phpstan.neon.dist" ]; then + PHPSTAN_CONFIG="phpstan.neon.dist" + fi + + php "$VENDOR_DIR/bin/phpstan" analyse --configuration="$PHPSTAN_CONFIG" --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true + composer ic:phpstan:sarif phpstan-results.json phpstan-results.sarif continue-on-error: true - name: Upload PHPStan Results @@ -114,10 +114,15 @@ jobs: category: "phpstan-${{ matrix.php-versions }}" if: always() && hashFiles('phpstan-results.sarif') != '' - # Run Psalm (Deep Taint Analysis) - name: Run Psalm Security Scan run: | - php ./vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --report=psalm-results.sarif || true + VENDOR_DIR="$(composer config vendor-dir)" + PSALM_CONFIG="$VENDOR_DIR/infocyph/phpforge/psalm.xml" + if [ -f "psalm.xml" ]; then + PSALM_CONFIG="psalm.xml" + fi + + php "$VENDOR_DIR/bin/psalm" --config="$PSALM_CONFIG" --security-analysis --threads=1 --report=psalm-results.sarif || true continue-on-error: true - name: Upload Psalm Results diff --git a/README.md b/README.md index f45bcdf..569320e 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ These IDs are unique & can't be backtracked. ```php // By default, it will generate id of length 24. -// You can pass in desired length in between 4 & 24 +// You can pass in desired length in between 4 & 32 \Infocyph\UID\RandomId::cuid2(); ``` diff --git a/SECURITY.md b/SECURITY.md index 56d709e..750e90e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,54 @@ # Security Policy -![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/infocyph/uid) - ## Supported Versions -| Version | Supported | -|---------|--------------------| -| 2.x | :white_check_mark: | -| 1.x | :x: | +Security fixes are provided for the latest major release line. + +| Version | Supported | +| --- | --- | +| `3.0.x` | ✅ | +| `< 3.0` | ❌ | ## Reporting a Vulnerability -Report any vulnerabilities to the [security advisory tracker](https://github.com/infocyph/uid/issues)! +Please report vulnerabilities privately. + +1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`). +2. If private reporting is unavailable, email: `infocyph@gmail.com`. +3. Do not open a public issue for security vulnerabilities. + +Please include: + +- Affected package version(s) +- PHP version and runtime environment +- Reproduction steps or proof of concept +- Impact assessment (confidentiality/integrity/availability) +- Any known workaround + +## Response Process + +- Initial acknowledgment target: within 3 business days +- Triage target: within 7 business days +- Fix and release timeline depends on severity and exploitability + +If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise. + +## Disclosure Policy + +This project follows coordinated disclosure: + +- Keep details private until a fix is released +- Publish advisory/release notes after remediation +- Share CVE information when applicable + +## Scope + +In scope: + +- Vulnerabilities in code under `src/` +- Supply-chain risks introduced by direct dependencies + +Out of scope: + +- Issues only affecting unsupported versions +- Local-only misconfiguration without a library defect diff --git a/docs/framework-integration.md b/docs/framework-integration.md index 46a2dad..75c9060 100644 --- a/docs/framework-integration.md +++ b/docs/framework-integration.md @@ -18,3 +18,4 @@ - Use `Id::nanoId()` for URL-safe public IDs. - Use configuration objects (`SnowflakeConfig`, `SonyflakeConfig`, `TBSLConfig`) for policy/output tuning. - Use value objects (`UuidValue`, `UlidValue`, etc.) for safer domain modeling. +- For distributed sequence coordination with PSR-16 caches, pass a shared synchronizer callback to `PsrSimpleCacheSequenceProvider`. diff --git a/src/OpaqueId.php b/src/OpaqueId.php index fbe0ca8..ddce19b 100644 --- a/src/OpaqueId.php +++ b/src/OpaqueId.php @@ -6,6 +6,7 @@ use Exception; use Infocyph\UID\Support\BaseEncoder; +use InvalidArgumentException; final class OpaqueId { @@ -28,6 +29,10 @@ public static function fromInt(int $value, string $salt = ''): string */ public static function random(int $length = 12): string { + if ($length < 1) { + throw new InvalidArgumentException('length must be greater than 0'); + } + $bytes = random_bytes(max(8, $length)); return substr(BaseEncoder::encodeBytes($bytes, 62), 0, $length); diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index cdda9de..60ef448 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -4,18 +4,27 @@ namespace Infocyph\UID\Sequence; +use Closure; use Infocyph\UID\Exceptions\FileLockException; use Psr\SimpleCache\CacheInterface; use Throwable; final readonly class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface { + private ?Closure $synchronizer; + + /** + * @param callable(string, callable():int):mixed|null $synchronizer + */ public function __construct( private CacheInterface $cache, private string $prefix = 'uid:seq:', private int $waitTime = 100, private int $maxAttempts = 10, - ) {} + ?callable $synchronizer = null, + ) { + $this->synchronizer = $synchronizer ? $synchronizer(...) : null; + } /** * @throws FileLockException @@ -23,23 +32,34 @@ public function __construct( public function next(string $type, int $machineId, int $timestamp): int { $key = $this->key($type, $machineId); - $lock = $this->acquireLock($key); - try { - $state = $this->cache->get($key); - $sequence = 1; - if ( - is_array($state) - && ($state['timestamp'] ?? null) === $timestamp - && isset($state['sequence']) - && is_int($state['sequence']) - ) { - $sequence = $state['sequence'] + 1; + if ($this->synchronizer !== null) { + try { + $sequence = ($this->synchronizer)( + $key, + fn(): int => $this->nextFromCacheState($key, $timestamp), + ); + } catch (FileLockException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new FileLockException( + 'Failed to read/write sequence state from PSR cache for key: ' . $key, + 0, + $exception, + ); } - $this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence]); + if (!is_int($sequence) || $sequence < 1) { + throw new FileLockException('Sequence synchronizer must return a positive integer'); + } return $sequence; + } + + $lock = $this->acquireLock($key); + + try { + return $this->nextFromCacheState($key, $timestamp); } catch (Throwable $exception) { throw new FileLockException( 'Failed to read/write sequence state from PSR cache for key: ' . $key, @@ -80,4 +100,22 @@ private function key(string $type, int $machineId): string { return $this->prefix . $type . ':' . $machineId; } + + private function nextFromCacheState(string $key, int $timestamp): int + { + $state = $this->cache->get($key); + $sequence = 1; + if ( + is_array($state) + && ($state['timestamp'] ?? null) === $timestamp + && isset($state['sequence']) + && is_int($state['sequence']) + ) { + $sequence = $state['sequence'] + 1; + } + + $this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence]); + + return $sequence; + } } diff --git a/src/Snowflake.php b/src/Snowflake.php index d24d378..1c1f67e 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -155,6 +155,9 @@ 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) { diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 543da2a..7780afa 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -148,6 +148,9 @@ 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) { diff --git a/src/TBSL.php b/src/TBSL.php index 1ee127c..9de1b06 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -138,6 +138,16 @@ public static function toBytes(string $tbsl): string return $bytes; } + /** + * @throws UIDException + */ + private static function assertMachineId(int $machineId): void + { + if ($machineId < 0 || $machineId > 99) { + throw new UIDException('Invalid machine ID, must be between 0 and 99'); + } + } + private static function formatOutput(string $id, IdOutputType $outputType): int|string { return match ($outputType) { @@ -157,6 +167,8 @@ private static function generateInternal( IdOutputType $outputType, ?SequenceProviderInterface $sequenceProvider = null, ): int|string { + self::assertMachineId($machineId); + [$micro, $seconds] = explode(' ', microtime()); $timeSequence = (int) ($seconds . substr($micro, 2, 6)); diff --git a/src/UUID.php b/src/UUID.php index d6f9be3..09946b2 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -319,7 +319,7 @@ public static function v1(?string $node = null): string substr($time, -12, 4), substr($time, -15, 3), random_int(0, 0x3fff) & 0x3fff | 0x8000, - $node ?? self::getNode(), + $node === null ? self::getNode() : self::normalizeNode($node), ); } @@ -406,6 +406,7 @@ public static function v7(?DateTimeInterface $dateTime = null, ?string $node = n { $unixTsMs = (int) ($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); $isExplicitTimestamp = $dateTime !== null; + $node = $node === null ? null : self::normalizeNode($node); if ($node === null) { [$unixTsMs, $tail] = self::nextV7DefaultState($unixTsMs, $isExplicitTimestamp); @@ -434,9 +435,10 @@ public static function v8(?string $node = null): string $unixTsMs = $unixTs * 1000 + intdiv($subSec, self::$secondIntervals78); $subSec = intdiv(($subSec % self::$secondIntervals78) << 14, self::$secondIntervals78); $subSecA = $subSec >> 2; + $subSecByte = ((ord(random_bytes(1)) & 0x0f) | (($subSec & 0x03) << 4)) & 0xff; $string = substr(str_pad(dechex($unixTsMs), 12, '0', STR_PAD_LEFT), -12) . '8' . str_pad(dechex($subSecA), 3, '0', STR_PAD_LEFT) - . bin2hex(chr(ord(random_bytes(1)) & 0x0f | ($subSec & 0x03) << 4)) + . bin2hex(chr($subSecByte)) . self::prepareNode(8, $node); return self::output(8, $string); @@ -710,6 +712,18 @@ private static function normalizeInputToHex(string $uuid): string return strtolower($hex); } + /** + * @throws UUIDException + */ + private static function normalizeNode(string $node): string + { + if (!preg_match('/^[0-9a-f]{12}$/i', $node)) { + throw new UUIDException('UUID node must be exactly 12 hexadecimal characters'); + } + + return strtolower($node); + } + /** * Resolves the given namespace. * @@ -760,11 +774,11 @@ private static function output(int $version, string $id): string */ private static function prepareNode(int $version, ?string $node = null): string { - if (!$node) { + if ($node === null) { return bin2hex(random_bytes(self::randomLengthFor($version) + 6)); } - return bin2hex(random_bytes(self::randomLengthFor($version))) . $node; + return bin2hex(random_bytes(self::randomLengthFor($version))) . self::normalizeNode($node); } /** diff --git a/tests/AdditionalIdsTest.php b/tests/AdditionalIdsTest.php index 1b8665b..3a57039 100644 --- a/tests/AdditionalIdsTest.php +++ b/tests/AdditionalIdsTest.php @@ -36,6 +36,11 @@ ->and($det1)->toBe($det2); }); +test('Opaque ID rejects non-positive lengths', function () { + expect(fn () => OpaqueId::random(0))->toThrow(\InvalidArgumentException::class) + ->and(fn () => OpaqueId::random(-1))->toThrow(\InvalidArgumentException::class); +}); + test('IdComparator sorts numeric and lexical values', function () { $sortedNumeric = IdComparator::sort(['10', '2', '1']); $sortedLexical = IdComparator::sort(['b', 'a', 'c']); diff --git a/tests/SequenceProviderTest.php b/tests/SequenceProviderTest.php index 211a082..e496d2b 100644 --- a/tests/SequenceProviderTest.php +++ b/tests/SequenceProviderTest.php @@ -104,3 +104,86 @@ public function has(string $key): bool expect((int)$id2)->toBeGreaterThan((int)$id1); }); + +test('psr-16 sequence provider supports custom synchronizer callback', function () { + $cache = new class implements CacheInterface { + /** @var array */ + private array $store = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->store[$key] ?? $default; + } + + public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool + { + unset($ttl); + $this->store[$key] = $value; + + return true; + } + + public function delete(string $key): bool + { + unset($this->store[$key]); + + return true; + } + + public function clear(): bool + { + $this->store = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $values = []; + foreach ($keys as $key) { + $values[$key] = $this->get($key, $default); + } + + return $values; + } + + public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set((string) $key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete((string) $key); + } + + return true; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->store); + } + }; + + $synchronizerCalls = 0; + $synchronizer = function (string $key, callable $criticalSection) use (&$synchronizerCalls): int { + expect($key)->toContain('uid:seq:'); + $synchronizerCalls++; + + return $criticalSection(); + }; + + Snowflake::setSequenceProvider(new PsrSimpleCacheSequenceProvider($cache, synchronizer: $synchronizer)); + + $id1 = Snowflake::generate(2, 3); + $id2 = Snowflake::generate(2, 3); + + expect((int) $id2)->toBeGreaterThan((int) $id1) + ->and($synchronizerCalls)->toBe(2); +}); diff --git a/tests/SnowflakeTest.php b/tests/SnowflakeTest.php index 77faaf8..9ef2a86 100644 --- a/tests/SnowflakeTest.php +++ b/tests/SnowflakeTest.php @@ -120,6 +120,11 @@ ->and(Snowflake::isValid('0'))->toBeFalse(); }); +test('Snowflake rejects invalid start timestamp format', function () { + expect(fn () => Snowflake::setStartTimeStamp('not-a-date')) + ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class); +}); + test('Snowflake bytes and base conversion roundtrip', function () { $id = Snowflake::generate(); $bytes = Snowflake::toBytes($id); diff --git a/tests/SonyflakeTest.php b/tests/SonyflakeTest.php index 15d730f..9607929 100644 --- a/tests/SonyflakeTest.php +++ b/tests/SonyflakeTest.php @@ -92,6 +92,11 @@ ->and(Sonyflake::isValid('0'))->toBeFalse(); }); +test('Sonyflake rejects invalid start timestamp format', function () { + expect(fn () => Sonyflake::setStartTimeStamp('not-a-date')) + ->toThrow(\Infocyph\UID\Exceptions\SonyflakeException::class); +}); + test('Sonyflake bytes and base conversion roundtrip', function () { $id = Sonyflake::generate(); $bytes = Sonyflake::toBytes($id); diff --git a/tests/TBSLTest.php b/tests/TBSLTest.php index d1028a7..a893e24 100644 --- a/tests/TBSLTest.php +++ b/tests/TBSLTest.php @@ -38,6 +38,11 @@ expect($parsed1['machineId'])->not->toBe($parsed2['machineId']); }); +test('TBSL rejects invalid machine IDs', function () { + expect(fn () => TBSL::generate(-1))->toThrow(\Infocyph\UID\Exceptions\UIDException::class) + ->and(fn () => TBSL::generate(100))->toThrow(\Infocyph\UID\Exceptions\UIDException::class); +}); + test('TBSL bytes conversion roundtrip', function () { $id = TBSL::generate(); $bytes = TBSL::toBytes($id); diff --git a/tests/ULIDTest.php b/tests/ULIDTest.php index d616deb..f526b2b 100644 --- a/tests/ULIDTest.php +++ b/tests/ULIDTest.php @@ -20,8 +20,6 @@ $class = new ReflectionClass(ULID::class); $lastGenTime = $class->getProperty('lastGenTime'); $lastRandChars = $class->getProperty('lastRandChars'); - $lastGenTime->setAccessible(true); - $lastRandChars->setAccessible(true); $previousTime = $lastGenTime->getValue(null); $previousChars = $lastRandChars->getValue(null); diff --git a/tests/UUIDTest.php b/tests/UUIDTest.php index 72abfdf..a1d611b 100644 --- a/tests/UUIDTest.php +++ b/tests/UUIDTest.php @@ -88,6 +88,30 @@ ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); }); +test('UUID node must be exactly 12 hex characters when provided', function () { + expect(fn () => UUID::v1('zzzzzzzzzzzz'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class) + ->and(fn () => UUID::v6('0123456789abcdef'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class) + ->and(fn () => UUID::v7(null, 'nothex123456'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class) + ->and(fn () => UUID::v8('123'))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class); +}); + +test('UUID node accepts uppercase hex input and normalizes output', function () { + $node = 'ABCDEF123456'; + $normalizedNode = strtolower($node); + + $ids = [ + UUID::v1($node), + UUID::v6($node), + UUID::v7(null, $node), + UUID::v8($node), + ]; + + foreach ($ids as $id) { + expect(UUID::isValid($id))->toBeTrue() + ->and(str_ends_with($id, $normalizedNode))->toBeTrue(); + } +}); + test('GUID', function () { $uid = UUID::guid(); expect($uid)->toBeString(); From 1146fe1b4b32e6f044cb65a8a1b799f092931d41 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 22 Apr 2026 16:42:05 +0600 Subject: [PATCH 3/6] pre-forge --- .github/scripts/composer-audit-guard.php | 85 ----------- .github/scripts/phpstan-sarif.php | 178 ----------------------- .github/scripts/syntax.php | 109 -------------- captainhook.json | 51 ------- composer.json | 65 --------- pest.xml | 22 --- phpbench.json | 26 ---- phpcs.xml.dist | 66 --------- phpstan.neon.dist | 16 -- phpunit.xml | 22 --- pint.json | 129 ---------------- psalm.xml | 39 ----- rector.php | 14 -- 13 files changed, 822 deletions(-) delete mode 100644 .github/scripts/composer-audit-guard.php delete mode 100644 .github/scripts/phpstan-sarif.php delete mode 100644 .github/scripts/syntax.php delete mode 100644 captainhook.json delete mode 100644 pest.xml delete mode 100644 phpbench.json delete mode 100644 phpcs.xml.dist delete mode 100644 phpstan.neon.dist delete mode 100644 phpunit.xml delete mode 100644 pint.json delete mode 100644 psalm.xml delete mode 100644 rector.php diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php deleted file mode 100644 index a1b1cdb..0000000 --- a/.github/scripts/composer-audit-guard.php +++ /dev/null @@ -1,85 +0,0 @@ - ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], -]; - -$process = proc_open($command, $descriptorSpec, $pipes); - -if (! \is_resource($process)) { - fwrite(STDERR, "Failed to start composer audit process.\n"); - exit(1); -} - -fclose($pipes[0]); -$stdout = stream_get_contents($pipes[1]) ?: ''; -$stderr = stream_get_contents($pipes[2]) ?: ''; -fclose($pipes[1]); -fclose($pipes[2]); - -$exitCode = proc_close($process); - -/** @var array|null $decoded */ -$decoded = json_decode($stdout, true); - -if (! \is_array($decoded)) { - fwrite(STDERR, "Unable to parse composer audit JSON output.\n"); - if (trim($stdout) !== '') { - fwrite(STDERR, $stdout . "\n"); - } - if (trim($stderr) !== '') { - fwrite(STDERR, $stderr . "\n"); - } - - exit($exitCode !== 0 ? $exitCode : 1); -} - -$advisories = $decoded['advisories'] ?? []; -$abandoned = $decoded['abandoned'] ?? []; - -$advisoryCount = 0; - -if (\is_array($advisories)) { - foreach ($advisories as $entries) { - if (\is_array($entries)) { - $advisoryCount += \count($entries); - } - } -} - -$abandonedPackages = []; - -if (\is_array($abandoned)) { - foreach ($abandoned as $package => $replacement) { - if (\is_string($package) && $package !== '') { - $abandonedPackages[$package] = $replacement; - } - } -} - -echo sprintf( - "Composer audit summary: %d advisories, %d abandoned packages.\n", - $advisoryCount, - \count($abandonedPackages), -); - -if ($abandonedPackages !== []) { - fwrite(STDERR, "Warning: abandoned packages detected (non-blocking):\n"); - foreach ($abandonedPackages as $package => $replacement) { - $target = \is_string($replacement) && $replacement !== '' ? $replacement : 'none'; - fwrite(STDERR, sprintf(" - %s (replacement: %s)\n", $package, $target)); - } -} - -if ($advisoryCount > 0) { - fwrite(STDERR, "Security vulnerabilities detected by composer audit.\n"); - exit(1); -} - -exit(0); diff --git a/.github/scripts/phpstan-sarif.php b/.github/scripts/phpstan-sarif.php deleted file mode 100644 index 2b01b26..0000000 --- a/.github/scripts/phpstan-sarif.php +++ /dev/null @@ -1,178 +0,0 @@ - [sarif-output] - */ - -$argv = $_SERVER['argv'] ?? []; -$input = $argv[1] ?? ''; -$output = $argv[2] ?? 'phpstan-results.sarif'; - -if (! is_string($input) || $input === '') { - fwrite(STDERR, "Error: missing input file.\n"); - fwrite(STDERR, "Usage: php .github/scripts/phpstan-sarif.php [sarif-output]\n"); - exit(2); -} - -if (! is_file($input) || ! is_readable($input)) { - fwrite(STDERR, "Error: input file not found or unreadable: {$input}\n"); - exit(2); -} - -$raw = file_get_contents($input); -if ($raw === false) { - fwrite(STDERR, "Error: failed to read input file: {$input}\n"); - exit(2); -} - -$decoded = json_decode($raw, true); -if (! is_array($decoded)) { - fwrite(STDERR, "Error: input is not valid JSON.\n"); - exit(2); -} - -/** - * @return non-empty-string - */ -function normalizeUri(string $path): string -{ - $normalized = str_replace('\\', '/', $path); - $cwd = getcwd(); - - if (is_string($cwd) && $cwd !== '') { - $cwd = rtrim(str_replace('\\', '/', $cwd), '/'); - - if (preg_match('/^[A-Za-z]:\//', $normalized) === 1) { - if (stripos($normalized, $cwd . '/') === 0) { - $normalized = substr($normalized, strlen($cwd) + 1); - } - } elseif (str_starts_with($normalized, '/')) { - if (str_starts_with($normalized, $cwd . '/')) { - $normalized = substr($normalized, strlen($cwd) + 1); - } - } - } - - $normalized = ltrim($normalized, './'); - - return $normalized === '' ? 'unknown.php' : $normalized; -} - -$results = []; -$rules = []; - -$globalErrors = $decoded['errors'] ?? []; -if (is_array($globalErrors)) { - foreach ($globalErrors as $error) { - if (! is_string($error) || $error === '') { - continue; - } - - $ruleId = 'phpstan.internal'; - $rules[$ruleId] = true; - $results[] = [ - 'ruleId' => $ruleId, - 'level' => 'error', - 'message' => [ - 'text' => $error, - ], - ]; - } -} - -$files = $decoded['files'] ?? []; -if (is_array($files)) { - foreach ($files as $filePath => $fileData) { - if (! is_string($filePath) || ! is_array($fileData)) { - continue; - } - - $messages = $fileData['messages'] ?? []; - if (! is_array($messages)) { - continue; - } - - foreach ($messages as $messageData) { - if (! is_array($messageData)) { - continue; - } - - $messageText = (string) ($messageData['message'] ?? 'PHPStan issue'); - $line = (int) ($messageData['line'] ?? 1); - $identifier = (string) ($messageData['identifier'] ?? ''); - $ruleId = $identifier !== '' ? $identifier : 'phpstan.issue'; - - if ($line < 1) { - $line = 1; - } - - $rules[$ruleId] = true; - $results[] = [ - 'ruleId' => $ruleId, - 'level' => 'error', - 'message' => [ - 'text' => $messageText, - ], - 'locations' => [[ - 'physicalLocation' => [ - 'artifactLocation' => [ - 'uri' => normalizeUri($filePath), - ], - 'region' => [ - 'startLine' => $line, - ], - ], - ]], - ]; - } - } -} - -$ruleDescriptors = []; -$ruleIds = array_keys($rules); -sort($ruleIds); - -foreach ($ruleIds as $ruleId) { - $ruleDescriptors[] = [ - 'id' => $ruleId, - 'name' => $ruleId, - 'shortDescription' => [ - 'text' => $ruleId, - ], - ]; -} - -$sarif = [ - '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', - 'version' => '2.1.0', - 'runs' => [[ - 'tool' => [ - 'driver' => [ - 'name' => 'PHPStan', - 'informationUri' => 'https://phpstan.org/', - 'rules' => $ruleDescriptors, - ], - ], - 'results' => $results, - ]], -]; - -$encoded = json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); -if (! is_string($encoded)) { - fwrite(STDERR, "Error: failed to encode SARIF JSON.\n"); - exit(2); -} - -$written = file_put_contents($output, $encoded . PHP_EOL); -if ($written === false) { - fwrite(STDERR, "Error: failed to write output file: {$output}\n"); - exit(2); -} - -fwrite(STDOUT, sprintf("SARIF generated: %s (%d findings)\n", $output, count($results))); -exit(0); diff --git a/.github/scripts/syntax.php b/.github/scripts/syntax.php deleted file mode 100644 index 043bf53..0000000 --- a/.github/scripts/syntax.php +++ /dev/null @@ -1,109 +0,0 @@ -isFile()) { - continue; - } - - $filename = $entry->getFilename(); - if (! str_ends_with($filename, '.php')) { - continue; - } - - $files[] = $entry->getPathname(); - } -} - -$files = array_values(array_unique($files)); -sort($files); - -if ($files === []) { - fwrite(STDOUT, "No PHP files found.\n"); - exit(0); -} - -$failed = []; - -foreach ($files as $file) { - $command = [PHP_BINARY, '-d', 'display_errors=1', '-l', $file]; - $descriptorSpec = [ - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $process = proc_open($command, $descriptorSpec, $pipes); - - if (! is_resource($process)) { - $failed[] = [$file, 'Could not start PHP lint process']; - continue; - } - - $stdout = stream_get_contents($pipes[1]); - fclose($pipes[1]); - - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[2]); - - $exitCode = proc_close($process); - - if ($exitCode !== 0) { - $output = trim((string) $stdout . "\n" . (string) $stderr); - $failed[] = [$file, $output !== '' ? $output : 'Unknown lint failure']; - } -} - -if ($failed === []) { - fwrite(STDOUT, sprintf("Syntax OK: %d PHP files checked.\n", count($files))); - exit(0); -} - -fwrite(STDERR, sprintf("Syntax errors in %d file(s):\n", count($failed))); - -foreach ($failed as [$file, $error]) { - fwrite(STDERR, "- {$file}\n{$error}\n"); -} - -exit(1); diff --git a/captainhook.json b/captainhook.json deleted file mode 100644 index fa19900..0000000 --- a/captainhook.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "commit-msg": { - "enabled": false, - "actions": [] - }, - "pre-push": { - "enabled": false, - "actions": [] - }, - "pre-commit": { - "enabled": true, - "actions": [ - { - "action": "composer validate --strict", - "options": [] - }, - { - "action": "composer release:audit", - "options": [] - }, - { - "action": "composer tests", - "options": [] - } - ] - }, - "prepare-commit-msg": { - "enabled": false, - "actions": [] - }, - "post-commit": { - "enabled": false, - "actions": [] - }, - "post-merge": { - "enabled": false, - "actions": [] - }, - "post-checkout": { - "enabled": false, - "actions": [] - }, - "post-rewrite": { - "enabled": false, - "actions": [] - }, - "post-change": { - "enabled": false, - "actions": [] - } -} diff --git a/composer.json b/composer.json index 1f63b9e..d37bfa6 100644 --- a/composer.json +++ b/composer.json @@ -39,76 +39,11 @@ "ext-bcmath": "*", "psr/simple-cache": "^3.0" }, - "require-dev": { - "captainhook/captainhook": "^5.29.2", - "laravel/pint": "^1.29", - "pestphp/pest": "^4.6.3", - "pestphp/pest-plugin-drift": "^4.1", - "phpbench/phpbench": "^1.6.1", - "phpstan/phpstan": "^2.1.50", - "rector/rector": "^2.4.2", - "squizlabs/php_codesniffer": "^4.0.1", - "symfony/var-dumper": "^7.3 || ^8.0.8", - "tomasvotruba/cognitive-complexity": "^1.1", - "vimeo/psalm": "^6.16.1" - }, - "scripts": { - "test:syntax": "@php .github/scripts/syntax.php src tests benchmarks examples", - "test:code": "@php vendor/bin/pest", - "test:lint": "@php vendor/bin/pint --test", - "test:sniff": "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=full", - "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", - "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --no-cache", - "test:refactor": "@php vendor/bin/rector process --dry-run --debug", - "test:bench": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", - "test:details": [ - "@test:syntax", - "@test:code", - "@test:lint", - "@test:sniff", - "@test:static", - "@test:security", - "@test:refactor" - ], - "test:all": [ - "@test:syntax", - "@php vendor/bin/pest --parallel --processes=10", - "@php vendor/bin/pint --test", - "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", - "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", - "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --threads=1 --no-progress --no-cache", - "@php vendor/bin/rector process --dry-run --debug" - ], - "release:audit": "@php .github/scripts/composer-audit-guard.php", - "release:guard": [ - "@composer validate --strict", - "@release:audit", - "@tests" - ], - "process:lint": "@php vendor/bin/pint", - "process:sniff:fix": "@php vendor/bin/phpcbf --standard=phpcs.xml.dist --runtime-set ignore_errors_on_exit 1", - "process:refactor": "@php vendor/bin/rector process", - "process:all": [ - "@process:refactor", - "@process:lint", - "@process:sniff:fix" - ], - "bench:run": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", - "bench:quick": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate --revs=10 --iterations=3 --warmup=1", - "bench:chart": "@php vendor/bin/phpbench run --config=phpbench.json --report=chart", - "tests": "@test:all", - "process": "@process:all", - "benchmark": "@bench:run", - "post-autoload-dump": "captainhook install --only-enabled -nf" - }, "minimum-stability": "stable", "prefer-stable": true, "config": { "sort-packages": true, "optimize-autoloader": true, "classmap-authoritative": true, - "allow-plugins": { - "pestphp/pest-plugin": true - } } } diff --git a/pest.xml b/pest.xml deleted file mode 100644 index d5d12d8..0000000 --- a/pest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index fff7e05..0000000 --- a/phpbench.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php", - "runner.path": "benchmarks", - "runner.file_pattern": "*Bench.php", - "runner.attributes": true, - "runner.annotations": false, - "runner.progress": "dots", - "runner.retry_threshold": 8, - "report.generators": { - "chart": { - "title": "Benchmark Chart", - "description": "Console bar chart grouped by benchmark subject", - "generator": "component", - "components": [ - { - "component": "bar_chart_aggregate", - "x_partition": ["subject_name"], - "bar_partition": ["benchmark_name"], - "y_expr": "mode(partition['result_time_avg'])", - "y_axes_label": "yValue as time precision 1" - } - ] - } - } -} diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 1cf0c6c..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,66 +0,0 @@ - - - Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. - - - - - - - ./src - ./tests - - */vendor/* - */.git/* - */.idea/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 650fbdf..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,16 +0,0 @@ -includes: - - vendor/tomasvotruba/cognitive-complexity/config/extension.neon - -parameters: - customRulesetUsed: true - level: max - paths: - - src - parallel: - maximumNumberOfProcesses: 2 - cognitive_complexity: - class: 80 - function: 12 - dependency_tree: 80 - dependency_tree_types: [] - reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index d5d12d8..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/pint.json b/pint.json deleted file mode 100644 index 6f546dc..0000000 --- a/pint.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "preset": "per", - "exclude": [ - "tests" - ], - "notPath": [ - "rector.php" - ], - "rules": { - "ordered_imports": { - "imports_order": [ - "class", - "function", - "const" - ], - "sort_algorithm": "alpha" - }, - "no_unused_imports": true, - "class_attributes_separation": { - "elements": { - "trait_import": "none", - "case": "one", - "const": "one", - "property": "one", - "method": "one" - } - }, - "ordered_class_elements": { - "order": [ - "use_trait", - "case", - "constant_public", - "constant_protected", - "constant_private", - "constant", - "property_public_static", - "property_protected_static", - "property_private_static", - "property_static", - "property_public_readonly", - "property_protected_readonly", - "property_private_readonly", - "property_public_abstract", - "property_protected_abstract", - "property_public", - "property_protected", - "property_private", - "property", - "construct", - "destruct", - "magic", - "phpunit", - "method_public_abstract_static", - "method_protected_abstract_static", - "method_private_abstract_static", - "method_public_abstract", - "method_protected_abstract", - "method_private_abstract", - "method_abstract", - "method_public_static", - "method_public", - "method_protected_static", - "method_protected", - "method_private_static", - "method_private", - "method_static", - "method" - ], - "sort_algorithm": "alpha" - }, - "blank_line_after_opening_tag": true, - "no_alias_functions": true, - "multiline_whitespace_before_semicolons": true, - "no_trailing_whitespace": true, - "blank_line_before_statement": { - "statements": [ - "break", - "continue", - "declare", - "return", - "throw", - "try" - ] - }, - "phpdoc_align": { - "align": "left" - }, - "binary_operator_spaces": { - "default": "single_space" - }, - "concat_space": { - "spacing": "one" - }, - "cast_spaces": true, - "unary_operator_spaces": true, - "ternary_operator_spaces": true, - "array_indentation": true, - "trim_array_spaces": true, - "method_argument_space": { - "on_multiline": "ensure_fully_multiline" - }, - "trailing_comma_in_multiline": { - "elements": [ - "arrays", - "arguments", - "parameters", - "match" - ] - }, - "single_quote": true, - "single_line_empty_body": true, - "no_multiple_statements_per_line": true, - "no_extra_blank_lines": true, - "no_whitespace_in_blank_line": true, - "single_blank_line_at_eof": true, - "statement_indentation": true, - "control_structure_braces": true, - "control_structure_continuation_position": true, - "declare_parentheses": true, - "declare_strict_types": true, - "lowercase_keywords": true, - "constant_case": true, - "lowercase_static_reference": true, - "native_function_casing": true, - "nullable_type_declaration_for_default_null_value": true, - "no_superfluous_phpdoc_tags": true, - "phpdoc_trim": true - } -} diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 0cbbcd3..0000000 --- a/psalm.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/rector.php b/rector.php deleted file mode 100644 index e30ddf8..0000000 --- a/rector.php +++ /dev/null @@ -1,14 +0,0 @@ -withPaths([__DIR__ . '/src']) - ->withPreparedSets(deadCode: true) - ->withPhpVersion( - constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), - ) - ->withPhpSets(); From 16a2bdc8f526c2b09e56df0816938bac18715827 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 22 Apr 2026 16:43:21 +0600 Subject: [PATCH 4/6] pre-forge --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d37bfa6..4b04835 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,6 @@ "config": { "sort-packages": true, "optimize-autoloader": true, - "classmap-authoritative": true, + "classmap-authoritative": true } } From fcc1782ef7389b83455b064cb0f35b8a7bcf1f08 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 23 Apr 2026 15:41:25 +0600 Subject: [PATCH 5/6] centralized workflow --- .github/workflows/security-standards.yml | 26 +++++++++++ captainhook.json | 51 ++++++++++++++++++++++ composer.json | 9 +++- src/Snowflake.php | 55 +++++++++++++++++++++--- 4 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/security-standards.yml create mode 100644 captainhook.json diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..30d52f9 --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,26 @@ +name: "Security & Standards" + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + phpforge: + uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + permissions: + security-events: write + actions: read + contents: read + with: + php_versions: '["8.2","8.3","8.4","8.5"]' + dependency_versions: '["prefer-lowest","prefer-stable"]' + php_extensions: "bcmath" + coverage: "xdebug" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000..3ab4dd4 --- /dev/null +++ b/captainhook.json @@ -0,0 +1,51 @@ +{ + "commit-msg": { + "enabled": false, + "actions": [] + }, + "pre-push": { + "enabled": false, + "actions": [] + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "composer validate --strict", + "options": [] + }, + { + "action": "composer ic:release:audit", + "options": [] + }, + { + "action": "composer ic:tests", + "options": [] + } + ] + }, + "prepare-commit-msg": { + "enabled": false, + "actions": [] + }, + "post-commit": { + "enabled": false, + "actions": [] + }, + "post-merge": { + "enabled": false, + "actions": [] + }, + "post-checkout": { + "enabled": false, + "actions": [] + }, + "post-rewrite": { + "enabled": false, + "actions": [] + }, + "post-change": { + "enabled": false, + "actions": [] + } +} diff --git a/composer.json b/composer.json index 4b04835..aff3e89 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,13 @@ "config": { "sort-packages": true, "optimize-autoloader": true, - "classmap-authoritative": true + "classmap-authoritative": true, + "allow-plugins": { + "infocyph/phpforge": true, + "pestphp/pest-plugin": true + } + }, + "require-dev": { + "infocyph/phpforge": "dev-main" } } diff --git a/src/Snowflake.php b/src/Snowflake.php index 1c1f67e..b19bab6 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -11,6 +11,7 @@ use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SnowflakeException; +use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; use Infocyph\UID\Support\OutputFormatter; @@ -19,6 +20,11 @@ final class Snowflake { use GetSequence; + /** + * @var array + */ + private static array $lastStateBySequenceKey = []; + private static int $lastTimestamp = 0; private static int $maxDatacenterLength = 5; @@ -251,16 +257,46 @@ private static function generateInternal( $currentTime = self::waitUntil(self::$lastTimestamp); } + $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); $sequenceKey = ($datacenter << self::$maxWorkIdLength) | $workerId; - while (($sequence = self::sequence( - $currentTime, - $sequenceKey, - 'snowflake', - $sequenceProvider, - )) > (-1 ^ (-1 << self::$maxSequenceLength))) { - ++$currentTime; + $stateKey = spl_object_id($resolvedSequenceProvider) . ':' . $sequenceKey; + $maxSequence = -1 ^ (-1 << self::$maxSequenceLength); + + while (true) { + while (($sequence = self::sequence( + $currentTime, + $sequenceKey, + 'snowflake', + $resolvedSequenceProvider, + )) > $maxSequence) { + ++$currentTime; + } + + $lastState = self::$lastStateBySequenceKey[$stateKey] ?? null; + + if ($lastState === null) { + break; + } + + // Sequence backends can transiently return a smaller sequence for the same + // timestamp under contention. Move forward and retry to preserve monotonic IDs. + if ( + $currentTime < $lastState['timestamp'] + || ($currentTime === $lastState['timestamp'] && $sequence <= $lastState['sequence']) + ) { + $currentTime = $lastState['timestamp'] + 1; + + continue; + } + + break; } + self::$lastTimestamp = $currentTime; + self::$lastStateBySequenceKey[$stateKey] = [ + 'timestamp' => $currentTime, + 'sequence' => $sequence, + ]; $workerLeftMoveLength = self::$maxSequenceLength; $datacenterLeftMoveLength = self::$maxWorkIdLength + $workerLeftMoveLength; @@ -282,6 +318,11 @@ private static function getStartTimeStamp(): int return self::$startTime ??= (strtotime('2020-01-01 00:00:00') * 1000); } + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + { + return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); + } + private static function waitUntil(int $timestamp): int { do { From 69f98a8d8eab1187c0097571877c52a7b6c5fdd7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 23 Apr 2026 15:44:48 +0600 Subject: [PATCH 6/6] centralized workflow --- .github/workflows/security-standards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 30d52f9..1f363f6 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.2","8.3","8.4","8.5"]' + php_versions: '["8.3","8.4","8.5"]' dependency_versions: '["prefer-lowest","prefer-stable"]' php_extensions: "bcmath" coverage: "xdebug"