This commit is contained in:
Kilian Hofmann
2021-06-01 19:55:55 +02:00
parent 052cbe3038
commit d8c489c714
328 changed files with 36645 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Illuminate\Http\Request;
abstract class BaseRequest implements GraphQLRequest
{
/**
* The current batch index.
*
* Is null if we are not resolving a batched query.
*
* @var int|null
*/
protected $batchIndex;
/**
* Get the contents of a field by key.
*
* This is expected to take batched requests into consideration.
*
* @param string $key
* @return array|string|null
*/
abstract protected function fieldValue(string $key);
/**
* Are there more batched queries to process?
*
* @return bool
*/
abstract protected function hasMoreBatches(): bool;
/**
* Construct this from a HTTP request.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
abstract public function __construct(Request $request);
/**
* Get the contained GraphQL query string.
*
* @return string
*/
public function query(): string
{
return $this->fieldValue('query');
}
/**
* Get the operationName of the current request.
*
* @return string|null
*/
public function operationName(): ?string
{
return $this->fieldValue('operationName');
}
/**
* Is the current query a batched query?
*
* @return bool
*/
public function isBatched(): bool
{
return ! is_null($this->batchIndex);
}
/**
* Get the index of the current batch.
*
* Returns null if we are not resolving a batched query.
*
* @return int|null
*/
public function batchIndex(): ?int
{
return $this->batchIndex;
}
/**
* Advance the batch index and indicate if there are more batches to process.
*
* @return bool
*/
public function advanceBatchIndex(): bool
{
if ($result = $this->hasMoreBatches()) {
$this->batchIndex++;
}
return $result;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective;
class Builder
{
/**
* A map from argument names to associated query builder directives.
*
* @var ArgBuilderDirective[]
*/
protected $builderDirectives = [];
/**
* Scopes to be applied to the query builder.
*
* @var string[]
*/
protected $scopes = [];
/**
* Apply the bound QueryBuilderDirectives to the given builder.
*
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
* @param mixed[] $args
* @return \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder
*/
public function apply($builder, array $args)
{
foreach ($args as $key => $value) {
// TODO switch to instanceof when we require bensampo/laravel-enum
// Unbox Enum values to ensure their underlying value is used for queries
if (is_a($value, '\BenSampo\Enum\Enum')) {
$value = $value->value;
}
/** @var \Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective $builderDirective */
if ($builderDirective = Arr::get($this->builderDirectives, $key)) {
$builder = $builderDirective->handleBuilder($builder, $value);
}
}
foreach ($this->scopes as $scope) {
call_user_func([$builder, $scope], $args);
}
return $builder;
}
/**
* Add scopes that are then called upon the query with the field arguments.
*
* @param string[] $scopes
* @return $this
*/
public function addScopes(array $scopes): self
{
$this->scopes = array_merge($this->scopes, $scopes);
return $this;
}
/**
* Add a query builder directive keyed by the argument name.
*
* @param string $argumentName
* @param \Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective $argBuilderDirective
* @return $this
*/
public function addBuilderDirective(string $argumentName, ArgBuilderDirective $argBuilderDirective): self
{
$this->builderDirectives[$argumentName] = $argBuilderDirective;
return $this;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Illuminate\Http\Request;
use Nuwave\Lighthouse\Schema\Context;
use Nuwave\Lighthouse\Support\Contracts\CreatesContext;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class ContextFactory implements CreatesContext
{
/**
* Generate GraphQL context.
*
* @param \Illuminate\Http\Request $request
* @return \Nuwave\Lighthouse\Support\Contracts\GraphQLContext
*/
public function generate(Request $request): GraphQLContext
{
return new Context($request);
}
}
@@ -0,0 +1,127 @@
<?php
namespace Nuwave\Lighthouse\Execution\DataLoader;
use Exception;
use GraphQL\Deferred;
use Illuminate\Support\Collection;
use Nuwave\Lighthouse\Execution\GraphQLRequest;
use Nuwave\Lighthouse\Support\Traits\HandlesCompositeKey;
abstract class BatchLoader
{
use HandlesCompositeKey;
/**
* Keys to resolve.
*
* @var array
*/
protected $keys = [];
/**
* Map of loaded results.
*
* [key => resolvedValue]
*
* @var mixed[]
*/
protected $results = [];
/**
* Check if data has been loaded.
*
* @var bool
*/
protected $hasLoaded = false;
/**
* Return an instance of a BatchLoader for a specific field.
*
* @param string $loaderClass The class name of the concrete BatchLoader to instantiate
* @param mixed[] $pathToField Path to the GraphQL field from the root, is used as a key for BatchLoader instances
* @param mixed[] $constructorArgs Those arguments are passed to the constructor of the new BatchLoader instance
* @return static
*
* @throws \Exception
*/
public static function instance(string $loaderClass, array $pathToField, array $constructorArgs = []): self
{
// The path to the field serves as the unique key for the instance
$instanceName = static::instanceKey($pathToField);
// If we are resolving a batched query, we need to assign each
// query a uniquely indexed instance
/** @var \Nuwave\Lighthouse\Execution\GraphQLRequest $graphQLRequest */
$graphQLRequest = app(GraphQLRequest::class);
if ($graphQLRequest->isBatched()) {
$currentBatchIndex = $graphQLRequest->batchIndex();
$instanceName = "batch_{$currentBatchIndex}_{$instanceName}";
}
// Only register a new instance if it is not already bound
$instance = app()->bound($instanceName)
? app($instanceName)
: app()->instance(
$instanceName,
app()->makeWith($loaderClass, $constructorArgs)
);
if (! $instance instanceof self) {
throw new Exception(
"The given class '$loaderClass' must resolve to an instance of Nuwave\Lighthouse\Execution\DataLoader\BatchLoader"
);
}
return $instance;
}
/**
* Generate a unique key for the instance, using the path in the query.
*
* @param mixed[] $path
* @return string
*/
public static function instanceKey(array $path): string
{
return (new Collection($path))
->filter(function ($path): bool {
// Ignore numeric path entries, as those signify an array of fields.
// Combining the queries for those is the very purpose of the
// batch loader, so they must not be included.
return ! is_numeric($path);
})
->implode('_');
}
/**
* Load object by key.
*
* @param mixed $key
* @param mixed[] $metaInfo
* @return \GraphQL\Deferred
*/
public function load($key, array $metaInfo = []): Deferred
{
$key = $this->buildKey($key);
$this->keys[$key] = $metaInfo;
return new Deferred(function () use ($key) {
if (! $this->hasLoaded) {
$this->results = $this->resolve();
$this->hasLoaded = true;
}
return $this->results[$key];
});
}
/**
* Resolve the keys.
*
* The result has to be an associative array: [key => result]
*
* @return mixed[]
*/
abstract public function resolve(): array;
}
@@ -0,0 +1,410 @@
<?php
namespace Nuwave\Lighthouse\Execution\DataLoader;
use Closure;
use ReflectionClass;
use ReflectionMethod;
use Illuminate\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Relations\Relation;
use Nuwave\Lighthouse\Support\Traits\HandlesCompositeKey;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
class ModelRelationFetcher
{
use HandlesCompositeKey;
/**
* The parent models that relations should be loaded for.
*
* @var \Illuminate\Database\Eloquent\Collection
*/
protected $models;
/**
* The relations to be loaded. Same format as the `with` method in Eloquent builder.
*
* @var mixed[]
*/
protected $relations;
/**
* @param mixed $models The parent models that relations should be loaded for
* @param mixed[] $relations The relations to be loaded. Same format as the `with` method in Eloquent builder.
* @return void
*/
public function __construct($models, array $relations)
{
$this->setModels($models);
$this->setRelations($relations);
}
/**
* Set the relations to be loaded.
*
* @param array $relations
* @return $this
*/
public function setRelations(array $relations): self
{
// Parse and set the relations.
$this->relations = $this->newModelQuery()
->with($relations)
->getEagerLoads();
return $this;
}
/**
* Return a fresh instance of a query builder for the underlying model.
*
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function newModelQuery(): EloquentBuilder
{
return $this->models()
->first()
->newModelQuery();
}
/**
* Get all the underlying models.
*
* @return \Illuminate\Database\Eloquent\Collection<\Illuminate\Database\Eloquent\Model>
*/
public function models(): EloquentCollection
{
return $this->models;
}
/**
* Set one or more Model instances as an EloquentCollection.
*
* @param mixed $models
* @return $this
*/
protected function setModels($models): self
{
$this->models = $models instanceof EloquentCollection
? $models
: new EloquentCollection($models);
return $this;
}
/**
* Load all the relations of all the models.
*
* @return $this
*/
public function loadRelations(): self
{
$this->models->load($this->relations);
return $this;
}
/**
* Load all relations for the model, but constrain the query to the current page.
*
* @param int $perPage
* @param int $page
* @return $this
*/
public function loadRelationsForPage(int $perPage, int $page = 1): self
{
foreach ($this->relations as $name => $constraints) {
$this->loadRelationForPage($perPage, $page, $name, $constraints);
}
return $this;
}
/**
* Load only one page of relations of all the models.
*
* The relation will be converted to a `Paginator` instance.
*
* @param int $first
* @param int $page
* @param string $relationName
* @param \Closure $relationConstraints
* @return $this
*/
public function loadRelationForPage(int $first, int $page, string $relationName, Closure $relationConstraints): self
{
// Load the count of relations of models, this will be the `total` argument of `Paginator`.
// Be aware that this will reload all the models entirely with the count of their relations,
// which will bring extra DB queries, always prefer querying without pagination if possible.
$this->reloadModelsWithRelationCount();
$relations = $this
->buildRelationsFromModels($relationName, $relationConstraints)
->map(
function (Relation $relation) use ($first, $page) {
return $relation->forPage($page, $first);
}
);
/** @var \Illuminate\Database\Eloquent\Collection $relationModels */
$relationModels = $this
->unionAllRelationQueries($relations)
->get();
$this->hydratePivotRelation($relationName, $relationModels);
$this->loadDefaultWith($relationModels);
$this->associateRelationModels($relationName, $relationModels);
$this->convertRelationToPaginator($first, $page, $relationName);
return $this;
}
/**
* Reload the models to get the `{relation}_count` attributes of models set.
*
* @return $this
*/
public function reloadModelsWithRelationCount(): self
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = $this->models()
->first()
->newQuery()
->withCount($this->relations);
$ids = $this->getModelIds();
$reloadedModels = $query
->whereKey($ids)
->get()
->filter(function (Model $model) use ($ids): bool {
return in_array(
$model->getKey(),
$ids,
true
);
});
return $this->setModels($reloadedModels);
}
/**
* Extract the primary keys from the underlying models.
*
* @return mixed[]
*/
protected function getModelIds(): array
{
return $this->models
->map(function (Model $model) {
return $model->getKey();
})
->all();
}
/**
* Get queries to fetch relationships.
*
* @param string $relationName
* @param \Closure $relationConstraints
* @return \Illuminate\Support\Collection<\Illuminate\Database\Eloquent\Relations\Relation>
*/
protected function buildRelationsFromModels(string $relationName, Closure $relationConstraints): Collection
{
return $this->models->toBase()->map(
function (Model $model) use ($relationName, $relationConstraints): Relation {
$relation = $this->getRelationInstance($relationName);
$relation->addEagerConstraints([$model]);
// Call the constraints
$relationConstraints($relation, $model);
if (method_exists($relation, 'shouldSelect')) {
$shouldSelect = new ReflectionMethod(get_class($relation), 'shouldSelect');
$shouldSelect->setAccessible(true);
$select = $shouldSelect->invoke($relation, ['*']);
$relation->addSelect($select);
} elseif (method_exists($relation, 'getSelectColumns')) {
$getSelectColumns = new ReflectionMethod(get_class($relation), 'getSelectColumns');
$getSelectColumns->setAccessible(true);
$select = $getSelectColumns->invoke($relation, ['*']);
$relation->addSelect($select);
}
$relation->initRelation([$model], $relationName);
return $relation;
}
);
}
/**
* Load default eager loads.
*
* @param \Illuminate\Database\Eloquent\Collection<\Illuminate\Database\Eloquent\Model> $collection
* @return $this
*/
protected function loadDefaultWith(EloquentCollection $collection): self
{
if ($collection->isEmpty()) {
return $this;
}
$model = $collection->first();
$reflection = new ReflectionClass($model);
$withProperty = $reflection->getProperty('with');
$withProperty->setAccessible(true);
$with = array_filter((array) $withProperty->getValue($model), function ($relation) use ($model): bool {
return ! $model->relationLoaded($relation);
});
if (! empty($with)) {
$collection->load($with);
}
return $this;
}
/**
* This is the name that Eloquent gives to the attribute that contains the count.
*
* @see \Illuminate\Database\Eloquent\Concerns\QueriesRelationships->withCount()
*
* @param string $relationName
* @return string
*/
public function getRelationCountName(string $relationName): string
{
return Str::snake("{$relationName}_count");
}
/**
* Get an associative array of relations, keyed by the models primary key.
*
* @param string $relationName
* @return mixed[]
*/
public function getRelationDictionary(string $relationName): array
{
return $this->models
->mapWithKeys(
function (Model $model) use ($relationName): array {
return [$this->buildKey($model->getKey()) => $model->getRelation($relationName)];
}
)->all();
}
/**
* Merge all the relation queries into a single query with UNION ALL.
*
* @param \Illuminate\Support\Collection $relations
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function unionAllRelationQueries(Collection $relations): EloquentBuilder
{
return $relations
->reduce(
function (EloquentBuilder $builder, Relation $relation) {
return $builder->unionAll(
$relation->getQuery()
);
},
// Use the first query as the initial starting point
$relations->shift()->getQuery()
);
}
/**
* @param int $first
* @param int $page
* @param string $relationName
* @return $this
*/
protected function convertRelationToPaginator(int $first, int $page, string $relationName): self
{
$this->models->each(function (Model $model) use ($page, $first, $relationName): void {
$total = $model->getAttribute(
$this->getRelationCountName($relationName)
);
$paginator = app()->makeWith(
LengthAwarePaginator::class,
[
'items' => $model->getRelation($relationName),
'total' => $total,
'perPage' => $first,
'currentPage' => $page,
'options' => [],
]
);
$model->setRelation($relationName, $paginator);
});
return $this;
}
/**
* Associate the collection of all fetched relationModels back with their parents.
*
* @param string $relationName
* @param \Illuminate\Database\Eloquent\Collection $relationModels
* @return $this
*/
protected function associateRelationModels(string $relationName, EloquentCollection $relationModels): self
{
$relation = $this->getRelationInstance($relationName);
$relation->match(
$this->models->all(),
$relationModels,
$relationName
);
return $this;
}
/**
* Ensure the pivot relation is hydrated too, if it exists.
*
* @param string $relationName
* @param \Illuminate\Database\Eloquent\Collection<\Illuminate\Database\Eloquent\Model> $relationModels
* @return $this
*/
protected function hydratePivotRelation(string $relationName, EloquentCollection $relationModels): self
{
$relation = $this->getRelationInstance($relationName);
if ($relationModels->isNotEmpty() && method_exists($relation, 'hydratePivotRelation')) {
$hydrationMethod = new ReflectionMethod(get_class($relation), 'hydratePivotRelation');
$hydrationMethod->setAccessible(true);
$hydrationMethod->invoke($relation, $relationModels->all());
}
return $this;
}
/**
* Use the underlying model to instantiate a relation by name.
*
* @param string $relationName
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
protected function getRelationInstance(string $relationName): Relation
{
return $this
->newModelQuery()
->getRelation($relationName);
}
}
@@ -0,0 +1,122 @@
<?php
namespace Nuwave\Lighthouse\Execution\DataLoader;
use Illuminate\Support\Collection;
use GraphQL\Type\Definition\ResolveInfo;
class RelationBatchLoader extends BatchLoader
{
/**
* The name of the Eloquent relation to load.
*
* @var string
*/
protected $relationName;
/**
* The arguments that were passed to the field.
*
* @var mixed[]
*/
protected $args;
/**
* Names of the scopes that have to be called for the query.
*
* @var string[]
*/
protected $scopes;
/**
* The ResolveInfo of the currently executing field.
*
* @var \GraphQL\Type\Definition\ResolveInfo
*/
protected $resolveInfo;
/**
* Present when using pagination, the amount of rows to be fetched.
*
* @var int|null
*/
protected $first;
/**
* Present when using pagination, the page to be fetched.
*
* @var int|null
*/
protected $page;
/**
* @param string $relationName
* @param mixed[] $args
* @param string[] $scopes
* @param \GraphQL\Type\Definition\ResolveInfo $resolveInfo
* @param int|null $first
* @param int|null $page
* @return void
*/
public function __construct(
string $relationName,
array $args,
array $scopes,
ResolveInfo $resolveInfo,
?int $first = null,
?int $page = null
) {
$this->relationName = $relationName;
$this->args = $args;
$this->scopes = $scopes;
$this->resolveInfo = $resolveInfo;
$this->first = $first;
$this->page = $page;
}
/**
* Resolve the keys.
*
* @return mixed[]
*/
public function resolve(): array
{
$modelRelationFetcher = $this->getRelationFetcher();
if ($this->first !== null) {
$modelRelationFetcher->loadRelationsForPage($this->first, $this->page);
} else {
$modelRelationFetcher->loadRelations();
}
return $modelRelationFetcher->getRelationDictionary($this->relationName);
}
/**
* Construct a new instance of a relation fetcher.
*
* @return \Nuwave\Lighthouse\Execution\DataLoader\ModelRelationFetcher
*/
protected function getRelationFetcher(): ModelRelationFetcher
{
return new ModelRelationFetcher(
$this->getParentModels(),
[$this->relationName => function ($query) {
return $this->resolveInfo
->builder
->addScopes($this->scopes)
->apply($query, $this->args);
}]
);
}
/**
* Get the parents from the keys that are present on the BatchLoader.
*
* @return \Illuminate\Support\Collection<\Illuminate\Database\Eloquent\Model>
*/
protected function getParentModels(): Collection
{
return (new Collection($this->keys))->pluck('parent');
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Closure;
use Nuwave\Lighthouse\Exceptions\GenericException;
class ErrorBuffer
{
/**
* The gathered error messages.
*
* @var string[]
*/
protected $errors = [];
/**
* @var string
*/
protected $errorType;
/**
* @var \Closure
*/
protected $exceptionResolver;
/**
* ErrorBuffer constructor.
*
* @param string $errorType
* @param \Closure|null $exceptionResolver
* @return void
*/
public function __construct(string $errorType = 'generic', ?Closure $exceptionResolver = null)
{
$this->errorType = $errorType;
$this->exceptionResolver = $exceptionResolver ?? $this->defaultExceptionResolver();
}
/**
* Construct a default exception resolver.
*
* @return \Closure
*/
protected function defaultExceptionResolver(): Closure
{
return function (string $errorMessage): GenericException {
return (new GenericException($errorMessage))
->setExtensions([$this->errorType => $this->errors])
->setCategory($this->errorType);
};
}
/**
* Set the Exception resolver.
*
* @param \Closure $exceptionResolver
* @return $this
*/
public function setExceptionResolver(Closure $exceptionResolver): self
{
$this->exceptionResolver = $exceptionResolver;
return $this;
}
/**
* Resolve the exception by calling the exception handler with the given args.
*
* @param mixed ...$args
* @return mixed
*/
protected function resolveException(...$args)
{
return ($this->exceptionResolver)(...$args);
}
/**
* Push an error message into the buffer.
*
* @param string $errorMessage
* @param string|null $key
* @return $this
*/
public function push(string $errorMessage, ?string $key = null): self
{
if ($key === null) {
$this->errors[] = $errorMessage;
} else {
$this->errors[$key][] = $errorMessage;
}
return $this;
}
/**
* Flush the errors.
*
* @param string $errorMessage
* @return void
*
* @throws \Exception
*/
public function flush(string $errorMessage): void
{
if (! $this->hasErrors()) {
return;
}
$exception = $this->resolveException($errorMessage, $this);
$this->clearErrors();
throw $exception;
}
/**
* Reset the errors to an empty array.
*
* @return void
*/
public function clearErrors(): void
{
$this->errors = [];
}
/**
* Get the error type.
*
* @return string
*/
public function errorType(): string
{
return $this->errorType;
}
/**
* Set the error type.
*
* @param string $errorType
* @return $this
*/
public function setErrorType(string $errorType): self
{
$this->errorType = $errorType;
return $this;
}
/**
* Have we encountered any errors yet?
*
* @return bool
*/
public function hasErrors(): bool
{
return count($this->errors) > 0;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Closure;
use GraphQL\Error\Error;
interface ErrorHandler
{
/**
* This function receives all GraphQL errors and may alter them or do something else with them.
*
* Always call $next($error) to keep the Pipeline going. Multiple such Handlers may be registered
* as an array in the config.
*
* @param \GraphQL\Error\Error $error
* @param \Closure $next
* @return array
*/
public static function handle(Error $error, Closure $next): array;
}
@@ -0,0 +1,39 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Closure;
use GraphQL\Error\Error;
use Nuwave\Lighthouse\Exceptions\RendersErrorsExtensions;
class ExtensionErrorHandler implements ErrorHandler
{
/**
* Handle Exceptions that implement Nuwave\Lighthouse\Exceptions\RendersErrorsExtensions
* and add extra content from them to the 'extensions' key of the Error that is rendered
* to the User.
*
* @param \GraphQL\Error\Error $error
* @param \Closure $next
* @return array
*/
public static function handle(Error $error, Closure $next): array
{
$underlyingException = $error->getPrevious();
if ($underlyingException instanceof RendersErrorsExtensions) {
// Reconstruct the error, passing in the extensions of the underlying exception
$error = new Error(
$error->message,
$error->nodes,
$error->getSource(),
$error->getPositions(),
$error->getPath(),
$underlyingException,
$underlyingException->extensionsContent()
);
}
return $next($error);
}
}
@@ -0,0 +1,57 @@
<?php
namespace Nuwave\Lighthouse\Execution;
/**
* May be returned from listeners of the event:.
* @see \Nuwave\Lighthouse\Events\BuildExtensionsResponse
*/
class ExtensionsResponse
{
/**
* Will be used as the key in the response map.
*
* @var string
*/
protected $key;
/**
* JSON-encodable content of the extension.
*
* @var mixed
*/
protected $content;
/**
* ExtensionsResponse constructor.
*
* @param string $key
* @param mixed $content
* @return void
*/
public function __construct(string $key, $content)
{
$this->key = $key;
$this->content = $content;
}
/**
* Return the key of the extension.
*
* @return string
*/
public function key(): string
{
return $this->key;
}
/**
* Return the JSON-encodable content of the extension.
*
* @return mixed
*/
public function content()
{
return $this->content;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Nuwave\Lighthouse\Execution;
interface GraphQLRequest
{
/**
* Get the contained GraphQL query string.
*
* @return string
*/
public function query(): string;
/**
* Get the given variables for the query.
*
* @return mixed[]
*/
public function variables(): array;
/**
* Get the operationName of the current request.
*
* @return string|null
*/
public function operationName(): ?string;
/**
* Is the current query a batched query?
*
* @return bool
*/
public function isBatched(): bool;
/**
* Advance the batch index and indicate if there are more batches to process.
*
* @return bool
*/
public function advanceBatchIndex(): bool;
/**
* Get the index of the current batch.
*
* Returns null if we are not resolving a batched query.
*
* @return int|null
*/
public function batchIndex(): ?int;
}
@@ -0,0 +1,54 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Illuminate\Support\Arr;
use Illuminate\Validation\Validator;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class GraphQLValidator extends Validator
{
/**
* Get the root object that was passed to the field that is being validated.
*
* @return mixed
*/
public function getRoot()
{
return Arr::get($this->customAttributes, 'root');
}
/**
* Get the context that was passed to the field that is being validated.
*
* @return \Nuwave\Lighthouse\Support\Contracts\GraphQLContext
*/
public function getContext(): GraphQLContext
{
return Arr::get($this->customAttributes, 'context');
}
/**
* Get the resolve info that was passed to the field that is being validated.
*
* @return \GraphQL\Type\Definition\ResolveInfo
*/
public function getResolveInfo(): ResolveInfo
{
return Arr::get($this->customAttributes, 'resolveInfo');
}
/**
* Return the dot separated path of the field that is being validated.
*
* @return string
*/
public function getFieldPath(): string
{
return implode(
'.',
$this->getResolveInfo()->path
);
}
}
@@ -0,0 +1,75 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Illuminate\Http\Request;
class LighthouseRequest extends BaseRequest
{
/**
* The incoming HTTP request.
*
* @var \Illuminate\Http\Request
*/
protected $request;
/**
* LighthouseRequest constructor.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
public function __construct(Request $request)
{
$this->request = $request;
// If the request has neither a query, nor an operationName,
// we assume we are resolving a batched query.
if (! $request->hasAny('query', 'operationName')) {
$this->batchIndex = 0;
}
}
/**
* Get the given variables for the query.
*
* @return mixed[]
*/
public function variables(): array
{
$variables = $this->fieldValue('variables');
// In case we are resolving a GET request, variables
// are sent as a JSON encoded string
if (is_string($variables)) {
return json_decode($variables, true) ?? [];
}
// If this is a POST request, Laravel already decoded the input for us
return $variables ?? [];
}
/**
* Are there more batched queries to process?
*
* @return bool
*/
protected function hasMoreBatches(): bool
{
return count($this->request->input()) - 1 > $this->batchIndex;
}
/**
* Get the contents of a field by key.
*
* This is expected to take batched requests into consideration.
*
* @param string $key
* @return array|string|null
*/
protected function fieldValue(string $key)
{
return $this->request->input($key)
?? $this->request->input("{$this->batchIndex}.{$key}");
}
}
@@ -0,0 +1,93 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Illuminate\Support\Arr;
use Illuminate\Http\Request;
use GraphQL\Error\InvariantViolation;
class MultipartFormRequest extends BaseRequest
{
/**
* One or more operations, consisting of query, variables and operationName.
*
* https://github.com/jaydenseric/graphql-multipart-request-spec#single-file
*
* @var mixed[]
*/
protected $operations;
/**
* MultipartFormRequest constructor.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
public function __construct(Request $request)
{
if (! $request->has('map')) {
throw new InvariantViolation(
'Could not find a valid map, be sure to conform to GraphQL multipart request specification: https://github.com/jaydenseric/graphql-multipart-request-spec'
);
}
$this->operations = json_decode(
$request->input('operations'),
true
);
// If operations is 0-indexed, we assume we are resolving a batched query
if (isset($this->operations[0])) {
$this->batchIndex = 0;
}
$map = json_decode($request->input('map'), true);
/**
* @var string
* @var array $operationsPaths
*/
foreach ($map as $fileKey => $operationsPaths) {
$file = $request->file($fileKey);
/** @var string $operationsPath */
foreach ($operationsPaths as $operationsPath) {
Arr::set($this->operations, $operationsPath, $file);
}
}
}
/**
* Get the given variables for the query.
*
* @return mixed[]
*/
public function variables(): array
{
return $this->fieldValue('variables') ?? [];
}
/**
* If we are dealing with a batched request, this gets the
* contents of the currently resolving batch index.
*
* @param string $key
* @return array|string|null
*/
protected function fieldValue(string $key)
{
return $this->isBatched()
? Arr::get($this->operations, $this->batchIndex.'.'.$key)
: $this->operations[$key] ?? null;
}
/**
* Are there more batched queries to process?
*
* @return bool
*/
protected function hasMoreBatches(): bool
{
return count($this->operations) - 1 > $this->batchIndex;
}
}
@@ -0,0 +1,426 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use ReflectionClass;
use ReflectionNamedType;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class MutationExecutor
{
/**
* Execute a create mutation.
*
* @param \Illuminate\Database\Eloquent\Model $model
* An empty instance of the model that should be created
* @param \Illuminate\Support\Collection $args
* The corresponding slice of the input arguments for creating this model
* @param \Illuminate\Database\Eloquent\Relations\Relation|null $parentRelation
* If we are in a nested create, we can use this to associate the new model to its parent
* @return \Illuminate\Database\Eloquent\Model
*/
public static function executeCreate(Model $model, Collection $args, ?Relation $parentRelation = null): Model
{
$reflection = new ReflectionClass($model);
[$hasMany, $remaining] = self::partitionArgsByRelationType($reflection, $args, HasMany::class);
[$morphMany, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, MorphMany::class);
[$hasOne, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, HasOne::class);
[$morphOne, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, MorphOne::class);
[$belongsToMany, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, BelongsToMany::class);
[$morphToMany, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, MorphToMany::class);
$model = self::saveModelWithPotentialParent($model, $remaining, $parentRelation);
$createOneToMany = function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\HasMany|\Illuminate\Database\Eloquent\Relations\MorphMany $relation */
$relation = $model->{$relationName}();
if (isset($nestedOperations['create'])) {
self::handleMultiRelationCreate(new Collection($nestedOperations['create']), $relation);
}
};
$hasMany->each($createOneToMany);
$morphMany->each($createOneToMany);
$createOneToOne = function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\HasOne|\Illuminate\Database\Eloquent\Relations\MorphOne $relation */
$relation = $model->{$relationName}();
if (isset($nestedOperations['create'])) {
self::handleSingleRelationCreate(new Collection($nestedOperations['create']), $relation);
}
};
$hasOne->each($createOneToOne);
$morphOne->each($createOneToOne);
$createManyToMany = function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\BelongsToMany|\Illuminate\Database\Eloquent\Relations\MorphToMany $relation */
$relation = $model->{$relationName}();
if (isset($nestedOperations['sync'])) {
$relation->sync($nestedOperations['sync']);
}
if (isset($nestedOperations['create'])) {
self::handleMultiRelationCreate(new Collection($nestedOperations['create']), $relation);
}
if (isset($nestedOperations['update'])) {
(new Collection($nestedOperations['update']))->each(function ($singleValues) use ($relation): void {
self::executeUpdate(
$relation->getModel()->newInstance(),
new Collection($singleValues),
$relation
);
});
}
if (isset($nestedOperations['connect'])) {
$relation->attach($nestedOperations['connect']);
}
};
$belongsToMany->each($createManyToMany);
$morphToMany->each($createManyToMany);
return $model;
}
/**
* Save a model that maybe has a parent.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Illuminate\Support\Collection $args
* @param \Illuminate\Database\Eloquent\Relations\Relation|null $parentRelation
* @return \Illuminate\Database\Eloquent\Model
*/
protected static function saveModelWithPotentialParent(Model $model, Collection $args, ?Relation $parentRelation = null): Model
{
$reflection = new ReflectionClass($model);
// Extract $morphTo first, as MorphTo extends BelongsTo
[$morphTo, $remaining] = self::partitionArgsByRelationType(
$reflection,
$args,
MorphTo::class
);
[$belongsTo, $remaining] = self::partitionArgsByRelationType(
$reflection,
$remaining,
BelongsTo::class
);
// Use all the remaining attributes and fill the model
$model->fill(
$remaining->all()
);
$belongsTo->each(function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\BelongsTo $relation */
$relation = $model->{$relationName}();
if (isset($nestedOperations['create'])) {
$belongsToModel = self::executeCreate(
$relation->getModel()->newInstance(),
new Collection($nestedOperations['create'])
);
$relation->associate($belongsToModel);
}
if (isset($nestedOperations['connect'])) {
$relation->associate($nestedOperations['connect']);
}
if (isset($nestedOperations['update'])) {
$belongsToModel = self::executeUpdate(
$relation->getModel()->newInstance(),
new Collection($nestedOperations['update'])
);
$relation->associate($belongsToModel);
}
// We proceed with disconnecting/deleting only if the given $values is truthy.
// There is no other information to be passed when issuing those operations,
// but GraphQL forces us to pass some value. It would be unintuitive for
// the end user if the given value had no effect on the execution.
if ($nestedOperations['disconnect'] ?? false) {
$relation->dissociate();
}
if ($nestedOperations['delete'] ?? false) {
$relation->delete();
}
});
$morphTo->each(function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\MorphTo $relation */
$relation = $model->{$relationName}();
// TODO implement create and update once we figure out how to do polymorphic input types https://github.com/nuwave/lighthouse/issues/900
if (isset($nestedOperations['connect'])) {
$connectArgs = $nestedOperations['connect'];
$morphToModel = $relation->createModelByType(
(string) $connectArgs['type']
);
$morphToModel->setAttribute(
$morphToModel->getKeyName(),
$connectArgs['id']
);
$relation->associate($morphToModel);
}
// We proceed with disconnecting/deleting only if the given $values is truthy.
// There is no other information to be passed when issuing those operations,
// but GraphQL forces us to pass some value. It would be unintuitive for
// the end user if the given value had no effect on the execution.
if ($nestedOperations['disconnect'] ?? false) {
$relation->dissociate();
}
if ($nestedOperations['delete'] ?? false) {
$relation->delete();
}
});
if ($parentRelation && ! $parentRelation instanceof BelongsToMany) {
// If we are already resolving a nested create, we might
// already have an instance of the parent relation available.
// In that case, use it to set the current model as a child.
$parentRelation->save($model);
return $model;
}
$model->save();
if ($parentRelation instanceof BelongsToMany) {
$parentRelation->syncWithoutDetaching($model);
}
return $model;
}
/**
* Handle the creation with multiple relations.
*
* @param \Illuminate\Support\Collection $multiValues
* @param \Illuminate\Database\Eloquent\Relations\Relation $relation
* @return void
*/
protected static function handleMultiRelationCreate(Collection $multiValues, Relation $relation): void
{
$multiValues->each(function ($singleValues) use ($relation): void {
self::handleSingleRelationCreate(new Collection($singleValues), $relation);
});
}
/**
* Handle the creation with a single relation.
*
* @param \Illuminate\Support\Collection $singleValues
* @param \Illuminate\Database\Eloquent\Relations\Relation $relation
* @return void
*/
protected static function handleSingleRelationCreate(Collection $singleValues, Relation $relation): void
{
self::executeCreate(
$relation->getModel()->newInstance(),
$singleValues,
$relation
);
}
/**
* Execute an update mutation.
*
* @param \Illuminate\Database\Eloquent\Model $model
* An empty instance of the model that should be updated
* @param \Illuminate\Support\Collection $args
* The corresponding slice of the input arguments for updating this model
* @param \Illuminate\Database\Eloquent\Relations\Relation|null $parentRelation
* If we are in a nested update, we can use this to associate the new model to its parent
* @return \Illuminate\Database\Eloquent\Model
*/
public static function executeUpdate(Model $model, Collection $args, ?Relation $parentRelation = null): Model
{
$id = $args->pull('id')
?? $args->pull(
$model->getKeyName()
);
$model = $model->newQuery()->findOrFail($id);
$reflection = new ReflectionClass($model);
[$hasMany, $remaining] = self::partitionArgsByRelationType($reflection, $args, HasMany::class);
[$morphMany, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, MorphMany::class);
[$hasOne, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, HasOne::class);
[$morphOne, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, MorphOne::class);
[$belongsToMany, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, BelongsToMany::class);
[$morphToMany, $remaining] = self::partitionArgsByRelationType($reflection, $remaining, MorphToMany::class);
$model = self::saveModelWithPotentialParent($model, $remaining, $parentRelation);
$updateOneToMany = function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\HasMany|\Illuminate\Database\Eloquent\Relations\MorphMany $relation */
$relation = $model->{$relationName}();
if (isset($nestedOperations['create'])) {
self::handleMultiRelationCreate(new Collection($nestedOperations['create']), $relation);
}
if (isset($nestedOperations['update'])) {
(new Collection($nestedOperations['update']))->each(function ($singleValues) use ($relation): void {
self::executeUpdate(
$relation->getModel()->newInstance(),
new Collection($singleValues),
$relation
);
});
}
if (isset($nestedOperations['delete'])) {
$relation->getModel()::destroy($nestedOperations['delete']);
}
};
$hasMany->each($updateOneToMany);
$morphMany->each($updateOneToMany);
$updateOneToOne = function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\HasOne|\Illuminate\Database\Eloquent\Relations\MorphOne $relation */
$relation = $model->{$relationName}();
if (isset($nestedOperations['create'])) {
self::handleSingleRelationCreate(new Collection($nestedOperations['create']), $relation);
}
if (isset($nestedOperations['update'])) {
self::executeUpdate(
$relation->getModel()->newInstance(),
new Collection($nestedOperations['update']),
$relation
);
}
if (isset($nestedOperations['delete'])) {
$relation->getModel()::destroy($nestedOperations['delete']);
}
};
$hasOne->each($updateOneToOne);
$morphOne->each($updateOneToOne);
$updateManyToMany = function (array $nestedOperations, string $relationName) use ($model): void {
/** @var \Illuminate\Database\Eloquent\Relations\BelongsToMany|\Illuminate\Database\Eloquent\Relations\MorphToMany $relation */
$relation = $model->{$relationName}();
if (isset($nestedOperations['sync'])) {
$relation->sync($nestedOperations['sync']);
}
if (isset($nestedOperations['create'])) {
self::handleMultiRelationCreate(new Collection($nestedOperations['create']), $relation);
}
if (isset($nestedOperations['update'])) {
(new Collection($nestedOperations['update']))->each(function ($singleValues) use ($relation): void {
self::executeUpdate(
$relation->getModel()->newInstance(),
new Collection($singleValues),
$relation
);
});
}
if (isset($nestedOperations['delete'])) {
$relation->detach($nestedOperations['delete']);
$relation->getModel()::destroy($nestedOperations['delete']);
}
if (isset($nestedOperations['connect'])) {
$relation->attach($nestedOperations['connect']);
}
if (isset($nestedOperations['disconnect'])) {
$relation->detach($nestedOperations['disconnect']);
}
};
$belongsToMany->each($updateManyToMany);
$morphToMany->each($updateManyToMany);
return $model;
}
/**
* Extract all the arguments that correspond to a relation of a certain type on the model.
*
* For example, if the args input looks like this:
*
* [
* 'comments' =>
* ['foo' => 'Bar'],
* 'name' => 'Ralf',
* ]
*
* and the model has a method "comments" that returns a HasMany relationship,
* the result will be:
* [
* [
* 'comments' =>
* ['foo' => 'Bar'],
* ],
* [
* 'name' => 'Ralf',
* ]
* ]
*
* @param \ReflectionClass $modelReflection
* @param \Illuminate\Support\Collection $args
* @param string $relationClass
* @return \Illuminate\Support\Collection [relationshipArgs, remainingArgs]
*/
protected static function partitionArgsByRelationType(ReflectionClass $modelReflection, Collection $args, string $relationClass): Collection
{
return $args->partition(
function ($value, string $key) use ($modelReflection, $relationClass): bool {
if (! $modelReflection->hasMethod($key)) {
return false;
}
$relationMethodCandidate = $modelReflection->getMethod($key);
if (! $returnType = $relationMethodCandidate->getReturnType()) {
return false;
}
if (! $returnType instanceof ReflectionNamedType) {
return false;
}
return is_a($returnType->getName(), $relationClass, true);
}
);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use GraphQL\Language\Parser;
use GraphQL\Language\AST\Node;
use Illuminate\Support\Collection;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Language\AST\FragmentDefinitionNode;
use GraphQL\Language\AST\OperationDefinitionNode;
class QueryAST
{
/**
* The definitions contained in the AST of an incoming query.
*
* @var \Illuminate\Support\Collection
*/
protected $definitions;
/**
* @param \GraphQL\Language\AST\DocumentNode $documentNode
* @return void
*/
public function __construct(DocumentNode $documentNode)
{
$this->definitions = new Collection($documentNode->definitions);
}
/**
* Create a new instance from a query string.
*
* @param string $query
* @return static
*/
public static function fromSource(string $query): self
{
return new static(
Parser::parse($query)
);
}
/**
* Get all operation definitions.
*
* @return \Illuminate\Support\Collection<\GraphQL\Language\AST\OperationDefinitionNode>
*/
public function operationDefinitions(): Collection
{
return $this->definitionsByType(OperationDefinitionNode::class);
}
/**
* Get all fragment definitions.
*
* @return \Illuminate\Support\Collection<\GraphQL\Language\AST\FragmentDefinitionNode>
*/
public function fragmentDefinitions(): Collection
{
return $this->definitionsByType(FragmentDefinitionNode::class);
}
/**
* Get all definitions of a given type.
*
* @param string $typeClassName
* @return \Illuminate\Support\Collection
*/
protected function definitionsByType(string $typeClassName): Collection
{
return $this->definitions
->filter(function (Node $node) use ($typeClassName): bool {
return $node instanceof $typeClassName;
});
}
}
@@ -0,0 +1,20 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use Symfony\Component\HttpFoundation\Response;
use Nuwave\Lighthouse\Support\Contracts\CreatesResponse;
class SingleResponse implements CreatesResponse
{
/**
* Create a HTTP response from the final result.
*
* @param mixed[] $result
* @return \Symfony\Component\HttpFoundation\Response
*/
public function createResponse(array $result): Response
{
return response($result);
}
}
@@ -0,0 +1,69 @@
<?php
namespace Nuwave\Lighthouse\Execution\Utils;
use Nuwave\Lighthouse\Support\Contracts\GlobalId as GlobalIdContract;
/**
* The default encoding of global IDs in Lighthouse.
*
* The way that IDs are generated basically works like this:
*
* 1. Take the name of a type, e.g. "User" and an ID, e.g. 123
* 2. Glue them together, separated by a colon, e.g. "User:123"
* 3. base64_encode the result
*
* This can then be reversed to uniquely identify an entity in our
* schema, just by looking at a single ID.
*/
class GlobalId implements GlobalIdContract
{
/**
* Glue together a type and an id to create a global id.
*
* @param string $type
* @param string|int $id
* @return string
*/
public function encode(string $type, $id): string
{
return base64_encode($type.':'.$id);
}
/**
* Split a global id into the type and the id it contains.
*
* @param string $globalID
* @return array Contains [$type, $id], e.g. ['User', '123']
*/
public function decode(string $globalID): array
{
return explode(':', base64_decode($globalID));
}
/**
* Decode the Global ID and get just the ID.
*
* @param string $globalID
* @return string
*/
public function decodeID(string $globalID): string
{
[$type, $id] = self::decode($globalID);
return $id;
}
/**
* Decode the Global ID and get just the type.
*
* @param string $globalID
* @return string
*/
public function decodeType(string $globalID): string
{
[$type, $id] = self::decode($globalID);
return $type;
}
}
@@ -0,0 +1,64 @@
<?php
namespace Nuwave\Lighthouse\Execution\Utils;
use Throwable;
use InvalidArgumentException;
use Nuwave\Lighthouse\GraphQL;
use Nuwave\Lighthouse\Subscriptions\SubscriptionRegistry;
use Nuwave\Lighthouse\Subscriptions\Contracts\BroadcastsSubscriptions;
use Nuwave\Lighthouse\Subscriptions\Contracts\SubscriptionExceptionHandler;
class Subscription
{
/**
* Broadcast subscription to client(s).
*
* @param string $subscriptionField
* @param mixed $root
* @param bool|null $shouldQueue
* @return void
*
* @throws \InvalidArgumentException
*/
public static function broadcast(string $subscriptionField, $root, ?bool $shouldQueue = null): void
{
// Ensure we have a schema and registered subscription fields
// in the event we are calling this method in code.
/** @var \Nuwave\Lighthouse\GraphQL $graphQL */
$graphQL = app(GraphQL::class);
$graphQL->prepSchema();
/** @var \Nuwave\Lighthouse\Subscriptions\SubscriptionRegistry $registry */
$registry = app(SubscriptionRegistry::class);
if (! $registry->has($subscriptionField)) {
throw new InvalidArgumentException("No subscription field registered for {$subscriptionField}");
}
/** @var \Nuwave\Lighthouse\Subscriptions\Contracts\BroadcastsSubscriptions $broadcaster */
$broadcaster = app(BroadcastsSubscriptions::class);
$shouldQueue = $shouldQueue === null
? config('lighthouse.subscriptions.queue_broadcasts', false)
: $shouldQueue;
$method = $shouldQueue
? 'queueBroadcast'
: 'broadcast';
try {
call_user_func(
[$broadcaster, $method],
$registry->subscription($subscriptionField),
$subscriptionField,
$root
);
} catch (Throwable $e) {
/** @var \Nuwave\Lighthouse\Subscriptions\Contracts\SubscriptionExceptionHandler $exceptionHandler */
$exceptionHandler = app(SubscriptionExceptionHandler::class);
$exceptionHandler->handleBroadcastError($e);
}
}
}