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/workflows/build.yml b/.github/workflows/build.yml index 3e6ad57..8537f7d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,41 +2,132 @@ name: "Security & Standards" on: schedule: - - cron: '0 0 * * 0' + - 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 ] + operating-system: [ "ubuntu-latest" ] + 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: | + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer ic:ci + else + composer ic:ci --prefer-lowest + 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 + run: composer ic:release:audit - - name: Test - run: composer tests + - name: Quality Gate (PHPStan) + run: composer ic:test:static + + - name: Security Gate (Psalm) + run: composer ic:test:security + + - name: Run PHPStan (Code Scanning) + run: | + 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 + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: phpstan-results.sarif + category: "phpstan-${{ matrix.php-versions }}" + if: always() && hashFiles('phpstan-results.sarif') != '' + + - name: Run Psalm Security Scan + run: | + 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 + 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/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..1f363f6 --- /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.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/.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..569320e 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'] ``` @@ -363,12 +365,14 @@ 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(); ``` ## 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/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/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": "*" + "php": ">=8.2", + "ext-bcmath": "*", + "psr/simple-cache": "^3.0" }, + "minimum-stability": "stable", + "prefer-stable": true, "config": { "sort-packages": true, "optimize-autoloader": true, + "classmap-authoritative": true, "allow-plugins": { + "infocyph/phpforge": true, "pestphp/pest-plugin": true } }, - "replace": { - "abmmhasan/uuid": "*" - }, "require-dev": { - "captainhook/captainhook": "^5.24", - "laravel/pint": "^1.20", - "pestphp/pest": "^3.7", - "rector/rector": "^2.0", - "symfony/var-dumper": "^7.2" - }, - "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:code", - "@test:lint", - "@test:refactor" - ], - "git:hook": "captainhook install --only-enabled -nf", - "test": "pest", - "refactor": "rector process", - "lint": "pint", - "post-autoload-dump": "@git:hook" + "infocyph/phpforge": "dev-main" } } 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..75c9060 --- /dev/null +++ b/docs/framework-integration.md @@ -0,0 +1,21 @@ +# 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. +- For distributed sequence coordination with PSR-16 caches, pass a shared synchronizer callback to `PsrSimpleCacheSequenceProvider`. diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 8f842bd..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - ./tests - - - - - - ./src - - - diff --git a/pint.json b/pint.json deleted file mode 100644 index e44f7b4..0000000 --- a/pint.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "preset": "psr12", - "exclude": [ - "tests" - ], - "notPath": [ - "rector.php", - "test.php" - ] -} \ No newline at end of file diff --git a/rector.php b/rector.php deleted file mode 100644 index 310abde..0000000 --- a/rector.php +++ /dev/null @@ -1,16 +0,0 @@ -paths([ - __DIR__ . '/src' - ]); - $rectorConfig->sets([ - constant("Rector\Set\ValueObject\LevelSetList::UP_TO_PHP_84") - ]); -}; 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..ddce19b --- /dev/null +++ b/src/OpaqueId.php @@ -0,0 +1,57 @@ + 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..60ef448 --- /dev/null +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -0,0 +1,121 @@ +synchronizer = $synchronizer ? $synchronizer(...) : null; + } + + /** + * @throws FileLockException + */ + public function next(string $type, int $machineId, int $timestamp): int + { + $key = $this->key($type, $machineId); + + 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, + ); + } + + 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, + 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; + } + + 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/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 @@ + + */ + private static array $lastStateBySequenceKey = []; + + private static int $lastTimestamp = 0; + private static int $maxDatacenterLength = 5; - private static int $maxWorkIdLength = 5; + private static int $maxSequenceLength = 12; + + private static int $maxTimestampLength = 41; + + private static int $maxWorkIdLength = 5; + private static ?int $startTime = null; + /** + * Decodes one of bases: 16, 32, 36, 58, 62 into Snowflake decimal. + * + * @throws SnowflakeException + */ + public static function fromBase(string $encoded, int $base): string + { + try { + return self::fromBytes(BaseEncoder::decodeToBytes($encoded, $base, 8)); + } catch (\InvalidArgumentException $exception) { + throw new SnowflakeException($exception->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 +80,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)), ]; } @@ -92,6 +161,9 @@ public static function parse(string $id): 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) { @@ -101,10 +173,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 +183,153 @@ 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 */ - private static function getStartTimeStamp(): float|int + 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 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); + } + + $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); + $sequenceKey = ($datacenter << self::$maxWorkIdLength) | $workerId; + $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; + $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 resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + { + return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); + } + + 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..7780afa 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,12 +143,14 @@ 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 { $time = strtotime($timeString); + if ($time === false) { + throw new SonyflakeException('Invalid start time format'); + } $current = time(); if ($time > $current) { @@ -96,20 +162,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 +219,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..9de1b06 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,136 @@ 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; + } + + /** + * @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) { + 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 { + self::assertMachineId($machineId); + + [$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..09946b2 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 === null ? self::getNode() : self::normalizeNode($node), ); } @@ -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,24 @@ 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; + $node = $node === null ? null : self::normalizeNode($node); + + 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 +425,44 @@ 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; - } + $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($subSecByte)) + . 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 +470,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 +515,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 +541,208 @@ 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); + } + + /** + * @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. + * + * @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 +753,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 === null) { + 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))) . self::normalizeNode($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('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']); + + 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..e496d2b --- /dev/null +++ b/tests/SequenceProviderTest.php @@ -0,0 +1,189 @@ +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); +}); + +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 0ff3b18..9ef2a86 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,95 @@ 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 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); + $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..9607929 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,67 @@ }); 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'); + } + + return 256; + } - expect((int) $id2)->toBeGreaterThan((int) $id1); -})->skip(); + 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 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); + $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..a893e24 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,36 @@ 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); + + 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..f526b2b 100644 --- a/tests/ULIDTest.php +++ b/tests/ULIDTest.php @@ -1,10 +1,60 @@ 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'); + + $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..a1d611b 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,68 @@ }); 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('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 () { @@ -88,3 +122,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)); +});