Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions src/DataMapper/ConstructorDataMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

namespace Quatrevieux\Form\DataMapper;

use LogicException;
use Quatrevieux\Form\DataMapper\Generator\DataMapperTypeGeneratorInterface;
use Quatrevieux\Form\RegistryInterface;
use Quatrevieux\Form\Util\Code;
use Quatrevieux\Form\Util\Expr;
use Quatrevieux\Form\Validator\Constraint\Required;
use Quatrevieux\Form\Validator\FieldError;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionType;

use function array_filter;
use function array_map;
use function get_object_vars;
use function sprintf;

/**
* Instantiate the form DTO using its constructor with promoted properties
*
* @template T as object
* @implements DataMapperInterface<T>
* @implements DataMapperTypeGeneratorInterface<ConstructorDataMapper<T>>
*/
final class ConstructorDataMapper implements DataMapperInterface, DataMapperTypeGeneratorInterface
{
/**
* @var array<string, ConstructorParameterMetadata>|null
*/
private ?array $parameters = null;

public function __construct(
/**
* Data transfer object class name
*
* @var class-string<T> $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 <<<PHP
\$errors = [];
\$dto = {$newDtoCode};

foreach ({$requiredParametersErrorsCode} as \$field => \$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<string, ConstructorParameterMetadata>
*/
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()) {

Check warning on line 165 in src/DataMapper/ConstructorDataMapper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "LogicalAnd": --- Original +++ New @@ @@ $required = $fallback !== null; // Null fallback means that the parameter is nullable, so it's not required $requiredMessage = $baseRequired->message; - if ($required && $parameter->isPromoted()) { + 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;
// 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) {

Check warning on line 190 in src/DataMapper/ConstructorDataMapper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "InstanceOf_": --- Original +++ New @@ @@ if (!$type || $type->allowsNull()) { return null; } - if (!$type instanceof ReflectionNamedType) { + if (!true) { throw new LogicException(sprintf('Cannot use complex type with %s on %s', self::class, $this->className)); } return match ($type->getName()) {
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,

Check warning on line 196 in src/DataMapper/ConstructorDataMapper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "OneZeroFloat": --- Original +++ New @@ @@ } return match ($type->getName()) { 'int' => 0, - 'float' => 0.0, + 'float' => 1.0, 'string' => '', 'bool' => false, 'array' => [],
'string' => '',
'bool' => false,

Check warning on line 198 in src/DataMapper/ConstructorDataMapper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "FalseValue": --- Original +++ New @@ @@ 'int' => 0, 'float' => 0.0, 'string' => '', - 'bool' => false, + 'bool' => true, 'array' => [], default => throw new LogicException(sprintf('Cannot resolve fallback value for type %s on %s', $type->getName(), $this->className)), }; } }
'array' => [],
default => throw new LogicException(sprintf('Cannot resolve fallback value for type %s on %s', $type->getName(), $this->className)),
};
}
}
13 changes: 13 additions & 0 deletions src/DataMapper/ConstructorParameterMetadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Quatrevieux\Form\DataMapper;

final class ConstructorParameterMetadata
{
public function __construct(
public readonly string $name,
public readonly mixed $fallback,
public readonly bool $required,
public readonly string $requiredMessage,
) {}
}
4 changes: 2 additions & 2 deletions src/DataMapper/DataMapperInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ interface DataMapperInterface
* Fields passed to this method must be transformed to the correct type.
*
* @param array<string, mixed> $fields Associative array of fields, where keys are field names and values are field values.
* @return T
* @return DataMapperResult<T>
*
* @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
Expand Down
5 changes: 4 additions & 1 deletion src/DataMapper/DataMapperProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,9 +14,10 @@ interface DataMapperProviderInterface
* Create the data mapper instance which handle given DTO class
*
* @param class-string<T> $dataClassName DTO class name
* @param RegistryInterface $registry
*
* @return DataMapperInterface<T>
* @template T as object
*/
public function getDataMapper(string $dataClassName): DataMapperInterface;
public function getDataMapper(string $dataClassName, RegistryInterface $registry): DataMapperInterface;
}
29 changes: 29 additions & 0 deletions src/DataMapper/DataMapperResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Quatrevieux\Form\DataMapper;

use Quatrevieux\Form\Validator\FieldError;

/**
* The result structure of {@see DataMapperInterface::toDataObject()} method
*
* @template T as object
*/
final class DataMapperResult
{
public function __construct(
/**
* The instantiated and hydrated DTO
*
* @var T
*/
public readonly object $dto,

/**
* Transformation errors, indexed by field name
*
* @var array<string, FieldError|mixed[]>
*/
public readonly array $errors = [],
) {}
}
52 changes: 22 additions & 30 deletions src/DataMapper/GeneratedDataMapperFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
}

/**
Expand All @@ -66,15 +58,15 @@
*/
protected function callConstructor(string $generatedClass): DataMapperInterface
{
return new $generatedClass();
return new $generatedClass($this->registry);
}

/**
* {@inheritdoc}
*/
protected function createRuntime(string $dataClass): DataMapperInterface
{
$factory = $this->factory ??= new RuntimeDataMapperFactory();
$factory = $this->factory ??= new RuntimeDataMapperFactory($this->registry);

Check warning on line 69 in src/DataMapper/GeneratedDataMapperFactory.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "AssignCoalesce": --- Original +++ New @@ @@ */ protected function createRuntime(string $dataClass) : DataMapperInterface { - $factory = $this->factory ??= new RuntimeDataMapperFactory($this->registry); + $factory = $this->factory = new RuntimeDataMapperFactory($this->registry); return $factory->create($dataClass); } /**

Check warning on line 69 in src/DataMapper/GeneratedDataMapperFactory.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "Assignment": --- Original +++ New @@ @@ */ protected function createRuntime(string $dataClass) : DataMapperInterface { - $factory = $this->factory ??= new RuntimeDataMapperFactory($this->registry); + $factory = $this->factory = new RuntimeDataMapperFactory($this->registry); return $factory->create($dataClass); } /**
return $factory->create($dataClass);
}

Expand All @@ -83,7 +75,7 @@
*/
protected function generate(string $generatedClassName, object $runtime): ?string
{
$generator = $this->generator ??= new DataMapperGenerator();

Check warning on line 78 in src/DataMapper/GeneratedDataMapperFactory.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "AssignCoalesce": --- Original +++ New @@ @@ */ protected function generate(string $generatedClassName, object $runtime) : ?string { - $generator = $this->generator ??= new DataMapperGenerator(); + $generator = $this->generator = new DataMapperGenerator(); return $generator->generate($generatedClassName, $runtime); } }

Check warning on line 78 in src/DataMapper/GeneratedDataMapperFactory.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "Assignment": --- Original +++ New @@ @@ */ protected function generate(string $generatedClassName, object $runtime) : ?string { - $generator = $this->generator ??= new DataMapperGenerator(); + $generator = $this->generator = new DataMapperGenerator(); return $generator->generate($generatedClassName, $runtime); } }
return $generator->generate($generatedClassName, $runtime);
}
}
7 changes: 7 additions & 0 deletions src/DataMapper/Generator/DataMapperClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading
Loading