Passed
Pull Request — master (#521)
by Alexander
05:06 queued 02:27
created

CompareHandler   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 184
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 9
Bugs 3 Features 0
Metric Value
wmc 29
eloc 57
c 9
b 3
f 0
dl 0
loc 184
ccs 35
cts 35
cp 1
rs 10

8 Methods

Rating   Name   Duplication   Size   Complexity  
B validate() 0 39 8
A checkValuesAreEqual() 0 15 5
A isValueAllowedForTypeCasting() 0 3 3
A normalizeNumber() 0 7 2
A isInputCorrect() 0 3 2
A checkFloatsAreEqual() 0 3 1
A getFormattedValue() 0 7 4
A compareValues() 0 21 4
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Rule;
6
7
use Stringable;
8
use Yiisoft\Validator\Exception\UnexpectedRuleException;
9
use Yiisoft\Validator\Result;
10
use Yiisoft\Validator\RuleHandlerInterface;
11
use Yiisoft\Validator\ValidationContext;
12
13
use function gettype;
14
use function in_array;
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, $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, $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->getOperator(), $rule->getType(), $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 AbstractCompare $rule The rule used for comparison.
76
     * @param mixed $value The validated value.
77 76
     *
78
     * @return bool `true` if value is correct and `false` otherwise.
79 76
     */
80 2
    private function isInputCorrect(AbstractCompare $rule, mixed $value): bool
81 2
    {
82
        return $rule->getType() !== CompareType::ORIGINAL ? $this->isValueAllowedForTypeCasting($value) : true;
83 74
    }
84 74
85
    /**
86
     * Checks whether the validated value is allowed for types that require type casting - {@see CompareType::NUMBER}
87 76
     * and {@see CompareType::STRING}.
88 17
     *
89 10
     * @param mixed $value The Validated value.
90 8
     *
91 6
     * @return bool `true` if value is allowed and `false` otherwise.
92 8
     */
93 8
    private function isValueAllowedForTypeCasting(mixed $value): bool
94 8
    {
95 76
        return $value === null || is_scalar($value) || $value instanceof Stringable;
96
    }
97
98
    /**
99
     * Gets representation of the value for using with error parameter.
100
     *
101
     * @param mixed $value The мalidated value.
102
     *
103
     * @return scalar|null Formatted value.
104
     */
105
    private function getFormattedValue(mixed $value): int|float|string|bool|null
106
    {
107
        if ($value === null || is_scalar($value)) {
108
            return $value;
109
        }
110
111
        return $value instanceof Stringable ? (string) $value : get_debug_type($value);
112
    }
113
114
    /**
115
     * Compares two values according to the specified type and operator.
116
     *
117
     * @param string $operator The comparison operator. One of `==`, `===`, `!=`, `!==`, `>`, `>=`, `<`, `<=`.
118
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
119
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
120
     *
121
     * @param mixed $value The validated value.
122
     * @param mixed $targetValue "Target" value set in rule options.
123
     *
124
     * @return bool Whether the result of comparison using the specified operator is true.
125
     */
126
    private function compareValues(string $type, string $operator, mixed $value, mixed $targetValue): bool
127
    {
128
        if (!in_array($operator, ['==', '===', '!=', '!=='])) {
129
            if ($type === CompareType::STRING) {
130
                $value = (string) $value;
131
                $targetValue = (string) $targetValue;
132
            } elseif ($type === CompareType::NUMBER) {
133
                $value = $this->normalizeNumber($value);
134
                $targetValue = $this->normalizeNumber($targetValue);
135
            }
136
        }
137
138
        return match ($operator) {
139
            '==' => $this->checkValuesAreEqual($type, $value, $targetValue),
140
            '===' => $this->checkValuesAreEqual($type, $value, $targetValue, strict: true),
141
            '!=' => !$this->checkValuesAreEqual($type, $value, $targetValue),
142
            '!==' => !$this->checkValuesAreEqual($type, $value, $targetValue, strict: true),
143
            '>' => $value > $targetValue,
144
            '>=' => $value >= $targetValue,
145
            '<' => $value < $targetValue,
146
            '<=' => $value <= $targetValue,
147
        };
148
    }
149
150
    /**
151
     * Checks whether a validated value equals to "target" value. For types other than {@see CompareType::ORIGINAL},
152
     * handles strict comparison before type casting and takes edge cases for float numbers into account.
153
     *
154
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
155
     * @param mixed $value The validated value.
156
     * @param mixed $targetValue "Target" value set in rule options.
157
     * @param bool $strict Whether the values must be equal (when set to `false`, default) / strictly equal (when set to
158
     * `true`).
159
     *
160
     * @return bool `true` if values are equal and `false` otherwise.
161
     */
162
    private function checkValuesAreEqual(string $type, mixed $value, mixed $targetValue, bool $strict = false): bool
163
    {
164
        if ($type === CompareType::ORIGINAL) {
165
            return $strict ? $value === $targetValue : $value == $targetValue;
166
        }
167
168
        if ($strict && gettype($value) !== gettype($targetValue)) {
169
            return false;
170
        }
171
172
        return match ($type) {
173
            CompareType::STRING => (string) $value === (string) $targetValue,
174
            CompareType::NUMBER => $this->checkFloatsAreEqual(
175
                $this->normalizeNumber($value),
176
                $this->normalizeNumber($targetValue),
177
            ),
178
        };
179
    }
180
181
    /**
182
     * Checks whether a validated float number equals to "target" float number. Handles a known problem of losing
183
     * precision during arithmetical operations.
184
     *
185
     * @param float $value The validated number.
186
     * @param float $targetValue "Target" number set in rule options.
187
     *
188
     * @return bool `true` if numbers are equal and `false` otherwise.
189
     *
190
     * @link https://floating-point-gui.de/
191
     */
192
    private function checkFloatsAreEqual(float $value, float $targetValue): bool
193
    {
194
        return abs($value - $targetValue) < PHP_FLOAT_EPSILON;
195
    }
196
197
    /**
198
     * Normalizes number that might be stored in a different type to float number.
199
     *
200
     * @param mixed $number Raw number. Can be within an object implementing {@see Stringable} interface or other
201
     * primitive type, such as `int`, `float`, `string`.
202
     *
203
     * @return float Float number ready for comparison.
204
     */
205
    private function normalizeNumber(mixed $number): float
206
    {
207
        if ($number instanceof Stringable) {
208
            $number = (string) $number;
209
        }
210
211
        return (float) $number;
212
    }
213
}
214