From 097b4bd9ea789c8982be7ffe3b1a4dccdb1308fe Mon Sep 17 00:00:00 2001 From: Christian Blank Date: Sat, 22 Aug 2026 01:43:36 +0200 Subject: [PATCH] Add Nested definition to support nested result structures Closes #6. Adds a new `Definition\Nested` that resolves one or more queries against a sub-source (navigated to via any other definition, typically a Path). Depending on whether the navigation resolves to a single object or a collection, it returns a nested associative array or a list of nested associative arrays, and can be composed to build arbitrarily deep structures. Propagates UnresolvedValue so optional() navigation still omits the key as expected. --- README.md | 45 +++++++++ src/Definition/Nested.php | 71 ++++++++++++++ tests/Unit/Definition/NestedTest.php | 134 +++++++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 src/Definition/Nested.php create mode 100644 tests/Unit/Definition/NestedTest.php diff --git a/README.md b/README.md index 72f429b..6ab6552 100644 --- a/README.md +++ b/README.md @@ -133,3 +133,48 @@ $resolver = new QueryResolver( $resolver->resolve($someShipWithoutAPilot); // ['pilotName' => 'Unknown pilot'] ``` + +### Nested + +The `Nested` definition lets you return nested data structures instead of a +flat associative array. It navigates to a sub-source using any other +definition (typically a `Path`) and then applies its own set of queries to +it. + +If the navigation resolves to a single object, `Nested` returns a nested +associative array. If it resolves to a collection, `Nested` applies its +queries to every entry and returns a list of nested associative arrays. +`Nested` definitions can be combined to build arbitrarily deep structures. + +```php +get('name')), + new Query('pilot', new Nested( + (new Path())->get('pilot'), + new Query('name', (new Path())->get('name')), + new Query('id', (new Path())->get('id')) + )), + new Query('starShips', new Nested( + (new Path())->get('starShips'), + new Query('name', (new Path())->get('name')) + )) +); + +$resolver->resolve($someCharacter); +// [ +// 'name' => 'Han Solo', +// 'pilot' => ['name' => 'Han Solo', 'id' => 1002], +// 'starShips' => [['name' => 'Millenium Falcon'], ['name' => 'Imperial shuttle']], +// ] +``` + +If the navigation definition is `optional()` and cannot be resolved, the +`Nested` definition behaves like any other and the corresponding key is +omitted from the result set. diff --git a/src/Definition/Nested.php b/src/Definition/Nested.php new file mode 100644 index 0000000..7c4492f --- /dev/null +++ b/src/Definition/Nested.php @@ -0,0 +1,71 @@ +queries = $queries; + } + + public function getValue(SourceInterface $source): mixed + { + $target = $this->definition->getValue($source); + + if ($target instanceof UnresolvedValue) { + return $target; + } + + if ($target instanceof ArraySource) { + return array_map( + fn (mixed $entry) => $this->resolveEntry($entry), + $target->getSource() + ); + } + + if ($target instanceof SourceInterface) { + return $this->resolver()->resolve($target); + } + + return $target; + } + + private function resolveEntry(mixed $entry): mixed + { + if (is_object($entry)) { + return $this->resolver()->resolve(new ObjectSource($entry)); + } + + if (is_array($entry)) { + return $this->resolver()->resolve(new ArraySource($entry)); + } + + return $entry; + } + + private function resolver(): QueryResolver + { + return new QueryResolver(...$this->queries); + } +} diff --git a/tests/Unit/Definition/NestedTest.php b/tests/Unit/Definition/NestedTest.php new file mode 100644 index 0000000..fde6008 --- /dev/null +++ b/tests/Unit/Definition/NestedTest.php @@ -0,0 +1,134 @@ +assertInstanceOf(DefinitionInterface::class, $actual); + } + + /** + * @test + */ + public function itShouldResolveANestedObject(): void + { + $pilot = (object) ['name' => 'Han Solo', 'id' => 1002]; + $ship = (object) ['name' => 'Millenium Falcon', 'pilot' => $pilot]; + + $resolver = new QueryResolver( + new Query('name', (new Path())->get('name')), + new Query('pilot', new Nested( + (new Path())->get('pilot'), + new Query('name', (new Path())->get('name')), + new Query('id', (new Path())->get('id')) + )) + ); + + $result = $resolver->resolve(new ObjectSource($ship)); + + $this->assertSame( + [ + 'name' => 'Millenium Falcon', + 'pilot' => ['name' => 'Han Solo', 'id' => 1002], + ], + $result + ); + } + + /** + * @test + */ + public function itShouldResolveANestedListOfObjects(): void + { + $ship = (object) [ + 'name' => 'Millenium Falcon', + 'crew' => [ + (object) ['name' => 'Han Solo'], + (object) ['name' => 'Chewbacca'], + ], + ]; + + $resolver = new QueryResolver( + new Query('crew', new Nested( + (new Path())->get('crew'), + new Query('name', (new Path())->get('name')) + )) + ); + + $result = $resolver->resolve(new ObjectSource($ship)); + + $this->assertSame( + ['crew' => [['name' => 'Han Solo'], ['name' => 'Chewbacca']]], + $result + ); + } + + /** + * @test + */ + public function itShouldSupportDeeplyNestedStructures(): void + { + $engine = (object) ['power' => 'strong']; + $pilot = (object) ['name' => 'Han Solo', 'ship' => (object) ['name' => 'Falcon', 'engine' => $engine]]; + + $resolver = new QueryResolver( + new Query('name', (new Path())->get('name')), + new Query('ship', new Nested( + (new Path())->get('ship'), + new Query('name', (new Path())->get('name')), + new Query('engine', new Nested( + (new Path())->get('engine'), + new Query('power', (new Path())->get('power')) + )) + )) + ); + + $result = $resolver->resolve(new ObjectSource($pilot)); + + $this->assertSame( + [ + 'name' => 'Han Solo', + 'ship' => [ + 'name' => 'Falcon', + 'engine' => ['power' => 'strong'], + ], + ], + $result + ); + } + + /** + * @test + */ + public function itShouldPropagateAnUnresolvedNavigationValue(): void + { + $ship = (object) ['name' => 'Millenium Falcon']; + + $resolver = new QueryResolver( + new Query('name', (new Path())->get('name')), + new Query('pilot', new Nested( + (new Path())->get('pilot')->get('name')->optional(), + new Query('name', (new Path())->get('name')) + )) + ); + + $result = $resolver->resolve(new ObjectSource($ship)); + + $this->assertSame(['name' => 'Millenium Falcon'], $result); + } +}