Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Uid
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
4 / 4
15
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 generate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 generateFast
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 generateRejection
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use InvalidArgumentException;
8
9final class Uid
10{
11    public const ALPHABET_ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
12    public const ALPHABET_LOWERCASE_WORD_SAFE = '123456789bcdfghklmnpqrstvwxyz';
13    public const ALPHABET_CROCKFORD_BASE_32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
14    public const ALPHABET_WORD_SAFE = 'FGHKLMNPRSTVWYZbdfhkmrstvwz23579';
15    public const ALPHABET_URL_SAFE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
16
17    private readonly string $alphabet;
18    private readonly int $alphabetSize;
19    private readonly int $threshold;
20    private readonly int $defaultLength;
21
22    public function __construct(
23        string $alphabet,
24        int $defaultLength,
25    ) {
26        $size = strlen($alphabet);
27
28        if ($size < 2) {
29            throw new InvalidArgumentException('Alphabet must contain at least 2 characters');
30        }
31        if ($size > 256) {
32            throw new InvalidArgumentException('Alphabet must contain at most 256 characters');
33        }
34        if (count(array_unique(str_split($alphabet))) !== $size) {
35            throw new InvalidArgumentException('Alphabet must not contain duplicate characters');
36        }
37        if ($defaultLength < 1) {
38            throw new InvalidArgumentException('Default length must be >= 1');
39        }
40
41        $this->alphabet = $alphabet;
42        $this->alphabetSize = $size;
43        // Largest multiple of $size that fits in one byte (0–255).
44        // Values at or above $threshold are discarded â†’ no modulo bias.
45        $this->threshold = intdiv(256, $size) * $size;
46        $this->defaultLength = $defaultLength;
47    }
48
49    public function generate(?int $length = null): string
50    {
51        $length ??= $this->defaultLength;
52
53        if ($length < 1) {
54            throw new InvalidArgumentException('Length must be >= 1');
55        }
56
57        // If the alphabet size is a divisor of 256 (2, 4, 8, 16, 32, 64, 128, 256),
58        // there is no bias and we can use the fast path without rejection sampling.
59        if ($this->threshold === 256) {
60            return $this->generateFast($length);
61        }
62
63        return $this->generateRejection($length);
64    }
65
66    private function generateFast(int $length): string
67    {
68        $bytes = random_bytes($length);
69        $id = '';
70        for ($i = 0; $i < $length; $i++) {
71            $id .= $this->alphabet[ord($bytes[$i]) % $this->alphabetSize];
72        }
73        return $id;
74    }
75
76    private function generateRejection(int $length): string
77    {
78        // Expected discard rate: 1 - threshold/256. We fetch slightly more bytes than
79        // needed so that in most cases a single random_bytes() call suffices.
80        $acceptRate = $this->threshold / 256;
81        $batchSize = (int) ceil(($length / $acceptRate) * 1.1);
82
83        $id = '';
84        $needed = $length;
85
86        while ($needed > 0) {
87            $bytes = random_bytes($batchSize);
88            $len = strlen($bytes);
89
90            for ($i = 0; $i < $len && $needed > 0; $i++) {
91                $b = ord($bytes[$i]);
92                if ($b >= $this->threshold) {
93                    continue;
94                }
95                $id .= $this->alphabet[$b % $this->alphabetSize];
96                $needed--;
97            }
98
99            // If we still need more, request smaller follow-up batches.
100            $batchSize = (int) ceil(($needed / $acceptRate) * 1.1);
101        }
102
103        return $id;
104    }
105}