diff --git a/src/DataMapper/ConstructorDataMapper.php b/src/DataMapper/ConstructorDataMapper.php new file mode 100644 index 0000000..29740aa --- /dev/null +++ b/src/DataMapper/ConstructorDataMapper.php @@ -0,0 +1,203 @@ + + * @implements DataMapperTypeGeneratorInterface> + */ +final class ConstructorDataMapper implements DataMapperInterface, DataMapperTypeGeneratorInterface +{ + /** + * @var array|null + */ + private ?array $parameters = null; + + public function __construct( + /** + * Data transfer object class name + * + * @var class-string $className + */ + private readonly string $className, + private readonly RegistryInterface $registry, + ) {} + + /** + * {@inheritdoc} + */ + public function toDataObject(array $fields): DataMapperResult + { + $errors = []; + $parameterValues = []; + + foreach ($this->parameters() as $field => $parameter) { + $fieldValue = $fields[$field] ?? null; + + if ($fieldValue === null) { + $fieldValue = $parameter->fallback; + + if ($parameter->required) { + $errors[$field] = new FieldError($parameter->requiredMessage, code: Required::CODE, translator: $this->registry->getTranslator()); + } + } + + $parameterValues[$field] = $fieldValue; + } + + return new DataMapperResult(new ($this->className)(...$parameterValues), $errors); + } + + /** + * {@inheritdoc} + */ + public function toArray(object $data): array + { + return get_object_vars($data); + } + + /** + * {@inheritdoc} + */ + public function className(): string + { + return $this->className; + } + + /** + * {@inheritdoc} + */ + public function generateToDataObject(DataMapperInterface $dataMapper): string + { + $parameters = $this->parameters(); + $parametersCode = []; + + foreach ($parameters as $parameter) { + $parametersCode[$parameter->name] = new Expr(sprintf('$fields[%s] ?? %s', Code::value($parameter->name), Code::value($parameter->fallback))); + } + + $newDtoCode = Code::new($this->className, $parametersCode); + + // Handle required parameters errors + $requiredParameters = array_filter($parameters, static fn(ConstructorParameterMetadata $param) => $param->required); + $requiredParametersErrors = array_map(static fn(ConstructorParameterMetadata $param) => $param->requiredMessage, $requiredParameters); + $requiredParametersErrorsCode = Code::value($requiredParametersErrors); + + $newFieldErrorCode = Code::new(FieldError::class, [new Expr('$message'), [], Required::CODE, Expr::this()->registry->getTranslator()]); + $newResultCode = Code::new(DataMapperResult::class, [new Expr('$dto'), new Expr('$errors')]); + + return << \$message) { + if (!isset(\$fields[\$field])) { + \$errors[\$field] = {$newFieldErrorCode}; + } + } + + return {$newResultCode}; + PHP; + } + + /** + * {@inheritdoc} + */ + public function generateToArray(DataMapperInterface $dataMapper): string + { + return 'return get_object_vars($data);'; + } + + /** + * Get the fallback values for the constructor parameters if there are missing from the form input. + * + * @return array + */ + private function parameters(): array + { + if ($this->parameters !== null) { + return $this->parameters; + } + + $reflectionClass = new ReflectionClass($this->className); + $constructor = $reflectionClass->getConstructor(); + + if ($constructor === null) { + return []; + } + + $parameters = []; + $baseRequired = new Required(); + + foreach ($constructor->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable()) { + $parameters[$parameter->getName()] = new ConstructorParameterMetadata( + name: $parameter->name, + fallback: $parameter->getDefaultValue(), + required: false, + requiredMessage: $baseRequired->message, + ); + } else { + $fallback = $this->resolveFallbackValueFromType($parameter->getType()); + $required = $fallback !== null; // Null fallback means that the parameter is nullable, so it's not required + $requiredMessage = $baseRequired->message; + + if ($required && $parameter->isPromoted()) { + // Get the actual required error message + foreach ((new ReflectionProperty($this->className, $parameter->name))->getAttributes(Required::class) as $attribute) { + $requiredMessage = $attribute->newInstance()->message; + } + } + + $parameters[$parameter->getName()] = new ConstructorParameterMetadata( + name: $parameter->name, + fallback: $fallback, + required: $required, + requiredMessage: $requiredMessage, + ); + } + } + + return $this->parameters = $parameters; + } + + private function resolveFallbackValueFromType(?ReflectionType $type): mixed + { + if (!$type || $type->allowsNull()) { + return null; + } + + if (!$type instanceof ReflectionNamedType) { + throw new LogicException(sprintf('Cannot use complex type with %s on %s', self::class, $this->className)); + } + + return match ($type->getName()) { + 'int' => 0, + 'float' => 0.0, + 'string' => '', + 'bool' => false, + 'array' => [], + 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 new file mode 100644 index 0000000..c04d543 --- /dev/null +++ b/src/DataMapper/ConstructorParameterMetadata.php @@ -0,0 +1,13 @@ + $fields Associative array of fields, where keys are field names and values are field values. - * @return T + * @return DataMapperResult * * @see FormTransformerInterface::transformFromHttp() For converting HTTP data to the correct type, to be passed to this method * @see DataMapperInterface::toArray() For the reverse operation */ - public function toDataObject(array $fields): object; + public function toDataObject(array $fields): DataMapperResult; /** * Extract the data object into an associative array of fields diff --git a/src/DataMapper/DataMapperProviderInterface.php b/src/DataMapper/DataMapperProviderInterface.php index f07b965..93f77b5 100644 --- a/src/DataMapper/DataMapperProviderInterface.php +++ b/src/DataMapper/DataMapperProviderInterface.php @@ -2,6 +2,8 @@ namespace Quatrevieux\Form\DataMapper; +use Quatrevieux\Form\RegistryInterface; + /** * Base type for perform creation of DataMapperInterface instance * Should be used as attribute @@ -12,9 +14,10 @@ interface DataMapperProviderInterface * Create the data mapper instance which handle given DTO class * * @param class-string $dataClassName DTO class name + * @param RegistryInterface $registry * * @return DataMapperInterface * @template T as object */ - public function getDataMapper(string $dataClassName): DataMapperInterface; + public function getDataMapper(string $dataClassName, RegistryInterface $registry): DataMapperInterface; } diff --git a/src/DataMapper/DataMapperResult.php b/src/DataMapper/DataMapperResult.php new file mode 100644 index 0000000..ab8baa5 --- /dev/null +++ b/src/DataMapper/DataMapperResult.php @@ -0,0 +1,29 @@ + + */ + public readonly array $errors = [], + ) {} +} diff --git a/src/DataMapper/GeneratedDataMapperFactory.php b/src/DataMapper/GeneratedDataMapperFactory.php index 57a53fd..23bd730 100644 --- a/src/DataMapper/GeneratedDataMapperFactory.php +++ b/src/DataMapper/GeneratedDataMapperFactory.php @@ -4,6 +4,7 @@ use Closure; use Quatrevieux\Form\DataMapper\Generator\DataMapperGenerator; +use Quatrevieux\Form\RegistryInterface; use Quatrevieux\Form\Util\AbstractGeneratedFactory; use Quatrevieux\Form\Util\Functions; @@ -15,42 +16,33 @@ final class GeneratedDataMapperFactory extends AbstractGeneratedFactory implements DataMapperFactoryInterface { /** - * Fallback data mapper factory - * Will be lazily instantiated to {@see RuntimeDataMapperFactory} if not provided in constructor - * - * @var DataMapperFactoryInterface|null - */ - private ?DataMapperFactoryInterface $factory = null; - - /** - * Code generator - * Will be lazily instantiated if not provided in constructor - * - * @var DataMapperGenerator|null - */ - private ?DataMapperGenerator $generator = null; - - /** - * @param DataMapperFactoryInterface|null $factory Fallback data mapper factory. If not provided, will be lazily instantiated to {@see RuntimeDataMapperFactory}. - * @param DataMapperGenerator|null $generator Code generator instance. If not provided, will be lazily instantiated. * @param (Closure(string):string)|null $savePathResolver Resolve data mapper class file path using data mapper class name as parameter. By default, save into `sys_get_temp_dir()` * @param (Closure(string):string)|null $classNameResolver Resolve data mapper class name using DTO class name as parameter. By default, replace namespace seprator by "_", and add "DataMapper" suffix */ - public function __construct(?DataMapperFactoryInterface $factory = null, ?DataMapperGenerator $generator = null, ?Closure $savePathResolver = null, ?Closure $classNameResolver = null) - { + public function __construct( + private readonly RegistryInterface $registry, + + /** + * Fallback data mapper factory + * Will be lazily instantiated to {@see RuntimeDataMapperFactory} if not provided in constructor + */ + private ?DataMapperFactoryInterface $factory = null, + + /** + * Code generator + * Will be lazily instantiated if not provided in constructor + * + * @var DataMapperGenerator|null + */ + private ?DataMapperGenerator $generator = null, + ?Closure $savePathResolver = null, + ?Closure $classNameResolver = null, + ) { parent::__construct( $savePathResolver ?? Functions::savePathResolver(), $classNameResolver ?? Functions::classNameResolver('DataMapper'), DataMapperInterface::class, ); - - if ($factory) { - $this->factory = $factory; - } - - if ($generator) { - $this->generator = $generator; - } } /** @@ -66,7 +58,7 @@ public function create(string $dataClass): DataMapperInterface */ protected function callConstructor(string $generatedClass): DataMapperInterface { - return new $generatedClass(); + return new $generatedClass($this->registry); } /** @@ -74,7 +66,7 @@ protected function callConstructor(string $generatedClass): DataMapperInterface */ protected function createRuntime(string $dataClass): DataMapperInterface { - $factory = $this->factory ??= new RuntimeDataMapperFactory(); + $factory = $this->factory ??= new RuntimeDataMapperFactory($this->registry); return $factory->create($dataClass); } diff --git a/src/DataMapper/Generator/DataMapperClass.php b/src/DataMapper/Generator/DataMapperClass.php index 366260a..54663f6 100644 --- a/src/DataMapper/Generator/DataMapperClass.php +++ b/src/DataMapper/Generator/DataMapperClass.php @@ -8,6 +8,7 @@ use Nette\PhpGenerator\PhpFile; use Nette\PhpGenerator\PsrPrinter; use Quatrevieux\Form\DataMapper\DataMapperInterface; +use Quatrevieux\Form\RegistryInterface; /** * Class generator helper for generates {@see DataMapperInterface} class @@ -33,6 +34,12 @@ public function __construct(string $className) $this->toArrayMethod = Method::from([DataMapperInterface::class, 'toArray'])->setComment(null); $this->class->addImplement(DataMapperInterface::class); + + $this->class->addMethod('__construct') + ->setVisibility('public') + ->addPromotedParameter('registry')->setType(RegistryInterface::class)->setReadOnly() + ; + $this->class->addMember($this->classNameMethod); $this->class->addMember($this->toDataObjectMethod); $this->class->addMember($this->toArrayMethod); diff --git a/src/DataMapper/InstantiateWith.php b/src/DataMapper/InstantiateWith.php index b00d9ca..f346a62 100644 --- a/src/DataMapper/InstantiateWith.php +++ b/src/DataMapper/InstantiateWith.php @@ -3,6 +3,7 @@ namespace Quatrevieux\Form\DataMapper; use Attribute; +use Quatrevieux\Form\RegistryInterface; /** * Define data mapper class to use for instantiate data object @@ -29,9 +30,9 @@ public function __construct( /** * {@inheritdoc} */ - public function getDataMapper(string $dataClassName): DataMapperInterface + public function getDataMapper(string $dataClassName, RegistryInterface $registry): DataMapperInterface { $className = $this->dataMapperClassName; - return new $className($dataClassName); + return new $className($dataClassName, $registry); } } diff --git a/src/DataMapper/PublicPropertyDataMapper.php b/src/DataMapper/PublicPropertyDataMapper.php index abf01c2..1573e91 100644 --- a/src/DataMapper/PublicPropertyDataMapper.php +++ b/src/DataMapper/PublicPropertyDataMapper.php @@ -10,6 +10,7 @@ use TypeError; use function get_object_vars; +use function sprintf; /** * Simple data mapper implementation using default constructor and fill directly public properties @@ -40,7 +41,7 @@ public function className(): string /** * {@inheritdoc} */ - public function toDataObject(array $fields): object + public function toDataObject(array $fields): DataMapperResult { $className = $this->className; $object = new $className(); @@ -53,7 +54,7 @@ public function toDataObject(array $fields): object } } - return $object; + return new DataMapperResult($object); } /** @@ -92,7 +93,7 @@ public function generateToDataObject(DataMapperInterface $dataMapper): string } } - $code .= 'return $object;'; + $code .= sprintf('return new \%s($object);', DataMapperResult::class); return $code; } diff --git a/src/DataMapper/RuntimeDataMapperFactory.php b/src/DataMapper/RuntimeDataMapperFactory.php index 4636ce6..c7f9bfc 100644 --- a/src/DataMapper/RuntimeDataMapperFactory.php +++ b/src/DataMapper/RuntimeDataMapperFactory.php @@ -2,6 +2,7 @@ namespace Quatrevieux\Form\DataMapper; +use Quatrevieux\Form\RegistryInterface; use ReflectionAttribute; use ReflectionClass; @@ -11,6 +12,10 @@ */ final class RuntimeDataMapperFactory implements DataMapperFactoryInterface { + public function __construct( + private readonly RegistryInterface $registry, + ) {} + /** * {@inheritdoc} */ @@ -19,7 +24,7 @@ public function create(string $dataClass): DataMapperInterface $dataMapper = null; foreach ((new ReflectionClass($dataClass))->getAttributes(DataMapperProviderInterface::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $dataMapper = $attribute->newInstance()->getDataMapper($dataClass); + $dataMapper = $attribute->newInstance()->getDataMapper($dataClass, $this->registry); } return $dataMapper ?? new PublicPropertyDataMapper($dataClass); diff --git a/src/DefaultFormFactory.php b/src/DefaultFormFactory.php index 2d99f09..77557ef 100644 --- a/src/DefaultFormFactory.php +++ b/src/DefaultFormFactory.php @@ -69,7 +69,7 @@ public static function runtime(?RegistryInterface $registry = null, bool $enable { $registry ??= new DefaultRegistry(); - $registry->setDataMapperFactory($dataMapperFactory = new RuntimeDataMapperFactory()); + $registry->setDataMapperFactory($dataMapperFactory = new RuntimeDataMapperFactory($registry)); $registry->setValidatorFactory($validatorFactory = new RuntimeValidatorFactory($registry)); $registry->setTransformerFactory($transformerFactory = new RuntimeFormTransformerFactory($registry)); @@ -96,6 +96,7 @@ public static function generated(?RegistryInterface $registry = null, ?Closure $ $registry ??= new DefaultRegistry(); $registry->setDataMapperFactory($dataMapperFactory = new GeneratedDataMapperFactory( + $registry, savePathResolver: $savePathResolver, )); $registry->setValidatorFactory($validatorFactory = new GeneratedValidatorFactory( diff --git a/src/Embedded/ArrayOfTransformer.php b/src/Embedded/ArrayOfTransformer.php index 9b3251a..29eb1e5 100644 --- a/src/Embedded/ArrayOfTransformer.php +++ b/src/Embedded/ArrayOfTransformer.php @@ -50,7 +50,7 @@ public function transformFromHttp(DelegatedFieldTransformerInterface $configurat if ($transformationResult->errors) { $errors[$key] = $transformationResult->errors; } else { - $result[$key] = $dataMapper->toDataObject($transformationResult->values); + $result[$key] = $dataMapper->toDataObject($transformationResult->values)->dto; } } @@ -102,7 +102,7 @@ public function generateTransformFromHttp(object $transformer, string $previousE . 'if ($transformationResult->errors) {' . '$errors[$key] = $transformationResult->errors;' . '} else {' - . '$result[$key] = $dataMapper->toDataObject($transformationResult->values);' + . '$result[$key] = $dataMapper->toDataObject($transformationResult->values)->dto;' . '}' . '}' . 'if ($errors) {' diff --git a/src/Embedded/EmbeddedTransformer.php b/src/Embedded/EmbeddedTransformer.php index 11d83ad..a5bf7d5 100644 --- a/src/Embedded/EmbeddedTransformer.php +++ b/src/Embedded/EmbeddedTransformer.php @@ -47,7 +47,7 @@ public function transformFromHttp(DelegatedFieldTransformerInterface $configurat throw new TransformerException('Embedded form has errors', $transformationResult->errors); } - return $dataMapper->toDataObject($transformationResult->values); + return $dataMapper->toDataObject($transformationResult->values)->dto; } /** @@ -79,7 +79,7 @@ public function generateTransformFromHttp(object $transformer, string $previousE $transformationResultVarName = Expr::varName($transformationResult); $transformerException = Code::new(TransformerException::class, ['Embedded form has errors', $transformationResultVarName->errors]); - return "is_array({$varName} = {$previousExpression}) ? {$dataMapper}->toDataObject(({$transformationResultVarName} = {$transformationResult})->errors ? throw {$transformerException} : {$transformationResultVarName}->values) : null"; + return "is_array({$varName} = {$previousExpression}) ? {$dataMapper}->toDataObject(({$transformationResultVarName} = {$transformationResult})->errors ? throw {$transformerException} : {$transformationResultVarName}->values)->dto : null"; } /** diff --git a/src/Form.php b/src/Form.php index f0f7025..f2bd844 100644 --- a/src/Form.php +++ b/src/Form.php @@ -46,13 +46,13 @@ public function submit(array $data): SubmittedFormInterface { $transformation = $this->transformer->transformFromHttp($data); $dto = $this->dataMapper->toDataObject($transformation->values); - $errors = $this->validator->validate($dto, $transformation->errors); + $errors = $this->validator->validate($dto->dto, $transformation->errors + $dto->errors); return new SubmittedForm( $this, $this->viewInstantiator, $data, - $dto, + $dto->dto, $errors, ); } diff --git a/tests/DataMapper/GeneratedDataMapperFactoryTest.php b/tests/DataMapper/GeneratedDataMapperFactoryTest.php index 4942aa6..c72d085 100644 --- a/tests/DataMapper/GeneratedDataMapperFactoryTest.php +++ b/tests/DataMapper/GeneratedDataMapperFactoryTest.php @@ -3,6 +3,7 @@ namespace Quatrevieux\Form\DataMapper; use PHPUnit\Framework\TestCase; +use Quatrevieux\Form\DefaultRegistry; use Quatrevieux\Form\Fixtures\SimpleRequest; class GeneratedDataMapperFactoryTest extends TestCase @@ -12,6 +13,7 @@ class GeneratedDataMapperFactoryTest extends TestCase protected function setUp(): void { $this->factory = new GeneratedDataMapperFactory( + new DefaultRegistry(), savePathResolver: fn(string $className) => __DIR__.'/_tmp/'.$className.'.php', classNameResolver: fn(string $dataClass) => 'Test'.(new \ReflectionClass($dataClass))->getShortName().'DataMapper' ); @@ -46,17 +48,22 @@ public function test_create_should_generate_instantiator_class() class TestSimpleRequestDataMapper implements Quatrevieux\Form\DataMapper\DataMapperInterface { + public function __construct( + public readonly Quatrevieux\Form\RegistryInterface $registry, + ) { + } + function className(): string { return Quatrevieux\Form\Fixtures\SimpleRequest::class; } - function toDataObject(array $fields): object + function toDataObject(array $fields): Quatrevieux\Form\DataMapper\DataMapperResult { $object = new \Quatrevieux\Form\Fixtures\SimpleRequest(); $object->foo = $fields['foo'] ?? null; $object->bar = $fields['bar'] ?? null; - return $object; + return new \Quatrevieux\Form\DataMapper\DataMapperResult($object); } function toArray(object $data): array diff --git a/tests/DataMapper/Generator/DataMapperClassTest.php b/tests/DataMapper/Generator/DataMapperClassTest.php index 79c5173..84c7bb2 100644 --- a/tests/DataMapper/Generator/DataMapperClassTest.php +++ b/tests/DataMapper/Generator/DataMapperClassTest.php @@ -19,12 +19,17 @@ public function test_code() class MyDataMapper implements Quatrevieux\Form\DataMapper\DataMapperInterface { + public function __construct( + public readonly Quatrevieux\Form\RegistryInterface $registry, + ) { + } + function className(): string { return MyDto::class; } - function toDataObject(array $fields): object + function toDataObject(array $fields): Quatrevieux\Form\DataMapper\DataMapperResult { return MyDto::create($data); } diff --git a/tests/DataMapper/Generator/DataMapperGeneratorTest.php b/tests/DataMapper/Generator/DataMapperGeneratorTest.php index c442ed6..85bd791 100644 --- a/tests/DataMapper/Generator/DataMapperGeneratorTest.php +++ b/tests/DataMapper/Generator/DataMapperGeneratorTest.php @@ -29,17 +29,22 @@ public function test_generate_simple() class DataMapperWithSimpleProperties implements Quatrevieux\Form\DataMapper\DataMapperInterface { + public function __construct( + public readonly Quatrevieux\Form\RegistryInterface $registry, + ) { + } + function className(): string { return Quatrevieux\Form\Fixtures\SimpleRequest::class; } - function toDataObject(array $fields): object + function toDataObject(array $fields): Quatrevieux\Form\DataMapper\DataMapperResult { $object = new \Quatrevieux\Form\Fixtures\SimpleRequest(); $object->foo = $fields['foo'] ?? null; $object->bar = $fields['bar'] ?? null; - return $object; + return new \Quatrevieux\Form\DataMapper\DataMapperResult($object); } function toArray(object $data): array @@ -63,12 +68,17 @@ public function test_generate_with_non_nullable_properties() class DataMapperWithNonNullableProperties implements Quatrevieux\Form\DataMapper\DataMapperInterface { + public function __construct( + public readonly Quatrevieux\Form\RegistryInterface $registry, + ) { + } + function className(): string { return Quatrevieux\Form\Fixtures\RequiredParametersRequest::class; } - function toDataObject(array $fields): object + function toDataObject(array $fields): Quatrevieux\Form\DataMapper\DataMapperResult { $object = new \Quatrevieux\Form\Fixtures\RequiredParametersRequest(); if (($__tmp_acbd18db4cc2f85cedef654fccc4a4d8 = $fields['foo'] ?? null) !== null) { @@ -77,7 +87,7 @@ function toDataObject(array $fields): object if (($__tmp_37b51d194a7513e45b56f6524f2d51f2 = $fields['bar'] ?? null) !== null) { $object->bar = $__tmp_37b51d194a7513e45b56f6524f2d51f2; } - return $object; + return new \Quatrevieux\Form\DataMapper\DataMapperResult($object); } function toArray(object $data): array diff --git a/tests/DataMapper/PublicPropertyDataMapperTest.php b/tests/DataMapper/PublicPropertyDataMapperTest.php index 6099c0a..ce1be5c 100644 --- a/tests/DataMapper/PublicPropertyDataMapperTest.php +++ b/tests/DataMapper/PublicPropertyDataMapperTest.php @@ -14,7 +14,7 @@ public function test_empty() $mapper = new PublicPropertyDataMapper(stdClass::class); $this->assertSame(stdClass::class, $mapper->className()); - $this->assertEquals(new stdClass(), $mapper->toDataObject([])); + $this->assertEquals(new stdClass(), $mapper->toDataObject([])->dto); $this->assertSame([], $mapper->toArray(new stdClass())); } @@ -23,14 +23,15 @@ public function test_simple() $mapper = new PublicPropertyDataMapper(SimpleRequest::class); $this->assertSame(SimpleRequest::class, $mapper->className()); - $this->assertEquals(new SimpleRequest(), $mapper->toDataObject([])); + $this->assertEquals(new SimpleRequest(), $mapper->toDataObject([])->dto); $dto = $mapper->toDataObject(['foo' => 'bar', 'bar' => '42']); - $this->assertInstanceOf(SimpleRequest::class, $dto); - $this->assertSame('bar', $dto->foo); - $this->assertSame('42', $dto->bar); + $this->assertSame([], $dto->errors); + $this->assertInstanceOf(SimpleRequest::class, $dto->dto); + $this->assertSame('bar', $dto->dto->foo); + $this->assertSame('42', $dto->dto->bar); - $this->assertSame(['foo' => 'bar', 'bar' => '42'], $mapper->toArray($dto)); + $this->assertSame(['foo' => 'bar', 'bar' => '42'], $mapper->toArray($dto->dto)); } public function test_should_ignore_non_nullable_properties() @@ -38,9 +39,10 @@ public function test_should_ignore_non_nullable_properties() $mapper = new PublicPropertyDataMapper(RequiredParametersRequest::class); $dto = $mapper->toDataObject(['foo' => null, 'bar' => null]); - $this->assertInstanceOf(RequiredParametersRequest::class, $dto); + $this->assertSame([], $dto->errors); + $this->assertInstanceOf(RequiredParametersRequest::class, $dto->dto); - $this->assertFalse(isset($dto->foo)); - $this->assertFalse(isset($dto->bar)); + $this->assertFalse(isset($dto->dto->foo)); + $this->assertFalse(isset($dto->dto->bar)); } } diff --git a/tests/DataMapper/RuntimeDataMapperFactoryTest.php b/tests/DataMapper/RuntimeDataMapperFactoryTest.php index f7214e5..2edcb5a 100644 --- a/tests/DataMapper/RuntimeDataMapperFactoryTest.php +++ b/tests/DataMapper/RuntimeDataMapperFactoryTest.php @@ -3,6 +3,7 @@ namespace Quatrevieux\Form\DataMapper; use ArrayObject; +use Quatrevieux\Form\DefaultRegistry; use Quatrevieux\Form\Fixtures\SimpleRequest; use Quatrevieux\Form\FormTestCase; @@ -10,7 +11,7 @@ class RuntimeDataMapperFactoryTest extends FormTestCase { public function test_create_simple() { - $factory = new RuntimeDataMapperFactory(); + $factory = new RuntimeDataMapperFactory(new DefaultRegistry()); $dataMapper = $factory->create(SimpleRequest::class); $this->assertEquals(new PublicPropertyDataMapper(SimpleRequest::class), $dataMapper); @@ -19,7 +20,7 @@ public function test_create_simple() public function test_create_with_custom_data_mapper() { - $factory = new RuntimeDataMapperFactory(); + $factory = new RuntimeDataMapperFactory(new DefaultRegistry()); $dataMapper = $factory->create(FormWithCustomDataMapper::class); $this->assertEquals(new CustomDataMapper(FormWithCustomDataMapper::class), $dataMapper); @@ -27,12 +28,13 @@ public function test_create_with_custom_data_mapper() $dto = $dataMapper->toDataObject(['foo' => 'bar', 'bar' => 42]); - $this->assertInstanceOf(FormWithCustomDataMapper::class, $dto); - $this->assertSame('bar', $dto->foo); - $this->assertSame(42, $dto->bar); - $this->assertEquals(['foo' => 'bar', 'bar' => 42], $dto->getArrayCopy()); + $this->assertSame([], $dto->errors); + $this->assertInstanceOf(FormWithCustomDataMapper::class, $dto->dto); + $this->assertSame('bar', $dto->dto->foo); + $this->assertSame(42, $dto->dto->bar); + $this->assertEquals(['foo' => 'bar', 'bar' => 42], $dto->dto->getArrayCopy()); - $this->assertEquals(['foo' => 'bar', 'bar' => 42], $dataMapper->toArray($dto)); + $this->assertEquals(['foo' => 'bar', 'bar' => 42], $dataMapper->toArray($dto->dto)); } } @@ -50,11 +52,11 @@ public function __construct( ) { } - public function toDataObject(array $fields): object + public function toDataObject(array $fields): DataMapperResult { $class = $this->className; - return new $class($fields, ArrayObject::ARRAY_AS_PROPS | ArrayObject::STD_PROP_LIST); + return new DataMapperResult(new $class($fields, ArrayObject::ARRAY_AS_PROPS | ArrayObject::STD_PROP_LIST)); } public function toArray(object $data): array diff --git a/tests/Embedded/ArrayOfTest.php b/tests/Embedded/ArrayOfTest.php index 2f1c0ce..737753d 100644 --- a/tests/Embedded/ArrayOfTest.php +++ b/tests/Embedded/ArrayOfTest.php @@ -155,7 +155,7 @@ public function test_generate() $generator = new FormTransformerGenerator($registry); $this->assertGeneratedValidator('!is_array(($data->foo ?? null)) ? null : (function ($value) {$validator = $this->registry->getValidatorFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\ArrayItem\');$errors = [];foreach ($value as $key => $item) {if ($itemErrors = $validator->validate($item)) {$errors[$key] = $itemErrors;}}return $errors ?: null;})(($data->foo ?? null))', $arrayOf); - $this->assertSame('!is_array($__tmp_44e18f0f3b2a419fae74cbbaef66f40e = $data->foo ?? null) ? null : (function ($value) {$transformer = $this->registry->getTransformerFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\ArrayItem\');$dataMapper = $this->registry->getDataMapperFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\ArrayItem\');$result = [];$errors = [];foreach ($value as $key => $item) {$transformationResult = $transformer->transformFromHttp((array) $item);if ($transformationResult->errors) {$errors[$key] = $transformationResult->errors;} else {$result[$key] = $dataMapper->toDataObject($transformationResult->values);}}if ($errors) {throw new \Quatrevieux\Form\Transformer\TransformerException(\'Some elements are invalid\', $errors);}return $result;})($__tmp_44e18f0f3b2a419fae74cbbaef66f40e)', $transformer->generateTransformFromHttp($arrayOf, '$data->foo ?? null', $generator)); + $this->assertSame('!is_array($__tmp_44e18f0f3b2a419fae74cbbaef66f40e = $data->foo ?? null) ? null : (function ($value) {$transformer = $this->registry->getTransformerFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\ArrayItem\');$dataMapper = $this->registry->getDataMapperFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\ArrayItem\');$result = [];$errors = [];foreach ($value as $key => $item) {$transformationResult = $transformer->transformFromHttp((array) $item);if ($transformationResult->errors) {$errors[$key] = $transformationResult->errors;} else {$result[$key] = $dataMapper->toDataObject($transformationResult->values)->dto;}}if ($errors) {throw new \Quatrevieux\Form\Transformer\TransformerException(\'Some elements are invalid\', $errors);}return $result;})($__tmp_44e18f0f3b2a419fae74cbbaef66f40e)', $transformer->generateTransformFromHttp($arrayOf, '$data->foo ?? null', $generator)); $this->assertSame('!is_array($__tmp_44e18f0f3b2a419fae74cbbaef66f40e = $data->foo ?? null) ? null : (function ($value) {$transformer = $this->registry->getTransformerFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\ArrayItem\');$dataMapper = $this->registry->getDataMapperFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\ArrayItem\');$result = [];foreach ($value as $key => $item) {$result[$key] = $transformer->transformToHttp($dataMapper->toArray($item));}return $result;})($__tmp_44e18f0f3b2a419fae74cbbaef66f40e)', $transformer->generateTransformToHttp($arrayOf, '$data->foo ?? null', $generator)); } diff --git a/tests/Embedded/EmbeddedTest.php b/tests/Embedded/EmbeddedTest.php index 12663ac..c77c117 100644 --- a/tests/Embedded/EmbeddedTest.php +++ b/tests/Embedded/EmbeddedTest.php @@ -446,7 +446,7 @@ public function test_generate_validator() public function test_generate_from_http() { $transformer = new Embedded(EmbeddedForm::class); - $this->assertSame('is_array($__tmp_4e6c78d168de10f915401b0dad567ede = $data["foo"]) ? $this->registry->getDataMapperFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\EmbeddedForm\')->toDataObject(($__tmp_8e69f24495b190aeee9b13db3b08f883 = $this->registry->getTransformerFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\EmbeddedForm\')->transformFromHttp($__tmp_4e6c78d168de10f915401b0dad567ede))->errors ? throw new \Quatrevieux\Form\Transformer\TransformerException(\'Embedded form has errors\', $__tmp_8e69f24495b190aeee9b13db3b08f883->errors) : $__tmp_8e69f24495b190aeee9b13db3b08f883->values) : null', $transformer->getTransformer($this->registry)->generateTransformFromHttp($transformer, '$data["foo"]', new FormTransformerGenerator($this->registry))); + $this->assertSame('is_array($__tmp_4e6c78d168de10f915401b0dad567ede = $data["foo"]) ? $this->registry->getDataMapperFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\EmbeddedForm\')->toDataObject(($__tmp_8e69f24495b190aeee9b13db3b08f883 = $this->registry->getTransformerFactory()->create(\'Quatrevieux\\\Form\\\Embedded\\\EmbeddedForm\')->transformFromHttp($__tmp_4e6c78d168de10f915401b0dad567ede))->errors ? throw new \Quatrevieux\Form\Transformer\TransformerException(\'Embedded form has errors\', $__tmp_8e69f24495b190aeee9b13db3b08f883->errors) : $__tmp_8e69f24495b190aeee9b13db3b08f883->values)->dto : null', $transformer->getTransformer($this->registry)->generateTransformFromHttp($transformer, '$data["foo"]', new FormTransformerGenerator($this->registry))); } public function test_generate_to_http() diff --git a/tests/Fixtures/RequestWithDefaultValueConstructor.php b/tests/Fixtures/RequestWithDefaultValueConstructor.php new file mode 100644 index 0000000..4951eb0 --- /dev/null +++ b/tests/Fixtures/RequestWithDefaultValueConstructor.php @@ -0,0 +1,15 @@ +form(SimpleRequestConstructor::class); + + $submitted = $form->submit(['foo' => 'aaa', 'bar' => 'bbb']); + + $this->assertTrue($submitted->valid()); + $this->assertInstanceOf(SimpleRequestConstructor::class, $submitted->value()); + $this->assertSame('aaa', $submitted->value()->foo); + $this->assertSame('bbb', $submitted->value()->bar); + } + + public function test_submit_with_required_errors() + { + $form = $this->form(RequiredParametersRequestConstructor::class); + + $submitted = $form->submit([]); + + $this->assertFalse($submitted->valid()); + $this->assertEquals([ + 'foo' => 'This value is required', + 'bar' => 'bar must be set', + ], $submitted->errors()); + + $this->assertSame(0, $submitted->value()->foo); + $this->assertSame('', $submitted->value()->bar); + } + + public function test_submit_with_constraint_error() + { + $form = $this->form(RequiredParametersRequestConstructor::class); + + $submitted = $form->submit(['foo' => 3, 'bar' => 'a']); + + $this->assertFalse($submitted->valid()); + $this->assertSame(3, $submitted->value()->foo); + $this->assertSame('a', $submitted->value()->bar); + $this->assertEquals([ + 'bar' => 'The value is too short. It should have 3 characters or more.', + ], $submitted->errors()); + } + + public function test_submit_with_constraint_error_translated() + { + $this->configureTranslator('fr', [ + 'This value is required' => 'Ce champ est requis', + 'The value is too short. It should have {{ min }} characters or more.' => 'La valeur est trop courte. Elle doit avoir au moins {{ min }} caractères.', + ]); + + $form = $this->form(RequiredParametersRequestConstructor::class); + + $submitted = $form->submit(['bar' => 'a']); + + $this->assertFalse($submitted->valid()); + $this->assertEquals([ + 'foo' => 'Ce champ est requis', + 'bar' => 'La valeur est trop courte. Elle doit avoir au moins 3 caractères.', + ], $submitted->errors()); + } + + public function test_submit_with_constraint_success() + { + $form = $this->form(RequiredParametersRequestConstructor::class); + + $submitted = $form->submit(['foo' => 3, 'bar' => 'aaa']); + + $this->assertTrue($submitted->valid()); + $this->assertSame(3, $submitted->value()->foo); + $this->assertSame('aaa', $submitted->value()->bar); + $this->assertEmpty($submitted->errors()); + } + + public function test_submit_with_incompatible_data_type_should_be_filtered() + { + $form = $this->form(SimpleRequestConstructor::class); + + $submitted = $form->submit(['foo' => ['bar'], 'bar' => new \stdClass()]); + + $this->assertNull($submitted->value()->foo); + $this->assertNull($submitted->value()->bar); + } + + public function test_with_transformer() + { + $form = $this->form(WithTransformerRequestConstructor::class); + + $submitted = $form->submit(['list' => 'foo,bar,baz']); + + $this->assertTrue($submitted->valid()); + $this->assertSame(['foo', 'bar', 'baz'], $submitted->value()->list); + } + + public function test_import_simple() + { + $request = new SimpleRequestConstructor('aaa', 'bbb'); + + $imported = $this->form(SimpleRequestConstructor::class)->import($request); + + $this->assertSame($request, $imported->value()); + $this->assertSame(['foo' => 'aaa', 'bar' => 'bbb'], $imported->httpValue()); + } + + public function test_import_with_transformer() + { + $request = new WithTransformerRequestConstructor(['a"aa', 'b,bb', 'ccc']); + + $imported = $this->form(WithTransformerRequestConstructor::class)->import($request); + + $this->assertSame($request, $imported->value()); + $this->assertSame(['list' => '"a""aa","b,bb",ccc'], $imported->httpValue()); + } + + public function test_with_transformer_with_dependencies() + { + $this->container->set(FooImplementation::class, new FooImplementation('zsx')); + + $submitted = $this->form(WithExternalDependencyTransformerRequestConstructor::class)->submit(['foo' => 'bar']); + + $this->assertSame('zsxbaraqw', $submitted->value()->foo); + } + + public function test_http_field_mapping() + { + $form = $this->form(WithFieldNameMappingConstructor::class); + + $submitted = $form->submit(['my_complex_name' => 'foo', 'other' => 123]); + + $this->assertSame('foo', $submitted->value()->myComplexName); + $this->assertSame(123, $submitted->value()->otherField); + + $obj = new WithFieldNameMappingConstructor('bar', 456); + + $this->assertSame(['my_complex_name' => 'bar', 'other' => 456], $form->import($obj)->httpValue()); + } + + public function test_view_simple_form() + { + $form = $this->form(SimpleRequestConstructor::class); + $view = $form->view(); + + $this->assertSame(SimpleRequestConstructor::class, $view->class); + $this->assertEquals([ + 'foo' => new FieldView('foo', null, null, []), + 'bar' => new FieldView('bar', null, null, []), + ], $view->fields); + $this->assertSame([], $view->value); + + $view = $form->submit(['foo' => 'aaa', 'bar' => 'bbb'])->view(); + + $this->assertSame(SimpleRequestConstructor::class, $view->class); + $this->assertEquals([ + 'foo' => new FieldView('foo', 'aaa', null, []), + 'bar' => new FieldView('bar', 'bbb', null, []), + ], $view->fields); + $this->assertSame(['foo' => 'aaa', 'bar' => 'bbb'], $view->value); + + $request = new SimpleRequestConstructor('ccc', 'ddd'); + + $view = $form->import($request)->view(); + + $this->assertSame(SimpleRequestConstructor::class, $view->class); + $this->assertEquals([ + 'foo' => new FieldView('foo', 'ccc', null, []), + 'bar' => new FieldView('bar', 'ddd', null, []), + ], $view->fields); + $this->assertSame(['foo' => 'ccc', 'bar' => 'ddd'], $view->value); + } + + public function test_view_with_constraint_error() + { + $form = $this->form(RequiredParametersRequestConstructor::class); + + $view = $form->submit(['bar' => 'a'])->view(); + + $this->assertSame(RequiredParametersRequestConstructor::class, $view->class); + $this->assertEquals([ + 'foo' => new FieldView('foo', null, new FieldError('This value is required', [], Required::CODE, DummyTranslator::instance()), ['required' => true]), + 'bar' => new FieldView('bar', 'a', new FieldError('The value is too short. It should have {{ min }} characters or more.', ['min' => 3], Length::CODE, DummyTranslator::instance()), ['required' => true, 'minlength' => 3]), + ], $view->fields); + $this->assertSame(['bar' => 'a'], $view->value); + + $this->assertEquals('', (string) $view->fields['foo']); + $this->assertEquals('', (string) $view->fields['bar']); + } + + public function test_import_then_submit_should_perform_patch() + { + $form = $this->form(RequiredParametersRequestConstructor::class); + + $request = new RequiredParametersRequestConstructor(456, 'azerty'); + + $submitted = $form->import($request)->submit([]); + + $this->assertTrue($submitted->valid()); + $this->assertEquals($request, $submitted->value()); + + $submitted = $form->import($request)->submit(['foo' => 123]); + + $this->assertTrue($submitted->valid()); + $this->assertNotEquals($request, $submitted->value()); + $this->assertSame(123, $submitted->value()->foo); + $this->assertSame('azerty', $submitted->value()->bar); + } + + public function test_submit_twice_will_perform_patch() + { + $form = $this->form(RequiredParametersRequestConstructor::class); + + $submitted = $form->submit(['foo' => '123'])->submit(['bar' => 'azerty']); + + $this->assertTrue($submitted->valid()); + $this->assertSame(123, $submitted->value()->foo); + $this->assertSame('azerty', $submitted->value()->bar); + + $submitted = $submitted->submit(['foo' => '456']); + + $this->assertTrue($submitted->valid()); + $this->assertSame(456, $submitted->value()->foo); + $this->assertSame('azerty', $submitted->value()->bar); + + $submitted = $submitted->submit(['foo' => null]); + + $this->assertFalse($submitted->valid()); + $this->assertErrors(['foo' => 'This value is required'], $submitted->errors()); + } + + public function test_default_value() + { + $form = $this->form(RequestWithDefaultValueConstructor::class); + + $submitted = $form->submit([]); + + $this->assertTrue($submitted->valid()); + $this->assertSame(42, $submitted->value()->foo); + $this->assertSame('???', $submitted->value()->bar); + + $submitted = $form->submit([ + 'foo' => 123, + 'bar' => 'abc', + ]); + + $this->assertTrue($submitted->valid()); + $this->assertSame(123, $submitted->value()->foo); + $this->assertSame('abc', $submitted->value()->bar); + } + + public function form(string $dataClass): FormInterface + { + return $this->runtimeForm($dataClass); + } +} diff --git a/tests/GeneratedCodeWithConstructorFunctionalTest.php b/tests/GeneratedCodeWithConstructorFunctionalTest.php new file mode 100644 index 0000000..311f763 --- /dev/null +++ b/tests/GeneratedCodeWithConstructorFunctionalTest.php @@ -0,0 +1,35 @@ +generatedForm($dataClass); + } + + public function test_should_generate_classes_and_use_internally() + { + $form = $this->generatedForm(FormGenerationTestRequestConstructor::class); + + $this->assertFormIsGenerated(FormGenerationTestRequestConstructor::class); + $this->assertTrue(class_exists('Quatrevieux_Form_FormGenerationTestRequestConstructorDataMapper')); + $this->assertTrue(class_exists('Quatrevieux_Form_FormGenerationTestRequestConstructorValidator')); + + $this->assertInstanceOf('Quatrevieux_Form_FormGenerationTestRequestConstructorDataMapper', (new \ReflectionProperty($form, 'dataMapper'))->getValue($form)); + $this->assertInstanceOf('Quatrevieux_Form_FormGenerationTestRequestConstructorValidator', (new \ReflectionProperty($form, 'validator'))->getValue($form)); + } +} + +// Use a dedicated class to avoid conflicts with the generated class +#[InstantiateWith(ConstructorDataMapper::class)] +class FormGenerationTestRequestConstructor extends RequiredParametersRequestConstructor +{ +}