Skip to content
Open
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
2 changes: 2 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[*.php]
insert_final_newline = true
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.idea/
.idea/
vendor/
composer.lock
112 changes: 112 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,118 @@ $apartmentQuery
$result = $graphQLService->fetch();
```

### Querying external related objects using a deferred resolver
Using deferred resolvers allows to getting relate data by one query after receiving the data of the base query, which solve the N + 1 problem.

Suppose we have next GraphQL query:
```json
{
wallet {
WalletFind {
items {
id
amount
company {
id
title
}
}
}
}
}
```

Where _company_ object is part of an external service, wich we'd like to get by one query to the external service.
To solve this problem, we need:
1. Create CompanyType, which we'd like use in our schema:
```php
<?php
namespace App\GraphQL\DataSource\Type;

use Youshido\GraphQL\Field\Field;
use Youshido\GraphQL\Type\Object\AbstractObjectType;
use Youshido\GraphQL\Type\Scalar\StringType;

/**
* Class CompanyType
*/
class CompanyType extends AbstractObjectType
{
public function build($config): void
{
$config->addField(new Field(['name' => 'id', 'type' => new StringType()]))
->addField(new Field(['name' => 'title', 'type' => new StringType()]));
}
}
```

2. Create CompanyDataSource, which we'd like use to get external data:
```php
<?php
namespace App\GraphQL\DataSource;

use \Garlic\Wrapper\Service\GraphQL\DataSource\AbstractDataSource;

class CompanyDataSource extends AbstractDataSource
{
public function getQueryName(): string
{
return 'company.CompanyFind';
}
}
```
We have to create _getQueryName()_ method that returns the name of the query to fetch the companies data.

3. Create WalletCompanyRelation that implements _Garlic\Wrapper\Service\GraphQL\DataSource\DataSourceRelationInterface_ which returns a description of the relation between CompanySource and WalletType.
```php
<?php

namespace App\GraphQL\DataSource;

use App\Entity\Wallet;
use \Garlic\Wrapper\Service\GraphQL\DataSource\DataSourceRelationInterface;

class WalletCompanyRelation implements DataSourceRelationInterface
{
/**
* @param Wallet $entity
*
* @return array
*/
public function relation($entity): array
{
return ['id' => $entity->getCompanyId()];
}
}
```
4. Add the Company field to WalletType:
```php
<?php

namespace App\GraphQL\Type;

use App\GraphQL\DataSource\CompanyDataSource;
use App\GraphQL\DataSource\WalletCompanyRelation;
use Garlic\Wrapper\Service\GraphQL\DataSource\DataSourceResolver;
use App\GraphQL\DataSource\Type\CompanyType;
use Garlic\GraphQL\Type\TypeAbstract;
use Youshido\GraphQL\Type\Scalar\FloatType;
use Youshido\GraphQL\Type\Scalar\IdType;

class WalletType extends TypeAbstract
{
public function build($builder)
{
$builder->addField('id', new IdType())
->addField('amount', new FloatType(), ['argument' => false])
->addField('company', new CompanyType(), [
'argument' => false,
'resolve' => DataSourceResolver::build(CompanyDataSource::class, WalletCompanyRelation::class),
]);
}
//...
```

### GraphQL mutations

Mutation is the way to change service data by sending some kinds of query. What this queries are and how they could created read below.
Expand Down
136 changes: 136 additions & 0 deletions Service/GraphQL/DataSource/AbstractDataSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

namespace Garlic\Wrapper\Service\GraphQL\DataSource;

use Garlic\Helpers\ArrayHelper;
use Garlic\Wrapper\Service\GraphQLService;

abstract class AbstractDataSource implements DataSourceInterface
{
/**
* @var array
*/
protected $buffer = [];

/**
* @var array|null
*/
protected $result;

/**
* @var string
*/
protected $glueKey = '.';

/**
* @var GraphQLService
*/
private $graphQLService;

/**
* CompanyDataSource constructor.
*/
public function __construct(GraphQLService $graphQLService)
{
$this->graphQLService = $graphQLService;
}

public function enqueue(array $args): self
{
if ($this->result !== null) {
$this->clear();
}

$this->buffer[] = $args;

return $this;
}

public function resolve(array $args, array $fields)
{
if ($this->result === null) {
try {
$this->result = $this->applyMap($this->fetch($fields));
} catch (\Exception $exception) {
$this->result = [];
}
}

return $this->result[$this->makePKByArgs($args)] ?? null;
}

abstract public function getQueryName(): string;

protected function clear(): void
{
$this->buffer = [];
$this->result = null;
}

protected function getPrimaryKey(): array
{
return ['id'];
}

protected function getRelatedFields(array $fields): array
{
return array_values(array_unique(array_merge($fields, $this->getPrimaryKey())));
}

protected function getRelatedConditions(): array
{
return array_map(function ($item) {
return array_values(array_unique(ArrayHelper::wrap($item)));
}, array_merge_recursive(...$this->buffer));
}

protected function getResponseRootPath(): string
{
return $this->packPath(['data', $this->getQueryName()]);
}

protected function fetch(array $fields): array
{
$this->graphQLService->createQuery($this->getQueryName())
->select($this->getRelatedFields($fields))
->where($this->getRelatedConditions());

try {
return $this->graphQLService->fetch();
} catch (\Exception $exception) {
/*
* todo[egrik]: add logging exception and retry request
*/
}

return [];
}

protected function makePKByArgs(array $args): string
{
$data = ArrayHelper::mapByKeys($args, $this->getPrimaryKey());

return implode($this->glueKey, array_map(function (...$items) {
return implode($this->glueKey, $items);
}, array_keys($data), $data));
}

protected function applyMap(array $result): array
{
$rootItem = ArrayHelper::get($result, $this->getResponseRootPath(), []);
$columns = array_map([$this, 'makePKByArgs'], $rootItem);

return array_map($callback = function ($array) use (&$callback) {
if (is_array($array)) {
return (object)array_map($callback, $array);
}

return $array;
}, array_combine($columns, $rootItem));
}

protected function packPath($items): string
{
return implode($this->glueKey, $items);
}
}
10 changes: 10 additions & 0 deletions Service/GraphQL/DataSource/DataSourceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Garlic\Wrapper\Service\GraphQL\DataSource;

interface DataSourceInterface
{
public function enqueue(array $args);

public function resolve(array $args, array $fields);
}
8 changes: 8 additions & 0 deletions Service/GraphQL/DataSource/DataSourceRelationInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Garlic\Wrapper\Service\GraphQL\DataSource;

interface DataSourceRelationInterface
{
public function relation($entity): array;
}
69 changes: 69 additions & 0 deletions Service/GraphQL/DataSource/DataSourceResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace Garlic\Wrapper\Service\GraphQL\DataSource;

use Youshido\GraphQL\Execution\DeferredResolver;
use Youshido\GraphQL\Execution\ResolveInfo;
use Youshido\GraphQL\Parser\Ast\Field;

class DataSourceResolver
{
/**
* @var DataSourceInterface|null
*/
protected $dataSource;

/**
* @var ResolveInfo
*/
protected $resolverInfo;

protected $value;

/**
* @var DataSourceRelationInterface
*/
private $relation;

public static function build(string $dataSourceAlias, string $relationAlias): callable
{
return function ($value, $args, ResolveInfo $resolveInfo) use ($dataSourceAlias, $relationAlias) {
$container = $resolveInfo->getContainer();

return (new static($container->get($dataSourceAlias), $container->get($relationAlias)))(...func_get_args());
};
}

/**
* DataSourceResolver constructor.
*/
public function __construct(DataSourceInterface $dataSource, DataSourceRelationInterface $relation)
{
$this->dataSource = $dataSource;
$this->relation = $relation;
}

public function __invoke($value, $args, ResolveInfo $resolveInfo): DeferredResolver
{
$this->resolverInfo = $resolveInfo;
$this->value = $value;

$this->dataSource->enqueue($this->handleRelationCallback());

return new DeferredResolver(function () {
return $this->dataSource->resolve($this->handleRelationCallback(), $this->getFields());
});
}

protected function handleRelationCallback(): array
{
return $this->relation->relation($this->value);
}

protected function getFields(): array
{
return array_map(function (Field $field) {
return $field->getName();
}, $this->resolverInfo->getFieldASTList());
}
}
10 changes: 9 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,21 @@
"Garlic\\Wrapper\\": ""
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/egrik/helpers.git"
}
],
"require": {
"php": ">=7.1",
"garlic/bus": "1.*",
"jms/serializer-bundle": "^2.0",
"enqueue/enqueue-bundle": "^0.8",
"enqueue/amqp-ext": "^0.8",
"dflydev/dot-access-data": "^2.0",
"ext-json": "*"
"ext-json": "*",
"garlic/graphql": "^1.2",
"garlic/helpers": "^1.0"
}
}