diff --git a/src/DataMapper/ConstructorDataMapper.php b/src/DataMapper/ConstructorDataMapper.php index 29740aa..5e7527b 100644 --- a/src/DataMapper/ConstructorDataMapper.php +++ b/src/DataMapper/ConstructorDataMapper.php @@ -13,6 +13,7 @@ use ReflectionNamedType; use ReflectionProperty; use ReflectionType; +use stdClass; use function array_filter; use function array_map; @@ -93,7 +94,7 @@ public function generateToDataObject(DataMapperInterface $dataMapper): string $parametersCode = []; foreach ($parameters as $parameter) { - $parametersCode[$parameter->name] = new Expr(sprintf('$fields[%s] ?? %s', Code::value($parameter->name), Code::value($parameter->fallback))); + $parametersCode[$parameter->name] = new Expr(sprintf('$fields[%s] ?? %s', Code::value($parameter->name), $parameter->compiledFallback())); } $newDtoCode = Code::new($this->className, $parametersCode); @@ -191,12 +192,18 @@ private function resolveFallbackValueFromType(?ReflectionType $type): mixed throw new LogicException(sprintf('Cannot use complex type with %s on %s', self::class, $this->className)); } + if (!$type->isBuiltin()) { + // @phpstan-ignore-next-line + return (new ReflectionClass($type->getName()))->newInstanceWithoutConstructor(); + } + return match ($type->getName()) { 'int' => 0, 'float' => 0.0, 'string' => '', 'bool' => false, 'array' => [], + 'object' => new stdClass(), default => throw new LogicException(sprintf('Cannot resolve fallback value for type %s on %s', $type->getName(), $this->className)), }; } diff --git a/src/DataMapper/ConstructorParameterMetadata.php b/src/DataMapper/ConstructorParameterMetadata.php index c04d543..6d09f07 100644 --- a/src/DataMapper/ConstructorParameterMetadata.php +++ b/src/DataMapper/ConstructorParameterMetadata.php @@ -2,6 +2,13 @@ namespace Quatrevieux\Form\DataMapper; +use Quatrevieux\Form\Util\Code; +use Quatrevieux\Form\Util\Expr; +use ReflectionClass; +use stdClass; + +use function is_object; + final class ConstructorParameterMetadata { public function __construct( @@ -10,4 +17,13 @@ public function __construct( public readonly bool $required, public readonly string $requiredMessage, ) {} + + public function compiledFallback(): string + { + if (is_object($this->fallback) && $this->fallback::class !== stdClass::class) { + return (string) Expr::new(ReflectionClass::class, [$this->fallback::class])->newInstanceWithoutConstructor(); + } + + return Code::value($this->fallback); + } } diff --git a/tests/DataMapper/ConstructorDataMapperTest.php b/tests/DataMapper/ConstructorDataMapperTest.php new file mode 100644 index 0000000..a9392e2 --- /dev/null +++ b/tests/DataMapper/ConstructorDataMapperTest.php @@ -0,0 +1,188 @@ +toDataObject([]); + + $this->assertSame(stdClass::class, $mapper->className()); + $this->assertEquals(new stdClass(), $dto->dto); + $this->assertSame([], $dto->errors); + $this->assertSame([], $mapper->toArray($dto->dto)); + } + + public function test_nullable_parameters(): void + { + $mapper = new ConstructorDataMapper(SimpleRequestConstructor::class, new DefaultRegistry()); + + $empty = $mapper->toDataObject([]); + + $this->assertSame([], $empty->errors); + $this->assertInstanceOf(SimpleRequestConstructor::class, $empty->dto); + $this->assertSame(null, $empty->dto->foo); + $this->assertSame(null, $empty->dto->bar); + + $filled = $mapper->toDataObject(['foo' => 'abc', 'bar' => 'def']); + + $this->assertSame([], $filled->errors); + $this->assertSame('abc', $filled->dto->foo); + $this->assertSame('def', $filled->dto->bar); + $this->assertSame(['foo' => 'abc', 'bar' => 'def'], $mapper->toArray($filled->dto)); + } + + public function test_default_values(): void + { + $mapper = new ConstructorDataMapper(RequestWithDefaultValueConstructor::class, new DefaultRegistry()); + + $dto = $mapper->toDataObject([]); + + $this->assertSame([], $dto->errors); + $this->assertSame(42, $dto->dto->foo); + $this->assertSame('???', $dto->dto->bar); + } + + public function test_required_parameters(): void + { + $mapper = new ConstructorDataMapper(RequiredParametersRequestConstructor::class, new DefaultRegistry()); + + $dto = $mapper->toDataObject([]); + + $this->assertInstanceOf(RequiredParametersRequestConstructor::class, $dto->dto); + $this->assertSame(0, $dto->dto->foo); + $this->assertSame('', $dto->dto->bar); + + $this->assertSame(['foo', 'bar'], array_keys($dto->errors)); + $this->assertSame(Required::CODE, $dto->errors['foo']->code); + $this->assertSame(Required::CODE, $dto->errors['bar']->code); + $this->assertSame('This value is required', $dto->errors['foo']->message); + $this->assertSame('bar must be set', $dto->errors['bar']->message); + + $filled = $mapper->toDataObject(['foo' => 12, 'bar' => 'value']); + + $this->assertSame([], $filled->errors); + $this->assertSame(12, $filled->dto->foo); + $this->assertSame('value', $filled->dto->bar); + } + + public function test_generate_to_array_code(): void + { + $mapper = new ConstructorDataMapper(SimpleRequestConstructor::class, new DefaultRegistry()); + + $this->assertSame('return get_object_vars($data);', $mapper->generateToArray($mapper)); + } + + public function test_generate_to_data_object_code(): void + { + $mapper = new ConstructorDataMapper(RequiredParametersRequestConstructor::class, new DefaultRegistry()); + + $this->assertSame(<<<'PHP' + $errors = []; + $dto = new \Quatrevieux\Form\Fixtures\RequiredParametersRequestConstructor(foo: $fields['foo'] ?? 0, bar: $fields['bar'] ?? ''); + + foreach (['foo' => 'This value is required', 'bar' => 'bar must be set'] as $field => $message) { + if (!isset($fields[$field])) { + $errors[$field] = new \Quatrevieux\Form\Validator\FieldError($message, [], 'b1ac3a70-06db-5cd6-8f0e-8e6b98b3fcb5', $this->registry->getTranslator()); + } + } + + return new \Quatrevieux\Form\DataMapper\DataMapperResult($dto, $errors); +PHP, $mapper->generateToDataObject($mapper)); + } + + public function test_generated_mapper_code_and_runtime_behavior(): void + { + $generator = new DataMapperGenerator(); + $mapper = new ConstructorDataMapper(SimpleRequestConstructor::class, new DefaultRegistry()); + $code = $generator->generate('GeneratedSimpleRequestConstructorDataMapper', $mapper); + + $this->assertNotNull($code); + $this->assertStringContainsString('class GeneratedSimpleRequestConstructorDataMapper implements Quatrevieux\\Form\\DataMapper\\DataMapperInterface', $code); + $this->assertStringContainsString('return Quatrevieux\\Form\\Fixtures\\SimpleRequestConstructor::class;', $code); + $this->assertStringContainsString('return get_object_vars($data);', $code); + + eval(str_replace('toDataObject(['foo' => 'gen-foo', 'bar' => 'gen-bar']); + + $this->assertSame([], $dto->errors); + $this->assertSame('gen-foo', $dto->dto->foo); + $this->assertSame('gen-bar', $dto->dto->bar); + $this->assertSame(['foo' => 'gen-foo', 'bar' => 'gen-bar'], $generatedMapper->toArray($dto->dto)); + } + + public function test_with_embedded() + { + $mapper = new ConstructorDataMapper(WithEmbeddedConstructor::class, new DefaultRegistry()); + + $dto = $mapper->toDataObject([]); + + $this->assertSame(WithEmbeddedConstructor::class, $mapper->className()); + $this->assertInstanceOf(WithEmbeddedConstructor::class, $dto->dto); + + $this->assertSame('', $dto->dto->foo); + $this->assertSame('', $dto->dto->bar); + $this->assertInstanceOf(EmbeddedFormConstructor::class, $dto->dto->embedded); + $this->assertFalse(isset($dto->dto->embedded->baz)); + $this->assertFalse(isset($dto->dto->embedded->rab)); + + $this->assertCount(3, $dto->errors); + $this->assertEquals('This value is required', (string) $dto->errors['foo']); + $this->assertEquals('This value is required', (string) $dto->errors['bar']); + $this->assertEquals('This value is required', (string) $dto->errors['embedded']); + + + $dto = $mapper->toDataObject([ + 'foo' => 'azerty', + 'bar' => 'uiop', + 'embedded' => new EmbeddedFormConstructor( + baz: 'qsdfgh', + rab: 'jklm', + ), + ]); + + $this->assertEquals(new WithEmbeddedConstructor( + foo: 'azerty', + bar: 'uiop', + embedded: new EmbeddedFormConstructor( + baz: 'qsdfgh', + rab: 'jklm', + ) + ), $dto->dto); + + $this->assertEmpty($dto->errors); + + $this->assertSame( + <<<'PHP' + $errors = []; + $dto = new \Quatrevieux\Form\Fixtures\WithEmbeddedConstructor(foo: $fields['foo'] ?? '', bar: $fields['bar'] ?? '', embedded: $fields['embedded'] ?? (new \ReflectionClass('Quatrevieux\\Form\\Fixtures\\EmbeddedFormConstructor'))->newInstanceWithoutConstructor()); + + foreach (['foo' => 'This value is required', 'bar' => 'This value is required', 'embedded' => 'This value is required'] as $field => $message) { + if (!isset($fields[$field])) { + $errors[$field] = new \Quatrevieux\Form\Validator\FieldError($message, [], 'b1ac3a70-06db-5cd6-8f0e-8e6b98b3fcb5', $this->registry->getTranslator()); + } + } + + return new \Quatrevieux\Form\DataMapper\DataMapperResult($dto, $errors); + PHP, + $mapper->generateToDataObject($mapper) + ); + } +} diff --git a/tests/Fixtures/WithEmbeddedConstructor.php b/tests/Fixtures/WithEmbeddedConstructor.php new file mode 100644 index 0000000..a392ce8 --- /dev/null +++ b/tests/Fixtures/WithEmbeddedConstructor.php @@ -0,0 +1,23 @@ +