RandomProvider   A
last analyzed

Complexity

Total Complexity 19

Size/Duplication

Total Lines 224
Duplicated Lines 0 %

Test Coverage

Coverage 77.42%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
dl 0
loc 224
ccs 48
cts 62
cp 0.7742
rs 10
c 1
b 0
f 0
eloc 71
wmc 19

4 Methods

Rating   Name   Duplication   Size   Complexity  
A randomReal() 0 28 2
A getRandomizer() 0 9 1
A randomDecimal() 0 13 2
C randomInt() 0 135 14
1
<?php
2
3
namespace Samsara\Fermat\Core\Provider;
4
5
use Exception;
6
use Random\RandomException;
7
use Samsara\Exceptions\SystemError\PlatformError\MissingPackage;
8
use Samsara\Exceptions\UsageError\IntegrityConstraint;
9
use Samsara\Exceptions\SystemError\LogicalError\IncompatibleObjectState;
10
use Samsara\Exceptions\UsageError\OptionalExit;
11
use Samsara\Fermat\Core\Enums\NumberBase;
12
use Samsara\Fermat\Core\Enums\RandomMode;
13
use Samsara\Fermat\Core\Numbers;
14
use Samsara\Fermat\Core\Types\Base\Interfaces\Numbers\DecimalInterface;
15
use Samsara\Fermat\Core\Values\ImmutableDecimal;
16
use Random\Randomizer;
17
use Random\Engine\Secure;
18
use Random\Engine\PcgOneseq128XslRr64;
19
use Random\Engine\Xoshiro256StarStar;
20
21
/**
22
 *
23
 */
24
class RandomProvider
25
{
26
27
    /**
28
     * @param int|string|DecimalInterface $min
29
     * @param int|string|DecimalInterface $max
30
     * @param RandomMode $mode
31
     * @param int|null $seed
32
     * @return ImmutableDecimal
33
     * @throws RandomException
34
     * @throws IntegrityConstraint
35
     */
36 14
    public static function randomInt(
37
        int|string|DecimalInterface $min,
38
        int|string|DecimalInterface $max,
39
        RandomMode $mode = RandomMode::Entropy,
40
        ?int $seed = null
41
    ): ImmutableDecimal
42
    {
43 14
        $minDecimal = Numbers::makeOrDont(Numbers::IMMUTABLE, $min);
44 14
        $maxDecimal = Numbers::makeOrDont(Numbers::IMMUTABLE, $max);
45
46
        /**
47
         * We want to prevent providing non-integer values for min and max, even in cases where
48
         * the supplied value is a string or a DecimalInterface.
49
         */
50 14
        if ($minDecimal->isFloat() || $maxDecimal->isFloat()) {
51
            throw new IntegrityConstraint(
52
                'Random integers cannot be generated with boundaries which are floats',
53
                'Provide only whole number, integer values for min and max.',
54
                'An attempt was made to generate a random integer with boundaries which are non-integers. Min Provided: '.$min->getValue(NumberBase::Ten).' -- Max Provided: '.$max->getValue(NumberBase::Ten)
0 ignored issues
show
Unused Code introduced by
The call to Samsara\Fermat\Core\Type...erInterface::getValue() has too many arguments starting with Samsara\Fermat\Core\Enums\NumberBase::Ten. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

54
                'An attempt was made to generate a random integer with boundaries which are non-integers. Min Provided: '.$min->/** @scrutinizer ignore-call */ getValue(NumberBase::Ten).' -- Max Provided: '.$max->getValue(NumberBase::Ten)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
55
            );
56
        }
57
58
        /**
59
         * Because of optimistic optimizing with the rand() and random_int() functions, we do
60
         * need the arguments to be provided in the correct order.
61
         */
62 14
        if ($minDecimal->isGreaterThan($maxDecimal)) {
63
            $tempDecimal = $minDecimal;
64
            $minDecimal = $maxDecimal;
65
            $maxDecimal = $tempDecimal;
66
            unset($tempDecimal);
67
        }
68
69
        /**
70
         * For some applications it might be better to throw an exception here, however it
71
         * would probably be hard to recover in most applications from a situation which
72
         * resulted in this situation.
73
         *
74
         * So instead we will trigger a language level warning and return the only valid
75
         * value for the parameters given.
76
         */
77 14
        if ($minDecimal->isEqual($maxDecimal)) {
78 2
            trigger_error(
79 2
                'Attempted to get a random value for a range of no size, with minimum of '.$minDecimal->getValue(NumberBase::Ten).' and maximum of '.$maxDecimal->getValue(NumberBase::Ten),
80
                E_USER_WARNING
81
            );
82
83
            return $minDecimal;
84
        }
85
86 12
        if ($minDecimal->isLessThanOrEqualTo(PHP_INT_MAX) && $minDecimal->isGreaterThanOrEqualTo(PHP_INT_MIN)) {
87
            /** @noinspection PhpUnhandledExceptionInspection */
88 12
            $min = $minDecimal->asInt();
89
        }
90
91 12
        if ($maxDecimal->isLessThanOrEqualTo(PHP_INT_MAX) && $maxDecimal->isGreaterThanOrEqualTo(PHP_INT_MIN)) {
92
            /** @noinspection PhpUnhandledExceptionInspection */
93 10
            $max = $maxDecimal->asInt();
94
        }
95
96 12
        $randomizer = self::getRandomizer($mode, $seed);
97
98 12
        if (is_int($min) && is_int($max)) {
99 10
            $num = $randomizer->getInt($min, $max);
100 10
            return new ImmutableDecimal($num);
101
        } else {
102
            /**
103
             * We only need to request enough bytes to find a number within the range, since we
104
             * will be adding the minimum value to it at the end.
105
             */
106
            /** @noinspection PhpUnhandledExceptionInspection */
107 2
            $range = $maxDecimal->subtract($minDecimal);
108
            /** @noinspection PhpUnhandledExceptionInspection */
109 2
            $bitsNeeded = $range->ln(2)->divide(Numbers::makeNaturalLog2(2), 2)->floor()->add(1);
110 2
            $bytesNeeded = $bitsNeeded->divide(8, 2)->ceil();
111
112
            do {
113
                try {
114
                    /**
115
                     * Returns random bytes based on sources of entropy within the system.
116
                     *
117
                     * For documentation on these sources please see:
118
                     *
119
                     * https://www.php.net/manual/en/function.random-bytes.php
120
                     */
121 2
                    $entropyBytes = $randomizer->getBytes($bytesNeeded->asInt());
122 2
                    $baseTwoBytes = '';
123 2
                    for($i = 0; $i < strlen($entropyBytes); $i++){
124 2
                        $baseTwoBytes .= decbin( ord( $entropyBytes[$i] ) );
125
                    }
126
                } catch (Exception $e) {
127
                    throw new OptionalExit(
128
                        'System error from random_bytes().',
129
                        'Ensure your system is configured correctly.',
130
                        'A call to random_bytes() threw a system level exception. Most often this is due to a problem with entropy sources in your configuration. Original exception message: ' . $e->getMessage()
131
                    );
132
                }
133
134
                /**
135
                 * Since the number of digits is equal to the bits needed, but random_bytes() only
136
                 * returns in chunks of 8 bits (duh, bytes), we can substr() from the right to
137
                 * select only the correct number of digits by multiplying the number of bits
138
                 * needed by -1 and using that as the starting point.
139
                 */
140 2
                $randomValue = BaseConversionProvider::convertStringToBaseTen(
141 2
                    substr($baseTwoBytes, $bitsNeeded->multiply(-1)->asInt()),
142
                    NumberBase::Two
143
                );
144
145
                /**
146
                 * @var ImmutableDecimal $num
147
                 */
148 2
                $num = Numbers::make(
149
                    type: Numbers::IMMUTABLE,
150
                    value: $randomValue
151
                );
152 2
            } while ($num->isGreaterThan($range));
153
            /**
154
             * It is strictly speaking possible for this to loop infinitely. In the worst case
155
             * scenario where 50% of possible values are invalid, it takes 7 loops for there to
156
             * be a less than a 1% chance of still not having an answer.
157
             *
158
             * After only 10 loops the chance is less than 1/1000.
159
             *
160
             * NOTE: Worst case scenario is a range of size 2^n + 1.
161
             */
162
163
            /**
164
             * Add the minimum since we effectively subtracted it by finding a random number
165
             * bounded between 0 and range. If our requested range included negative numbers,
166
             * this operation will also return those values into our data by effectively
167
             * shifting the result window.
168
             */
169
            /** @noinspection PhpUnhandledExceptionInspection */
170 2
            return $num->add($minDecimal);
171
        }
172
    }
173
174
    /**
175
     * @param int $scale
176
     * @param RandomMode $mode
177
     * @return ImmutableDecimal
178
     * @throws RandomException
179
     */
180 8
    public static function randomDecimal(
181
        int $scale = 10,
182
        RandomMode $mode = RandomMode::Entropy
183
    ): ImmutableDecimal
184
    {
185 8
        $randomizer = self::getRandomizer($mode);
186
187 8
        $result = '0.';
188 8
        for ($i = 0; $i < $scale; $i++) {
189 8
            $result .= $randomizer->getInt(0, 9);
190
        }
191
192 8
        return new ImmutableDecimal($result, $scale);
193
    }
194
195
    /**
196
     * @param int|string|DecimalInterface $min
197
     * @param int|string|DecimalInterface $max
198
     * @param int $scale
199
     * @param RandomMode $mode
200
     * @return ImmutableDecimal
201
     * @throws RandomException
202
     */
203 6
    public static function randomReal(
204
        int|string|DecimalInterface $min,
205
        int|string|DecimalInterface $max,
206
        int $scale,
207
        RandomMode $mode = RandomMode::Entropy
208
    ): ImmutableDecimal
209
    {
210 6
        $min = new ImmutableDecimal($min);
211 6
        $max = new ImmutableDecimal($max);
212
213 6
        if ($min->isEqual($max)) {
214
            trigger_error(
215
                'Attempted to get a random value for a range of no size, with minimum of '.$min->getValue(NumberBase::Ten).' and maximum of '.$max->getValue(NumberBase::Ten),
216
                E_USER_WARNING
217
            );
218
219
            return $min;
220
        }
221
222 6
        $intScale = $scale + 2;
223
224 6
        $range = $max->subtract($min);
225
226 6
        $intScale = $intScale + $range->numberOfTotalDigits();
227
228 6
        $randomDecimal = self::randomDecimal($intScale, $mode);
229
230 6
        return $randomDecimal->multiply($range)->add($min)->truncateToScale($scale);
231
    }
232
233
    /**
234
     * @param RandomMode $mode
235
     * @param int|null $seed
236
     * @return Randomizer
237
     * @throws RandomException
238
     */
239 20
    private static function getRandomizer(RandomMode $mode, ?int $seed = null): Randomizer
240
    {
241 20
        $engine = match ($mode) {
242 20
            RandomMode::Speed => new Xoshiro256StarStar($seed),
243 16
            RandomMode::Entropy => new PcgOneseq128XslRr64($seed),
244
            RandomMode::Secure => new Secure(),
245
        };
246
247 20
        return new Randomizer($engine);
248
    }
249
250
}