Passed
Pull Request — master (#521)
by Alexander
02:37
created

CompareHandler::getFormattedValue()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 5
c 1
b 0
f 0
nc 3
nop 2
dl 0
loc 11
cc 5
ccs 0
cts 0
cp 0
crap 30
rs 9.6111
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
use function in_array;
16
17
/**
18
 * Compares the specified value with "target" value provided directly or within an attribute.
19
 *
20
 * @see AbstractCompare
21
 * @see Equal
22
 * @see GreaterThan
23
 * @see GreaterThanOrEqual
24
 * @see LessThan
25
 * @see LessThanOrEqual
26
 * @see Compare
27 84
 * @see NotEqual
28
 */
29 84
final class CompareHandler implements RuleHandlerInterface
30 1
{
31
    public function validate(mixed $value, object $rule, ValidationContext $context): Result
32
    {
33 83
        if (!$rule instanceof AbstractCompare) {
34 83
            throw new UnexpectedRuleException(AbstractCompare::class, $rule);
35 4
        }
36 4
37 4
        $result = new Result();
38
        if (!$this->isInputCorrect($rule->getType(), $value)) {
39
            return $result->addError($rule->getIncorrectInputMessage(), [
40
                'attribute' => $context->getTranslatedAttribute(),
41 79
                'type' => get_debug_type($value),
42 79
            ]);
43
        }
44 79
45
        /** @var mixed $targetValue */
46 8
        $targetValue = $rule->getTargetValue();
47 8
        $targetAttribute = $rule->getTargetAttribute();
48 3
49 3
        if ($targetValue === null && $targetAttribute !== null) {
50
            /** @var mixed $targetValue */
51
            $targetValue = $context->getDataSet()->getAttributeValue($targetAttribute);
52
            if (!$this->isInputCorrect($rule->getType(), $targetValue)) {
53
                return $result->addError($rule->getIncorrectDataSetTypeMessage(), [
54 76
                    'type' => get_debug_type($targetValue),
55 34
                ]);
56
            }
57
        }
58 42
59 42
        if ($this->compareValues($rule->getType(), $rule->getOperator(), $value, $targetValue)) {
60 42
            return new Result();
61 42
        }
62
63
        return (new Result())->addError($rule->getMessage(), [
64
            'attribute' => $context->getTranslatedAttribute(),
65
            'targetValue' => $this->getFormattedValue($rule->getType(), $rule->getTargetValue()),
66
            'targetAttribute' => $targetAttribute,
67
            'targetAttributeValue' => $targetAttribute !== null ? $this->getFormattedValue($rule->getType(), $targetValue) : null,
68
            'targetValueOrAttribute' => $targetAttribute ?? $this->getFormattedValue($rule->getType(), $targetValue),
69
            'value' => $this->getFormattedValue($rule->getType(), $value),
70
        ]);
71
    }
72
73
    /**
74
     * Checks whether the validated value has correct type depending on selected {@see AbstractCompare::$type}.
75
     *
76
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
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 string $type The type of the values being compared ({@see AbstractCompare::$type}).
108
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
109
     *
110
     * @param mixed $value The validated value.
111
     *
112
     * @return scalar|null Formatted value.
113
     */
114
    private function getFormattedValue(string $type, mixed $value): int|float|string|bool|null
115
    {
116
        if ($value === null || is_scalar($value)) {
117
            return $value;
118
        }
119
120
        if ($value instanceof Stringable && $type !== CompareType::ORIGINAL) {
121
            return (string) $value;
122
        }
123
124
        return get_debug_type($value);
125
    }
126
127
    /**
128
     * Compares two values according to the specified type and operator.
129
     *
130
     * @param string $operator The comparison operator. One of `==`, `===`, `!=`, `!==`, `>`, `>=`, `<`, `<=`.
131
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
132
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
133
     *
134
     * @param mixed $value The validated value.
135
     * @param mixed $targetValue "Target" value set in rule options.
136
     *
137
     * @return bool Whether the result of comparison using the specified operator is true.
138
     */
139
    private function compareValues(string $type, string $operator, mixed $value, mixed $targetValue): bool
140
    {
141
        if (!in_array($operator, ['==', '===', '!=', '!=='])) {
142
            if ($type === CompareType::STRING) {
143
                $value = (string) $value;
144
                $targetValue = (string) $targetValue;
145
            } elseif ($type === CompareType::NUMBER) {
146
                $value = $this->normalizeNumber($value);
147
                $targetValue = $this->normalizeNumber($targetValue);
148
            }
149
        }
150
151
        return match ($operator) {
152
            '==' => $this->checkValuesAreEqual($type, $value, $targetValue),
153
            '===' => $this->checkValuesAreEqual($type, $value, $targetValue, strict: true),
154
            '!=' => !$this->checkValuesAreEqual($type, $value, $targetValue),
155
            '!==' => !$this->checkValuesAreEqual($type, $value, $targetValue, strict: true),
156
            '>' => $value > $targetValue,
157
            '>=' => $value >= $targetValue,
158
            '<' => $value < $targetValue,
159
            '<=' => $value <= $targetValue,
160
        };
161
    }
162
163
    /**
164
     * Checks whether a validated value equals to "target" value. For types other than {@see CompareType::ORIGINAL},
165
     * handles strict comparison before type casting and takes edge cases for float numbers into account.
166
     *
167
     * @param string $type The type of the values being compared ({@see AbstractCompare::$type}).
168
     * @psalm-param CompareType::ORIGINAL | CompareType::STRING | CompareType::NUMBER $type
169
     *
170
     * @param mixed $value The validated value.
171
     * @param mixed $targetValue "Target" value set in rule options.
172
     * @param bool $strict Whether the values must be equal (when set to `false`, default) / strictly equal (when set to
173
     * `true`).
174
     *
175
     * @return bool `true` if values are equal and `false` otherwise.
176
     */
177
    private function checkValuesAreEqual(string $type, mixed $value, mixed $targetValue, bool $strict = false): bool
178
    {
179
        if ($type === CompareType::ORIGINAL) {
180
            return $strict ? $value === $targetValue : $value == $targetValue;
181
        }
182
183
        if ($strict && gettype($value) !== gettype($targetValue)) {
184
            return false;
185
        }
186
187
        return match ($type) {
188
            CompareType::STRING => (string) $value === (string) $targetValue,
189
            CompareType::NUMBER => $this->checkFloatsAreEqual(
190
                $this->normalizeNumber($value),
191
                $this->normalizeNumber($targetValue),
192
            ),
193
        };
194
    }
195
196
    /**
197
     * Checks whether a validated float number equals to "target" float number. Handles a known problem of losing
198
     * precision during arithmetical operations.
199
     *
200
     * @param float $value The validated number.
201
     * @param float $targetValue "Target" number set in rule options.
202
     *
203
     * @return bool `true` if numbers are equal and `false` otherwise.
204
     *
205
     * @link https://floating-point-gui.de/
206
     */
207
    private function checkFloatsAreEqual(float $value, float $targetValue): bool
208
    {
209
        return abs($value - $targetValue) < PHP_FLOAT_EPSILON;
210
    }
211
212
    /**
213
     * Normalizes number that might be stored in a different type to float number.
214
     *
215
     * @param mixed $number Raw number. Can be within an object implementing {@see Stringable} /
216
     * {@see DateTimeInterface} or other primitive type, such as `int`, `float`, `string`.
217
     *
218
     * @return float Float number ready for comparison.
219
     */
220
    private function normalizeNumber(mixed $number): float
221
    {
222
        if ($number instanceof Stringable) {
223
            $number = (string) $number;
224
        } elseif ($number instanceof DateTimeInterface) {
225
            $number = $number->format('U');
226
        }
227
228
        return (float) $number;
229
    }
230
}
231