packages/sql-faker/src/Generation/Value/IntegerDomain.php
1<?php
2
3declare(strict_types=1);
4
5namespace SqlFaker\Generation\Value;
6
7use Closure;
8use InvalidArgumentException;
9use LogicException;
10use Override;
11
12/**
13 * Constructs bounded unsigned decimals by prefix intervals, independent of native integer width.
14 */
15final class IntegerDomain implements ValueDomain
16{
17 /**
18 * @var positive-int
19 */
20 private readonly int $widthChoices;
21 /**
22 * @var positive-int
23 */
24 private readonly int $paddingChoices;
25
26 private readonly string $sampleMaximum;
27
28 /**
29 * Binds normalized inclusive magnitudes; leading zeroes are spelling choices, not magnitude changes.
30 * @throws InvalidArgumentException When magnitudes or padding bounds are invalid
31 */
32 public function __construct(private readonly string $minimum, private readonly ?string $maximum, private readonly int $maximumLeadingZeroes = 16, private readonly bool $digitSeparators = false)
33 {
34 $sampleMaximum = $maximum ?? str_repeat('9', max(65, strlen($minimum)));
35 $widthChoices = strlen($sampleMaximum) - strlen($minimum) + 1;
36 $paddingChoices = $maximumLeadingZeroes + 1;
37 if ($paddingChoices < 1 || $paddingChoices > 1025 || $widthChoices < 1 || !$this->normalized($minimum)
38 || !$this->normalized($sampleMaximum)
39 || strlen($minimum) > strlen($sampleMaximum)
40 || (strlen($minimum) === strlen($sampleMaximum) && strcmp($minimum, $sampleMaximum) > 0)) {
41 throw new InvalidArgumentException('Require normalized unsigned decimal bounds in ascending order.');
42 }
43 $this->sampleMaximum = $sampleMaximum;
44 $this->widthChoices = $widthChoices;
45 $this->paddingChoices = $paddingChoices;
46 }
47
48 /**
49 * Every magnitude in the interval is reachable; no sample rejection or domain enumeration is needed.
50 * @param Closure(positive-int): int $choose
51 * @throws LogicException When a caller decision makes a decimal prefix impossible
52 */
53 #[Override]
54 public function choose(Closure $choose): string
55 {
56 $length = strlen($this->minimum) + $choose($this->widthChoices);
57 $lower = $length === strlen($this->minimum) ? $this->minimum : '1' . str_repeat('0', $length - 1);
58 $upper = $length === strlen($this->sampleMaximum) ? $this->sampleMaximum : str_repeat('9', $length);
59 $value = '';
60 for ($index = 0; $index < $length; ++$index) {
61 $low = $value === substr($lower, 0, $index) ? (int) $lower[$index] : 0;
62 $high = $value === substr($upper, 0, $index) ? (int) $upper[$index] : 9;
63 $count = $high - $low + 1;
64 if ($count < 1) {
65 throw new LogicException('No digit completes the selected decimal prefix.');
66 }
67 $value .= (string) ($low + $choose($count));
68 }
69 return str_repeat('0', $choose($this->paddingChoices)) . $value;
70 }
71 /**
72 * Checks declaration bounds without interpreting them as machine integers.
73 */
74 public function normalized(string $value): bool
75 {
76 return $value !== '' && strspn($value, '0123456789') === strlen($value)
77 && ($value === '0' || $value[0] !== '0');
78 }
79
80 /**
81 * @return list<int>
82 */
83 #[Override]
84 public function match(string $value, int $offset = 0): array
85 {
86 $ends = [];
87 $digits = '';
88 while (isset($value[$offset])) {
89 $character = $value[$offset++];
90 if ($this->digitSeparators && $character === '_' && $digits !== ''
91 && isset($value[$offset]) && str_contains('0123456789', $value[$offset])) {
92 $character = $value[$offset++];
93 }
94 if (!str_contains('0123456789', $character)) {
95 break;
96 }
97 $digits .= $character;
98 $magnitude = ltrim($digits, '0');
99 $magnitude = $magnitude === '' ? '0' : $magnitude;
100 if ($this->compare($magnitude, $this->minimum) >= 0 && ($this->maximum === null || $this->compare($magnitude, $this->maximum) <= 0)) {
101 $ends[] = $offset;
102 }
103 }
104 return $ends;
105 }
106
107 /**
108 * Orders normalized magnitudes by width and then by digits.
109 */
110 public function compare(string $left, string $right): int
111 {
112 $width = strlen($left) <=> strlen($right);
113 return $width === 0 ? strcmp($left, $right) : $width;
114 }
115
116}
117