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
-
-
## 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));
+});