Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php

use Lemonade\ObjectQuery\Query\Query;
use Lemonade\ObjectQuery\QueryResolver;
use Lemonade\ObjectQuery\Definition\Nested;
use Lemonade\ObjectQuery\Definition\Path;

$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'))
)),
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.
71 changes: 71 additions & 0 deletions src/Definition/Nested.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace Lemonade\ObjectQuery\Definition;

use Lemonade\ObjectQuery\DefinitionInterface;
use Lemonade\ObjectQuery\QueryInterface;
use Lemonade\ObjectQuery\QueryResolver;
use Lemonade\ObjectQuery\Source\ArraySource;
use Lemonade\ObjectQuery\Source\ObjectSource;
use Lemonade\ObjectQuery\SourceInterface;

/**
* Resolves one or more queries against a sub-source, allowing the resolver
* to build nested data structures instead of a flat associative array.
*
* The given definition (typically a Path) is used to navigate to the
* sub-source first. Depending on what it resolves to, the nested queries
* are then applied either to a single object (resulting in a nested
* associative array) or to every entry of a collection (resulting in a
* list of nested associative arrays).
*/
final class Nested implements DefinitionInterface
{
/** @var QueryInterface[] */
private array $queries;

public function __construct(private readonly DefinitionInterface $definition, QueryInterface ...$queries)
{
$this->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);
}
}
134 changes: 134 additions & 0 deletions tests/Unit/Definition/NestedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace Lemonade\ObjectQuery\Tests\Unit\Definition;

use Lemonade\ObjectQuery\Definition\Nested;
use Lemonade\ObjectQuery\Definition\Path;
use Lemonade\ObjectQuery\DefinitionInterface;
use Lemonade\ObjectQuery\Query\Query;
use Lemonade\ObjectQuery\QueryResolver;
use Lemonade\ObjectQuery\Source\ObjectSource;
use PHPUnit\Framework\TestCase;

class NestedTest extends TestCase
{
/**
* @test
*/
public function itCanBeConstructed(): void
{
$actual = new Nested(new Path());

$this->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);
}
}
Loading