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