diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07ada8d..d2555de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
## 1.5.1 under development
-- no changes in this release.
+- New #63: Add nested mapping support via new `ObjectMap` class (@vjik)
## 1.5.0 September 17, 2024
diff --git a/docs/guide/en/mapping.md b/docs/guide/en/mapping.md
index 2a48888..0a32fed 100644
--- a/docs/guide/en/mapping.md
+++ b/docs/guide/en/mapping.md
@@ -44,6 +44,40 @@ $post = $hydrator->create(Post::class, new ArrayData($data, $map));
This way we take `header` key for `title` and `text` key for `body`.
+For nested objects mapping you can use `ObjectMap` class:
+
+```php
+use Yiisoft\Hydrator\ArrayData;
+use Yiisoft\Hydrator\Hydrator;
+use Yiisoft\Hydrator\ObjectMap;
+
+final class Message {
+ public string $subject = '';
+ public ?Body $body = null;
+}
+
+final class Body {
+ public string $text = '';
+ public string $html = '';
+}
+
+$hydrator = new Hydrator();
+
+$data = [
+ 'title' => 'Hello, World!',
+ 'textBody' => 'Nice to meet you.',
+ 'htmlBody' => '
Nice to meet you.
',
+];
+$map = [
+ 'subject' => 'title',
+ 'body' => new ObjectMap([
+ 'text' => 'textBody',
+ 'html' => 'htmlBody',
+ ]),
+];
+$message = $hydrator->create(Message::class, new ArrayData($data, $map));
+```
+
## Strict mode
You can enable strict mode by passing `true` as a third argument of `ArrayData`:
@@ -54,7 +88,7 @@ use Yiisoft\Hydrator\ArrayData;
$hydrator = new Hydrator();
-$map = ['title' => 'header', 'body' => 'text'],;
+$map = ['title' => 'header', 'body' => 'text'];
$post = $hydrator->create(Post::class, new ArrayData($data, $map, true));
```
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 7f38c7d..8d037c6 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -11,6 +11,7 @@
failOnWarning="true"
stopOnFailure="false"
colors="true"
+ displayDetailsOnPhpunitDeprecations="true"
>
diff --git a/src/ArrayData.php b/src/ArrayData.php
index b3ec813..8de2847 100644
--- a/src/ArrayData.php
+++ b/src/ArrayData.php
@@ -6,7 +6,6 @@
use Yiisoft\Strings\StringHelper;
-use function array_key_exists;
use function is_array;
use function is_string;
use function strlen;
@@ -14,32 +13,58 @@
/**
* Holds data to hydrate an object from and a map to use when populating an object.
*
- * @psalm-type MapType=array>
+ * @psalm-type MapType=array|ObjectMap>
*/
final class ArrayData implements DataInterface
{
+ private readonly ObjectMap $objectMap;
+
/**
* @param array $data Data to hydrate object from.
- * @param array $map Object property names mapped to keys in the data array that hydrator will use when hydrating
- * an object.
+ * @param array|ObjectMap $map Object property names mapped to keys in the data array that hydrator will use when
+ * hydrating an object.
* @param bool $strict Whether to hydrate properties from the map only.
*
- * @psalm-param MapType $map
+ * @psalm-param ObjectMap|MapType $map
*/
public function __construct(
- private array $data = [],
- private array $map = [],
- private bool $strict = false,
+ private readonly array $data = [],
+ array|ObjectMap $map = [],
+ private readonly bool $strict = false,
) {
+ $this->objectMap = is_array($map) ? new ObjectMap($map) : $map;
}
public function getValue(string $name): Result
{
- if ($this->strict && !array_key_exists($name, $this->map)) {
+ if ($this->strict && !$this->objectMap->exists($name)) {
return Result::fail();
}
- return $this->getValueByPath($this->data, $this->map[$name] ?? $name);
+ $path = $this->objectMap->getPath($name) ?? $name;
+ if ($path instanceof ObjectMap) {
+ return $this->getValueByObjectMap($this->data, $path);
+ }
+
+ return $this->getValueByPath($this->data, $path);
+ }
+
+ /**
+ * Get an array given a map as resolved result.
+ */
+ private function getValueByObjectMap(array $data, ObjectMap $objectMap): Result
+ {
+ $arrayData = new self($data, $objectMap);
+
+ $result = [];
+ foreach ($objectMap->getNames() as $name) {
+ $value = $arrayData->getValue($name);
+ if ($value->isResolved()) {
+ $result[$name] = $value->getValue();
+ }
+ }
+
+ return Result::success($result);
}
/**
diff --git a/src/ObjectMap.php b/src/ObjectMap.php
new file mode 100644
index 0000000..d7d9bd8
--- /dev/null
+++ b/src/ObjectMap.php
@@ -0,0 +1,57 @@
+|ObjectMap|null
+ */
+ public function getPath(string $name): string|array|self|null
+ {
+ return $this->map[$name] ?? null;
+ }
+
+ /**
+ * Returns a list of property names for which mapping is set.
+ *
+ * @return string[] List of property names.
+ * @psalm-return list
+ */
+ public function getNames(): array
+ {
+ return array_keys($this->map);
+ }
+
+ /**
+ * Checks if a given property name exists in the mapping array.
+ *
+ * @param string $name The property name.
+ * @return bool Whether the property name exists in the mapping array.
+ */
+ public function exists(string $name): bool
+ {
+ return array_key_exists($name, $this->map);
+ }
+}
diff --git a/tests/ObjectMap/Car.php b/tests/ObjectMap/Car.php
new file mode 100644
index 0000000..31d978d
--- /dev/null
+++ b/tests/ObjectMap/Car.php
@@ -0,0 +1,13 @@
+create(
+ Root::class,
+ new ArrayData(
+ ['key' => 'test'],
+ ['nested' => new ObjectMap(['var' => 'key'])],
+ ),
+ );
+
+ $this->assertSame('test', $root->nested?->var);
+ $this->assertNull($root->nested->nested2);
+ }
+
+ public function testNested2(): void
+ {
+ $hydrator = new Hydrator();
+
+ $root = $hydrator->create(
+ Root::class,
+ new ArrayData(
+ ['a' => 'A', 'b' => ['b1' => 'B1'], 'c' => 'C'],
+ [
+ 'nested' => new ObjectMap([
+ 'var' => 'a',
+ 'nested2' => new ObjectMap([
+ 'var1' => ['b', 'b1'],
+ 'var2' => 'c',
+ ]),
+ ]),
+ ],
+ ),
+ );
+
+ $this->assertSame('A', $root->nested?->var);
+ $this->assertSame('B1', $root->nested?->nested2?->var1);
+ $this->assertSame('C', $root->nested?->nested2?->var2);
+ }
+
+ public function testWithSameKeyInData(): void
+ {
+ $hydrator = new Hydrator();
+
+ $root = $hydrator->create(
+ Root::class,
+ new ArrayData(
+ [
+ 'var' => 'test',
+ 'var1' => 'A',
+ 'var2' => 'B',
+ ],
+ [
+ 'nested' => new ObjectMap([
+ 'nested2' => new ObjectMap([
+ 'var1' => 'var',
+ ]),
+ ]),
+ ],
+ ),
+ );
+
+ $this->assertSame('', $root->nested?->var);
+ $this->assertSame('test', $root->nested?->nested2?->var1);
+ $this->assertSame('', $root->nested?->nested2?->var2);
+ }
+
+ public function testWithoutMap(): void
+ {
+ $hydrator = new Hydrator();
+
+ $root = $hydrator->create(
+ Root::class,
+ new ArrayData(
+ [
+ 'nested' => [
+ 'nested2' => [
+ 'var1' => 'A',
+ 'var2' => 'B',
+ ],
+ ],
+ ],
+ ),
+ );
+
+ $this->assertSame('A', $root->nested?->nested2?->var1);
+ $this->assertSame('B', $root->nested?->nested2?->var2);
+ }
+
+ public function testNestedObjectWithRequiredConstructorArgumentWithData(): void
+ {
+ $hydrator = new Hydrator();
+
+ $object = $hydrator->create(
+ Car::class,
+ new ArrayData(
+ [
+ 'full-name' => 'V8',
+ 'engine-version' => '2.0',
+ ],
+ [
+ 'engine' => new ObjectMap([
+ 'name' => 'full-name',
+ 'version' => 'engine-version',
+ ]),
+ ],
+ ),
+ );
+
+ $this->assertSame('V8', $object->engine?->name);
+ $this->assertSame('2.0', $object->engine?->version);
+ }
+
+ public function testNestedObjectWithRequiredConstructorArgumentWithoutData(): void
+ {
+ $hydrator = new Hydrator();
+
+ $object = $hydrator->create(
+ Car::class,
+ new ArrayData(
+ [
+ 'full-name' => 'V8',
+ 'engine-version' => '2.0',
+ ],
+ [
+ 'engine' => new ObjectMap([
+ 'name' => 'engine-name',
+ 'version' => 'engine-version',
+ ]),
+ ],
+ ),
+ );
+
+ $this->assertNull($object->engine);
+ }
+}
diff --git a/tests/ObjectMap/Readme/Body.php b/tests/ObjectMap/Readme/Body.php
new file mode 100644
index 0000000..5eb34ca
--- /dev/null
+++ b/tests/ObjectMap/Readme/Body.php
@@ -0,0 +1,11 @@
+ 'Hello, World!',
+ 'textBody' => 'Nice to meet you.',
+ 'htmlBody' => 'Nice to meet you.
',
+ ];
+ $map = [
+ 'subject' => 'title',
+ 'body' => new ObjectMap([
+ 'text' => 'textBody',
+ 'html' => 'htmlBody',
+ ]),
+ ];
+
+ $message = $hydrator->create(Message::class, new ArrayData($data, $map));
+
+ $this->assertSame('Hello, World!', $message->subject);
+ $this->assertSame('Nice to meet you.', $message->body?->text);
+ $this->assertSame('Nice to meet you.
', $message->body?->html);
+ }
+}
diff --git a/tests/ObjectMap/Root.php b/tests/ObjectMap/Root.php
new file mode 100644
index 0000000..8e17f3e
--- /dev/null
+++ b/tests/ObjectMap/Root.php
@@ -0,0 +1,10 @@
+