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
9 changes: 8 additions & 1 deletion src/DataMapper/ConstructorDataMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionType;
use stdClass;

use function array_filter;
use function array_map;
Expand Down Expand Up @@ -93,7 +94,7 @@
$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);
Expand Down Expand Up @@ -162,7 +163,7 @@
$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 166 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;
Expand All @@ -183,20 +184,26 @@

private function resolveFallbackValueFromType(?ReflectionType $type): mixed
{
if (!$type || $type->allowsNull()) {

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

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "LogicalOr": --- Original +++ New @@ @@ } private function resolveFallbackValueFromType(?ReflectionType $type) : mixed { - if (!$type || $type->allowsNull()) { + if (!$type && $type->allowsNull()) { return null; } if (!$type instanceof ReflectionNamedType) {
return null;
}

if (!$type instanceof ReflectionNamedType) {

Check warning on line 191 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)); } if (!$type->isBuiltin()) {
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,

Check warning on line 202 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 204 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' => [], 'object' => new stdClass(), default => throw new LogicException(sprintf('Cannot resolve fallback value for type %s on %s', $type->getName(), $this->className)),
'array' => [],
'object' => new stdClass(),
default => throw new LogicException(sprintf('Cannot resolve fallback value for type %s on %s', $type->getName(), $this->className)),
};
}
Expand Down
16 changes: 16 additions & 0 deletions src/DataMapper/ConstructorParameterMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -10,4 +17,13 @@
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();

Check warning on line 24 in src/DataMapper/ConstructorParameterMetadata.php

View workflow job for this annotation

GitHub Actions / Mutation Testing

Escaped Mutant for Mutator "CastString": --- Original +++ New @@ @@ 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 Expr::new(ReflectionClass::class, [$this->fallback::class])->newInstanceWithoutConstructor(); } return Code::value($this->fallback); } }
}

return Code::value($this->fallback);
}
}
188 changes: 188 additions & 0 deletions tests/DataMapper/ConstructorDataMapperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

namespace Quatrevieux\Form\DataMapper;

use PHPUnit\Framework\TestCase;
use Quatrevieux\Form\DataMapper\Generator\DataMapperGenerator;
use Quatrevieux\Form\DefaultRegistry;
use Quatrevieux\Form\Fixtures\EmbeddedFormConstructor;
use Quatrevieux\Form\Fixtures\RequestWithDefaultValueConstructor;
use Quatrevieux\Form\Fixtures\RequiredParametersRequestConstructor;
use Quatrevieux\Form\Fixtures\SimpleRequestConstructor;
use Quatrevieux\Form\Fixtures\WithEmbeddedConstructor;
use Quatrevieux\Form\Validator\Constraint\Required;
use stdClass;

class ConstructorDataMapperTest extends TestCase
{
public function test_empty(): void
{
$mapper = new ConstructorDataMapper(stdClass::class, new DefaultRegistry());

$dto = $mapper->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('<?php', '', $code));

/** @var DataMapperInterface $generatedMapper */
$generatedMapper = new \GeneratedSimpleRequestConstructorDataMapper(new DefaultRegistry());
$dto = $generatedMapper->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)
);
}
}
23 changes: 23 additions & 0 deletions tests/Fixtures/WithEmbeddedConstructor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Quatrevieux\Form\Fixtures;

use Quatrevieux\Form\Embedded\Embedded;

class WithEmbeddedConstructor
{
public function __construct(
public string $foo,
public string $bar,
#[Embedded(EmbeddedFormConstructor::class)]
public EmbeddedFormConstructor $embedded,
) {}
}

class EmbeddedFormConstructor
{
public function __construct(
public ?string $baz,
public ?string $rab,
) {}
}
Loading