Skip to content

Commit 710ce04

Browse files
committed
feat: add class SafeCast
1 parent 5639afd commit 710ce04

File tree

4 files changed

+430
-1
lines changed

4 files changed

+430
-1
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,31 @@ composer require mll-lab/php-utils
2222

2323
See [tests](tests).
2424

25+
### SafeCast
26+
27+
PHP's native type casts like `(int)` and `(float)` can produce unexpected results, especially when casting from strings.
28+
The `SafeCast` utility provides safe alternatives that validate input before casting:
29+
30+
```php
31+
use MLL\Utils\SafeCast;
32+
33+
// Safe integer casting
34+
SafeCast::toInt(42); // 42
35+
SafeCast::toInt('42'); // 42
36+
SafeCast::toInt('hello'); // throws InvalidArgumentException
37+
38+
// Safe float casting
39+
SafeCast::toFloat(3.14); // 3.14
40+
SafeCast::toFloat('3.14'); // 3.14
41+
SafeCast::toFloat('abc'); // throws InvalidArgumentException
42+
43+
// Safe string casting
44+
SafeCast::toString(42); // '42'
45+
SafeCast::toString(null); // ''
46+
```
47+
48+
See [tests](tests/SafeCastTest.php) for more examples.
49+
2550
### Holidays
2651

2752
You can add custom holidays by registering a method that returns a map of holidays for a given year.

src/FluidXPlate/FluidXScanner.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public static function parseRawContent(string $rawContent): FluidXPlate
5454
$barcodes = [];
5555
$id = null;
5656
foreach ($lines as $line) {
57-
if ($line === '' || $line === self::READING || $line === self::XTR_96_CONNECTED) {
57+
if (in_array($line, ['', self::READING, self::XTR_96_CONNECTED], true)) {
5858
continue;
5959
}
6060
$content = explode(', ', $line);

src/SafeCast.php

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\Utils;
4+
5+
use Safe\Exceptions\PcreException;
6+
7+
use function Safe\preg_match;
8+
9+
/**
10+
* Safe type casting utilities to prevent unexpected or meaningless cast results.
11+
*
12+
* PHP's native type casts like (int) and (float) can produce unexpected results,
13+
* especially when casting from strings. This class provides safe alternatives that
14+
* validate the input before casting.
15+
*
16+
* Example of problematic native casts:
17+
* - (int)"hello" returns 0 (misleading, not an error)
18+
* - (int)"123abc" returns 123 (partial conversion, data loss)
19+
* - (float)"1.23.45" returns 1.23 (invalid format accepted)
20+
*
21+
* The methods in this class throw exceptions for invalid inputs instead of
22+
* silently producing incorrect values.
23+
*/
24+
class SafeCast
25+
{
26+
/**
27+
* Safely cast a value to an integer.
28+
*
29+
* Only accepts:
30+
* - Integers (returned as-is)
31+
* - Numeric strings that represent valid integers
32+
* - Floats that are exact integer values (e.g., 5.0)
33+
*
34+
* @param mixed $value The value to cast
35+
*
36+
* @throws \InvalidArgumentException If the value cannot be safely cast to an integer
37+
*/
38+
public static function toInt($value): int
39+
{
40+
if (is_int($value)) {
41+
return $value;
42+
}
43+
44+
// Allow floats that represent exact integers (e.g., 5.0 -> 5)
45+
if (is_float($value)) {
46+
if ($value === floor($value) && is_finite($value)) {
47+
return (int) $value;
48+
}
49+
50+
throw new \InvalidArgumentException('Float value "' . $value . '" cannot be safely cast to int (not a whole number or not finite)');
51+
}
52+
53+
if (is_string($value)) {
54+
$trimmed = trim($value);
55+
56+
// Empty string is not a valid integer
57+
if ($trimmed === '') {
58+
throw new \InvalidArgumentException('Empty string cannot be cast to int');
59+
}
60+
61+
// Check if the string represents a valid integer
62+
if (! self::isIntegerString($trimmed)) {
63+
throw new \InvalidArgumentException('String value "' . $value . '" is not a valid integer format');
64+
}
65+
66+
return (int) $trimmed;
67+
}
68+
69+
throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to int');
70+
}
71+
72+
/**
73+
* Safely cast a value to a float.
74+
*
75+
* Only accepts:
76+
* - Floats (returned as-is)
77+
* - Integers (cast to float)
78+
* - Numeric strings that represent valid floats
79+
*
80+
* @param mixed $value The value to cast
81+
*
82+
* @throws \InvalidArgumentException If the value cannot be safely cast to a float
83+
*/
84+
public static function toFloat($value): float
85+
{
86+
if (is_float($value)) {
87+
return $value;
88+
}
89+
90+
if (is_int($value)) {
91+
return (float) $value;
92+
}
93+
94+
if (is_string($value)) {
95+
$trimmed = trim($value);
96+
97+
// Empty string is not a valid float
98+
if ($trimmed === '') {
99+
throw new \InvalidArgumentException('Empty string cannot be cast to float');
100+
}
101+
102+
// Check if the string represents a valid numeric value
103+
if (! self::isNumericString($trimmed)) {
104+
throw new \InvalidArgumentException('String value "' . $value . '" is not a valid numeric format');
105+
}
106+
107+
return (float) $trimmed;
108+
}
109+
110+
throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to float');
111+
}
112+
113+
/**
114+
* Safely cast a value to a string.
115+
*
116+
* Only accepts:
117+
* - Strings (returned as-is)
118+
* - Integers and floats (converted to string)
119+
* - Objects with __toString() method
120+
* - null (converted to empty string)
121+
*
122+
* @param mixed $value The value to cast
123+
*
124+
* @throws \InvalidArgumentException If the value cannot be safely cast to a string
125+
*/
126+
public static function toString($value): string
127+
{
128+
if (is_string($value)) {
129+
return $value;
130+
}
131+
132+
if (is_int($value) || is_float($value)) {
133+
return (string) $value;
134+
}
135+
136+
if ($value === null) {
137+
return '';
138+
}
139+
140+
if (is_object($value) && method_exists($value, '__toString')) {
141+
return (string) $value;
142+
}
143+
144+
throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to string');
145+
}
146+
147+
/**
148+
* Check if a string represents a valid integer.
149+
*
150+
* Accepts optional leading/trailing whitespace, optional sign, and digits only.
151+
*/
152+
private static function isIntegerString(string $value): bool
153+
{
154+
try {
155+
return preg_match('/^[+-]?\d+$/', $value) === 1;
156+
} catch (PcreException $ex) {
157+
return false;
158+
}
159+
}
160+
161+
/**
162+
* Check if a string represents a valid numeric value (integer or float).
163+
*
164+
* Accepts scientific notation, decimals with optional sign.
165+
*/
166+
private static function isNumericString(string $value): bool
167+
{
168+
// Use is_numeric() but verify it's not in a weird format
169+
if (! is_numeric($value)) {
170+
return false;
171+
}
172+
173+
// is_numeric accepts some formats we might want to reject
174+
// like hexadecimal (0x1F) or binary (0b1010)
175+
// Check for these and reject them for stricter validation
176+
try {
177+
$hasHexOrBinary = preg_match('/^0[xXbB]/', $value) === 1;
178+
179+
return ! $hasHexOrBinary;
180+
} catch (PcreException $ex) {
181+
return false;
182+
}
183+
}
184+
}

0 commit comments

Comments
 (0)