CompareHandler::checkValuesAreEqual()   A
last analyzed

Complexity

Conditions 5
Paths 4

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
cc 5
eloc 9
nc 4
nop 4
dl 0
loc 15
rs 9.6111
c 0
b 0
f 0
ccs 0
cts 0
cp 0
crap 30
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Rule;
6
7
use DateTimeInterface;
8
use Stringable;
9
use Yiisoft\Validator\Exception\UnexpectedRuleException;
10
use Yiisoft\Validator\Result;
11
use Yiisoft\Validator\RuleHandlerInterface;
12
use Yiisoft\Validator\ValidationContext;
13
14
use function gettype;
15
16
/**
17
 * Compares the specified value with "target" value provided directly or within an attribute.
18
 *
19
 * @see AbstractCompare
20
 * @see Equal
21
 * @see GreaterThan
22
 * @see GreaterThanOrEqual
23
 * @see LessThan
24
 * @see LessThanOrEqual
25
 * @see Compare
26
 * @see NotEqual
27 84
 */
28
final class CompareHandler implements RuleHandlerInterface
29 84
{
30 1
    public function validate(mixed $value, object $rule, ValidationContext $context): Result
31
    {
32
        if (!$rule instanceof AbstractCompare) {
33 83
            throw new UnexpectedRuleException(AbstractCompare::class, $rule);
34 83
        }
35 4
36 4
        $result = new Result();
37 4
        if (!$this->isInputCorrect($rule->getType(), $value)) {
38
            return $result->addError($rule->getIncorrectInputMessage(), [
39
                'attribute' => $context->getTranslatedAttribute(),
40
                'type' => get_debug_type($value),
41 79
            ]);
42 79
        }
43
44 79
        /** @var mixed $targetValue */
45
        $targetValue = $rule->getTargetValue();
46 8
        $targetAttribute = $rule->getTargetAttribute();
47 8
48 3
        if ($targetValue === null && $targetAttribute !== null) {
49 3
            /** @var mixed $targetValue */
50
            $targetValue = $context->getDataSet()->getAttributeValue($targetAttribute);
51
            if (!$this->isInputCorrect($rule->getType(), $targetValue)) {
52
                return $result->addError($rule->getIncorrectDataSetTypeMessage(), [
53
                    'type' => get_debug_type($targetValue),
54 76
                ]);
55 34
            }
56
        }
57
58 42
        if ($this->compareValues($rule->getType(), $rule->getOperator(), $value, $targetValue)) {
59 42
            return new Result();
60 42
        }
61 42
62
        return (new Result())->addError($rule->getMessage(), [
63
            'attribute' => $context->getTranslatedAttribute(),
64
            'targetValue' => $this->getFormattedValue($rule->getTargetValue()),
65
            'targetAttribute' => $targetAttribute,
66
            'targetAttributeValue' => $targetAttribute !== null ? $this->getFormattedValue($targetValue) : null,
67
            'targetValueOrAttribute' => $targetAttribute ?? $this->getFormattedValue($targetValue),
68
            'value' => $this->getFormattedValue($value),
69
        ]);
70
    }
71
72
    /**
73
     * Checks whether the validated value has correct type depending on selected {@see AbstractCompare::$type}.
74
     *
75
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
76
     *
77 76
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
78
     *
79 76
     * @param mixed $value The validated value.
80 2
     *
81 2
     * @return bool `true` if value is correct and `false` otherwise.
82
     */
83 74
    private function isInputCorrect(string $type, mixed $value): bool
84 74
    {
85
        return $type !== CompareType::ORIGINAL ? $this->isValueAllowedForTypeCasting($value) : true;
86
    }
87 76
88 17
    /**
89 10
     * Checks whether the validated value is allowed for types that require type casting - {@see CompareType::NUMBER}
90 8
     * and {@see CompareType::STRING}.
91 6
     *
92 8
     * @param mixed $value The Validated value.
93 8
     *
94 8
     * @return bool `true` if value is allowed and `false` otherwise.
95 76
     */
96
    private function isValueAllowedForTypeCasting(mixed $value): bool
97
    {
98
        return $value === null ||
99
            is_scalar($value) ||
100
            $value instanceof Stringable ||
101
            $value instanceof DateTimeInterface;
102
    }
103
104
    /**
105
     * Gets representation of the value for using with error parameter.
106
     *
107
     * @param mixed $value The validated value.
108
     *
109
     * @return scalar|null Formatted value.
110
     */
111
    private function getFormattedValue(mixed $value): int|float|string|bool|null
112
    {
113
        if ($value === null || is_scalar($value)) {
114
            return $value;
115
        }
116
117
        if ($value instanceof Stringable) {
118
            return (string) $value;
119
        }
120
121
        if ($value instanceof DateTimeInterface) {
122
            return $value->format('U');
123
        }
124
125
        return get_debug_type($value);
126
    }
127
128
    /**
129
     * Compares two values according to the specified type and operator.
130
     *
131
     * @param string $operator The comparison operator. One of `==`, `===`, `!=`, `!==`, `>`, `>=`, `<`, `<=`.
132
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
133
     *
134
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
135
     *
136
     * @param mixed $value The validated value.
137
     * @param mixed $targetValue "Target" value set in rule options.
138
     *
139
     * @return bool Whether the result of comparison using the specified operator is true.
140
     */
141
    private function compareValues(string $type, string $operator, mixed $value, mixed $targetValue): bool
142
    {
143
        if ($operator === '>' || $operator === '<') {
144
            /** @var mixed $value */
145
            $value = $this->normalizeValue($type, $value);
146
            /** @var mixed $targetValue */
147
            $targetValue = $this->normalizeValue($type, $targetValue);
148
        }
149
150
        return match ($operator) {
151
            '==' => $this->checkValuesAreEqual($type, $value, $targetValue),
152
            '===' => $this->checkValuesAreEqual($type, $value, $targetValue, strict: true),
153
            '!=' => !$this->checkValuesAreEqual($type, $value, $targetValue),
154
            '!==' => !$this->checkValuesAreEqual($type, $value, $targetValue, strict: true),
155
            '>' => $value > $targetValue,
156
            /** @infection-ignore-all */
157
            '>=' => $this->checkValuesAreEqual($type, $value, $targetValue) ||
158
                $this->normalizeValue($type, $value) > $this->normalizeValue($type, $targetValue),
159
            '<' => $value < $targetValue,
160
            /** @infection-ignore-all */
161
            '<=' => $this->checkValuesAreEqual($type, $value, $targetValue) ||
162
                $this->normalizeValue($type, $value) < $this->normalizeValue($type, $targetValue),
163
        };
164
    }
165
166
    /**
167
     * Checks whether a validated value equals to "target" value. For types other than {@see CompareType::ORIGINAL},
168
     * handles strict comparison before type casting and takes edge cases for float numbers into account.
169
     *
170
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
171
     *
172
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
173
     *
174
     * @param mixed $value The validated value.
175
     * @param mixed $targetValue "Target" value set in rule options.
176
     * @param bool $strict Whether the values must be equal (when set to `false`, default) / strictly equal (when set to
177
     * `true`).
178
     *
179
     * @return bool `true` if values are equal and `false` otherwise.
180
     */
181
    private function checkValuesAreEqual(string $type, mixed $value, mixed $targetValue, bool $strict = false): bool
182
    {
183
        if ($type === CompareType::ORIGINAL) {
184
            return $strict ? $value === $targetValue : $value == $targetValue;
185
        }
186
187
        if ($strict && gettype($value) !== gettype($targetValue)) {
188
            return false;
189
        }
190
191
        return match ($type) {
192
            CompareType::STRING => $this->normalizeString($value) === $this->normalizeString($targetValue),
193
            CompareType::NUMBER => $this->checkFloatsAreEqual(
194
                $this->normalizeNumber($value),
195
                $this->normalizeNumber($targetValue),
196
            ),
197
        };
198
    }
199
200
    /**
201
     * Checks whether a validated float number equals to "target" float number. Handles a known problem of losing
202
     * precision during arithmetical operations.
203
     *
204
     * @param float $value The validated number.
205
     * @param float $targetValue "Target" number set in rule options.
206
     *
207
     * @return bool `true` if numbers are equal and `false` otherwise.
208
     *
209
     * @link https://floating-point-gui.de/
210
     */
211
    private function checkFloatsAreEqual(float $value, float $targetValue): bool
212
    {
213
        return abs($value - $targetValue) < PHP_FLOAT_EPSILON;
214
    }
215
216
    /**
217
     * Normalizes compared value depending on selected {@see AbstractCompare::$type}.
218
     *
219
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
220
     *
221
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
222
     *
223
     * @param mixed $value One of the compared values. Both validated and target value can be used.
224
     *
225
     * @return mixed Normalized value ready for comparison.
226
     */
227
    private function normalizeValue(string $type, mixed $value): mixed
228
    {
229
        return match ($type) {
230
            CompareType::ORIGINAL => $value,
231
            CompareType::STRING => $this->normalizeString($value),
232
            CompareType::NUMBER => $this->normalizeNumber($value),
233
        };
234
    }
235
236
    /**
237
     * Normalizes number that might be stored in a different type to float number.
238
     *
239
     * @param mixed $number Raw number. Can be within an object implementing {@see Stringable} /
240
     * {@see DateTimeInterface} or other primitive type, such as `int`, `float`, `string`.
241
     *
242
     * @return float Float number ready for comparison.
243
     */
244
    private function normalizeNumber(mixed $number): float
245
    {
246
        if ($number instanceof Stringable) {
247
            $number = (string) $number;
248
        } elseif ($number instanceof DateTimeInterface) {
249
            $number = $number->format('U');
250
        }
251
252
        return (float) $number;
253
    }
254
255
    /**
256
     * Normalizes string that might be stored in a different type to simple string.
257
     *
258
     * @param mixed $string Raw string. Can be within an object implementing {@see DateTimeInterface} or other primitive
259
     * type, such as `int`, `float`, `string`.
260
     *
261
     * @return string String ready for comparison.
262
     */
263
    private function normalizeString(mixed $string): string
264
    {
265
        if ($string instanceof DateTimeInterface) {
266
            $string = $string->format('U');
267
        }
268
269
        return (string) $string;
270
    }
271
}
272