diff --git a/README.md b/README.md index 3cc88c1..f9d6e99 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,37 @@ composer require mll-lab/php-utils See [tests](tests). +### SafeCast + +PHP's native type casts like `(int)` and `(float)` can produce unexpected results, especially when casting from strings. +The `SafeCast` utility provides safe alternatives that validate input before casting: + +```php +use MLL\Utils\SafeCast; + +// Safe integer casting +SafeCast::toInt(42); // 42 +SafeCast::toInt('42'); // 42 +SafeCast::toInt('hello'); // throws InvalidArgumentException + +// Safe float casting +SafeCast::toFloat(3.14); // 3.14 +SafeCast::toFloat('3.14'); // 3.14 +SafeCast::toFloat('abc'); // throws InvalidArgumentException + +// Safe string casting +SafeCast::toString(42); // '42' +SafeCast::toString(null); // '' + +// Safe boolean casting +SafeCast::toBool(true); // true +SafeCast::toBool(1); // true +SafeCast::toBool('0'); // false +SafeCast::toBool('true'); // throws InvalidArgumentException +``` + +See [tests](tests/SafeCastTest.php) for more examples. + ### Holidays You can add custom holidays by registering a method that returns a map of holidays for a given year. diff --git a/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php b/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php index 03715cf..33565ac 100644 --- a/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php +++ b/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php @@ -4,6 +4,7 @@ use MLL\Utils\IlluminaSampleSheet\IlluminaSampleSheetException; use MLL\Utils\IlluminaSampleSheet\V2\HeaderSection; +use MLL\Utils\SafeCast; class OverrideCycles { @@ -55,7 +56,7 @@ public function makeOverrideCycle(string $cycleString): OverrideCycle return new OverrideCycle( array_map( - fn (array $match): CycleTypeWithCount => new CycleTypeWithCount(new CycleType($match[1]), (int) $match[2]), + fn (array $match): CycleTypeWithCount => new CycleTypeWithCount(new CycleType($match[1]), SafeCast::toInt($match[2])), $matches ) ); diff --git a/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php b/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php index d40dea0..014a8c0 100644 --- a/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php +++ b/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php @@ -3,6 +3,7 @@ namespace MLL\Utils\LightcyclerExportSheet; use Illuminate\Support\Collection; +use MLL\Utils\SafeCast; trait LightcyclerDataParsingTrait { @@ -14,11 +15,7 @@ protected function parseFloatValue(?string $value): ?float return null; } - if (! is_numeric($cleanString)) { - throw new \InvalidArgumentException("Invalid float value: '{$cleanString}'"); - } - - return (float) $cleanString; + return SafeCast::toFloat($cleanString); } /** @return array{float, float} */ diff --git a/src/LightcyclerExportSheet/LightcyclerXmlParser.php b/src/LightcyclerExportSheet/LightcyclerXmlParser.php index 3f3b0f0..08a75ac 100644 --- a/src/LightcyclerExportSheet/LightcyclerXmlParser.php +++ b/src/LightcyclerExportSheet/LightcyclerXmlParser.php @@ -5,6 +5,7 @@ use Illuminate\Support\Collection; use MLL\Utils\Microplate\Coordinates; use MLL\Utils\Microplate\CoordinateSystem12x8; +use MLL\Utils\SafeCast; use function Safe\simplexml_load_string; @@ -77,7 +78,7 @@ private function extractPropertiesFromXml(\SimpleXMLElement $xmlElement): array $properties = []; foreach ($xmlElement->prop as $propertyNode) { - $propertyName = (string) $propertyNode->attributes()->name; + $propertyName = SafeCast::toString($propertyNode->attributes()->name); $propertyValue = $propertyNode->__toString(); if (! isset($properties[$propertyName])) { diff --git a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php index 9a236b8..eefb394 100644 --- a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php +++ b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php @@ -4,6 +4,7 @@ use MLL\Utils\Microplate\Coordinates; use MLL\Utils\Microplate\CoordinateSystem12x8; +use MLL\Utils\SafeCast; class AbsoluteQuantificationSample { @@ -42,7 +43,11 @@ public static function formatConcentration(?int $concentration): ?string return null; } - $exponent = (int) floor(log10(abs($concentration))); + if ($concentration === 0) { + return '0.00E0'; + } + + $exponent = SafeCast::toInt(floor(log10(abs($concentration)))); $mantissa = $concentration / (10 ** $exponent); return number_format($mantissa, 2) . 'E' . $exponent; diff --git a/src/Microplate/CoordinateSystem.php b/src/Microplate/CoordinateSystem.php index e63014b..729fe29 100644 --- a/src/Microplate/CoordinateSystem.php +++ b/src/Microplate/CoordinateSystem.php @@ -3,6 +3,7 @@ namespace MLL\Utils\Microplate; use Illuminate\Support\Arr; +use MLL\Utils\SafeCast; /** * Children should be called `CoordinateSystemXxY`, where X is the number of columns and Y is the number of rows. @@ -34,14 +35,14 @@ public function paddedColumns(): array /** 0-pad column to be as long as the longest column in the coordinate system. */ public function padColumn(int $column): string { - $maxColumnLength = strlen((string) $this->columnsCount()); + $maxColumnLength = strlen(SafeCast::toString($this->columnsCount())); - return str_pad((string) $column, $maxColumnLength, '0', STR_PAD_LEFT); + return str_pad(SafeCast::toString($column), $maxColumnLength, '0', STR_PAD_LEFT); } public function rowForRowFlowPosition(int $position): string { - $index = (int) floor(($position - 1) / $this->columnsCount()); + $index = SafeCast::toInt(floor(($position - 1) / $this->columnsCount())); return $this->rows()[$index]; } @@ -58,7 +59,7 @@ public function columnForRowFlowPosition(int $position): int public function columnForColumnFlowPosition(int $position): int { - $index = (int) floor(($position - 1) / $this->rowsCount()); + $index = SafeCast::toInt(floor(($position - 1) / $this->rowsCount())); return $this->columns()[$index]; } diff --git a/src/Microplate/Coordinates.php b/src/Microplate/Coordinates.php index a13c7b4..dd7cb36 100644 --- a/src/Microplate/Coordinates.php +++ b/src/Microplate/Coordinates.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use MLL\Utils\Microplate\Enums\FlowDirection; use MLL\Utils\Microplate\Exceptions\UnexpectedFlowDirection; +use MLL\Utils\SafeCast; use function Safe\preg_match; @@ -89,7 +90,7 @@ public static function fromString(string $coordinatesString, CoordinateSystem $c } /** @var array{1: string, 2: string} $matches */ - return new static($matches[1], (int) $matches[2], $coordinateSystem); + return new static($matches[1], SafeCast::toInt($matches[2]), $coordinateSystem); } /** diff --git a/src/Microplate/FullColumnSection.php b/src/Microplate/FullColumnSection.php index 6a87c26..e985aae 100644 --- a/src/Microplate/FullColumnSection.php +++ b/src/Microplate/FullColumnSection.php @@ -4,6 +4,7 @@ use MLL\Utils\Microplate\Exceptions\MicroplateIsFullException; use MLL\Utils\Microplate\Exceptions\SectionIsFullException; +use MLL\Utils\SafeCast; /** * A section that occupies all wells of a column if one sample exists in this column. @@ -90,6 +91,6 @@ private function sectionCanGrow(): bool private function reservedColumns(): int { - return (int) ceil($this->sectionItems->count() / $this->sectionedMicroplate->coordinateSystem->rowsCount()); + return SafeCast::toInt(ceil($this->sectionItems->count() / $this->sectionedMicroplate->coordinateSystem->rowsCount())); } } diff --git a/src/Microplate/MicroplateSet/MicroplateSet.php b/src/Microplate/MicroplateSet/MicroplateSet.php index f00b249..ce181ca 100644 --- a/src/Microplate/MicroplateSet/MicroplateSet.php +++ b/src/Microplate/MicroplateSet/MicroplateSet.php @@ -5,6 +5,7 @@ use MLL\Utils\Microplate\Coordinates; use MLL\Utils\Microplate\CoordinateSystem; use MLL\Utils\Microplate\Enums\FlowDirection; +use MLL\Utils\SafeCast; /** @template TCoordinateSystem of CoordinateSystem */ abstract class MicroplateSet @@ -39,7 +40,7 @@ public function locationFromPosition(int $setPosition, FlowDirection $direction) throw new \OutOfRangeException("Expected a position between 1-{$positionsCount}, got: {$setPosition}."); } - $plateIndex = (int) floor(($setPosition - 1) / $this->coordinateSystem->positionsCount()); + $plateIndex = SafeCast::toInt(floor(($setPosition - 1) / $this->coordinateSystem->positionsCount())); $positionOnSinglePlate = $setPosition - ($plateIndex * $this->coordinateSystem->positionsCount()); return new Location( diff --git a/src/SafeCast.php b/src/SafeCast.php new file mode 100644 index 0000000..db56323 --- /dev/null +++ b/src/SafeCast.php @@ -0,0 +1,199 @@ + 5) + if (is_float($value)) { + if ($value === floor($value) && is_finite($value)) { + return (int) $value; + } + + throw new \InvalidArgumentException('Float value "' . $value . '" cannot be safely cast to int (not a whole number or not finite)'); + } + + if (is_string($value)) { + $trimmed = trim($value); + + // Empty string is not a valid integer + if ($trimmed === '') { + throw new \InvalidArgumentException('Empty string cannot be cast to int'); + } + + // Check if the string represents a valid integer + if (! self::isIntegerString($trimmed)) { + throw new \InvalidArgumentException('String value "' . $value . '" is not a valid integer format'); + } + + return (int) $trimmed; + } + + throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to int'); + } + + /** + * Safely cast a value to a float. + * + * Only accepts: + * - Floats (returned as-is) + * - Integers (cast to float) + * - Numeric strings that represent valid floats + * + * @param mixed $value The value to cast + * + * @throws \InvalidArgumentException If the value cannot be safely cast to a float + */ + public static function toFloat($value): float + { + if (is_float($value)) { + return $value; + } + + if (is_int($value)) { + return (float) $value; + } + + if (is_string($value)) { + $trimmed = trim($value); + + // Empty string is not a valid float + if ($trimmed === '') { + throw new \InvalidArgumentException('Empty string cannot be cast to float'); + } + + // Check if the string represents a valid numeric value + if (! self::isNumericString($trimmed)) { + throw new \InvalidArgumentException('String value "' . $value . '" is not a valid numeric format'); + } + + return (float) $trimmed; + } + + throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to float'); + } + + /** + * Safely cast a value to a string. + * + * Only accepts: + * - Strings (returned as-is) + * - Integers and floats (converted to string) + * - Objects with __toString() method + * - null (converted to empty string) + * + * @param mixed $value The value to cast + * + * @throws \InvalidArgumentException If the value cannot be safely cast to a string + */ + public static function toString($value): string + { + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + if ($value === null) { + return ''; + } + + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to string'); + } + + /** + * Safely cast a value to a boolean. + * + * Only accepts: + * - Booleans (returned as-is) + * - Integer 0 or 1 + * - String "0" or "1" + * + * @param mixed $value The value to cast + * + * @throws \InvalidArgumentException If the value cannot be safely cast to a boolean + */ + public static function toBool($value): bool + { + if (is_bool($value)) { + return $value; + } + + if ($value === 0 || $value === '0') { + return false; + } + + if ($value === 1 || $value === '1') { + return true; + } + + throw new \InvalidArgumentException('Cannot safely cast value of type "' . gettype($value) . '" to bool. Only bool, int 0/1, or string "0"/"1" are accepted.'); + } + + /** + * Check if a string represents a valid integer. + * + * Accepts optional leading/trailing whitespace, optional sign, and digits only. + */ + private static function isIntegerString(string $value): bool + { + return preg_match('/^[+-]?\d+$/', $value) === 1; + } + + /** + * Check if a string represents a valid numeric value (integer or float). + * + * Accepts scientific notation, decimals with optional sign. + */ + private static function isNumericString(string $value): bool + { + if (! is_numeric($value)) { + return false; + } + + // is_numeric accepts some formats we want to reject, like hexadecimal (0x1F) or binary (0b1010). + // Check for these and reject them for stricter validation + return preg_match('/^0[xXbB]/', $value) !== 1; + } +} diff --git a/src/StringUtil.php b/src/StringUtil.php index 21a6092..bddc93e 100644 --- a/src/StringUtil.php +++ b/src/StringUtil.php @@ -171,11 +171,12 @@ private static function guessEncoding(string $text): string */ public static function leftPadNumber($number, int $length): string { - if (is_string($number) && ! is_numeric($number)) { - throw new \InvalidArgumentException("Expected numeric string, got: {$number}"); + // For strings, validate they're numeric by casting to float first + if (is_string($number)) { + $number = SafeCast::toFloat($number); } - return str_pad((string) $number, $length, '0', STR_PAD_LEFT); + return str_pad(SafeCast::toString($number), $length, '0', STR_PAD_LEFT); } /** Remove forbidden chars (<,>,:,",/,\,|,?,*) from file name. */ diff --git a/src/Tecan/BasicCommands/BasicPipettingActionCommand.php b/src/Tecan/BasicCommands/BasicPipettingActionCommand.php index 37fe25d..80defdc 100644 --- a/src/Tecan/BasicCommands/BasicPipettingActionCommand.php +++ b/src/Tecan/BasicCommands/BasicPipettingActionCommand.php @@ -2,6 +2,7 @@ namespace MLL\Utils\Tecan\BasicCommands; +use MLL\Utils\SafeCast; use MLL\Utils\Tecan\LiquidClass\LiquidClass; use MLL\Utils\Tecan\Location\Location; @@ -36,6 +37,6 @@ protected function getTipMask(): string public function setTipMask(int $tipMask): void { - $this->tipMask = (string) $tipMask; + $this->tipMask = SafeCast::toString($tipMask); } } diff --git a/tests/IlluminaSampleSheet/V2/DataSectionTest.php b/tests/IlluminaSampleSheet/V2/DataSectionTest.php index af44ba2..cffb3e4 100644 --- a/tests/IlluminaSampleSheet/V2/DataSectionTest.php +++ b/tests/IlluminaSampleSheet/V2/DataSectionTest.php @@ -55,4 +55,13 @@ public function testToStringWithProject(): void self::assertSame($expected, $dataSection->convertSectionToString()); } + + public function testThrowsExceptionForInvalidCycleFormat(): void + { + $dataSection = new DataSection(); + + $this->expectException(IlluminaSampleSheetException::class); + $this->expectExceptionMessage('Invalid Override Cycle Part'); + new OverrideCycles($dataSection, 'invalid', 'I8', null, null); + } } diff --git a/tests/LightcyclerExportSheet/QpcrXmlParserTest.php b/tests/LightcyclerExportSheet/QpcrXmlParserTest.php index 24ee8cc..42e45c7 100644 --- a/tests/LightcyclerExportSheet/QpcrXmlParserTest.php +++ b/tests/LightcyclerExportSheet/QpcrXmlParserTest.php @@ -219,7 +219,7 @@ public function testParseXmlHandlesInvalidFloatValues(): void XML; - $this->expectExceptionObject(new \InvalidArgumentException("Invalid float value: 'invalid'")); + $this->expectExceptionObject(new \InvalidArgumentException('String value "invalid" is not a valid numeric format')); $parser = new LightcyclerXmlParser(); $parser->parse($xmlWithInvalidFloat); diff --git a/tests/SafeCastTest.php b/tests/SafeCastTest.php new file mode 100644 index 0000000..aa5aedf --- /dev/null +++ b/tests/SafeCastTest.php @@ -0,0 +1,231 @@ + */ + public static function validIntProvider(): iterable + { + yield [42, 42]; + yield [-123, -123]; + yield [0, 0]; + yield [42, '42']; + yield [-123, '-123']; + yield [0, '0']; + yield [999, ' 999 ']; + yield [5, 5.0]; + yield [-10, -10.0]; + yield [0, 0.0]; + yield [42, '042']; + yield [0, '000']; + yield [42, '+42']; + } + + /** + * @dataProvider invalidIntProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidIntProvider')] + public function testToIntThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toInt($input); + } + + /** @return iterable */ + public static function invalidIntProvider(): iterable + { + yield ['String value "hello" is not a valid integer format', 'hello']; + yield ['String value "123abc" is not a valid integer format', '123abc']; + yield ['String value "12.34" is not a valid integer format', '12.34']; + yield ['Empty string cannot be cast to int', '']; + yield ['Float value "5.5" cannot be safely cast to int', 5.5]; + yield ['cannot be safely cast to int (not a whole number or not finite)', INF]; + yield ['Cannot cast value of type "array" to int', []]; + yield ['Cannot cast value of type "boolean" to int', true]; + yield ['Cannot cast value of type "boolean" to int', false]; + } + + /** + * @dataProvider validFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validFloatProvider')] + public function testToFloatWithValidInput(float $expected, $input): void + { + self::assertSame($expected, SafeCast::toFloat($input)); + } + + /** @return iterable */ + public static function validFloatProvider(): iterable + { + yield [3.14, 3.14]; + yield [-2.5, -2.5]; + yield [0.0, 0.0]; + yield [42.0, 42]; + yield [-123.0, -123]; + yield [0.0, 0]; + yield [3.14, '3.14']; + yield [-2.5, '-2.5']; + yield [42.0, '42']; + yield [1.23, ' 1.23 ']; + yield [1.5e3, '1.5e3']; + yield [2.5e-2, '2.5e-2']; + } + + /** + * @dataProvider invalidFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidFloatProvider')] + public function testToFloatThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toFloat($input); + } + + /** @return iterable */ + public static function invalidFloatProvider(): iterable + { + yield ['String value "hello" is not a valid numeric format', 'hello']; + yield ['String value "3.14abc" is not a valid numeric format', '3.14abc']; + yield ['String value "1.2.3" is not a valid numeric format', '1.2.3']; + yield ['Empty string cannot be cast to float', '']; + yield ['Cannot cast value of type "array" to float', []]; + yield ['is not a valid numeric format', '0x1A']; + yield ['is not a valid numeric format', '0b1010']; + yield ['Cannot cast value of type "boolean" to float', true]; + yield ['Cannot cast value of type "boolean" to float', false]; + } + + /** + * @dataProvider validStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validStringProvider')] + public function testToStringWithValidInput(string $expected, $input): void + { + self::assertSame($expected, SafeCast::toString($input)); + } + + /** @return iterable */ + public static function validStringProvider(): iterable + { + yield ['hello', 'hello']; + yield ['', '']; + yield ['42', 42]; + yield ['-123', -123]; + yield ['0', 0]; + yield ['3.14', 3.14]; + yield ['-2.5', -2.5]; + yield ['', null]; + } + + public function testToStringWithObjectHavingToStringMethod(): void + { + $object = new class() implements \Stringable { + public function __toString(): string + { + return 'object-string'; + } + }; + + self::assertSame('object-string', SafeCast::toString($object)); + } + + /** + * @dataProvider invalidStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidStringProvider')] + public function testToStringThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toString($input); + } + + /** @return iterable */ + public static function invalidStringProvider(): iterable + { + yield ['Cannot cast value of type "object" to string', new \stdClass()]; + yield ['Cannot cast value of type "array" to string', []]; + yield ['Cannot cast value of type "boolean" to string', true]; + yield ['Cannot cast value of type "boolean" to string', false]; + } + + /** + * @dataProvider validBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validBoolProvider')] + public function testToBoolWithValidInput(bool $expected, $input): void + { + self::assertSame($expected, SafeCast::toBool($input)); + } + + /** @return iterable */ + public static function validBoolProvider(): iterable + { + yield [true, true]; + yield [false, false]; + yield [true, 1]; + yield [false, 0]; + yield [true, '1']; + yield [false, '0']; + } + + /** + * @dataProvider invalidBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidBoolProvider')] + public function testToBoolThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toBool($input); + } + + /** @return iterable */ + public static function invalidBoolProvider(): iterable + { + yield ['Cannot safely cast value of type "string" to bool', 'true']; + yield ['Cannot safely cast value of type "string" to bool', 'false']; + yield ['Cannot safely cast value of type "string" to bool', 'yes']; + yield ['Cannot safely cast value of type "string" to bool', 'no']; + yield ['Cannot safely cast value of type "string" to bool', '']; + yield ['Cannot safely cast value of type "NULL" to bool', null]; + yield ['Cannot safely cast value of type "integer" to bool', 2]; + yield ['Cannot safely cast value of type "integer" to bool', -1]; + yield ['Cannot safely cast value of type "array" to bool', []]; + yield ['Cannot safely cast value of type "object" to bool', new \stdClass()]; + yield ['Cannot safely cast value of type "double" to bool', 1.0]; + yield ['Cannot safely cast value of type "double" to bool', 0.0]; + } +} diff --git a/tests/StringUtilTest.php b/tests/StringUtilTest.php index c0ac9bc..1234b30 100644 --- a/tests/StringUtilTest.php +++ b/tests/StringUtilTest.php @@ -154,9 +154,17 @@ public function testLeftPadNumber(): void ); self::expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('is not a valid numeric format'); StringUtil::leftPadNumber('foo', 3); } + public function testLeftPadNumberRejectsHexAndBinary(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('is not a valid numeric format'); + StringUtil::leftPadNumber('0x1A', 5); + } + public function testHasContent(): void { self::assertFalse(StringUtil::hasContent(null));