Skip to content

Commit eeacf75

Browse files
authored
Merge pull request #19 from rekalogika:feat/source-dynamic-properties
feat: Supports dynamic properties (including `stdClass`) on the target side.
2 parents d1993e9 + d40e296 commit eeacf75

File tree

10 files changed

+183
-31
lines changed

10 files changed

+183
-31
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
* feat: `PresetTransformer`.
66
* fix: Typo in `RemoveOptionalDefinitionPass`
7+
* feat: Supports dynamic properties (including `stdClass`) on the target side.
78

89
## 1.0.0
910

phpunit.xml.dist

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
failOnRisky="true"
99
failOnWarning="false"
1010
cacheDirectory=".phpunit.cache"
11-
beStrictAboutCoverageMetadata="true">
11+
beStrictAboutCoverageMetadata="true"
12+
displayDetailsOnTestsThatTriggerWarnings="true">
1213
<testsuites>
1314
<testsuite name="default">
1415
<directory>tests</directory>

src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php

+31-5
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ public function createObjectToObjectMetadata(
8181

8282
$targetReflection = new \ReflectionClass($targetClass);
8383

84-
// check if source and target classes are internal
84+
$sourceAllowsDynamicProperties = $this->allowsDynamicProperties($sourceReflection);
85+
$targetAllowsDynamicProperties = $this->allowsDynamicProperties($targetReflection);
8586

86-
if ($sourceReflection->isInternal()) {
87+
// check if source and target classes are internal. we allow stdClass at
88+
// the source side
89+
if (!$sourceAllowsDynamicProperties && $sourceReflection->isInternal()) {
8790
throw new InternalClassUnsupportedException($sourceClass);
8891
}
8992

@@ -143,9 +146,16 @@ public function createObjectToObjectMetadata(
143146
// process source read mode
144147

145148
if ($sourceReadInfo === null) {
146-
$sourceReadMode = ReadMode::None;
147-
$sourceReadName = null;
148-
$sourceReadVisibility = Visibility::None;
149+
// if source allows dynamic properties, including stdClass
150+
if ($sourceAllowsDynamicProperties) {
151+
$sourceReadMode = ReadMode::DynamicProperty;
152+
$sourceReadName = $sourceProperty;
153+
$sourceReadVisibility = Visibility::Public;
154+
} else {
155+
$sourceReadMode = ReadMode::None;
156+
$sourceReadName = null;
157+
$sourceReadVisibility = Visibility::None;
158+
}
149159
} else {
150160
$sourceReadMode = match ($sourceReadInfo->getType()) {
151161
PropertyReadInfo::TYPE_METHOD => ReadMode::Method,
@@ -320,6 +330,8 @@ public function createObjectToObjectMetadata(
320330
sourceClass: $sourceClass,
321331
targetClass: $targetClass,
322332
providedTargetClass: $providedTargetClass,
333+
sourceAllowsDynamicProperties: $sourceAllowsDynamicProperties,
334+
targetAllowsDynamicProperties: $targetAllowsDynamicProperties,
323335
allPropertyMappings: $propertyMappings,
324336
instantiable: $instantiable,
325337
cloneable: $cloneable,
@@ -412,4 +424,18 @@ private function listInitializableProperties(
412424

413425
return $initializableProperties;
414426
}
427+
428+
/**
429+
* @param \ReflectionClass<object> $class
430+
*/
431+
private function allowsDynamicProperties(\ReflectionClass $class): bool
432+
{
433+
do {
434+
if (count($class->getAttributes(\AllowDynamicProperties::class)) > 0) {
435+
return true;
436+
}
437+
} while ($class = $class->getParentClass());
438+
439+
return false;
440+
}
415441
}

src/Transformer/ObjectToObjectMetadata/ObjectToObjectMetadata.php

+40-24
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public function __construct(
5858
private string $sourceClass,
5959
private string $targetClass,
6060
private string $providedTargetClass,
61+
private bool $sourceAllowsDynamicProperties,
62+
private bool $targetAllowsDynamicProperties,
6163
array $allPropertyMappings,
6264
private bool $instantiable,
6365
private bool $cloneable,
@@ -104,18 +106,20 @@ public function withTargetProxy(
104106
bool $constructorIsEager,
105107
): self {
106108
return new self(
107-
$this->sourceClass,
108-
$this->targetClass,
109-
$this->providedTargetClass,
110-
$this->allPropertyMappings,
111-
$this->instantiable,
112-
$this->cloneable,
113-
$this->initializableTargetPropertiesNotInSource,
114-
$this->sourceModifiedTime,
115-
$this->targetModifiedTime,
116-
$this->targetReadOnly,
117-
$constructorIsEager,
118-
$targetProxySkippedProperties,
109+
sourceClass: $this->sourceClass,
110+
targetClass: $this->targetClass,
111+
providedTargetClass: $this->providedTargetClass,
112+
sourceAllowsDynamicProperties: $this->sourceAllowsDynamicProperties,
113+
targetAllowsDynamicProperties: $this->targetAllowsDynamicProperties,
114+
allPropertyMappings: $this->allPropertyMappings,
115+
instantiable: $this->instantiable,
116+
cloneable: $this->cloneable,
117+
initializableTargetPropertiesNotInSource: $this->initializableTargetPropertiesNotInSource,
118+
sourceModifiedTime: $this->sourceModifiedTime,
119+
targetModifiedTime: $this->targetModifiedTime,
120+
targetReadOnly: $this->targetReadOnly,
121+
constructorIsEager: $constructorIsEager,
122+
targetProxySkippedProperties: $targetProxySkippedProperties,
119123
cannotUseProxyReason: null
120124
);
121125
}
@@ -124,18 +128,20 @@ public function withReasonCannotUseProxy(
124128
string $reason
125129
): self {
126130
return new self(
127-
$this->sourceClass,
128-
$this->targetClass,
129-
$this->providedTargetClass,
130-
$this->allPropertyMappings,
131-
$this->instantiable,
132-
$this->cloneable,
133-
$this->initializableTargetPropertiesNotInSource,
134-
$this->sourceModifiedTime,
135-
$this->targetModifiedTime,
136-
$this->targetReadOnly,
137-
$this->constructorIsEager,
138-
[],
131+
sourceClass: $this->sourceClass,
132+
targetClass: $this->targetClass,
133+
providedTargetClass: $this->providedTargetClass,
134+
sourceAllowsDynamicProperties: $this->sourceAllowsDynamicProperties,
135+
targetAllowsDynamicProperties: $this->targetAllowsDynamicProperties,
136+
allPropertyMappings: $this->allPropertyMappings,
137+
instantiable: $this->instantiable,
138+
cloneable: $this->cloneable,
139+
initializableTargetPropertiesNotInSource: $this->initializableTargetPropertiesNotInSource,
140+
sourceModifiedTime: $this->sourceModifiedTime,
141+
targetModifiedTime: $this->targetModifiedTime,
142+
targetReadOnly: $this->targetReadOnly,
143+
constructorIsEager: $this->constructorIsEager,
144+
targetProxySkippedProperties: [],
139145
cannotUseProxyReason: $reason,
140146
);
141147
}
@@ -277,4 +283,14 @@ public function constructorIsEager(): bool
277283
{
278284
return $this->constructorIsEager;
279285
}
286+
287+
public function getSourceAllowsDynamicProperties(): bool
288+
{
289+
return $this->sourceAllowsDynamicProperties;
290+
}
291+
292+
public function getTargetAllowsDynamicProperties(): bool
293+
{
294+
return $this->targetAllowsDynamicProperties;
295+
}
280296
}

src/Transformer/ObjectToObjectMetadata/ReadMode.php

+1
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ enum ReadMode
2121
case None;
2222
case Method;
2323
case Property;
24+
case DynamicProperty;
2425
}

src/Transformer/Util/ReaderWriter.php

+6
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ public function readSourceProperty(
6060
} elseif ($mode === ReadMode::Method) {
6161
/** @psalm-suppress MixedMethodCall */
6262
return $source->{$accessorName}();
63+
} elseif ($mode === ReadMode::DynamicProperty) {
64+
if (isset($source->{$accessorName})) {
65+
return $source->{$accessorName};
66+
} else {
67+
return null;
68+
}
6369
}
6470
return null;
6571
} catch (\Error $e) {

src/Util/ClassUtil.php

+4
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ public static function getLastModifiedTime(
8080
$class = new \ReflectionClass($class);
8181
}
8282

83+
if ($class->isInternal()) {
84+
return 0;
85+
}
86+
8387
$fileName = $class->getFileName();
8488

8589
if ($fileName === false) {

templates/data_collector.html.twig

+9-1
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,12 @@
251251
<tbody>
252252
<tr>
253253
<th>Source class</th>
254-
<td>{{ metadata.sourceClass|abbr_class }}</td>
254+
<td>
255+
{{ metadata.sourceClass|abbr_class }}
256+
{% if metadata.sourceAllowsDynamicProperties %}
257+
<span class="label status-info">Allows dynamic properties</span>
258+
{% endif %}
259+
</td>
255260
</tr>
256261
<tr>
257262
<th>Wanted target class</th>
@@ -267,6 +272,9 @@
267272
{% if not metadata.instantiable %}
268273
<span class="label status-error">Not instantiable</span>
269274
{% endif %}
275+
{% if metadata.targetAllowsDynamicProperties %}
276+
<span class="label status-info">Allows dynamic properties</span>
277+
{% endif %}
270278
</td>
271279
</tr>
272280
<tr>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of rekalogika/mapper package.
7+
*
8+
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
9+
*
10+
* For the full copyright and license information, please view the LICENSE file
11+
* that was distributed with this source code.
12+
*/
13+
14+
namespace Rekalogika\Mapper\Tests\Fixtures\DynamicProperty;
15+
16+
class ObjectExtendingStdClass extends \stdClass
17+
{
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of rekalogika/mapper package.
7+
*
8+
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
9+
*
10+
* For the full copyright and license information, please view the LICENSE file
11+
* that was distributed with this source code.
12+
*/
13+
14+
namespace Rekalogika\Mapper\Tests\IntegrationTest;
15+
16+
use Rekalogika\Mapper\Tests\Common\FrameworkTestCase;
17+
use Rekalogika\Mapper\Tests\Fixtures\DynamicProperty\ObjectExtendingStdClass;
18+
use Rekalogika\Mapper\Tests\Fixtures\ScalarDto\ObjectWithScalarPropertiesDto;
19+
20+
class DynamicPropertyTest extends FrameworkTestCase
21+
{
22+
public function testStdClassToObject(): void
23+
{
24+
$source = new \stdClass();
25+
$source->a = 1;
26+
$source->b = 'string';
27+
$source->c = true;
28+
$source->d = 1.1;
29+
30+
$target = $this->mapper->map($source, ObjectWithScalarPropertiesDto::class);
31+
32+
$this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $target);
33+
$this->assertSame(1, $target->a);
34+
$this->assertSame('string', $target->b);
35+
$this->assertTrue($target->c);
36+
$this->assertSame(1.1, $target->d);
37+
}
38+
39+
public function testObjectExtendingStdClassToObject(): void
40+
{
41+
$source = new ObjectExtendingStdClass();
42+
/** @psalm-suppress UndefinedPropertyAssignment */
43+
$source->a = 1;
44+
/** @psalm-suppress UndefinedPropertyAssignment */
45+
$source->b = 'string';
46+
/** @psalm-suppress UndefinedPropertyAssignment */
47+
$source->c = true;
48+
/** @psalm-suppress UndefinedPropertyAssignment */
49+
$source->d = 1.1;
50+
51+
$target = $this->mapper->map($source, ObjectWithScalarPropertiesDto::class);
52+
53+
$this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $target);
54+
$this->assertSame(1, $target->a);
55+
$this->assertSame('string', $target->b);
56+
$this->assertTrue($target->c);
57+
$this->assertSame(1.1, $target->d);
58+
}
59+
60+
public function testStdClassWithoutPropertiesToObject(): void
61+
{
62+
$source = new \stdClass();
63+
$target = $this->mapper->map($source, ObjectWithScalarPropertiesDto::class);
64+
65+
$this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $target);
66+
$this->assertNull($target->a);
67+
$this->assertNull($target->b);
68+
$this->assertNull($target->c);
69+
$this->assertNull($target->d);
70+
}
71+
}

0 commit comments

Comments
 (0)