diff --git a/README.md b/README.md index 3cc88c10..a96e793c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,45 @@ 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: + +Each type has two variants: +- `toX()`: returns the cast value or throws `\InvalidArgumentException` +- `tryX()`: returns the cast value or `null` (like `Enum::tryFrom()`) + +```php +use MLL\Utils\SafeCast; + +// Safe integer casting +SafeCast::toInt(42); // 42 +SafeCast::toInt('42'); // 42 +SafeCast::toInt('hello'); // throws InvalidArgumentException +SafeCast::tryInt('hello'); // null + +// Safe float casting +SafeCast::toFloat(3.14); // 3.14 +SafeCast::toFloat('3.14'); // 3.14 +SafeCast::toFloat('abc'); // throws InvalidArgumentException +SafeCast::tryFloat('abc'); // null + +// Safe string casting +SafeCast::toString(42); // '42' +SafeCast::toString(null); // '' +SafeCast::tryString([1, 2]); // null + +// Safe boolean casting +SafeCast::toBool(true); // true +SafeCast::toBool(1); // true +SafeCast::toBool('0'); // false +SafeCast::toBool('true'); // throws InvalidArgumentException +SafeCast::tryBool('true'); // null +``` + +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/LightcyclerExportSheet/LightcyclerDataParsingTrait.php b/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php index d40dea02..014a8c06 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 3f3b0f00..08a75acc 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 9a236b8c..eefb394c 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 865ca432..2bb4b297 100644 --- a/src/Microplate/CoordinateSystem.php +++ b/src/Microplate/CoordinateSystem.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; /** * Children should be called `CoordinateSystemXxY`, where X is the number of columns and Y is the number of rows. @@ -36,14 +37,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]; } @@ -60,7 +61,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 a13c7b40..dd7cb367 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 6a87c26b..e985aaeb 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 3aacf7ef..0584ad39 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 @@ -41,7 +42,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 00000000..e1163eeb --- /dev/null +++ b/src/SafeCast.php @@ -0,0 +1,263 @@ +,:,",/,\,|,?,*) from file name. */ diff --git a/src/Tecan/BasicCommands/BasicPipettingActionCommand.php b/src/Tecan/BasicCommands/BasicPipettingActionCommand.php index 37fe25d5..80defdca 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/LightcyclerExportSheet/QpcrXmlParserTest.php b/tests/LightcyclerExportSheet/QpcrXmlParserTest.php index 24ee8ccb..42e45c79 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 00000000..7bae3081 --- /dev/null +++ b/tests/SafeCastTest.php @@ -0,0 +1,340 @@ + */ + 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]; + } + + public function testToStringWithNull(): void + { + self::assertSame('', SafeCast::toString(null)); + } + + public function testTryStringWithNullReturnsNull(): void + { + self::assertNull(SafeCast::tryString(null)); + } + + public function testToStringWithObjectHavingToStringMethod(): void + { + $object = new class() implements \Stringable { + public function __toString(): string + { + return 'object-string'; + } + }; + + self::assertSame('object-string', SafeCast::toString($object)); + } + + public function testTryStringWithObjectHavingToStringMethod(): void + { + $object = new class() implements \Stringable { + public function __toString(): string + { + return 'object-string'; + } + }; + + self::assertSame('object-string', SafeCast::tryString($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 validIntProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validIntProvider')] + public function testTryIntWithValidInput(int $expected, $input): void + { + self::assertSame($expected, SafeCast::tryInt($input)); + } + + /** + * @dataProvider invalidIntProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidIntProvider')] + public function testTryIntReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryInt($input)); + } + + /** + * @dataProvider validFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validFloatProvider')] + public function testTryFloatWithValidInput(float $expected, $input): void + { + self::assertSame($expected, SafeCast::tryFloat($input)); + } + + /** + * @dataProvider invalidFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidFloatProvider')] + public function testTryFloatReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryFloat($input)); + } + + /** + * @dataProvider validStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validStringProvider')] + public function testTryStringWithValidInput(string $expected, $input): void + { + self::assertSame($expected, SafeCast::tryString($input)); + } + + /** + * @dataProvider invalidStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidStringProvider')] + public function testTryStringReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryString($input)); + } + + /** + * @dataProvider validBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validBoolProvider')] + public function testTryBoolWithValidInput(bool $expected, $input): void + { + self::assertSame($expected, SafeCast::tryBool($input)); + } + + /** + * @dataProvider invalidBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidBoolProvider')] + public function testTryBoolReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryBool($input)); + } + + /** + * @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 c0ac9bcd..1234b30b 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));