Skip to content

Commit 4c2f6a8

Browse files
committed
Added skip unitialized values context param
1 parent 467c803 commit 4c2f6a8

File tree

13 files changed

+249
-16
lines changed

13 files changed

+249
-16
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Added
1010
- [GH#223](https://github.com/jolicode/automapper/pull/223) Handle array to Doctrine Collection transformations
1111
- [GH#225](https://github.com/jolicode/automapper/pull/225) Add mapCollection method to base interface
12+
- [GH#200](https://github.com/jolicode/automapper/pull/200) Added skip_uninitialized_values context to skip non initialized properties
13+
- [GH#200](https://github.com/jolicode/automapper/pull/200) Changed skip_null_values behavior to not handle initialized properties anymore
14+
15+
### Removed
16+
- [GH#200](https://github.com/jolicode/automapper/pull/200) Drop nikic/php-parser < 5.0 compatibility
1217

1318
## [9.2.1] - 2025-01-31
1419
### Fixed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
],
1818
"require": {
1919
"php": "^8.2",
20-
"nikic/php-parser": "^4.18 || ^5.0",
20+
"nikic/php-parser": "^5.0",
2121
"symfony/deprecation-contracts": "^3.0",
2222
"symfony/event-dispatcher": "^6.4 || ^7.0",
2323
"symfony/expression-language": "^6.4 || ^7.0",

docs/getting-started/context.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ $target = $autoMapper->map($source, $target, ['skip_null_values' => true]);
121121
assert($target->name === 'Jane'); // true
122122
```
123123

124+
### Skip uninitialized values
125+
126+
Like for null values and `skip_null_values`, in some case having an uninitialized value may precise that the value should not be mapped and the target should keep its value.
127+
128+
You can enable this behavior by passing the `skip_uninitialized_values` option.
129+
130+
```php
131+
class User
132+
{
133+
public string $name;
134+
}
135+
136+
$source = new User(); // do not initialize $name property
137+
$target = new UserDTO();
138+
$target->name = 'Jane';
139+
$target = $autoMapper->map($source, $target, ['skip_uninitialized_values' => true]);
140+
141+
assert($target->name === 'Jane'); // true
142+
```
143+
124144
### Date Time format
125145

126146
When mapping a `DateTimeInterface` object to a string, AutoMapper will format the date, you can change the format by

src/Extractor/ReadAccessor.php

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,18 +188,18 @@ public function getIsNullExpression(Expr\Variable $input): Expr
188188
/*
189189
* Use the property fetch to read the value
190190
*
191-
* isset($input->property_name)
191+
* isset($input->property_name) && null === $input->property_name
192192
*/
193-
return new Expr\BooleanNot(new Expr\Isset_([new Expr\PropertyFetch($input, $this->accessor)]));
193+
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)));
194194
}
195195

196196
if (self::TYPE_ARRAY_DIMENSION === $this->type) {
197197
/*
198198
* Use the array dim fetch to read the value
199199
*
200-
* isset($input['property_name'])
200+
* isset($input['property_name']) && null === $input->property_name
201201
*/
202-
return new Expr\BooleanNot(new Expr\Isset_([new Expr\ArrayDimFetch($input, new Scalar\String_($this->accessor))]));
202+
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)));
203203
}
204204

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

215+
public function getIsUndefinedExpression(Expr\Variable $input): Expr
216+
{
217+
if (\in_array($this->type, [self::TYPE_METHOD, self::TYPE_SOURCE])) {
218+
/*
219+
* false
220+
*/
221+
return new Expr\ConstFetch(new Name('false'));
222+
}
223+
224+
if (self::TYPE_PROPERTY === $this->type) {
225+
if ($this->private) {
226+
/*
227+
* When the property is private we use the extract callback that can read this value
228+
*
229+
* @see \AutoMapper\Extractor\ReadAccessor::getExtractIsUndefinedCallback()
230+
*
231+
* $this->extractIsUndefinedCallbacks['property_name']($input)
232+
*/
233+
return new Expr\FuncCall(
234+
new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'extractIsUndefinedCallbacks'), new Scalar\String_($this->accessor)),
235+
[
236+
new Arg($input),
237+
]
238+
);
239+
}
240+
241+
/*
242+
* Use the property fetch to read the value
243+
*
244+
* !array_key_exists($property_name, (object) $input)
245+
*/
246+
return new Expr\BooleanNot(new Expr\FuncCall(new Name('array_key_exists'), [new Arg(new Scalar\String_($this->accessor)), new Arg(new Expr\Cast\Array_($input))]));
247+
}
248+
249+
if (self::TYPE_ARRAY_DIMENSION === $this->type) {
250+
/*
251+
* Use the array dim fetch to read the value
252+
*
253+
* !array_key_exists('property_name', $input)
254+
*/
255+
return new Expr\BooleanNot(new Expr\FuncCall(new Name('array_key_exists'), [new Arg(new Scalar\String_($this->accessor)), new Arg($input)]));
256+
}
257+
258+
throw new CompileException('Invalid accessor for read expression');
259+
}
260+
215261
/**
216262
* Get AST expression for binding closure when dealing with a private property.
217263
*/
@@ -261,6 +307,38 @@ public function getExtractIsNullCallback(string $className): ?Expr
261307
return null;
262308
}
263309

310+
/*
311+
* Create extract is null callback for this accessor
312+
*
313+
* \Closure::bind(function ($object) {
314+
* return !isset($object->property_name) && null === $object->property_name;
315+
* }, null, $className)
316+
*/
317+
return new Expr\StaticCall(new Name\FullyQualified(\Closure::class), 'bind', [
318+
new Arg(
319+
new Expr\Closure([
320+
'params' => [
321+
new Param(new Expr\Variable('object')),
322+
],
323+
'stmts' => [
324+
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)))),
325+
],
326+
])
327+
),
328+
new Arg(new Expr\ConstFetch(new Name('null'))),
329+
new Arg(new Scalar\String_($className)),
330+
]);
331+
}
332+
333+
/**
334+
* Get AST expression for binding closure when dealing with a private property.
335+
*/
336+
public function getExtractIsUndefinedCallback(string $className): ?Expr
337+
{
338+
if ($this->type !== self::TYPE_PROPERTY || !$this->private) {
339+
return null;
340+
}
341+
264342
/*
265343
* Create extract is null callback for this accessor
266344
*

src/GeneratedMapper.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ public function registerMappers(AutoMapperRegistryInterface $registry): void
4848
/** @var array<string, callable(): bool>) */
4949
protected array $extractIsNullCallbacks = [];
5050

51+
/** @var array<string, callable(): bool>) */
52+
protected array $extractIsUndefinedCallbacks = [];
53+
5154
/** @var Target|\ReflectionClass<object> */
5255
protected mixed $cachedTarget;
5356
}

src/Generator/MapperConstructorGenerator.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function getStatements(GeneratorMetadata $metadata): array
3131
foreach ($metadata->propertiesMetadata as $propertyMetadata) {
3232
$constructStatements[] = $this->extractCallbackForProperty($metadata, $propertyMetadata);
3333
$constructStatements[] = $this->extractIsNullCallbackForProperty($metadata, $propertyMetadata);
34+
$constructStatements[] = $this->extractIsUndefinedCallbackForProperty($metadata, $propertyMetadata);
3435
$constructStatements[] = $this->hydrateCallbackForProperty($metadata, $propertyMetadata);
3536
}
3637

@@ -83,6 +84,28 @@ private function extractIsNullCallbackForProperty(GeneratorMetadata $metadata, P
8384
));
8485
}
8586

87+
/**
88+
* Add read callback to the constructor of the generated mapper.
89+
*
90+
* ```php
91+
* $this->extractIsUndefinedCallbacks['propertyName'] = $extractIsNullCallback;
92+
* ```
93+
*/
94+
private function extractIsUndefinedCallbackForProperty(GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata): ?Stmt\Expression
95+
{
96+
$extractUndefinedCallback = $propertyMetadata->source->accessor?->getExtractIsUndefinedCallback($metadata->mapperMetadata->source);
97+
98+
if (!$extractUndefinedCallback) {
99+
return null;
100+
}
101+
102+
return new Stmt\Expression(
103+
new Expr\Assign(
104+
new Expr\ArrayDimFetch(new Expr\PropertyFetch(new Expr\Variable('this'), 'extractIsUndefinedCallbacks'), new Scalar\String_($propertyMetadata->source->property)),
105+
$extractUndefinedCallback
106+
));
107+
}
108+
86109
/**
87110
* Add hydrate callback to the constructor of the generated mapper.
88111
*

src/Generator/PropertyConditionsGenerator.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,11 @@ private function isAllowedAttribute(GeneratorMetadata $metadata, PropertyMetadat
138138
return new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'isAllowedAttribute', [
139139
new Arg($variableRegistry->getContext()),
140140
new Arg(new Scalar\String_($propertyMetadata->source->property)),
141-
new Arg($propertyMetadata->source->accessor->getIsNullExpression($variableRegistry->getSourceInput())),
141+
new Arg(new Expr\Closure([
142+
'uses' => [new Expr\ClosureUse($variableRegistry->getSourceInput())],
143+
'stmts' => [new Stmt\Return_($propertyMetadata->source->accessor->getIsNullExpression($variableRegistry->getSourceInput()))],
144+
])),
145+
new Arg($propertyMetadata->source->accessor->getIsUndefinedExpression($variableRegistry->getSourceInput())),
142146
]);
143147
}
144148

src/MapperContext.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* "deep_target_to_populate"?: bool,
2929
* "constructor_arguments"?: array<string, array<string, mixed>>,
3030
* "skip_null_values"?: bool,
31+
* "skip_uninitialized_values"?: bool,
3132
* "allow_readonly_target_to_populate"?: bool,
3233
* "datetime_format"?: string,
3334
* "datetime_force_timezone"?: string,
@@ -49,6 +50,7 @@ class MapperContext
4950
public const DEEP_TARGET_TO_POPULATE = 'deep_target_to_populate';
5051
public const CONSTRUCTOR_ARGUMENTS = 'constructor_arguments';
5152
public const SKIP_NULL_VALUES = 'skip_null_values';
53+
public const SKIP_UNINITIALIZED_VALUES = 'skip_uninitialized_values';
5254
public const ALLOW_READONLY_TARGET_TO_POPULATE = 'allow_readonly_target_to_populate';
5355
public const DATETIME_FORMAT = 'datetime_format';
5456
public const DATETIME_FORCE_TIMEZONE = 'datetime_force_timezone';
@@ -135,6 +137,13 @@ public function setSkipNullValues(bool $skipNullValues): self
135137
return $this;
136138
}
137139

140+
public function setSkipUnitializedValues(bool $skipUnitializedValues): self
141+
{
142+
$this->context[self::SKIP_UNINITIALIZED_VALUES] = $skipUnitializedValues;
143+
144+
return $this;
145+
}
146+
138147
public function setAllowReadOnlyTargetToPopulate(bool $allowReadOnlyTargetToPopulate): self
139148
{
140149
$this->context[self::ALLOW_READONLY_TARGET_TO_POPULATE] = $allowReadOnlyTargetToPopulate;
@@ -231,9 +240,13 @@ public static function withReference(array $context, string $reference, mixed &$
231240
*
232241
* @internal
233242
*/
234-
public static function isAllowedAttribute(array $context, string $attribute, bool $valueIsNullOrUndefined): bool
243+
public static function isAllowedAttribute(array $context, string $attribute, callable $valueIsNull, bool $valueIsUndefined): bool
235244
{
236-
if (($context[self::SKIP_NULL_VALUES] ?? false) && $valueIsNullOrUndefined) {
245+
if (($context[self::SKIP_UNINITIALIZED_VALUES] ?? false) && $valueIsUndefined) {
246+
return false;
247+
}
248+
249+
if (($context[self::SKIP_NULL_VALUES] ?? false) && !$valueIsUndefined && $valueIsNull()) {
237250
return false;
238251
}
239252

tests/AutoMapperTest.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
use AutoMapper\Tests\Fixtures\Issue111\Colour;
4545
use AutoMapper\Tests\Fixtures\Issue111\ColourTransformer;
4646
use AutoMapper\Tests\Fixtures\Issue111\FooDto;
47+
use AutoMapper\Tests\Fixtures\Issue189\User as Issue189User;
48+
use AutoMapper\Tests\Fixtures\Issue189\UserPatchInput as Issue189UserPatchInput;
4749
use AutoMapper\Tests\Fixtures\ObjectsUnion\Bar;
4850
use AutoMapper\Tests\Fixtures\ObjectsUnion\Foo;
4951
use AutoMapper\Tests\Fixtures\ObjectsUnion\ObjectsUnionProperty;
@@ -1013,7 +1015,7 @@ public function testSkipNullValues(): void
10131015
$input = new Fixtures\SkipNullValues\Input();
10141016

10151017
/** @var Fixtures\SkipNullValues\Entity $entity */
1016-
$entity = $this->autoMapper->map($input, $entity, ['skip_null_values' => true]);
1018+
$entity = $this->autoMapper->map($input, $entity, [MapperContext::SKIP_NULL_VALUES => true]);
10171019
self::assertEquals('foobar', $entity->getName());
10181020
}
10191021

@@ -1377,7 +1379,7 @@ public function testNoErrorWithUninitializedProperty(): void
13771379

13781380
self::assertSame(
13791381
['bar' => 'bar'],
1380-
$this->autoMapper->map(new Uninitialized(), 'array', ['skip_null_values' => true])
1382+
$this->autoMapper->map(new Uninitialized(), 'array', [MapperContext::SKIP_UNINITIALIZED_VALUES => true])
13811383
);
13821384
}
13831385

@@ -1760,4 +1762,18 @@ public function testMapCollectionToArray(): void
17601762
self::assertIsArray($userDatas[1]['address']);
17611763
self::assertIsString($userDatas[1]['createdAt']);
17621764
}
1765+
1766+
public function testUninitializedProperties(): void
1767+
{
1768+
$payload = new Issue189UserPatchInput();
1769+
$payload->firstName = 'John';
1770+
$payload->lastName = 'Doe';
1771+
1772+
/** @var Issue189User $data */
1773+
$data = $this->autoMapper->map($payload, Issue189User::class, [MapperContext::SKIP_UNINITIALIZED_VALUES => true]);
1774+
1775+
$this->assertEquals('John', $data->getFirstName());
1776+
$this->assertEquals('Doe', $data->getLastName());
1777+
$this->assertTrue(!isset($data->birthDate));
1778+
}
17631779
}

tests/Fixtures/Issue189/User.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AutoMapper\Tests\Fixtures\Issue189;
6+
7+
class User
8+
{
9+
public string $lastName;
10+
public string $firstName;
11+
public ?\DateTimeImmutable $birthDate;
12+
13+
public function getLastName(): string
14+
{
15+
return $this->lastName;
16+
}
17+
18+
public function setLastName(string $lastName): self
19+
{
20+
$this->lastName = $lastName;
21+
22+
return $this;
23+
}
24+
25+
public function getFirstName(): string
26+
{
27+
return $this->firstName;
28+
}
29+
30+
public function setFirstName(string $firstName): self
31+
{
32+
$this->firstName = $firstName;
33+
34+
return $this;
35+
}
36+
37+
public function getBirthDate(): ?\DateTimeImmutable
38+
{
39+
return $this->birthDate;
40+
}
41+
42+
public function setBirthDate(?\DateTimeImmutable $birthDate): self
43+
{
44+
$this->birthDate = $birthDate;
45+
46+
return $this;
47+
}
48+
}

0 commit comments

Comments
 (0)