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 @@ +