diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..027a44a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.php] +insert_final_newline = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 62c8935..072705c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.idea/ \ No newline at end of file +.idea/ +vendor/ +composer.lock diff --git a/README.md b/README.md index a82ce78..fbe9fe7 100644 --- a/README.md +++ b/README.md @@ -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 + 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 + $entity->getCompanyId()]; + } + } + ``` +4. Add the Company field to WalletType: + ```php + 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. diff --git a/Service/GraphQL/DataSource/AbstractDataSource.php b/Service/GraphQL/DataSource/AbstractDataSource.php new file mode 100644 index 0000000..fc18ca3 --- /dev/null +++ b/Service/GraphQL/DataSource/AbstractDataSource.php @@ -0,0 +1,136 @@ +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); + } +} diff --git a/Service/GraphQL/DataSource/DataSourceInterface.php b/Service/GraphQL/DataSource/DataSourceInterface.php new file mode 100644 index 0000000..c1ef627 --- /dev/null +++ b/Service/GraphQL/DataSource/DataSourceInterface.php @@ -0,0 +1,10 @@ +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()); + } +} diff --git a/composer.json b/composer.json index 24d1022..bd7ac29 100755 --- a/composer.json +++ b/composer.json @@ -15,6 +15,12 @@ "Garlic\\Wrapper\\": "" } }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/egrik/helpers.git" + } + ], "require": { "php": ">=7.1", "garlic/bus": "1.*", @@ -22,6 +28,8 @@ "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" } }