From efab819a98adddae0b606d670b28160080fe6d36 Mon Sep 17 00:00:00 2001 From: Andrey Postal Date: Mon, 31 Mar 2025 13:21:00 -0300 Subject: [PATCH] Improve tests and fix type handling --- src/Payload.php | 15 ++++++++ src/SimpleHydrator.php | 57 +++++++++++++++++++-------- src/SimpleSerializer.php | 14 +++---- tests/HydratorTest.php | 62 ++++++++++++++++++++++++++++-- tests/SerializerTest.php | 66 ++++++++++++++++++++------------ tests/Utils/SimpleTestObject.php | 29 -------------- tests/Utils/TestObject.php | 63 ++++++++++++++++++++++++++++++ tests/bootstrap.php | 2 +- 8 files changed, 227 insertions(+), 81 deletions(-) create mode 100644 src/Payload.php delete mode 100644 tests/Utils/SimpleTestObject.php create mode 100644 tests/Utils/TestObject.php diff --git a/src/Payload.php b/src/Payload.php new file mode 100644 index 0000000..334b26d --- /dev/null +++ b/src/Payload.php @@ -0,0 +1,15 @@ +getProperties(); foreach ($properties as $property) { - $value = $this->processProperty($property, $data, $skipAttributeCheck); - if ($value !== null) { - $property->setValue($instance, $value); + $payload = $this->processProperty($property, $data, $skipAttributeCheck); + + if ($payload->skipped) { + continue; + } + + if ($payload->error !== null) { + throw $payload->error; } + + $property->setValue($instance, $payload->data); } return $instance; @@ -54,7 +61,7 @@ private function processClass(ReflectionClass $class, array $data): object /** * @throws ReflectionException */ - private function processProperty(ReflectionProperty $property, array $jsonArr, bool $skipAttributeCheck): mixed + private function processProperty(ReflectionProperty $property, array $jsonArr, bool $skipAttributeCheck): Payload { // Check if property has ItemAttribute $attributes = $property->getAttributes(Item::class); @@ -66,7 +73,7 @@ private function processProperty(ReflectionProperty $property, array $jsonArr, b // We do not touch this property in this case if ($hasSkipAttribute || ($attr === null && !$skipAttributeCheck)) { - return null; + return new Payload(skipped: true); } /** @var Item $item we set to an empty new item, means that it has ValueObject attribute */ @@ -78,11 +85,11 @@ private function processProperty(ReflectionProperty $property, array $jsonArr, b // Simple validation for required items $arrKeyExists = array_key_exists($key, $jsonArr); if ($item->required && !$arrKeyExists) { - throw new InvalidArgumentException(sprintf('required item <%s> not found', $key)); + return new Payload(error: new InvalidArgumentException(sprintf('required item <%s> not found', $key))); } if (!$arrKeyExists) { - return null; + return $this->buildNullableResponse($property, null); } if ($property->getType()?->isBuiltin()) { @@ -96,7 +103,7 @@ private function processProperty(ReflectionProperty $property, array $jsonArr, b /** * @throws ReflectionException */ - private function handleBuiltin(array $data, string $key, ReflectionProperty $property, Item $item): mixed + private function handleBuiltin(array $data, string $key, ReflectionProperty $property, Item $item): Payload { // If we define a type in the attribute and the property is an array, we perform simple type validation if ($item->type !== null && $property->getType()?->getName() === 'array') { @@ -105,17 +112,29 @@ private function handleBuiltin(array $data, string $key, ReflectionProperty $pro foreach ($data[$key] ?? [] as $k => $v) { $value = $v; if ($classExists) { - $value = $this->handleCustomType($value, $item->type); + $payload = $this->handleCustomType($value, $item->type); + + if ($payload->skipped) { + continue; + } + + if ($payload->error !== null) { + return $payload; + } + + $value = $payload->data; } elseif (gettype($v) !== $item->type) { - throw new LogicException(sprintf('expected array with items of type <%s> but found <%s>', $item->type, gettype($v))); + return new Payload( + error: new LogicException(sprintf('expected array with items of type <%s> but found <%s>', $item->type, gettype($v))), + ); } $output[$k] = $value; } - return $output; + return new Payload(data: $output); } // If no data is set, returns null which will be ignored by the set value, falling back to undefined or default value - return $data[$key] ?? null; + return $this->buildNullableResponse($property, $data[$key] ?? null); } /** @@ -125,13 +144,21 @@ private function handleCustomType(mixed $value, string $type): mixed { $typeReflection = new ReflectionClass($type); if ($typeReflection->isEnum()) { - return call_user_func($type.'::tryFrom', $value); + return new Payload(data: call_user_func($type.'::tryFrom', $value)); } // Recursively hydrate child/internal value objects - return $this->hydrate( + return new Payload(data: $this->hydrate( data: $value, class: $type, - ); + )); + } + + private function buildNullableResponse(ReflectionProperty $property, mixed $value): Payload + { + if ($value !== null || $property->getType()->allowsNull()) { + return new Payload(data: $value); + } + return new Payload(skipped: true); } } diff --git a/src/SimpleSerializer.php b/src/SimpleSerializer.php index 84220da..a65494f 100644 --- a/src/SimpleSerializer.php +++ b/src/SimpleSerializer.php @@ -80,21 +80,19 @@ private function handlePossibleArray(Item $item, mixed $value): mixed return $value; } - if (!class_exists($item->type)) { - throw new LogicException(sprintf('expected array with items of type <%s> but found <%s>', $item->type, gettype($value))); - } - - $class = new ReflectionClass($item->type); - $isEnum = $class->isEnum(); + $isBuiltin = !class_exists($item->type); + $isEnum = !$isBuiltin && (new ReflectionClass($item->type))->isEnum(); return array_reduce( array: $value, - callback: function(array $l, mixed $c) use ($isEnum): array { + callback: function(array $l, mixed $c) use ($isEnum, $isBuiltin): array { + $v = $c; if ($isEnum) { $v = $c->value; - } else { + } else if (!$isBuiltin) { $v = $this->serialize($c); } + $l[] = $v; return $l; }, diff --git a/tests/HydratorTest.php b/tests/HydratorTest.php index adc0775..b7277dc 100644 --- a/tests/HydratorTest.php +++ b/tests/HydratorTest.php @@ -7,10 +7,12 @@ use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\TestCase; -use Utils\SimpleTestObject; +use Utils\ChildObject; +use Utils\TestObject; #[CoversClass(Item::class)] #[CoversClass(ValueObject::class)] +#[CoversClass(SimpleHydrator::class)] final class HydratorTest extends TestCase { /** @@ -23,18 +25,72 @@ public function testSimpleHydrate(): void 'int' => 10, 'float' => 3.14, 'bool' => false, + 'missing_required' => 'Im here', 'item_name' => 'Different name', + 'single_child' => [ + 'i_have_a_name' => 'this is the name', + 'different_one' => 'other one', + 'and_im_an_array_of_int' => [ 0, 1, 2], + ], + 'array_of_children' => [ + [ + 'i_have_a_name' => 'c1', + 'different_one' => 'o1', + 'and_im_an_array_of_int' => [ 3, 4, 5], + ], + [ + 'i_have_a_name' => 'c2', + 'different_one' => 'o2', + 'and_im_an_array_of_int' => [ 6, 7, 8], + ] + ] ]; $hydrator = new SimpleHydrator(); - /** @var SimpleTestObject $obj */ - $obj = $hydrator->hydrate($data, SimpleTestObject::class); + /** @var TestObject $obj */ + $obj = $hydrator->hydrate($data, TestObject::class); $this->assertEquals('str', $obj->string); $this->assertEquals('Different name', $obj->itemName); $this->assertEquals(10, $obj->int); $this->assertEquals(3.14, $obj->float); $this->assertFalse($obj->bool); + $this->assertNull($obj->nullableInt); + + $this->assertInstanceOf(ChildObject::class, $obj->singleChild); + $this->assertIsArray($obj->singleChild->andImAnArrayOfInt); + $this->assertSame($data['single_child']['and_im_an_array_of_int'], $obj->singleChild->andImAnArrayOfInt); + + $this->assertIsArray($obj->arrayOfChildren); + foreach ($obj->arrayOfChildren as $ci => $child) { + $childArr = $data['array_of_children'][$ci]; + + $this->assertInstanceOf(ChildObject::class, $child); + $this->assertEquals($childArr['i_have_a_name'], $child->iHaveAName); + $this->assertEquals($childArr['different_one'], $child->butIHaveADifferentOne); + + $this->assertIsArray($child->andImAnArrayOfInt); + $this->assertSame($childArr['and_im_an_array_of_int'], $child->andImAnArrayOfInt); + + foreach ($child->andImAnArrayOfInt as $i => $int) { + $this->assertIsInt($int); + $this->assertEquals($childArr['and_im_an_array_of_int'][$i], $int); + } + } + } + + /** + * @throws ReflectionException + */ + public function testMissingRequiredItem(): void + { + $data = []; + + $hydrator = new SimpleHydrator(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('required item not found'); + $hydrator->hydrate($data, TestObject::class); } } diff --git a/tests/SerializerTest.php b/tests/SerializerTest.php index 412204f..2a3302c 100644 --- a/tests/SerializerTest.php +++ b/tests/SerializerTest.php @@ -5,47 +5,63 @@ use Andrey\PancakeObject\SimpleSerializer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Utils\SimpleTestObject; +use Utils\TestObject; #[CoversClass(Item::class)] #[CoversClass(ValueObject::class)] +#[CoversClass(SimpleSerializer::class)] final class SerializerTest extends TestCase { /** * @throws ReflectionException */ public function testSimpleSerialize(): void - { - $this->assertSimpleSerializedObject(new SimpleTestObject()); - } - - /** - * @throws ReflectionException - */ - private function assertSimpleSerializedObject(object $obj): void { $serializer = new SimpleSerializer(); - $arr = $serializer->serialize($obj); + $data = $serializer->serialize(new TestObject()); + + $this->assertIsString($data["string"]); + $this->assertSame("string", $data["string"]); + + $this->assertIsInt($data["int"]); + $this->assertSame(1, $data["int"]); + + $this->assertIsFloat($data["float"]); + $this->assertSame(1.2, $data["float"]); + + $this->assertIsBool($data["bool"]); + $this->assertTrue($data["bool"]); + + $this->assertIsString($data["item_name"]); + $this->assertSame("Item name", $data["item_name"]); + + $this->assertIsString($data["missing_required"]); + $this->assertSame("im here", $data["missing_required"]); + + $this->assertIsArray($data["single_child"]); + $this->assertArrayHasKey("i_have_a_name", $data["single_child"]); + $this->assertSame("child", $data["single_child"]["i_have_a_name"]); - $this->assertArrayHasKey('string', $arr); - $this->assertArrayHasKey('int', $arr); - $this->assertArrayHasKey('float', $arr); - $this->assertArrayHasKey('bool', $arr); - $this->assertArrayHasKey('item_name', $arr); + $this->assertArrayHasKey("different_one", $data["single_child"]); + $this->assertSame("other n", $data["single_child"]["different_one"]); - $this->assertIsBool($arr['bool']); - $this->assertTrue($arr['bool']); + $this->assertArrayHasKey("and_im_an_array_of_int", $data["single_child"]); + $this->assertIsArray($data["single_child"]["and_im_an_array_of_int"]); + $this->assertSame([4, 5, 6], $data["single_child"]["and_im_an_array_of_int"]); - $this->assertIsInt($arr['int']); - $this->assertEquals(1, $arr['int']); + $this->assertNull($data["nullable_int"]); - $this->assertIsFloat($arr['float']); - $this->assertEquals(1.2, $arr['float']); + $this->assertIsArray($data["array_of_children"]); + $this->assertCount(2, $data["array_of_children"]); - $this->assertIsString($arr['string']); - $this->assertEquals('string', $arr['string']); + $this->assertSame("n1", $data["array_of_children"][0]["i_have_a_name"]); + $this->assertSame("no1", $data["array_of_children"][0]["different_one"]); + $this->assertIsArray($data["array_of_children"][0]["and_im_an_array_of_int"]); + $this->assertEmpty($data["array_of_children"][0]["and_im_an_array_of_int"]); - $this->assertIsString($arr['item_name']); - $this->assertEquals('Item name', $arr['item_name']); + $this->assertSame("n2", $data["array_of_children"][1]["i_have_a_name"]); + $this->assertSame("no2", $data["array_of_children"][1]["different_one"]); + $this->assertIsArray($data["array_of_children"][1]["and_im_an_array_of_int"]); + $this->assertSame([1, 2, 3], $data["array_of_children"][1]["and_im_an_array_of_int"]); } } diff --git a/tests/Utils/SimpleTestObject.php b/tests/Utils/SimpleTestObject.php deleted file mode 100644 index 902fb59..0000000 --- a/tests/Utils/SimpleTestObject.php +++ /dev/null @@ -1,29 +0,0 @@ -string = 'string'; - $this->int = 1; - $this->float = 1.2; - $this->bool = true; - $this->itemName = 'Item name'; - } -} diff --git a/tests/Utils/TestObject.php b/tests/Utils/TestObject.php new file mode 100644 index 0000000..062fad4 --- /dev/null +++ b/tests/Utils/TestObject.php @@ -0,0 +1,63 @@ +string = 'string'; + $this->int = 1; + $this->float = 1.2; + $this->bool = true; + $this->itemName = 'Item name'; + $this->missingRequired = 'im here'; + $this->nullableInt = null; + $this->singleChild = new ChildObject('child', 'other n', [4, 5, 6]); + $this->arrayOfChildren = [ + new ChildObject('n1', 'no1', []), + new ChildObject('n2', 'no2', [1,2,3]), + ]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1ddbc5b..71fee35 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,4 +1,4 @@