diff --git a/packages/mapper/src/Casters/BooleanCaster.php b/packages/mapper/src/Casters/BooleanCaster.php index a2eac02b85..903fad2254 100644 --- a/packages/mapper/src/Casters/BooleanCaster.php +++ b/packages/mapper/src/Casters/BooleanCaster.php @@ -6,13 +6,19 @@ use Tempest\Core\Priority; use Tempest\Mapper\Caster; +use Tempest\Mapper\ConfigurableCaster; +use Tempest\Mapper\Context; use Tempest\Mapper\DynamicCaster; use Tempest\Reflection\PropertyReflector; use Tempest\Reflection\TypeReflector; #[Priority(Priority::NORMAL)] -final readonly class BooleanCaster implements Caster, DynamicCaster +final readonly class BooleanCaster implements Caster, DynamicCaster, ConfigurableCaster { + public function __construct( + private bool $nullable = false, + ) {} + public static function accepts(PropertyReflector|TypeReflector $input): bool { $type = $input instanceof PropertyReflector @@ -22,10 +28,19 @@ public static function accepts(PropertyReflector|TypeReflector $input): bool return in_array($type->getName(), ['bool', 'boolean'], strict: true); } - public function cast(mixed $input): bool + public static function configure(PropertyReflector $property, Context $context): self + { + return new self(nullable: $property->isNullable()); + } + + public function cast(mixed $input): ?bool { if (is_string($input)) { - $input = mb_strtolower($input); + $input = mb_strtolower(trim($input)); + } + + if ($this->nullable && ($input === null || $input === '' || $input === 'null')) { + return null; } return match ($input) { diff --git a/packages/mapper/src/Casters/EnumCaster.php b/packages/mapper/src/Casters/EnumCaster.php index 7de8fba526..8480feee73 100644 --- a/packages/mapper/src/Casters/EnumCaster.php +++ b/packages/mapper/src/Casters/EnumCaster.php @@ -22,6 +22,7 @@ */ public function __construct( private string $enum, + private bool $nullable = false, ) {} public static function accepts(PropertyReflector|TypeReflector $input): bool @@ -35,11 +36,22 @@ public static function accepts(PropertyReflector|TypeReflector $input): bool public static function configure(PropertyReflector $property, Context $context): self { - return new self(enum: $property->getType()->getName()); + return new self( + enum: $property->getType()->getName(), + nullable: $property->isNullable(), + ); } public function cast(mixed $input): ?object { + if (is_string($input)) { + $input = trim($input); + } + + if ($this->nullable && ($input === null || $input === '' || is_string($input) && mb_strtolower($input) === 'null')) { + return null; + } + if ($input === null) { return null; } diff --git a/packages/mapper/src/Casters/FloatCaster.php b/packages/mapper/src/Casters/FloatCaster.php index cc0f0ddb11..40aeda53c8 100644 --- a/packages/mapper/src/Casters/FloatCaster.php +++ b/packages/mapper/src/Casters/FloatCaster.php @@ -6,13 +6,19 @@ use Tempest\Core\Priority; use Tempest\Mapper\Caster; +use Tempest\Mapper\ConfigurableCaster; +use Tempest\Mapper\Context; use Tempest\Mapper\DynamicCaster; use Tempest\Reflection\PropertyReflector; use Tempest\Reflection\TypeReflector; #[Priority(Priority::NORMAL)] -final readonly class FloatCaster implements Caster, DynamicCaster +final readonly class FloatCaster implements Caster, DynamicCaster, ConfigurableCaster { + public function __construct( + private bool $nullable = false, + ) {} + public static function accepts(PropertyReflector|TypeReflector $input): bool { $type = $input instanceof PropertyReflector @@ -22,8 +28,21 @@ public static function accepts(PropertyReflector|TypeReflector $input): bool return in_array($type->getName(), ['float', 'double'], strict: true); } - public function cast(mixed $input): float + public static function configure(PropertyReflector $property, Context $context): self + { + return new self(nullable: $property->isNullable()); + } + + public function cast(mixed $input): ?float { + if (is_string($input)) { + $input = mb_strtolower(trim($input)); + } + + if ($this->nullable && ($input === null || $input === '' || $input === 'null')) { + return null; + } + return floatval($input); } } diff --git a/packages/mapper/src/Casters/IntegerCaster.php b/packages/mapper/src/Casters/IntegerCaster.php index 954284b645..1f9478850e 100644 --- a/packages/mapper/src/Casters/IntegerCaster.php +++ b/packages/mapper/src/Casters/IntegerCaster.php @@ -6,13 +6,19 @@ use Tempest\Core\Priority; use Tempest\Mapper\Caster; +use Tempest\Mapper\ConfigurableCaster; +use Tempest\Mapper\Context; use Tempest\Mapper\DynamicCaster; use Tempest\Reflection\PropertyReflector; use Tempest\Reflection\TypeReflector; #[Priority(Priority::NORMAL)] -final readonly class IntegerCaster implements Caster, DynamicCaster +final readonly class IntegerCaster implements Caster, DynamicCaster, ConfigurableCaster { + public function __construct( + private bool $nullable = false, + ) {} + public static function accepts(PropertyReflector|TypeReflector $input): bool { $type = $input instanceof PropertyReflector @@ -22,8 +28,21 @@ public static function accepts(PropertyReflector|TypeReflector $input): bool return in_array($type->getName(), ['int', 'integer'], strict: true); } - public function cast(mixed $input): int + public static function configure(PropertyReflector $property, Context $context): self + { + return new self(nullable: $property->isNullable()); + } + + public function cast(mixed $input): ?int { + if (is_string($input)) { + $input = mb_strtolower(trim($input)); + } + + if ($this->nullable && ($input === null || $input === '' || $input === 'null')) { + return null; + } + return intval($input); } } diff --git a/tests/Integration/Mapper/CasterFactoryTest.php b/tests/Integration/Mapper/CasterFactoryTest.php index a2e9651602..8d0b703031 100644 --- a/tests/Integration/Mapper/CasterFactoryTest.php +++ b/tests/Integration/Mapper/CasterFactoryTest.php @@ -24,8 +24,11 @@ public function test_for_property(): void $class = reflect(ObjectWithSerializerProperties::class); $this->assertInstanceOf(IntegerCaster::class, $factory->forProperty($class->getProperty('intProp'))); + $this->assertInstanceOf(IntegerCaster::class, $factory->forProperty($class->getProperty('nullableIntProp'))); $this->assertInstanceOf(FloatCaster::class, $factory->forProperty($class->getProperty('floatProp'))); + $this->assertInstanceOf(FloatCaster::class, $factory->forProperty($class->getProperty('nullableFloatProp'))); $this->assertInstanceOf(BooleanCaster::class, $factory->forProperty($class->getProperty('boolProp'))); + $this->assertInstanceOf(BooleanCaster::class, $factory->forProperty($class->getProperty('nullableBoolProp'))); $this->assertInstanceOf(NativeDateTimeCaster::class, $factory->forProperty($class->getProperty('nativeDateTimeImmutableProp'))); $this->assertInstanceOf(NativeDateTimeCaster::class, $factory->forProperty($class->getProperty('nativeDateTimeProp'))); $this->assertInstanceOf(NativeDateTimeCaster::class, $factory->forProperty($class->getProperty('nativeDateTimeInterfaceProp'))); diff --git a/tests/Integration/Mapper/Casters/BooleanCasterTest.php b/tests/Integration/Mapper/Casters/BooleanCasterTest.php index 5d87c39013..ccd676a19e 100644 --- a/tests/Integration/Mapper/Casters/BooleanCasterTest.php +++ b/tests/Integration/Mapper/Casters/BooleanCasterTest.php @@ -2,12 +2,14 @@ namespace Tests\Tempest\Integration\Mapper\Casters; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use Tempest\Mapper\Casters\BooleanCaster; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; final class BooleanCasterTest extends FrameworkIntegrationTestCase { + #[Test] #[TestWith(['true', true])] #[TestWith(['false', false])] #[TestWith([true, true])] @@ -21,8 +23,30 @@ final class BooleanCasterTest extends FrameworkIntegrationTestCase #[TestWith(['off', false])] #[TestWith(['disabled', false])] #[TestWith(['no', false])] - public function test_cast(mixed $input, bool $expected): void + public function cast(mixed $input, bool $expected): void { $this->assertSame($expected, new BooleanCaster()->cast($input)); } + + #[Test] + #[TestWith(['true', true])] + #[TestWith([true, true])] + #[TestWith(['false', false])] + #[TestWith([false, false])] + #[TestWith(['on', true])] + #[TestWith(['off', false])] + public function nullable_cast_with_values(mixed $input, bool $expected): void + { + $this->assertSame($expected, new BooleanCaster(nullable: true)->cast($input)); + } + + #[Test] + #[TestWith([null])] + #[TestWith([''])] + #[TestWith(['null'])] + #[TestWith([' '])] + public function nullable_cast_returns_null_for_empty_input(mixed $input): void + { + $this->assertNull(new BooleanCaster(nullable: true)->cast($input)); + } } diff --git a/tests/Integration/Mapper/Casters/EnumCasterTest.php b/tests/Integration/Mapper/Casters/EnumCasterTest.php index 59bbe5eb3c..03aac20196 100644 --- a/tests/Integration/Mapper/Casters/EnumCasterTest.php +++ b/tests/Integration/Mapper/Casters/EnumCasterTest.php @@ -2,6 +2,7 @@ namespace Tests\Tempest\Integration\Mapper\Casters; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use Tempest\Mapper\Casters\EnumCaster; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -11,12 +12,36 @@ final class EnumCasterTest extends FrameworkIntegrationTestCase { + #[Test] #[TestWith(['FOO', UnitEnumToSerialize::FOO, UnitEnumToSerialize::class])] #[TestWith(['BAR', UnitEnumToSerialize::BAR, UnitEnumToSerialize::class])] #[TestWith(['foo', BackedEnumToSerialize::FOO, BackedEnumToSerialize::class])] #[TestWith(['bar', BackedEnumToSerialize::BAR, BackedEnumToSerialize::class])] - public function test_cast(mixed $input, UnitEnum $expected, string $class): void + public function cast(mixed $input, UnitEnum $expected, string $class): void { $this->assertSame($expected, new EnumCaster($class)->cast($input)); } + + #[Test] + #[TestWith([null])] + #[TestWith([''])] + #[TestWith(['null'])] + #[TestWith(['NULL'])] + #[TestWith([' '])] + public function nullable_cast_returns_null_for_empty_input(mixed $input): void + { + $caster = new EnumCaster(enum: BackedEnumToSerialize::class, nullable: true); + + $this->assertNull($caster->cast($input)); + } + + #[Test] + #[TestWith(['foo', BackedEnumToSerialize::FOO])] + #[TestWith(['bar', BackedEnumToSerialize::BAR])] + public function nullable_cast_with_valid_values(mixed $input, UnitEnum $expected): void + { + $caster = new EnumCaster(enum: BackedEnumToSerialize::class, nullable: true); + + $this->assertSame($expected, $caster->cast($input)); + } } diff --git a/tests/Integration/Mapper/Casters/FloatCasterTest.php b/tests/Integration/Mapper/Casters/FloatCasterTest.php new file mode 100644 index 0000000000..240185022f --- /dev/null +++ b/tests/Integration/Mapper/Casters/FloatCasterTest.php @@ -0,0 +1,54 @@ +assertSame($expected, $caster->cast($input)); + } + + #[Test] + #[TestWith(['3.14', 3.14])] + #[TestWith([3.14, 3.14])] + #[TestWith(['0', 0.0])] + #[TestWith([0, 0.0])] + #[TestWith([' 3.14 ', 3.14])] + public function nullable_cast_with_values(mixed $input, float $expected): void + { + $caster = new FloatCaster(nullable: true); + + $this->assertSame($expected, $caster->cast($input)); + } + + #[Test] + #[TestWith([null])] + #[TestWith([''])] + #[TestWith(['null'])] + #[TestWith(['NULL'])] + #[TestWith(['Null'])] + #[TestWith([' '])] + public function nullable_cast_returns_null_for_empty_input(mixed $input): void + { + $caster = new FloatCaster(nullable: true); + + $this->assertNull($caster->cast($input)); + } +} diff --git a/tests/Integration/Mapper/Casters/IntegerCasterTest.php b/tests/Integration/Mapper/Casters/IntegerCasterTest.php new file mode 100644 index 0000000000..a0d9da7102 --- /dev/null +++ b/tests/Integration/Mapper/Casters/IntegerCasterTest.php @@ -0,0 +1,54 @@ +assertSame($expected, $caster->cast($input)); + } + + #[Test] + #[TestWith(['42', 42])] + #[TestWith([42, 42])] + #[TestWith(['0', 0])] + #[TestWith([0, 0])] + #[TestWith([' 42 ', 42])] + public function nullable_cast_with_values(mixed $input, int $expected): void + { + $caster = new IntegerCaster(nullable: true); + + $this->assertSame($expected, $caster->cast($input)); + } + + #[Test] + #[TestWith([null])] + #[TestWith([''])] + #[TestWith(['null'])] + #[TestWith(['NULL'])] + #[TestWith(['Null'])] + #[TestWith([' '])] + public function nullable_cast_returns_null_for_empty_input(mixed $input): void + { + $caster = new IntegerCaster(nullable: true); + + $this->assertNull($caster->cast($input)); + } +} diff --git a/tests/Integration/Mapper/Fixtures/ObjectWithSerializerProperties.php b/tests/Integration/Mapper/Fixtures/ObjectWithSerializerProperties.php index 3531a95a85..6b3b92ffd9 100644 --- a/tests/Integration/Mapper/Fixtures/ObjectWithSerializerProperties.php +++ b/tests/Integration/Mapper/Fixtures/ObjectWithSerializerProperties.php @@ -18,10 +18,16 @@ final class ObjectWithSerializerProperties public int $intProp = 1; + public ?int $nullableIntProp = null; + public float $floatProp = 0.1; + public ?float $nullableFloatProp = null; + public bool $boolProp = true; + public ?bool $nullableBoolProp = null; + public array $arrayProp = ['a']; #[SerializeWith(DoubleStringSerializer::class)] diff --git a/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php b/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php index ed03a31f63..34f09bab14 100644 --- a/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php +++ b/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php @@ -18,8 +18,11 @@ public function test_serialize(): void 'stringProp' => 'a', 'stringableProp' => 'a', 'intProp' => '1', + 'nullableIntProp' => null, 'floatProp' => '0.1', + 'nullableFloatProp' => null, 'boolProp' => 'true', + 'nullableBoolProp' => null, 'arrayProp' => '["a"]', 'serializeWithProp' => 'aa', 'doubleStringProp' => 'aa',