Skip to content

Commit

Permalink
Added skip unitialized values context param
Browse files Browse the repository at this point in the history
  • Loading branch information
Korbeil committed Oct 25, 2024
1 parent 253325d commit 33a4325
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 7 deletions.
86 changes: 82 additions & 4 deletions src/Extractor/ReadAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,18 +188,18 @@ public function getIsNullExpression(Expr\Variable $input): Expr
/*
* Use the property fetch to read the value
*
* isset($input->property_name)
* isset($input->property_name) && null === $input->property_name
*/
return new Expr\BooleanNot(new Expr\Isset_([new Expr\PropertyFetch($input, $this->accessor)]));
return new Expr\BinaryOp\LogicalAnd(new Expr\BooleanNot(new Expr\Isset_([new Expr\PropertyFetch($input, $this->accessor)])), new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), new Expr\PropertyFetch($input, $this->accessor)));
}

if (self::TYPE_ARRAY_DIMENSION === $this->type) {
/*
* Use the array dim fetch to read the value
*
* isset($input['property_name'])
* isset($input['property_name']) && null === $input->property_name
*/
return new Expr\BooleanNot(new Expr\Isset_([new Expr\ArrayDimFetch($input, new Scalar\String_($this->accessor))]));
return new Expr\BinaryOp\LogicalAnd(new Expr\BooleanNot(new Expr\Isset_([new Expr\PropertyFetch($input, $this->accessor)])), new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), new Expr\PropertyFetch($input, $this->accessor)));
}

if (self::TYPE_SOURCE === $this->type) {
Expand All @@ -212,6 +212,52 @@ public function getIsNullExpression(Expr\Variable $input): Expr
throw new CompileException('Invalid accessor for read expression');
}

public function getIsUndefinedExpression(Expr\Variable $input): Expr
{
if (\in_array($this->type, [self::TYPE_METHOD, self::TYPE_SOURCE])) {
/*
* false
*/
return new Expr\ConstFetch(new Name('false'));
}

if (self::TYPE_PROPERTY === $this->type) {
if ($this->private) {
/*
* When the property is private we use the extract callback that can read this value
*
* @see \AutoMapper\Extractor\ReadAccessor::getExtractIsUndefinedCallback()
*
* $this->extractIsUndefinedCallbacks['property_name']($input)
*/
return new Expr\FuncCall(
new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'extractIsUndefinedCallbacks'), new Scalar\String_($this->accessor)),
[
new Arg($input),
]
);
}

/*
* Use the property fetch to read the value
*
* !isset($input->property_name)
*/
return new Expr\BooleanNot(new Expr\Isset_([new Expr\PropertyFetch($input, $this->accessor)]));
}

if (self::TYPE_ARRAY_DIMENSION === $this->type) {
/*
* Use the array dim fetch to read the value
*
* !array_key_exists('property_name', $input)
*/
return new Expr\BooleanNot(new Expr\FuncCall(new Name('array_key_exists'), [new Arg(new Scalar\String_($this->accessor)), new Arg($input)]));
}

throw new CompileException('Invalid accessor for read expression');
}

/**
* Get AST expression for binding closure when dealing with a private property.
*/
Expand Down Expand Up @@ -261,6 +307,38 @@ public function getExtractIsNullCallback(string $className): ?Expr
return null;
}

/*
* Create extract is null callback for this accessor
*
* \Closure::bind(function ($object) {
* return !isset($object->property_name) && null === $object->property_name;
* }, null, $className)
*/
return new Expr\StaticCall(new Name\FullyQualified(\Closure::class), 'bind', [
new Arg(
new Expr\Closure([
'params' => [
new Param(new Expr\Variable('object')),
],
'stmts' => [
new Stmt\Return_(new Expr\BinaryOp\LogicalAnd(new Expr\BooleanNot(new Expr\Isset_([new Expr\PropertyFetch(new Expr\Variable('object'), $this->accessor)])), new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), new Expr\PropertyFetch(new Expr\Variable('object'), $this->accessor)))),
],
])
),
new Arg(new Expr\ConstFetch(new Name('null'))),
new Arg(new Scalar\String_($className)),
]);
}

/**
* Get AST expression for binding closure when dealing with a private property.
*/
public function getExtractIsUndefinedCallback(string $className): ?Expr
{
if ($this->type !== self::TYPE_PROPERTY || !$this->private) {
return null;
}

/*
* Create extract is null callback for this accessor
*
Expand Down
3 changes: 3 additions & 0 deletions src/GeneratedMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public function registerMappers(AutoMapperRegistryInterface $registry): void
/** @var array<string, callable(): bool>) */
protected array $extractIsNullCallbacks = [];

/** @var array<string, callable(): bool>) */
protected array $extractIsUndefinedCallbacks = [];

/** @var Target|\ReflectionClass<object> */
protected mixed $cachedTarget;
}
24 changes: 24 additions & 0 deletions src/Generator/MapperConstructorGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function getStatements(GeneratorMetadata $metadata): array
foreach ($metadata->propertiesMetadata as $propertyMetadata) {
$constructStatements[] = $this->extractCallbackForProperty($metadata, $propertyMetadata);
$constructStatements[] = $this->extractIsNullCallbackForProperty($metadata, $propertyMetadata);
$constructStatements[] = $this->extractIsUndefinedCallbackForProperty($metadata, $propertyMetadata);
$constructStatements[] = $this->hydrateCallbackForProperty($metadata, $propertyMetadata);
}

Expand Down Expand Up @@ -83,6 +84,29 @@ private function extractIsNullCallbackForProperty(GeneratorMetadata $metadata, P
));
}


/**
* Add read callback to the constructor of the generated mapper.
*
* ```php
* $this->extractIsUndefinedCallbacks['propertyName'] = $extractIsNullCallback;
* ```
*/
private function extractIsUndefinedCallbackForProperty(GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata): ?Stmt\Expression
{
$extractUndefinedCallback = $propertyMetadata->source->accessor?->getExtractIsUndefinedCallback($metadata->mapperMetadata->source);

if (!$extractUndefinedCallback) {
return null;
}

return new Stmt\Expression(
new Expr\Assign(
new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'extractIsUndefinedCallbacks'), new Scalar\String_($propertyMetadata->source->property)),
$extractUndefinedCallback
));
}

/**
* Add hydrate callback to the constructor of the generated mapper.
*
Expand Down
6 changes: 5 additions & 1 deletion src/Generator/PropertyConditionsGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@ private function isAllowedAttribute(GeneratorMetadata $metadata, PropertyMetadat
return new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'isAllowedAttribute', [
new Arg($variableRegistry->getContext()),
new Arg(new Scalar\String_($propertyMetadata->source->property)),
new Arg($propertyMetadata->source->accessor->getIsNullExpression($variableRegistry->getSourceInput())),
new Arg(new Expr\Closure([

Check failure on line 141 in src/Generator/PropertyConditionsGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $subNodes of class PhpParser\Node\Expr\Closure constructor expects array{static?: bool, byRef?: bool, params?: array<PhpParser\Node\Param>, uses?: array<PhpParser\Node\ClosureUse>, returnType?: PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|null, stmts?: array<PhpParser\Node\Stmt>, attrGroups?: array<PhpParser\Node\AttributeGroup>}, array{uses: array{PhpParser\Node\Expr\Variable}, stmts: array{PhpParser\Node\Stmt\Return_}} given.

Check failure on line 141 in src/Generator/PropertyConditionsGenerator.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $subNodes of class PhpParser\Node\Expr\Closure constructor expects array{static?: bool, byRef?: bool, params?: array<PhpParser\Node\Param>, uses?: array<PhpParser\Node\ClosureUse>, returnType?: PhpParser\Node\ComplexType|PhpParser\Node\Identifier|PhpParser\Node\Name|null, stmts?: array<PhpParser\Node\Stmt>, attrGroups?: array<PhpParser\Node\AttributeGroup>}, array{uses: array{PhpParser\Node\Expr\Variable}, stmts: array{PhpParser\Node\Stmt\Return_}} given.
'uses' => [$variableRegistry->getSourceInput()],
'stmts' => [new Stmt\Return_($propertyMetadata->source->accessor->getIsNullExpression($variableRegistry->getSourceInput()))],
])),
new Arg($propertyMetadata->source->accessor->getIsUndefinedExpression($variableRegistry->getSourceInput())),
]);
}

Expand Down
17 changes: 15 additions & 2 deletions src/MapperContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* "deep_target_to_populate"?: bool,
* "constructor_arguments"?: array<string, array<string, mixed>>,
* "skip_null_values"?: bool,
* "skip_uninitialized_values"?: bool,
* "allow_readonly_target_to_populate"?: bool,
* "datetime_format"?: string,
* "datetime_force_timezone"?: string,
Expand All @@ -49,6 +50,7 @@ class MapperContext
public const DEEP_TARGET_TO_POPULATE = 'deep_target_to_populate';
public const CONSTRUCTOR_ARGUMENTS = 'constructor_arguments';
public const SKIP_NULL_VALUES = 'skip_null_values';
public const SKIP_UNINITIALIZED_VALUES = 'skip_uninitialized_values';
public const ALLOW_READONLY_TARGET_TO_POPULATE = 'allow_readonly_target_to_populate';
public const DATETIME_FORMAT = 'datetime_format';
public const DATETIME_FORCE_TIMEZONE = 'datetime_force_timezone';
Expand Down Expand Up @@ -135,6 +137,13 @@ public function setSkipNullValues(bool $skipNullValues): self
return $this;
}

public function setSkipUnitializedValues(bool $skipUnitializedValues): self
{
$this->context[self::SKIP_UNINITIALIZED_VALUES] = $skipUnitializedValues;

return $this;
}

public function setAllowReadOnlyTargetToPopulate(bool $allowReadOnlyTargetToPopulate): self
{
$this->context[self::ALLOW_READONLY_TARGET_TO_POPULATE] = $allowReadOnlyTargetToPopulate;
Expand Down Expand Up @@ -231,9 +240,13 @@ public static function withReference(array $context, string $reference, mixed &$
*
* @internal
*/
public static function isAllowedAttribute(array $context, string $attribute, bool $valueIsNullOrUndefined): bool
public static function isAllowedAttribute(array $context, string $attribute, callable $valueIsNull, bool $valueIsUndefined): bool
{
if (($context[self::SKIP_NULL_VALUES] ?? false) && $valueIsNullOrUndefined) {
if (($context[self::SKIP_UNINITIALIZED_VALUES] ?? false) && $valueIsUndefined) {
return false;
}

if (($context[self::SKIP_NULL_VALUES] ?? false) && !$valueIsUndefined && $valueIsNull()) {
return false;
}

Expand Down
16 changes: 16 additions & 0 deletions tests/AutoMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
use AutoMapper\Tests\Fixtures\Issue111\Colour;
use AutoMapper\Tests\Fixtures\Issue111\ColourTransformer;
use AutoMapper\Tests\Fixtures\Issue111\FooDto;
use AutoMapper\Tests\Fixtures\Issue189\User as Issue189User;
use AutoMapper\Tests\Fixtures\Issue189\UserPatchInput as Issue189UserPatchInput;
use AutoMapper\Tests\Fixtures\ObjectsUnion\Bar;
use AutoMapper\Tests\Fixtures\ObjectsUnion\Foo;
use AutoMapper\Tests\Fixtures\ObjectsUnion\ObjectsUnionProperty;
Expand Down Expand Up @@ -1603,4 +1605,18 @@ public function testParamDocBlock(): void
'foo' => ['foo1', 'foo2'],
], $array);
}

public function testUninitializedProperties(): void
{
$payload = new Issue189UserPatchInput();
$payload->firstName = 'John';
$payload->lastName = 'Doe';

/** @var Issue189User $data */
$data = $this->autoMapper->map($payload, Issue189User::class, [MapperContext::SKIP_UNINITIALIZED_VALUES => true]);

$this->assertEquals('John', $data->getFirstName());
$this->assertEquals('Doe', $data->getLastName());
$this->assertTrue(!isset($data->birthDate));
}
}
48 changes: 48 additions & 0 deletions tests/Fixtures/Issue189/User.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Tests\Fixtures\Issue189;

class User
{
public string $lastName;
public string $firstName;
public ?\DateTimeImmutable $birthDate;

public function getLastName(): string
{
return $this->lastName;
}

public function setLastName(string $lastName): self
{
$this->lastName = $lastName;

return $this;
}

public function getFirstName(): string
{
return $this->firstName;
}

public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;

return $this;
}

public function getBirthDate(): ?\DateTimeImmutable
{
return $this->birthDate;
}

public function setBirthDate(?\DateTimeImmutable $birthDate): self
{
$this->birthDate = $birthDate;

return $this;
}
}
12 changes: 12 additions & 0 deletions tests/Fixtures/Issue189/UserPatchInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Tests\Fixtures\Issue189;

class UserPatchInput
{
public string $lastName;
public string $firstName;
public ?\DateTimeImmutable $birthDate;
}

0 comments on commit 33a4325

Please sign in to comment.