From faae1d626584ca63df87ca4794abed3655facfbc Mon Sep 17 00:00:00 2001 From: Baptiste Date: Tue, 22 Oct 2024 14:12:38 +0200 Subject: [PATCH] Added skip unitialized values context param --- src/Extractor/ReadAccessor.php | 86 ++++++++++++++++++- src/Generator/PropertyConditionsGenerator.php | 6 +- src/MapperContext.php | 17 +++- tests/AutoMapperTest.php | 16 ++++ tests/Fixtures/Issue189/User.php | 48 +++++++++++ tests/Fixtures/Issue189/UserPatchInput.php | 12 +++ 6 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 tests/Fixtures/Issue189/User.php create mode 100644 tests/Fixtures/Issue189/UserPatchInput.php diff --git a/src/Extractor/ReadAccessor.php b/src/Extractor/ReadAccessor.php index 8b2d40f5..8ae0953c 100644 --- a/src/Extractor/ReadAccessor.php +++ b/src/Extractor/ReadAccessor.php @@ -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) { @@ -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'), 'getExtractIsUndefinedCallback'), 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. */ @@ -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 * diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php index 79a7e611..90cadfed 100644 --- a/src/Generator/PropertyConditionsGenerator.php +++ b/src/Generator/PropertyConditionsGenerator.php @@ -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([ + 'uses' => [$variableRegistry->getSourceInput()], + 'stmts' => [new Stmt\Return_($propertyMetadata->source->accessor->getIsNullExpression($variableRegistry->getSourceInput()))], + ])), + new Arg($propertyMetadata->source->accessor->getIsUndefinedExpression($variableRegistry->getSourceInput())), ]); } diff --git a/src/MapperContext.php b/src/MapperContext.php index 14b46074..a889965f 100644 --- a/src/MapperContext.php +++ b/src/MapperContext.php @@ -28,6 +28,7 @@ * "deep_target_to_populate"?: bool, * "constructor_arguments"?: array>, * "skip_null_values"?: bool, + * "skip_uninitialized_values"?: bool, * "allow_readonly_target_to_populate"?: bool, * "datetime_format"?: string, * "datetime_force_timezone"?: string, @@ -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'; @@ -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; @@ -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; } diff --git a/tests/AutoMapperTest.php b/tests/AutoMapperTest.php index fc792233..66e9b5e2 100644 --- a/tests/AutoMapperTest.php +++ b/tests/AutoMapperTest.php @@ -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; @@ -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)); + } } diff --git a/tests/Fixtures/Issue189/User.php b/tests/Fixtures/Issue189/User.php new file mode 100644 index 00000000..e92b0813 --- /dev/null +++ b/tests/Fixtures/Issue189/User.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/tests/Fixtures/Issue189/UserPatchInput.php b/tests/Fixtures/Issue189/UserPatchInput.php new file mode 100644 index 00000000..463810a4 --- /dev/null +++ b/tests/Fixtures/Issue189/UserPatchInput.php @@ -0,0 +1,12 @@ +