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 Jan 28, 2025
1 parent fc6005c commit b1b2a16
Show file tree
Hide file tree
Showing 12 changed files with 209 additions and 13 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
],
"require": {
"php": "^8.2",
"nikic/php-parser": "^4.18 || ^5.0",
"nikic/php-parser": "^5.0",
"symfony/deprecation-contracts": "^3.0",
"symfony/event-dispatcher": "^6.4 || ^7.0",
"symfony/expression-language": "^6.4 || ^7.0",
Expand Down
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;
}
4 changes: 2 additions & 2 deletions src/Generator/CreateTargetStatementsGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ private function constructorArgument(GeneratorMetadata $metadata, PropertyMetada
new Arg(new Scalar\String_(sprintf('Cannot create an instance of "%s" from mapping data because its constructor requires the following parameters to be present : "$%s".', $metadata->mapperMetadata->target, $propertyMetadata->target->property))),
new Arg(create_scalar_int(0)),
new Arg(new Expr\ConstFetch(new Name('null'))),
new Arg(new Expr\Array_([ // @phpstan-ignore argument.type
new Arg(new Expr\Array_([
create_expr_array_item(new Scalar\String_($propertyMetadata->target->property)),
])),
new Arg(new Scalar\String_($metadata->mapperMetadata->target)),
Expand Down Expand Up @@ -262,7 +262,7 @@ private function constructorArgumentWithoutSource(GeneratorMetadata $metadata, \
new Arg(new Scalar\String_(sprintf('Cannot create an instance of "%s" from mapping data because its constructor requires the following parameters to be present : "$%s".', $metadata->mapperMetadata->target, $constructorParameter->getName()))),
new Arg(create_scalar_int(0)),
new Arg(new Expr\ConstFetch(new Name('null'))),
new Arg(new Expr\Array_([ // @phpstan-ignore argument.type
new Arg(new Expr\Array_([
create_expr_array_item(new Scalar\String_($constructorParameter->getName())),
])),
new Arg(new Scalar\String_($constructorParameter->getName())),
Expand Down
23 changes: 23 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,28 @@ 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
1 change: 0 additions & 1 deletion src/Generator/MapperGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ public function generate(GeneratorMetadata $metadata): array

$statements = [];
if ($metadata->strictTypes) {
// @phpstan-ignore argument.type
$statements[] = new Stmt\Declare_([create_declare_item('strict_types', create_scalar_int(1))]);
}
$statements[] = (new Builder\Class_($metadata->mapperMetadata->className))
Expand Down
8 changes: 6 additions & 2 deletions 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([
'uses' => [new Expr\ClosureUse($variableRegistry->getSourceInput())],
'stmts' => [new Stmt\Return_($propertyMetadata->source->accessor->getIsNullExpression($variableRegistry->getSourceInput()))],
])),
new Arg($propertyMetadata->source->accessor->getIsUndefinedExpression($variableRegistry->getSourceInput())),
]);
}

Expand Down Expand Up @@ -172,7 +176,7 @@ private function groupsCheck(VariableRegistry $variableRegistry, ?array $groups
new Expr\Array_()
)
),
new Arg(new Expr\Array_(array_map(function (string $group) { // @phpstan-ignore argument.type
new Arg(new Expr\Array_(array_map(function (string $group) {
return create_expr_array_item(new Scalar\String_($group));
}, $groups))),
])
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
2 changes: 1 addition & 1 deletion src/Transformer/BuiltinTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $

private function toArray(Expr $input): Expr
{
return new Expr\Array_([create_expr_array_item($input)]); // @phpstan-ignore argument.type
return new Expr\Array_([create_expr_array_item($input)]);
}

private function fromIteratorToArray(Expr $input): Expr
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 b1b2a16

Please sign in to comment.