This commit is contained in:
Your Name
2021-07-26 19:46:18 +02:00
parent e7a49138bb
commit aae17f10a6
818 changed files with 70695 additions and 16408 deletions
@@ -0,0 +1,79 @@
<?php
namespace Nuwave\Lighthouse\ClientDirectives;
use GraphQL\Executor\Values;
use GraphQL\Type\Definition\Directive;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Exceptions\DefinitionException;
use Nuwave\Lighthouse\GraphQL;
use Nuwave\Lighthouse\Schema\SchemaBuilder;
/**
* Provides information about where client directives
* were placed in the query and what arguments were given to them.
*
* TODO implement accessors for other locations https://spec.graphql.org/draft/#ExecutableDirectiveLocation
*/
class ClientDirective
{
/**
* @var string
*/
protected $name;
/**
* @var \GraphQL\Type\Definition\Directive|null
*/
protected $definition;
public function __construct(string $name)
{
$this->name = $name;
}
/**
* Get the given values for a client directive.
*
* This returns an array of the given arguments for all field nodes.
* The number of items in the returned result will always be equivalent
* to the number of field nodes, each having one of the following values:
* - When a field node does not have the directive on it: null
* - When the directive is present but has no arguments: []
* - When the directive is present with arguments: an associative array
*
* @return array<array<string, mixed>|null>
*/
public function forField(ResolveInfo $resolveInfo): array
{
$directive = $this->definition();
$arguments = [];
foreach ($resolveInfo->fieldNodes as $fieldNode) {
$arguments [] = Values::getDirectiveValues($directive, $fieldNode, $resolveInfo->variableValues);
}
return $arguments;
}
/**
* @throws \Nuwave\Lighthouse\Exceptions\DefinitionException
*/
protected function definition(): Directive
{
if ($this->definition !== null) {
return $this->definition;
}
/** @var \Nuwave\Lighthouse\Schema\SchemaBuilder $schemaBuilder */
$schemaBuilder = app(SchemaBuilder::class);
$schema = $schemaBuilder->schema();
$definition = $schema->getDirective($this->name);
if ($definition === null) {
throw new DefinitionException("Missing a schema definition for the client directive $this->name");
}
return $this->definition = $definition;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Nuwave\Lighthouse\Console;
use Illuminate\Console\Command;
use Nuwave\Lighthouse\Schema\AST\ASTBuilder;
class CacheCommand extends Command
{
protected $name = 'lighthouse:cache';
protected $description = 'Compile the GraphQL schema and cache it.';
public function handle(ASTBuilder $builder): void
{
$builder->documentAST();
$this->info('GraphQL schema cache created.');
}
}
+31 -19
View File
@@ -3,33 +3,45 @@
namespace Nuwave\Lighthouse\Console;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Contracts\Cache\Factory as CacheFactory;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Filesystem\Filesystem;
use Nuwave\Lighthouse\Exceptions\UnknownCacheVersionException;
class ClearCacheCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
* TODO remove once we require Laravel 6 which allows $this->call(ClearCacheCommand::class).
*/
protected $signature = 'lighthouse:clear-cache';
const NAME = 'lighthouse:clear-cache';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear the cache for the GraphQL AST.';
protected $name = self::NAME;
/**
* Execute the console command.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
* @return void
*/
public function handle(Repository $cache): void
protected $description = 'Clear the GraphQL schema cache.';
public function handle(ConfigRepository $config): void
{
$cache->forget(config('lighthouse.cache.key'));
$version = $config->get('lighthouse.cache.version', 1);
switch ($version) {
case 1:
/** @var \Illuminate\Contracts\Cache\Factory $cacheFactory */
$cacheFactory = app(CacheFactory::class);
$cacheFactory
->store($config->get('lighthouse.cache.store'))
->forget($config->get('lighthouse.cache.key'));
break;
case 2:
/** @var \Illuminate\Filesystem\Filesystem $filesystem */
$filesystem = app(Filesystem::class);
$path = $config->get('lighthouse.cache.path')
?? base_path('bootstrap/cache/lighthouse-schema.php');
$filesystem->delete($path);
break;
default:
throw new UnknownCacheVersionException($version);
}
$this->info('GraphQL AST schema cache deleted.');
}
@@ -0,0 +1,228 @@
<?php
namespace Nuwave\Lighthouse\Console;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgDirectiveForArray;
use Nuwave\Lighthouse\Support\Contracts\ArgManipulator;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
use Nuwave\Lighthouse\Support\Contracts\ArgTransformerDirective;
use Nuwave\Lighthouse\Support\Contracts\FieldManipulator;
use Nuwave\Lighthouse\Support\Contracts\FieldMiddleware;
use Nuwave\Lighthouse\Support\Contracts\FieldResolver;
use Nuwave\Lighthouse\Support\Contracts\TypeExtensionManipulator;
use Nuwave\Lighthouse\Support\Contracts\TypeManipulator;
use Nuwave\Lighthouse\Support\Contracts\TypeMiddleware;
use Nuwave\Lighthouse\Support\Contracts\TypeResolver;
use Symfony\Component\Console\Input\InputOption;
class DirectiveCommand extends LighthouseGeneratorCommand
{
const ARGUMENT_INTERFACES = [
ArgTransformerDirective::class,
ArgBuilderDirective::class,
ArgResolver::class,
ArgManipulator::class,
];
const FIELD_INTERFACES = [
FieldResolver::class,
FieldMiddleware::class,
FieldManipulator::class,
];
const TYPE_INTERFACES = [
TypeManipulator::class,
TypeMiddleware::class,
TypeResolver::class,
TypeExtensionManipulator::class,
];
protected $name = 'lighthouse:directive';
protected $description = 'Create a class for a custom schema directive.';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Directive';
/**
* The required imports.
*
* @var \Illuminate\Support\Collection<string>
*/
protected $imports;
/**
* The implemented interfaces.
*
* @var \Illuminate\Support\Collection<string>
*/
protected $implements;
/**
* The method stubs.
*
* @var \Illuminate\Support\Collection<string>
*/
protected $methods;
protected function getNameInput(): string
{
return parent::getNameInput().'Directive';
}
protected function namespaceConfigKey(): string
{
return 'directives';
}
/**
* @param string $name
*
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
*/
protected function buildClass($name): string
{
$this->imports = new Collection();
$this->implements = new Collection();
$this->methods = new Collection();
$stub = parent::buildClass($name);
$forType = $this->option('type');
$forField = $this->option('field');
$forArgument = $this->option('argument');
if (! $forType && ! $forField && ! $forArgument) {
throw new \Exception('Must specify at least one of: --type, --field or --argument');
}
if ($forType) {
$this->askForInterfaces(self::TYPE_INTERFACES);
}
if ($forField) {
$this->askForInterfaces(self::FIELD_INTERFACES);
}
if ($forArgument) {
// Arg directives always either implement ArgDirective or ArgDirectiveForArray.
if ($this->confirm('Will your argument directive apply to a list of items?')) {
$this->implementInterface(ArgDirectiveForArray::class);
} else {
$this->implementInterface(ArgDirective::class);
}
$this->askForInterfaces(self::ARGUMENT_INTERFACES);
}
$stub = str_replace(
'{{ imports }}',
$this->imports
->filter()
->unique()
->implode("\n"),
$stub
);
$stub = str_replace(
'{{ methods }}',
$this->methods->implode("\n"),
$stub
);
return str_replace(
'{{ implements }}',
$this->implements->implode(', '),
$stub
);
}
/**
* Ask the user if the directive should implement any of the given interfaces.
*
* @param array<class-string> $interfaces
*/
protected function askForInterfaces(array $interfaces): void
{
foreach ($interfaces as $interface) {
if ($this->confirm("Should the directive implement the {$this->shortName($interface)} middleware?")) {
$this->implementInterface($interface);
}
}
}
/**
* @param class-string $interface
*/
protected function shortName(string $interface): string
{
return Str::afterLast($interface, '\\');
}
/**
* @param class-string $interface
*/
protected function implementInterface(string $interface): void
{
$shortName = $this->shortName($interface);
$this->implements->push($shortName);
$this->imports->push("use {$interface};");
if ($imports = $this->interfaceImports($shortName)) {
$imports = explode("\n", $imports);
$this->imports->push(...$imports);
}
if ($methods = $this->interfaceMethods($shortName)) {
$this->methods->push($methods);
}
}
protected function getStub(): string
{
return __DIR__.'/stubs/directive.stub';
}
protected function interfaceMethods(string $interface): ?string
{
return $this->getFileIfExists(
__DIR__.'/stubs/directives/'.Str::snake($interface).'_methods.stub'
);
}
protected function interfaceImports(string $interface): ?string
{
return $this->getFileIfExists(
__DIR__.'/stubs/directives/'.Str::snake($interface).'_imports.stub'
);
}
protected function getFileIfExists(string $path): ?string
{
if (! $this->files->exists($path)) {
return null;
}
return $this->files->get($path);
}
/**
* @return array<int, array<int, mixed>>
*/
protected function getOptions(): array
{
return [
['type', null, InputOption::VALUE_NONE, 'Create a directive that can be applied to types.'],
['field', null, InputOption::VALUE_NONE, 'Create a directive that can be applied to fields.'],
['argument', null, InputOption::VALUE_NONE, 'Create a directive that can be applied to arguments.'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace Nuwave\Lighthouse\Console;
use Symfony\Component\Console\Input\InputOption;
abstract class FieldGeneratorCommand extends LighthouseGeneratorCommand
{
protected function getStub(): string
{
$stub = $this->option('full')
? 'field_full'
: 'field_simple';
return __DIR__."/stubs/{$stub}.stub";
}
/**
* @return array<int, array<int, mixed>>
*/
protected function getOptions(): array
{
return [
['full', 'F', InputOption::VALUE_NONE, 'Include the seldom needed resolver arguments $context and $resolveInfo'],
];
}
}
+124 -89
View File
@@ -2,88 +2,91 @@
namespace Nuwave\Lighthouse\Console;
use Illuminate\Console\Command;
use GraphQL\Type\Definition\Type;
use GraphQL\Utils\SchemaPrinter;
use HaydenPierce\ClassFinder\ClassFinder;
use Nuwave\Lighthouse\Schema\AST\PartialParser;
use Nuwave\Lighthouse\Schema\DirectiveNamespacer;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Schema\DirectiveLocator;
use Nuwave\Lighthouse\Schema\TypeRegistry;
use Nuwave\Lighthouse\Support\Contracts\Directive;
use Nuwave\Lighthouse\Support\Contracts\DefinedDirective;
use HaydenPierce\ClassFinder\Exception\ClassFinderException;
class IdeHelperCommand extends Command
{
const GENERATED_NOTICE = <<<'SDL'
public const OPENING_PHP_TAG = /** @lang GraphQL */ "<?php\n";
public const GENERATED_NOTICE = /** @lang GraphQL */ <<<'GRAPHQL'
# File generated by "php artisan lighthouse:ide-helper".
# Do not edit this file directly.
# This file should be ignored by git.
# This file should be ignored by git as it can be autogenerated.
SDL;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lighthouse:ide-helper';
GRAPHQL;
/**
* The console command description.
*
* @var string
*/
protected $description = 'Gather all schema directive definitions and write them to a file.';
protected $name = 'lighthouse:ide-helper';
/**
* Execute the console command.
*
* @param \Nuwave\Lighthouse\Schema\DirectiveNamespacer $directiveNamespaces
* @return int
*/
public function handle(DirectiveNamespacer $directiveNamespaces): int
protected $description = 'Create IDE helper files to improve type checking and autocompletion.';
public function handle(DirectiveLocator $directiveLocator, TypeRegistry $typeRegistry): int
{
if (! class_exists('HaydenPierce\ClassFinder\ClassFinder')) {
$this->error(
"This command requires haydenpierce/class-finder. Install it by running:\n"
."\n"
." composer require --dev haydenpierce/class-finder\n"
);
$this->schemaDirectiveDefinitions($directiveLocator);
$this->programmaticTypes($typeRegistry);
$this->phpIdeHelper();
return 1;
}
$directiveClasses = $this->scanForDirectives(
$directiveNamespaces->gather()
);
$schema = $this->buildSchemaString($directiveClasses);
$filePath = static::filePath();
file_put_contents($filePath, $schema);
$this->info("Wrote schema directive definitions to $filePath.");
$this->info("\nIt is recommended to add them to your .gitignore file.");
return 0;
}
/**
* Create and write schema directive definitions to a file.
*/
protected function schemaDirectiveDefinitions(DirectiveLocator $directiveLocator): void
{
$schema = /** @lang GraphQL */ <<<'GRAPHQL'
"""
Placeholder type for various directives such as `@orderBy`.
Will be replaced by a generated type during schema manipulation.
"""
scalar _
GRAPHQL;
$directiveClasses = $this->scanForDirectives(
$directiveLocator->namespaces()
);
foreach ($directiveClasses as $directiveClass) {
$definition = $this->define($directiveClass);
$schema .= /** @lang GraphQL */ <<<GRAPHQL
# Directive class: $directiveClass
$definition
GRAPHQL;
}
$filePath = static::schemaDirectivesPath();
\Safe\file_put_contents($filePath, self::GENERATED_NOTICE.$schema);
$this->info("Wrote schema directive definitions to $filePath.");
}
/**
* Scan the given namespaces for directive classes.
*
* @param string[] $directiveNamespaces
* @return string[]
* @param array<string> $directiveNamespaces
* @return array<string, class-string<\Nuwave\Lighthouse\Support\Contracts\Directive>>
*/
protected function scanForDirectives(array $directiveNamespaces): array
{
$directives = [];
foreach ($directiveNamespaces as $directiveNamespace) {
try {
$classesInNamespace = ClassFinder::getClassesInNamespace($directiveNamespace);
} catch (ClassFinderException $classFinderException) {
// TODO remove if https://gitlab.com/hpierce1102/ClassFinder/merge_requests/16 is merged
// The ClassFinder throws if no classes are found. Since we can not know
// in advance if the user has defined custom directives, this behaviour is problematic.
continue;
}
/** @var array<class-string> $classesInNamespace */
$classesInNamespace = ClassFinder::getClassesInNamespace($directiveNamespace);
foreach ($classesInNamespace as $class) {
$reflection = new \ReflectionClass($class);
@@ -94,10 +97,7 @@ SDL;
if (! is_a($class, Directive::class, true)) {
continue;
}
/** @var \Nuwave\Lighthouse\Support\Contracts\Directive $instance */
$instance = app($class);
$name = $instance->name();
$name = DirectiveLocator::directiveName($class);
// The directive was already found, so we do not add it twice
if (isset($directives[$name])) {
@@ -112,42 +112,77 @@ SDL;
}
/**
* @param string[] $directiveClasses
* @return string
* @param class-string<\Nuwave\Lighthouse\Support\Contracts\Directive> $directiveClass
* @throws \Nuwave\Lighthouse\Exceptions\DefinitionException
*/
protected function buildSchemaString(array $directiveClasses): string
protected function define(string $directiveClass): string
{
$schema = self::GENERATED_NOTICE;
$definition = $directiveClass::definition();
foreach ($directiveClasses as $name => $directiveClass) {
$definition = $this->define($name, $directiveClass);
// Throws if the definition is invalid
ASTHelper::extractDirectiveDefinition($definition);
$schema .= "\n"
."# Directive class: $directiveClass\n"
.$definition."\n";
}
return $schema;
return trim($definition);
}
protected function define(string $name, string $directiveClass): string
{
if (is_a($directiveClass, DefinedDirective::class, true)) {
/** @var DefinedDirective $directiveClass */
$definition = $directiveClass::definition();
// This operation throws if the schema definition is invalid
PartialParser::directiveDefinition($definition);
return trim($definition);
} else {
return '# Add a proper definition by implementing '.DefinedDirective::class."\n"
."directive @{$name}";
}
}
public static function filePath(): string
public static function schemaDirectivesPath(): string
{
return base_path().'/schema-directives.graphql';
}
protected function programmaticTypes(TypeRegistry $typeRegistry): void
{
// Users may register types programmatically, e.g. in service providers
// In order to allow referencing those in the schema, it is useful to print
// those types to a helper schema, excluding types the user defined in the schema
$types = new Collection($typeRegistry->resolvedTypes());
$filePath = static::programmaticTypesPath();
if ($types->isEmpty() && file_exists($filePath)) {
\Safe\unlink($filePath);
return;
}
$schema = $types
->map(function (Type $type): string {
return SchemaPrinter::printType($type);
})
->implode("\n");
\Safe\file_put_contents($filePath, self::GENERATED_NOTICE.$schema);
$this->info("Wrote definitions for programmatically registered types to $filePath.");
}
public static function programmaticTypesPath(): string
{
return base_path().'/programmatic-types.graphql';
}
protected function phpIdeHelper(): void
{
$filePath = static::phpIdeHelperPath();
$contents = \Safe\file_get_contents(__DIR__.'/../../_ide_helper.php');
\Safe\file_put_contents($filePath, $this->withGeneratedNotice($contents));
$this->info("Wrote PHP definitions to $filePath.");
}
public static function phpIdeHelperPath(): string
{
return base_path().'/_lighthouse_ide_helper.php';
}
protected function withGeneratedNotice(string $phpContents): string
{
return substr_replace(
$phpContents,
self::OPENING_PHP_TAG.self::GENERATED_NOTICE,
0,
strlen(self::OPENING_PHP_TAG)
);
}
}
+2 -28
View File
@@ -4,43 +4,17 @@ namespace Nuwave\Lighthouse\Console;
class InterfaceCommand extends LighthouseGeneratorCommand
{
/**
* The name of the console command.
*
* @var string
*/
protected $name = 'lighthouse:interface';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a class for a GraphQL interface type.';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Interface';
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
* @return string
*/
protected function getDefaultNamespace($rootNamespace): string
protected function namespaceConfigKey(): string
{
return config('lighthouse.namespaces.interfaces');
return 'interfaces';
}
/**
* Get the stub file for the generator.
*
* @return string
*/
protected function getStub(): string
{
return __DIR__.'/stubs/typeResolver.stub';
@@ -3,6 +3,7 @@
namespace Nuwave\Lighthouse\Console;
use Illuminate\Console\GeneratorCommand;
use InvalidArgumentException;
abstract class LighthouseGeneratorCommand extends GeneratorCommand
{
@@ -12,11 +13,83 @@ abstract class LighthouseGeneratorCommand extends GeneratorCommand
* As a typical workflow would be to write the schema first and then copy-paste
* a field name to generate a class for it, we uppercase it so the user does
* not run into unnecessary errors. You're welcome.
*
* @return string
*/
protected function getNameInput(): string
{
return ucfirst(trim($this->argument('name')));
$name = $this->argument('name');
if (! is_string($name)) {
throw new InvalidArgumentException('You must the name for the class to generate.');
}
return ucfirst(trim($name));
}
/**
* @param string $rootNamespace
*/
protected function getDefaultNamespace($rootNamespace): string
{
$namespaces = config('lighthouse.namespaces.'.$this->namespaceConfigKey());
return static::commonNamespace((array) $namespaces);
}
/**
* Get the config key that holds the default namespaces for the class.
*/
abstract protected function namespaceConfigKey(): string;
/**
* Find the common namespace of a list of namespaces.
*
* For example, ['App\\Foo\\A', 'App\\Foo\\B'] would return 'App\\Foo'.
*
* @param array<string> $namespaces
*/
public static function commonNamespace(array $namespaces): string
{
if ($namespaces === []) {
throw new InvalidArgumentException(
'A default namespace is required for code generation.'
);
}
if (count($namespaces) === 1) {
return reset($namespaces);
}
// Save the first namespace
$preferredNamespaceFallback = reset($namespaces);
// If the strings are sorted, any prefix common to all strings
// will be common to the sorted first and last strings.
// All the strings in the middle can be ignored.
\Safe\sort($namespaces);
$firstParts = explode('\\', reset($namespaces));
$lastParts = explode('\\', end($namespaces));
$matching = [];
foreach ($firstParts as $i => $part) {
// We ran out of elements to compare, so we reached the maximum common length
if (! isset($lastParts[$i])) {
break;
}
// We found an element that differs
if ($lastParts[$i] !== $part) {
break;
}
$matching [] = $part;
}
// We could not determine a common part of the configured namespaces,
// so we just assume the user will prefer the first one in the list.
if ($matching === []) {
return $preferredNamespaceFallback;
}
return implode('\\', $matching);
}
}
+3 -34
View File
@@ -2,47 +2,16 @@
namespace Nuwave\Lighthouse\Console;
class MutationCommand extends LighthouseGeneratorCommand
class MutationCommand extends FieldGeneratorCommand
{
/**
* The name of the console command.
*
* @var string
*/
protected $name = 'lighthouse:mutation';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a class for a single field on the root Mutation type.';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Mutation';
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
* @return string
*/
protected function getDefaultNamespace($rootNamespace): string
protected function namespaceConfigKey(): string
{
return config('lighthouse.namespaces.mutations');
}
/**
* Get the stub file for the generator.
*
* @return string
*/
protected function getStub(): string
{
return __DIR__.'/stubs/field.stub';
return 'mutations';
}
}
+43 -34
View File
@@ -2,53 +2,62 @@
namespace Nuwave\Lighthouse\Console;
use Nuwave\Lighthouse\GraphQL;
use Illuminate\Console\Command;
use GraphQL\Type\Introspection;
use GraphQL\Type\Schema;
use GraphQL\Utils\SchemaPrinter;
use Illuminate\Cache\Repository;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Filesystem;
use Nuwave\Lighthouse\Schema\SchemaBuilder;
class PrintSchemaCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = '
lighthouse:print-schema
{--W|write : Write the output to a file}
';
public const GRAPHQL_FILENAME = 'lighthouse-schema.graphql';
public const JSON_FILENAME = 'lighthouse-schema.json';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Compile the final GraphQL schema and print the result.';
protected $signature = <<<'SIGNATURE'
lighthouse:print-schema
{--W|write : Write the output to a file}
{--json : Output JSON instead of GraphQL SDL}
SIGNATURE;
/**
* Execute the console command.
*
* @param \Illuminate\Cache\Repository $cache
* @param \Illuminate\Contracts\Filesystem\Filesystem $storage
* @param \Nuwave\Lighthouse\GraphQL $graphQL
* @return void
*/
public function handle(Repository $cache, Filesystem $storage, GraphQL $graphQL): void
protected $description = 'Compile the GraphQL schema and print the result.';
public function handle(Filesystem $storage, SchemaBuilder $schemaBuilder): void
{
// Clear the cache so this always gets the current schema
$cache->forget(config('lighthouse.cache.key'));
$this->callSilent(ClearCacheCommand::NAME);
$schema = SchemaPrinter::doPrint(
$graphQL->prepSchema()
);
$schema = $schemaBuilder->schema();
if ($this->option('json')) {
$filename = self::JSON_FILENAME;
$schemaString = $this->toJson($schema);
} else {
$filename = self::GRAPHQL_FILENAME;
$schemaString = SchemaPrinter::doPrint($schema);
}
if ($this->option('write')) {
$storage->put('lighthouse-schema.graphql', $schema);
$this->info('Wrote schema to the default file storage (usually storage/app) as "lighthouse-schema.graphql".');
$storage->put($filename, $schemaString);
$this->info('Wrote schema to the default file storage (usually storage/app) as "'.$filename.'".');
} else {
$this->info($schema);
$this->info($schemaString);
}
}
protected function toJson(Schema $schema): string
{
$introspectionResult = Introspection::fromSchema($schema);
if ($introspectionResult === null) {
throw new \Exception(<<<'MESSAGE'
Did not receive a valid introspection result.
Check if your schema is correct with:
php artisan lighthouse:validate-schema
MESSAGE
);
}
return \Safe\json_encode($introspectionResult);
}
}
+3 -34
View File
@@ -2,47 +2,16 @@
namespace Nuwave\Lighthouse\Console;
class QueryCommand extends LighthouseGeneratorCommand
class QueryCommand extends FieldGeneratorCommand
{
/**
* The name of the console command.
*
* @var string
*/
protected $name = 'lighthouse:query';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a class for a single field on the root Query type.';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Query';
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
* @return string
*/
protected function getDefaultNamespace($rootNamespace): string
protected function namespaceConfigKey(): string
{
return config('lighthouse.namespaces.queries');
}
/**
* Get the stub file for the generator.
*
* @return string
*/
protected function getStub(): string
{
return __DIR__.'/stubs/field.stub';
return 'queries';
}
}
+2 -28
View File
@@ -4,43 +4,17 @@ namespace Nuwave\Lighthouse\Console;
class ScalarCommand extends LighthouseGeneratorCommand
{
/**
* The name of the console command.
*
* @var string
*/
protected $name = 'lighthouse:scalar';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a class for a GraphQL scalar type.';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Scalar';
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
* @return string
*/
protected function getDefaultNamespace($rootNamespace): string
protected function namespaceConfigKey(): string
{
return config('lighthouse.namespaces.scalars');
return 'scalars';
}
/**
* Get the stub file for the generator.
*
* @return string
*/
protected function getStub(): string
{
return __DIR__.'/stubs/scalar.stub';
@@ -4,43 +4,17 @@ namespace Nuwave\Lighthouse\Console;
class SubscriptionCommand extends LighthouseGeneratorCommand
{
/**
* The name of the console command.
*
* @var string
*/
protected $name = 'lighthouse:subscription';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a class for a single field on the root Subscription type.';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Subscription';
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
* @return string
*/
protected function getDefaultNamespace($rootNamespace): string
protected function namespaceConfigKey(): string
{
return config('lighthouse.namespaces.subscriptions');
return 'subscriptions';
}
/**
* Get the stub file for the generator.
*
* @return string
*/
protected function getStub(): string
{
return __DIR__.'/stubs/subscription.stub';
+2 -28
View File
@@ -4,43 +4,17 @@ namespace Nuwave\Lighthouse\Console;
class UnionCommand extends LighthouseGeneratorCommand
{
/**
* The name of the console command.
*
* @var string
*/
protected $name = 'lighthouse:union';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a class for a GraphQL union type.';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Union';
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
* @return string
*/
protected function getDefaultNamespace($rootNamespace): string
protected function namespaceConfigKey(): string
{
return config('lighthouse.namespaces.unions');
return 'unions';
}
/**
* Get the stub file for the generator.
*
* @return string
*/
protected function getStub(): string
{
return __DIR__.'/stubs/typeResolver.stub';
@@ -2,38 +2,52 @@
namespace Nuwave\Lighthouse\Console;
use Nuwave\Lighthouse\GraphQL;
use GraphQL\Type\Schema;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Contracts\Events\Dispatcher as EventsDispatcher;
use Nuwave\Lighthouse\Events\ValidateSchema;
use Nuwave\Lighthouse\Schema\DirectiveLocator;
use Nuwave\Lighthouse\Schema\Factories\DirectiveFactory;
use Nuwave\Lighthouse\Schema\FallbackTypeNodeConverter;
use Nuwave\Lighthouse\Schema\SchemaBuilder;
use Nuwave\Lighthouse\Schema\TypeRegistry;
class ValidateSchemaCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lighthouse:validate-schema';
/**
* The console command description.
*
* @var string
*/
protected $name = 'lighthouse:validate-schema';
protected $description = 'Validate the GraphQL schema definition.';
/**
* Execute the console command.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
* @param \Nuwave\Lighthouse\GraphQL $graphQL
* @return void
*/
public function handle(Repository $cache, GraphQL $graphQL): void
{
public function handle(
EventsDispatcher $eventsDispatcher,
SchemaBuilder $schemaBuilder,
DirectiveLocator $directiveLocator,
TypeRegistry $typeRegistry
): void {
// Clear the cache so this always validates the current schema
$cache->forget(config('lighthouse.cache.key'));
$this->call(ClearCacheCommand::NAME);
$graphQL->prepSchema()->assertValid();
$originalSchema = $schemaBuilder->schema();
$schemaConfig = $originalSchema->getConfig();
// We add schema directive definitions only here, since it is very slow
$directiveFactory = new DirectiveFactory(
new FallbackTypeNodeConverter($typeRegistry)
);
foreach ($directiveLocator->definitions() as $directiveDefinition) {
// TODO consider a solution that feels less hacky
if ($directiveDefinition->name->value !== 'deprecated') {
$schemaConfig->directives [] = $directiveFactory->handle($directiveDefinition);
}
}
$schema = new Schema($schemaConfig);
$schema->assertValid();
// Allow plugins to do their own schema validations
$eventsDispatcher->dispatch(
new ValidateSchema($schema)
);
$this->info('The defined schema is valid.');
}
@@ -0,0 +1,40 @@
<?php
namespace Nuwave\Lighthouse\Console;
class ValidatorCommand extends LighthouseGeneratorCommand
{
/**
* The name of the console command.
*
* @var string
*/
protected $name = 'lighthouse:validator';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a validator class';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Validator';
protected function namespaceConfigKey(): string
{
return 'validators';
}
/**
* Get the stub file for the generator.
*/
protected function getStub(): string
{
return __DIR__.'/stubs/validator.stub';
}
}
@@ -0,0 +1,12 @@
<?php
namespace DummyNamespace;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
{{ imports }}
class DummyClass extends BaseDirective implements {{ implements }}
{
// TODO implement the directive https://lighthouse-php.com/master/custom-directives/getting-started.html
{{ methods }}}
@@ -0,0 +1,11 @@
/**
* Add additional constraints to the builder based on the given argument value.
*
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
* @param mixed $value
* @return \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder
*/
public function handleBuilder($builder, $value)
{
// TODO implement the arg builder
}
@@ -0,0 +1,4 @@
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputValueDefinitionNode;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
@@ -0,0 +1,17 @@
/**
* Manipulate the AST.
*
* @param \Nuwave\Lighthouse\Schema\AST\DocumentAST $documentAST
* @param \GraphQL\Language\AST\InputValueDefinitionNode $argDefinition
* @param \GraphQL\Language\AST\FieldDefinitionNode $parentField
* @param \GraphQL\Language\AST\ObjectTypeDefinitionNode $parentType
* @return void
*/
public function manipulateArgDefinition(
DocumentAST &$documentAST,
InputValueDefinitionNode &$argDefinition,
FieldDefinitionNode &$parentField,
ObjectTypeDefinitionNode &$parentType
) {
// TODO implement the arg manipulator
}
@@ -0,0 +1,9 @@
/**
* @param mixed $root The result of the parent resolver.
* @param mixed|\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $value The slice of arguments that belongs to this nested resolver.
* @return mixed
*/
public function __invoke($root, $value)
{
// TODO implement the arg resolver
}
@@ -0,0 +1,10 @@
/**
* Apply transformations on the value of an argument given to a field.
*
* @param mixed $argumentValue
* @return mixed
*/
public function transform($argumentValue)
{
// TODO implement the arg transformer
}
@@ -0,0 +1,3 @@
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
@@ -0,0 +1,15 @@
/**
* Manipulate the AST based on a field definition.
*
* @param \Nuwave\Lighthouse\Schema\AST\DocumentAST $documentAST
* @param \GraphQL\Language\AST\FieldDefinitionNode $fieldDefinition
* @param \GraphQL\Language\AST\ObjectTypeDefinitionNode $parentType
* @return void
*/
public function manipulateFieldDefinition(
DocumentAST &$documentAST,
FieldDefinitionNode &$fieldDefinition,
ObjectTypeDefinitionNode &$parentType
) {
// TODO implement the field manipulator
}
@@ -0,0 +1,2 @@
use Closure;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
@@ -0,0 +1,11 @@
/**
* Wrap around the final field resolver.
*
* @param \Nuwave\Lighthouse\Schema\Values\FieldValue $fieldValue
* @param \Closure $next
* @return \Nuwave\Lighthouse\Schema\Values\FieldValue
*/
public function handleField(FieldValue $fieldValue, Closure $next)
{
// TODO implement the field middleware
}
@@ -0,0 +1 @@
use Nuwave\Lighthouse\Schema\Values\FieldValue;
@@ -0,0 +1,13 @@
/**
* Set a field resolver on the FieldValue.
*
* This must call $fieldValue->setResolver() before returning
* the FieldValue.
*
* @param \Nuwave\Lighthouse\Schema\Values\FieldValue $fieldValue
* @return \Nuwave\Lighthouse\Schema\Values\FieldValue
*/
public function resolveField(FieldValue $fieldValue)
{
// TODO implement the field resolver
}
@@ -0,0 +1,2 @@
use GraphQL\Language\AST\TypeExtensionNode;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
@@ -0,0 +1,11 @@
/**
* Apply manipulations from a type extension node.
*
* @param \Nuwave\Lighthouse\Schema\AST\DocumentAST $documentAST
* @param \GraphQL\Language\AST\TypeExtensionNode $typeExtension
* @return void
*/
public function manipulateTypeExtension(DocumentAST &$documentAST, TypeExtensionNode &$typeExtension)
{
// TODO implement the type extension manipulator
}
@@ -0,0 +1,2 @@
use GraphQL\Language\AST\TypeDefinitionNode;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
@@ -0,0 +1,11 @@
/**
* Apply manipulations from a type definition node.
*
* @param \Nuwave\Lighthouse\Schema\AST\DocumentAST $documentAST
* @param \GraphQL\Language\AST\TypeDefinitionNode $typeDefinition
* @return void
*/
public function manipulateTypeDefinition(DocumentAST &$documentAST, TypeDefinitionNode &$typeDefinition)
{
// TODO implement the type manipulator
}
@@ -0,0 +1,2 @@
use Closure;
use Nuwave\Lighthouse\Schema\Values\TypeValue;
@@ -0,0 +1,11 @@
/**
* Handle a type AST as it is converted to an executable type.
*
* @param \Nuwave\Lighthouse\Schema\Values\TypeValue $value
* @param \Closure $next
* @return \GraphQL\Type\Definition\Type
*/
public function handleNode(TypeValue $value, Closure $next)
{
// TODO implement the type middleware
}
@@ -0,0 +1 @@
use Nuwave\Lighthouse\Schema\Values\TypeValue;
@@ -0,0 +1,10 @@
/**
* Resolve a type AST to a GraphQL Type.
*
* @param \Nuwave\Lighthouse\Schema\Values\TypeValue $value
* @return \GraphQL\Type\Definition\Type
*/
public function resolveNode(TypeValue $value)
{
// TODO implement the type resolver
}
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace DummyNamespace;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class DummyClass
{
/**
* Return a value for the field.
*
* @param null $rootValue Usually contains the result returned from the parent field. In this case, it is always `null`.
* @param mixed[] $args The arguments that were passed into the field.
* @param \Nuwave\Lighthouse\Support\Contracts\GraphQLContext $context Arbitrary data that is shared between all fields of a single query.
* @param \GraphQL\Type\Definition\ResolveInfo $resolveInfo Information about the query itself, such as the execution state, the field name, path to the field from the root, and more.
* @return mixed
*/
public function __invoke($rootValue, array $args, GraphQLContext $context, ResolveInfo $resolveInfo)
{
// TODO implement the resolver
}
}
@@ -0,0 +1,23 @@
<?php
namespace DummyNamespace;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class DummyClass
{
/**
* Return a value for the field.
*
* @param @param null $root Always null, since this field has no parent.
* @param array<string, mixed> $args The field arguments passed by the client.
* @param \Nuwave\Lighthouse\Support\Contracts\GraphQLContext $context Shared between all fields.
* @param \GraphQL\Type\Definition\ResolveInfo $resolveInfo Metadata for advanced query resolution.
* @return mixed
*/
public function __invoke($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo)
{
// TODO implement the resolver
}
}
@@ -0,0 +1,15 @@
<?php
namespace DummyNamespace;
class DummyClass
{
/**
* @param null $_
* @param array<string, mixed> $args
*/
public function __invoke($_, array $args)
{
// TODO implement the resolver
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ namespace DummyNamespace;
use GraphQL\Type\Definition\ScalarType;
/**
* Read more about scalars here http://webonyx.github.io/graphql-php/type-system/scalar-types/
* Read more about scalars here https://webonyx.github.io/graphql-php/type-definitions/scalars
*/
class DummyClass extends ScalarType
{
@@ -45,7 +45,7 @@ class DummyClass extends ScalarType
* }
*
* @param \GraphQL\Language\AST\Node $valueNode
* @param mixed[]|null $variables
* @param array<string, mixed>|null $variables
* @return mixed
*/
public function parseLiteral($valueNode, ?array $variables = null)
@@ -5,6 +5,7 @@ namespace DummyNamespace;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Schema\TypeRegistry;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class DummyClass
{
@@ -15,12 +16,6 @@ class DummyClass
*/
protected $typeRegistry;
/**
* Constructor.
*
* @param \Nuwave\Lighthouse\Schema\TypeRegistry $typeRegistry
* @return void
*/
public function __construct(TypeRegistry $typeRegistry)
{
$this->typeRegistry = $typeRegistry;
@@ -0,0 +1,20 @@
<?php
namespace DummyNamespace;
use Nuwave\Lighthouse\Validation\Validator;
class DummyClass extends Validator
{
/**
* Return the validation rules.
*
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
// TODO Add your validation rules
];
}
}
+110 -147
View File
@@ -3,14 +3,13 @@
namespace Nuwave\Lighthouse\Defer;
use Closure;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Events\StartExecution;
use Nuwave\Lighthouse\GraphQL;
use Nuwave\Lighthouse\Events\ManipulateAST;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Symfony\Component\HttpFoundation\Response;
use Nuwave\Lighthouse\Schema\AST\PartialParser;
use Nuwave\Lighthouse\Support\Contracts\CreatesResponse;
use Nuwave\Lighthouse\Support\Contracts\CanStreamResponse;
use Nuwave\Lighthouse\Support\Contracts\CreatesResponse;
use Symfony\Component\HttpFoundation\Response;
class Defer implements CreatesResponse
{
@@ -25,161 +24,134 @@ class Defer implements CreatesResponse
protected $graphQL;
/**
* @var mixed[]
* @var \Nuwave\Lighthouse\Events\StartExecution
*/
protected $result = [];
protected $startExecution;
/**
* @var mixed[]
* A map from paths to deferred resolvers.
*
* @var array<string, \Closure(): mixed>
*/
protected $deferred = [];
/**
* @var mixed[]
* Paths resolved during the current nesting of defers.
*
* @var array<int, mixed>
*/
protected $resolved = [];
/**
* @var bool
* The entire result of resolving the query up until the current nesting.
*
* @var array<string, mixed>
*/
protected $acceptFurtherDeferring = true;
protected $result = [];
/**
* Should further deferring happen?
*
* @var bool
*/
protected $shouldDeferFurther = true;
/**
* Are we currently streaming deferred results?
*
* @var bool
*/
protected $isStreaming = false;
/**
* @var int
* @var float|int
*/
protected $maxExecutionTime = 0;
/**
* @var int
*/
protected $maxNestedFields = 0;
/**
* @param \Nuwave\Lighthouse\Support\Contracts\CanStreamResponse $stream
* @param \Nuwave\Lighthouse\GraphQL $graphQL
* @return void
*/
public function __construct(CanStreamResponse $stream, GraphQL $graphQL)
public function __construct(CanStreamResponse $stream, GraphQL $graphQL, ConfigRepository $config)
{
$this->stream = $stream;
$this->graphQL = $graphQL;
$this->maxNestedFields = config('lighthouse.defer.max_nested_fields', 0);
$executionTime = $config->get('lighthouse.defer.max_execution_ms', 0);
if ($executionTime > 0) {
$this->maxExecutionTime = microtime(true) + $executionTime * 1000;
}
$this->maxNestedFields = $config->get('lighthouse.defer.max_nested_fields', 0);
}
/**
* Set the tracing directive on all fields of the query to enable tracing them.
*
* @param \Nuwave\Lighthouse\Events\ManipulateAST $manipulateAST
* @return void
*/
public function handleManipulateAST(ManipulateAST $manipulateAST): void
public function handleStartExecution(StartExecution $startExecution): void
{
ASTHelper::attachDirectiveToObjectTypeFields(
$manipulateAST->documentAST,
PartialParser::directive('@deferrable')
);
$manipulateAST->documentAST->setDirectiveDefinition(
PartialParser::directiveDefinition('
"""
Use this directive on expensive or slow fields to resolve them asynchronously.
Must not be placed upon:
- Non-Nullable fields
- Mutation root fields
"""
directive @defer(if: Boolean = true) on FIELD
')
);
}
/**
* @return bool
*/
public function isStreaming(): bool
{
return $this->isStreaming;
$this->startExecution = $startExecution;
}
/**
* Register deferred field.
*
* @param \Closure $resolver
* @param string $path
* @return mixed
* @param \Closure(): mixed $resolver
* @return mixed The data if it is already available.
*/
public function defer(Closure $resolver, string $path)
{
if ($data = Arr::get($this->result, "data.{$path}")) {
$data = $this->getData($path);
if ($data !== null) {
return $data;
}
if ($this->isDeferred($path) || ! $this->acceptFurtherDeferring) {
// If we have been here before, now is the time to resolve this field
$deferredResolver = $this->deferred[$path] ?? null;
if ($deferredResolver) {
return $this->resolve($deferredResolver, $path);
}
if (! $this->shouldDeferFurther) {
return $this->resolve($resolver, $path);
}
$this->deferred[$path] = $resolver;
return null;
}
/**
* @param \Closure $originalResolver
* @param string $path
* @return mixed
* @return mixed The data at the path
*/
public function findOrResolve(Closure $originalResolver, string $path)
protected function getData(string $path)
{
if (! $this->hasData($path)) {
if (isset($this->deferred[$path])) {
unset($this->deferred[$path]);
}
return $this->resolve($originalResolver, $path);
}
return Arr::get($this->result, "data.{$path}");
}
/**
* Resolve field with data or resolver.
*
* @param \Closure $originalResolver
* @param string $path
* @return mixed
* @param \Closure(): mixed $resolver
* @return mixed The loaded data
*/
public function resolve(Closure $originalResolver, string $path)
protected function resolve(Closure $resolver, string $path)
{
$isDeferred = $this->isDeferred($path);
$resolver = $isDeferred
? $this->deferred[$path]
: $originalResolver;
if ($isDeferred) {
$this->resolved[] = $path;
unset($this->deferred[$path]);
}
unset($this->deferred[$path]);
$this->resolved [] = $path;
return $resolver();
}
/**
* @param string $path
* @return bool
* @param \Closure(): mixed $originalResolver
* @return mixed The loaded data
*/
public function isDeferred(string $path): bool
public function findOrResolve(Closure $originalResolver, string $path)
{
return isset($this->deferred[$path]);
if ($this->hasData($path)) {
return $this->getData($path);
}
return $originalResolver();
}
/**
* @param string $path
* @return bool
*/
public function hasData(string $path): bool
protected function hasData(string $path): bool
{
return Arr::has($this->result, "data.{$path}");
}
@@ -187,30 +159,26 @@ directive @defer(if: Boolean = true) on FIELD
/**
* Return either a final response or a stream of responses.
*
* @param mixed[] $result
* @param array<string, mixed> $result
* @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse
*/
public function createResponse(array $result): Response
{
if (empty($this->deferred)) {
if (! $this->hasRemainingDeferred()) {
return response($result);
}
$this->result = $result;
$this->isStreaming = true;
return response()->stream(
function () use ($result): void {
function (): void {
$this->stream();
$nested = 1;
$this->result = $result;
$this->isStreaming = true;
$this->stream->stream($result, [], empty($this->deferred));
if ($executionTime = config('lighthouse.defer.max_execution_ms', 0)) {
$this->maxExecutionTime = microtime(true) + ($executionTime * 1000);
}
// TODO: Allow nested_levels to be set in config to break out of loop early.
while (
count($this->deferred)
&& ! $this->executionTimeExpired()
$this->hasRemainingDeferred()
&& ! $this->maxExecutionTimeReached()
&& ! $this->maxNestedFieldsResolved($nested)
) {
$nested++;
@@ -218,48 +186,40 @@ directive @defer(if: Boolean = true) on FIELD
}
// We've hit the max execution time or max nested levels of deferred fields.
$this->shouldDeferFurther = false;
// We process remaining deferred fields, but are no longer allowing additional
// fields to be deferred.
if (count($this->deferred)) {
$this->acceptFurtherDeferring = false;
if ($this->hasRemainingDeferred()) {
$this->executeDeferred();
}
},
200,
[
// TODO: Allow headers to be set in config
'X-Accel-Buffering' => 'no',
'Content-Type' => 'multipart/mixed; boundary="-"',
]
);
}
/**
* @param int $time
* @return void
*/
public function setMaxExecutionTime(int $time): void
protected function hasRemainingDeferred(): bool
{
$this->maxExecutionTime = $time;
return count($this->deferred) > 0;
}
protected function stream(): void
{
$this->stream->stream(
$this->result,
$this->resolved,
! $this->hasRemainingDeferred()
);
}
/**
* Override max nested fields.
*
* @param int $max
* @return void
* Check if we reached the maximum execution time.
*/
public function setMaxNestedFields(int $max): void
{
$this->maxNestedFields = $max;
}
/**
* Check if the maximum execution time has expired.
*
* @return bool
*/
protected function executionTimeExpired(): bool
protected function maxExecutionTimeReached(): bool
{
if ($this->maxExecutionTime === 0) {
return false;
@@ -270,9 +230,6 @@ directive @defer(if: Boolean = true) on FIELD
/**
* Check if the maximum number of nested field has been resolved.
*
* @param int $nested
* @return bool
*/
protected function maxNestedFieldsResolved(int $nested): bool
{
@@ -280,26 +237,32 @@ directive @defer(if: Boolean = true) on FIELD
return false;
}
return $nested >= $this->maxNestedFields;
return $this->maxNestedFields <= $nested;
}
/**
* Execute deferred fields.
*
* @return void
*/
protected function executeDeferred(): void
{
$this->result = app()->call(
[$this->graphQL, 'executeRequest']
$executionResult = $this->graphQL->executeQuery(
$this->startExecution->query,
$this->startExecution->context,
$this->startExecution->variables,
null,
$this->startExecution->operationName
);
$this->stream->stream(
$this->result,
$this->resolved,
empty($this->deferred)
);
$this->result = $this->graphQL->serializable($executionResult);
$this->stream();
$this->resolved = [];
}
public function setMaxExecutionTime(float $time): void
{
$this->maxExecutionTime = $time;
}
public function setMaxNestedFields(int $max): void
{
$this->maxNestedFields = $max;
}
}
+42 -20
View File
@@ -2,43 +2,65 @@
namespace Nuwave\Lighthouse\Defer;
use Illuminate\Support\ServiceProvider;
use GraphQL\Language\Parser;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\ServiceProvider;
use Nuwave\Lighthouse\Events\ManipulateAST;
use Nuwave\Lighthouse\Schema\Factories\DirectiveFactory;
use Nuwave\Lighthouse\Events\RegisterDirectiveNamespaces;
use Nuwave\Lighthouse\Events\StartExecution;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Support\Contracts\CreatesResponse;
class DeferServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @param \Nuwave\Lighthouse\Schema\Factories\DirectiveFactory $directiveFactory
* @param \Illuminate\Contracts\Events\Dispatcher $dispatcher
* @return void
*/
public function boot(DirectiveFactory $directiveFactory, Dispatcher $dispatcher): void
public function register(): void
{
$directiveFactory->addResolved(
DeferrableDirective::NAME,
DeferrableDirective::class
$this->app->singleton(Defer::class);
$this->app->singleton(CreatesResponse::class, Defer::class);
}
public function boot(Dispatcher $dispatcher): void
{
$dispatcher->listen(
RegisterDirectiveNamespaces::class,
static function (): string {
return __NAMESPACE__;
}
);
$dispatcher->listen(
ManipulateAST::class,
Defer::class.'@handleManipulateAST'
function (ManipulateAST $manipulateAST): void {
$this->handleManipulateAST($manipulateAST);
}
);
$dispatcher->listen(
StartExecution::class,
Defer::class.'@handleStartExecution'
);
}
/**
* Register any application services.
*
* @return void
* Set the tracing directive on all fields of the query to enable tracing them.
*/
public function register(): void
public function handleManipulateAST(ManipulateAST $manipulateAST): void
{
$this->app->singleton(Defer::class);
ASTHelper::attachDirectiveToObjectTypeFields(
$manipulateAST->documentAST,
Parser::constDirective(/** @lang GraphQL */ '@deferrable')
);
$this->app->singleton(CreatesResponse::class, Defer::class);
$manipulateAST->documentAST->setDirectiveDefinition(
Parser::directiveDefinition(/** @lang GraphQL */ '
"""
Use this directive on expensive or slow fields to resolve them asynchronously.
Must not be placed upon:
- Non-Nullable fields
- Mutation root fields
"""
directive @defer(if: Boolean = true) on FIELD
')
);
}
}
+50 -65
View File
@@ -4,31 +4,32 @@ namespace Nuwave\Lighthouse\Defer;
use Closure;
use GraphQL\Error\Error;
use GraphQL\Language\AST\TypeNode;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Language\AST\NonNullTypeNode;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use GraphQL\Language\AST\TypeNode;
use GraphQL\Type\Definition\Directive;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\ClientDirectives\ClientDirective;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
use Nuwave\Lighthouse\Schema\RootType;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldMiddleware;
use Nuwave\Lighthouse\Support\Contracts\DefinedDirective;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class DeferrableDirective extends BaseDirective implements DefinedDirective, FieldMiddleware
class DeferrableDirective extends BaseDirective implements FieldMiddleware
{
const NAME = 'deferrable';
const THE_DEFER_DIRECTIVE_CANNOT_BE_USED_ON_A_ROOT_MUTATION_FIELD = 'The @defer directive cannot be used on a root mutation field.';
const THE_DEFER_DIRECTIVE_CANNOT_BE_USED_ON_A_NON_NULLABLE_FIELD = 'The @defer directive cannot be used on a Non-Nullable field.';
public const THE_DEFER_DIRECTIVE_CANNOT_BE_USED_ON_A_ROOT_MUTATION_FIELD = 'The @defer directive cannot be used on a root mutation field.';
public const THE_DEFER_DIRECTIVE_CANNOT_BE_USED_ON_A_NON_NULLABLE_FIELD = 'The @defer directive cannot be used on a Non-Nullable field.';
public const DEFER_DIRECTIVE_NAME = 'defer';
public static function definition(): string
{
return /* @lang GraphQL */ <<<'SDL'
return /** @lang GraphQL */ <<<'GRAPHQL'
"""
Do not use this directive directly, it is automatically added to the schema
when using the defer extension.
"""
directive @deferrable on FIELD_DEFINITION
SDL;
GRAPHQL;
}
/**
@@ -36,32 +37,11 @@ SDL;
*/
protected $defer;
/**
* @param \Nuwave\Lighthouse\Defer\Defer $defer
* @return void
*/
public function __construct(Defer $defer)
{
$this->defer = $defer;
}
/**
* Name of the directive.
*
* @return string
*/
public function name(): string
{
return self::NAME;
}
/**
* Resolve the field directive.
*
* @param \Nuwave\Lighthouse\Schema\Values\FieldValue $fieldValue
* @param \Closure $next
* @return \Nuwave\Lighthouse\Schema\Values\FieldValue
*/
public function handleField(FieldValue $fieldValue, Closure $next): FieldValue
{
$previousResolver = $fieldValue->getResolver();
@@ -78,9 +58,7 @@ SDL;
return $this->defer->defer($wrappedResolver, $path);
}
return $this->defer->isStreaming()
? $this->defer->findOrResolve($wrappedResolver, $path)
: $previousResolver($root, $args, $context, $resolveInfo);
return $this->defer->findOrResolve($wrappedResolver, $path);
}
);
@@ -90,49 +68,56 @@ SDL;
/**
* Determine if field should be deferred.
*
* @param \GraphQL\Language\AST\TypeNode $fieldType
* @param \GraphQL\Type\Definition\ResolveInfo $resolveInfo
* @return bool
*
* @throws \GraphQL\Error\Error
*/
protected function shouldDefer(TypeNode $fieldType, ResolveInfo $resolveInfo): bool
{
foreach ($resolveInfo->fieldNodes as $fieldNode) {
$deferDirective = ASTHelper::directiveDefinition($fieldNode, 'defer');
if (! $deferDirective) {
return false;
}
$defers = (new ClientDirective(self::DEFER_DIRECTIVE_NAME))->forField($resolveInfo);
if ($resolveInfo->parentType->name === 'Mutation') {
if ($this->anyFieldHasDefer($defers)) {
if ($resolveInfo->parentType->name === RootType::MUTATION) {
throw new Error(self::THE_DEFER_DIRECTIVE_CANNOT_BE_USED_ON_A_ROOT_MUTATION_FIELD);
}
if (! ASTHelper::directiveArgValue($deferDirective, 'if', true)) {
return false;
if ($fieldType instanceof NonNullTypeNode) {
throw new Error(self::THE_DEFER_DIRECTIVE_CANNOT_BE_USED_ON_A_NON_NULLABLE_FIELD);
}
}
$skipDirective = ASTHelper::directiveDefinition($fieldNode, 'skip');
if (
$skipDirective
&& ASTHelper::directiveArgValue($skipDirective, 'if') === true
) {
return false;
}
$includeDirective = ASTHelper::directiveDefinition($fieldNode, 'include');
if (
$includeDirective
&& ASTHelper::directiveArgValue($includeDirective, 'if') === false
) {
// Following the semantics of Apollo:
// All declarations of a field have to contain @defer for the field to be deferred
foreach ($defers as $defer) {
if ($defer === null || $defer === [Directive::IF_ARGUMENT_NAME => false]) {
return false;
}
}
if ($fieldType instanceof NonNullTypeNode) {
throw new Error(self::THE_DEFER_DIRECTIVE_CANNOT_BE_USED_ON_A_NON_NULLABLE_FIELD);
$skips = (new ClientDirective(Directive::SKIP_NAME))->forField($resolveInfo);
foreach ($skips as $skip) {
if ($skip === [Directive::IF_ARGUMENT_NAME => true]) {
return false;
}
}
return true;
$includes = (new ClientDirective(Directive::INCLUDE_NAME))->forField($resolveInfo);
return ! in_array(
[Directive::IF_ARGUMENT_NAME => false],
$includes,
true
);
}
/**
* @param array<array<string, mixed>|null> $defers
*/
protected function anyFieldHasDefer(array $defers): bool
{
foreach ($defers as $defer) {
if ($defer !== null) {
return true;
}
}
return false;
}
}
@@ -5,9 +5,8 @@ namespace Nuwave\Lighthouse\Events;
/**
* Fires after a query was resolved.
*
* Listeners of this event may return an instance of
* @see \Nuwave\Lighthouse\Execution\ExtensionsResponse
* that is then added to the response.
* Listeners may return a @see \Nuwave\Lighthouse\Execution\ExtensionsResponse
* to include in the response.
*/
class BuildExtensionsResponse
{
@@ -19,12 +19,6 @@ class BuildSchemaString
*/
public $userSchema;
/**
* BuildSchemaString constructor.
*
* @param string $userSchema
* @return void
*/
public function __construct(string $userSchema)
{
$this->userSchema = $userSchema;
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Nuwave\Lighthouse\Events;
use GraphQL\Executor\ExecutionResult;
use Illuminate\Support\Carbon;
/**
* Fires after resolving a single operation.
*/
class EndExecution
{
/**
* The result of resolving a single operation.
*
* @var \GraphQL\Executor\ExecutionResult
*/
public $result;
/**
* The point in time when the result was ready.
*
* @var \Illuminate\Support\Carbon
*/
public $moment;
public function __construct(ExecutionResult $result)
{
$this->result = $result;
$this->moment = Carbon::now();
}
}
@@ -0,0 +1,24 @@
<?php
namespace Nuwave\Lighthouse\Events;
/**
* Fires after resolving all operations.
*/
class EndOperationOrOperations
{
/**
* The result of either a single or multiple operations.
*
* @var array<string, mixed>|array<int, array<string, mixed>>
*/
public $resultOrResults;
/**
* @param array<string, mixed>|array<int, array<string, mixed>> $resultOrResults
*/
public function __construct(array $resultOrResults)
{
$this->resultOrResults = $resultOrResults;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Nuwave\Lighthouse\Events;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpFoundation\Response;
/**
* Fires right after building the HTTP response in the GraphQLController.
*
* Can be used for logging or for measuring and monitoring
* the time a request takes to resolve.
*
* @see \Nuwave\Lighthouse\Support\Http\Controllers\GraphQLController
*/
class EndRequest
{
/**
* The response that is about to be sent to the client.
*
* @var \Symfony\Component\HttpFoundation\Response
*/
public $response;
/**
* The point in time when the response was ready.
*
* @var \Illuminate\Support\Carbon
*/
public $moment;
public function __construct(Response $response)
{
$this->response = $response;
$this->moment = Carbon::now();
}
}
-6
View File
@@ -21,12 +21,6 @@ class ManipulateAST
*/
public $documentAST;
/**
* BuildSchemaString constructor.
*
* @param \Nuwave\Lighthouse\Schema\AST\DocumentAST $documentAST
* @return void
*/
public function __construct(DocumentAST &$documentAST)
{
$this->documentAST = $documentAST;
@@ -19,12 +19,6 @@ class ManipulateResult
*/
public $result;
/**
* ManipulateResult constructor.
*
* @param \GraphQL\Executor\ExecutionResult $result
* @return void
*/
public function __construct(ExecutionResult &$result)
{
$this->result = $result;
@@ -8,7 +8,7 @@ namespace Nuwave\Lighthouse\Events;
* Listeners may return one or more strings that are used as the base
* namespace for locating directives.
*
* @see \Nuwave\Lighthouse\Schema\Factories\DirectiveFactory
* @see \Nuwave\Lighthouse\Schema\DirectiveLocator::namespaces()
*/
class RegisterDirectiveNamespaces
{
+40 -9
View File
@@ -2,30 +2,61 @@
namespace Nuwave\Lighthouse\Events;
use Carbon\Carbon;
use GraphQL\Language\AST\DocumentNode;
use Illuminate\Support\Carbon;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
/**
* Fires right before resolving an individual query.
* Fires right before resolving a single operation.
*
* Might happen multiple times in a single request if
* query batching is used.
* Might happen multiple times in a single request if batching is used.
*/
class StartExecution
{
/**
* The client given parsed query string.
*
* @var \GraphQL\Language\AST\DocumentNode
*/
public $query;
/**
* The client given variables, neither validated nor transformed.
*
* @var array<string, mixed>|null
*/
public $variables;
/**
* The client given operation name.
*
* @var string|null
*/
public $operationName;
/**
* The context for the operation.
*
* @var \Nuwave\Lighthouse\Support\Contracts\GraphQLContext
*/
public $context;
/**
* The point in time when the query execution started.
*
* @var \Carbon\Carbon
* @var \Illuminate\Support\Carbon
*/
public $moment;
/**
* StartRequest constructor.
*
* @return void
* @param array<string, mixed>|null $variables
*/
public function __construct()
public function __construct(DocumentNode $query, ?array $variables, ?string $operationName, GraphQLContext $context)
{
$this->query = $query;
$this->variables = $variables;
$this->operationName = $operationName;
$this->context = $context;
$this->moment = Carbon::now();
}
}
@@ -0,0 +1,24 @@
<?php
namespace Nuwave\Lighthouse\Events;
/**
* Fires after receiving the parsed operation (single query) or operations (batched query).
*/
class StartOperationOrOperations
{
/**
* One or multiple parsed GraphQL operations.
*
* @var \GraphQL\Server\OperationParams|array<int, \GraphQL\Server\OperationParams>
*/
public $operationOrOperations;
/**
* @param \GraphQL\Server\OperationParams|array<int, \GraphQL\Server\OperationParams> $operationOrOperations
*/
public function __construct($operationOrOperations)
{
$this->operationOrOperations = $operationOrOperations;
}
}
+6 -12
View File
@@ -2,8 +2,8 @@
namespace Nuwave\Lighthouse\Events;
use Carbon\Carbon;
use Nuwave\Lighthouse\Execution\GraphQLRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
/**
* Fires right after a request reaches the GraphQLController.
@@ -16,26 +16,20 @@ use Nuwave\Lighthouse\Execution\GraphQLRequest;
class StartRequest
{
/**
* GraphQL request instance.
* The request sent from the client.
*
* @var \Nuwave\Lighthouse\Execution\GraphQLRequest
* @var \Illuminate\Http\Request
*/
public $request;
/**
* The point in time when the request started.
*
* @var \Carbon\Carbon
* @var \Illuminate\Support\Carbon
*/
public $moment;
/**
* StartRequest constructor.
*
* @param \Nuwave\Lighthouse\Execution\GraphQLRequest $request
* @return void
*/
public function __construct(GraphQLRequest $request)
public function __construct(Request $request)
{
$this->request = $request;
$this->moment = Carbon::now();
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Nuwave\Lighthouse\Events;
use GraphQL\Type\Schema;
/**
* Dispatched when php artisan lighthouse:validate-schema is called.
*
* Listeners should throw a descriptive error if the schema is wrong.
*/
class ValidateSchema
{
/**
* The final schema to validate.
*
* @var \GraphQL\Type\Schema
*/
public $schema;
public function __construct(Schema $schema)
{
$this->schema = $schema;
}
}
@@ -6,35 +6,21 @@ use Illuminate\Auth\AuthenticationException as IlluminateAuthenticationException
class AuthenticationException extends IlluminateAuthenticationException implements RendersErrorsExtensions
{
/**
* Returns true when exception message is safe to be displayed to a client.
*
* @api
* @return bool
*/
public const MESSAGE = 'Unauthenticated.';
public const CATEGORY = 'authentication';
public function isClientSafe(): bool
{
return true;
}
/**
* Returns string describing a category of the error.
*
* Value "graphql" is reserved for errors produced by query parsing or validation, do not use it.
*
* @api
* @return string
*/
public function getCategory(): string
{
return 'authentication';
return self::CATEGORY;
}
/**
* Return the content that is put in the "extensions" part
* of the returned error.
*
* @return array
* @return array<string, array<string>>
*/
public function extensionsContent(): array
{
@@ -7,30 +7,13 @@ use Illuminate\Auth\Access\AuthorizationException as IlluminateAuthorizationExce
class AuthorizationException extends IlluminateAuthorizationException implements ClientAware
{
/**
* @var string
*/
const CATEGORY = 'authorization';
public const CATEGORY = 'authorization';
/**
* Returns true when exception message is safe to be displayed to a client.
*
* @api
* @return bool
*/
public function isClientSafe(): bool
{
return true;
}
/**
* Returns string describing a category of the error.
*
* Value "graphql" is reserved for errors produced by query parsing or validation, do not use it.
*
* @api
* @return string
*/
public function getCategory(): string
{
return self::CATEGORY;
@@ -5,27 +5,18 @@ namespace Nuwave\Lighthouse\Exceptions;
use Exception;
use GraphQL\Error\ClientAware;
/**
* Thrown when the schema definition or related code is wrong.
*
* This signals a developer error, so we do not show this exception to the user.
*/
class DefinitionException extends Exception implements ClientAware
{
/**
* Returns true when exception message is safe to be displayed to a client.
*
* @api
* @return bool
*/
public function isClientSafe(): bool
{
return false;
}
/**
* Returns string describing a category of the error.
*
* Value "graphql" is reserved for errors produced by query parsing or validation, do not use it.
*
* @api
* @return string
*/
public function getCategory(): string
{
return 'schema';
@@ -7,25 +7,11 @@ use GraphQL\Error\ClientAware;
class DirectiveException extends Exception implements ClientAware
{
/**
* Returns true when exception message is safe to be displayed to a client.
*
* @api
* @return bool
*/
public function isClientSafe(): bool
{
return false;
}
/**
* Returns string describing a category of the error.
*
* Value "graphql" is reserved for errors produced by query parsing or validation, do not use it.
*
* @api
* @return string
*/
public function getCategory(): string
{
return 'schema';
@@ -0,0 +1,10 @@
<?php
namespace Nuwave\Lighthouse\Exceptions;
use Exception;
class FederationException extends Exception
{
//
}
@@ -6,30 +6,20 @@ use GraphQL\Error\Error;
class GenericException extends Error
{
/**
* The category.
*
* @var string
*/
protected $category = 'generic';
/**
* Set the contents that will be rendered under the "extensions" key of the error response.
*
* @param mixed $extensions
* @param array<string, mixed> $extensions
* @return $this
*/
public function setExtensions($extensions): self
public function setExtensions(array $extensions): self
{
$this->extensions = (array) $extensions;
$this->extensions = $extensions;
return $this;
}
/**
* Set the category that will be rendered under the "extensions" key of the error response.
*
* @param string $category
* @return $this
*/
public function setCategory(string $category): self
+17 -14
View File
@@ -4,28 +4,31 @@ namespace Nuwave\Lighthouse\Exceptions;
use Exception;
use GraphQL\Error\ClientAware;
use GraphQL\Error\SyntaxError;
use GraphQL\Language\Source;
class ParseException extends Exception implements ClientAware
{
/**
* Returns true when exception message is safe to be displayed to a client.
*
* @api
* @return bool
*/
public function __construct(SyntaxError $error)
{
$message = $error->getMessage();
$source = $error->getSource();
$positions = $error->getPositions();
if ($source instanceof Source && count($positions) > 0) {
$position = $positions[0];
$message .= ', near: '.\Safe\substr($source->body, max(0, $position - 50), 100);
}
parent::__construct($message);
}
public function isClientSafe(): bool
{
return false;
}
/**
* Returns string describing a category of the error.
*
* Value "graphql" is reserved for errors produced by query parsing or validation, do not use it.
*
* @api
* @return string
*/
public function getCategory(): string
{
return 'schema';
@@ -0,0 +1,30 @@
<?php
namespace Nuwave\Lighthouse\Exceptions;
use GraphQL\Error\ClientAware;
use RuntimeException;
/**
* Thrown when the user has reached the rate limit for a field.
*/
class RateLimitException extends RuntimeException implements ClientAware
{
public const MESSAGE = 'Rate limit exceeded. Please try later.';
public const CATEGORY = 'rate-limit';
public function __construct()
{
parent::__construct(self::MESSAGE);
}
public function isClientSafe(): bool
{
return true;
}
public function getCategory(): string
{
return self::CATEGORY;
}
}
@@ -16,7 +16,7 @@ interface RendersErrorsExtensions extends ClientAware
* Return the content that is put in the "extensions" part
* of the returned error.
*
* @return array
* @return array<string, mixed>
*/
public function extensionsContent(): array;
}
@@ -1,35 +0,0 @@
<?php
namespace Nuwave\Lighthouse\Exceptions;
use InvalidArgumentException;
use GraphQL\Error\ClientAware;
class SubscriptionException extends InvalidArgumentException implements ClientAware
{
/**
* Returns true when exception message is safe to be displayed to a client.
*
* @api
*
* @return bool
*/
public function isClientSafe(): bool
{
return true;
}
/**
* Returns string describing a category of the error.
*
* Value "graphql" is reserved for errors produced by query parsing or validation, do not use it.
*
* @api
*
* @return string
*/
public function getCategory(): string
{
return 'subscription';
}
}
@@ -0,0 +1,16 @@
<?php
namespace Nuwave\Lighthouse\Exceptions;
use Exception;
class UnknownCacheVersionException extends Exception
{
/**
* @param mixed $version Should be int, but could be something else
*/
public function __construct($version)
{
parent::__construct("Expected lighthouse.cache.version to be 1 or 2, got: {$version}.");
}
}
@@ -2,36 +2,62 @@
namespace Nuwave\Lighthouse\Exceptions;
class ValidationException extends \Illuminate\Validation\ValidationException implements RendersErrorsExtensions
use Exception;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Validation\ValidationException as LaravelValidationException;
class ValidationException extends Exception implements RendersErrorsExtensions
{
const CATEGORY = 'validation';
/**
* Returns true when exception message is safe to be displayed to a client.
*
* @return bool
* @var \Illuminate\Contracts\Validation\Validator
*/
public function isClientSafe()
protected $validator;
public function __construct(string $message, Validator $validator)
{
parent::__construct($message);
$this->validator = $validator;
}
public static function fromLaravel(LaravelValidationException $laravelException): self
{
return new static(
$laravelException->getMessage(),
$laravelException->validator
);
}
/**
* Instantiate from a plain array of messages.
*
* @see \Illuminate\Validation\ValidationException::withMessages()
*
* @param array<string, string|array<string>> $messages
*/
public static function withMessages(array $messages): self
{
return static::fromLaravel(
LaravelValidationException::withMessages($messages)
);
}
public function isClientSafe(): bool
{
return true;
}
/**
* Returns string describing a category of the error.
*
* @return string
*/
public function getCategory()
public function getCategory(): string
{
return 'validation';
return self::CATEGORY;
}
/**
* Return the content that is put in the "extensions" part
* of the returned error.
*
* @return array
*/
public function extensionsContent(): array
{
return ['validation' => $this->errors()];
return [
self::CATEGORY => $this->validator->errors()->messages(),
];
}
}
@@ -0,0 +1,209 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Nuwave\Lighthouse\Exceptions\DefinitionException;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
use Nuwave\Lighthouse\Support\Utils;
use ReflectionClass;
use ReflectionNamedType;
class ArgPartitioner
{
/**
* Partition the arguments into nested and regular.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @return array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet>
*/
public static function nestedArgResolvers(ArgumentSet $argumentSet, $root): array
{
$model = $root instanceof Model
? new \ReflectionClass($root)
: null;
foreach ($argumentSet->arguments as $name => $argument) {
static::attachNestedArgResolver($name, $argument, $model);
}
return static::partition(
$argumentSet,
static function (string $name, Argument $argument): bool {
return $argument->resolver !== null;
}
);
}
/**
* 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:
*
* [
* 'name' => 'Ralf',
* 'comments' =>
* ['foo' => 'Bar'],
* ]
*
* and the model has a method "comments" that returns a HasMany relationship,
* the result will be:
* [
* [
* 'comments' =>
* ['foo' => 'Bar'],
* ],
* [
* 'name' => 'Ralf',
* ]
* ]
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet}
*/
public static function relationMethods(
ArgumentSet $argumentSet,
Model $model,
string $relationClass
): array {
$modelReflection = new ReflectionClass($model);
[$relations, $remaining] = static::partition(
$argumentSet,
static function (string $name) use ($modelReflection, $relationClass): bool {
return static::methodReturnsRelation($modelReflection, $name, $relationClass);
}
);
$nonNullRelations = new ArgumentSet();
$nonNullRelations->arguments = array_filter(
$relations->arguments,
static function (Argument $argument): bool {
return null !== $argument->value;
}
);
return [$nonNullRelations, $remaining];
}
/**
* Attach a nested argument resolver to an argument.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\Argument $argument
*/
protected static function attachNestedArgResolver(string $name, Argument &$argument, ?ReflectionClass $model): void
{
$resolverDirective = $argument->directives->first(
Utils::instanceofMatcher(ArgResolver::class)
);
if ($resolverDirective) {
$argument->resolver = $resolverDirective;
return;
}
if (isset($model)) {
$isRelation = static function (string $relationClass) use ($model, $name): bool {
return static::methodReturnsRelation($model, $name, $relationClass);
};
if (
$isRelation(HasOne::class)
|| $isRelation(MorphOne::class)
) {
$argument->resolver = new ResolveNested(new NestedOneToOne($name));
return;
}
if (
$isRelation(HasMany::class)
|| $isRelation(MorphMany::class)
) {
$argument->resolver = new ResolveNested(new NestedOneToMany($name));
return;
}
if (
$isRelation(BelongsToMany::class)
|| $isRelation(MorphToMany::class)
) {
$argument->resolver = new ResolveNested(new NestedManyToMany($name));
return;
}
}
}
/**
* Partition arguments based on a predicate.
*
* The predicate will be called for each argument within the ArgumentSet
* with the following parameters:
* 1. The name of the argument
* 2. The argument itself
*
* Returns an array of two new ArgumentSet instances:
* - the first one contains all arguments for which the predicate matched
* - the second one contains all arguments for which the predicate did not match
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet}
*/
public static function partition(ArgumentSet $argumentSet, \Closure $predicate): array
{
$matched = new ArgumentSet();
$notMatched = new ArgumentSet();
foreach ($argumentSet->arguments as $name => $argument) {
if ($predicate($name, $argument)) {
$matched->arguments[$name] = $argument;
} else {
$notMatched->arguments[$name] = $argument;
}
}
return [
$matched,
$notMatched,
];
}
/**
* Does a method on the model return a relation of the given class?
*/
public static function methodReturnsRelation(
ReflectionClass $modelReflection,
string $name,
string $relationClass
): bool {
if (! $modelReflection->hasMethod($name)) {
return false;
}
$relationMethodCandidate = $modelReflection->getMethod($name);
$returnType = $relationMethodCandidate->getReturnType();
if ($returnType === null) {
return false;
}
if (! $returnType instanceof ReflectionNamedType) {
return false;
}
if (! class_exists($returnType->getName())) {
throw new DefinitionException('Class '.$returnType->getName().' does not exist, did you forget to import the Eloquent relation class?');
}
return is_a($returnType->getName(), $relationClass, true);
}
}
@@ -0,0 +1,70 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Support\Collection;
class Argument
{
/**
* The value given by the client.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet>|mixed|array<mixed>
*/
public $value;
/**
* The type of the argument.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType|null
*/
public $type;
/**
* A list of directives associated with that argument.
*
* @var \Illuminate\Support\Collection<\Nuwave\Lighthouse\Support\Contracts\Directive>
*/
public $directives;
/**
* An argument may have a resolver that handles it's given value.
*
* @var \Nuwave\Lighthouse\Support\Contracts\ArgResolver|null
*/
public $resolver;
public function __construct()
{
$this->directives = new Collection();
}
/**
* Get the plain PHP value of this argument.
*
* @return mixed The plain PHP value.
*/
public function toPlain()
{
return static::toPlainRecursive($this->value);
}
/**
* Convert the given value to plain PHP values recursively.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet>|mixed|array<mixed> $value
* @return mixed|array<mixed>
*/
protected static function toPlainRecursive($value)
{
if ($value instanceof ArgumentSet) {
return $value->toArray();
}
if (is_array($value)) {
return array_map([static::class, 'toPlainRecursive'], $value);
}
return $value;
}
}
@@ -0,0 +1,275 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Closure;
use Nuwave\Lighthouse\Schema\Directives\RenameDirective;
use Nuwave\Lighthouse\Schema\Directives\SpreadDirective;
use Nuwave\Lighthouse\Scout\ScoutEnhancer;
use Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective;
use Nuwave\Lighthouse\Support\Contracts\FieldBuilderDirective;
use Nuwave\Lighthouse\Support\Utils;
class ArgumentSet
{
/**
* An associative array from argument names to arguments.
*
* @var array<string, \Nuwave\Lighthouse\Execution\Arguments\Argument>
*/
public $arguments = [];
/**
* An associative array of arguments that were not given.
*
* @var array<string, \Nuwave\Lighthouse\Execution\Arguments\Argument>
*/
public $undefined = [];
/**
* A list of directives.
*
* This may be coming from
* - the field the arguments are a part of
* - the parent argument when in a tree of nested inputs.
*
* @var \Illuminate\Support\Collection<\Nuwave\Lighthouse\Support\Contracts\Directive>
*/
public $directives;
/**
* Get a plain array representation of this ArgumentSet.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$plainArguments = [];
foreach ($this->arguments as $name => $argument) {
$plainArguments[$name] = $argument->toPlain();
}
return $plainArguments;
}
/**
* Check if the ArgumentSet has a non-null value with the given key.
*/
public function has(string $key): bool
{
$argument = $this->arguments[$key] ?? null;
if ($argument === null) {
return false;
}
return $argument->value !== null;
}
/**
* Apply the @spread directive and return a new, modified instance.
*
* @noRector \Rector\DeadCode\Rector\ClassMethod\RemoveDeadRecursiveClassMethodRector
*/
public function spread(): self
{
$argumentSet = new self();
$argumentSet->directives = $this->directives;
foreach ($this->arguments as $name => $argument) {
$value = $argument->value;
// In this case, we do not care about argument sets nested within
// lists, spreading only makes sense for single nested inputs.
if ($value instanceof self) {
// Recurse down first, as that resolves the more deeply nested spreads first
$value = $value->spread();
if ($argument->directives->contains(
Utils::instanceofMatcher(SpreadDirective::class)
)) {
$argumentSet->arguments += $value->arguments;
continue;
}
}
$argumentSet->arguments[$name] = $argument;
}
return $argumentSet;
}
/**
* Apply the @rename directive and return a new, modified instance.
*
* @noRector \Rector\DeadCode\Rector\ClassMethod\RemoveDeadRecursiveClassMethodRector
*/
public function rename(): self
{
$argumentSet = new self();
$argumentSet->directives = $this->directives;
foreach ($this->arguments as $name => $argument) {
// Recursively apply the renaming to nested inputs.
// We look for further ArgumentSet instances, they
// might be contained within an array.
$argument->value = Utils::applyEach(
function ($value) {
if ($value instanceof self) {
return $value->rename();
}
return $value;
},
$argument->value
);
/** @var \Nuwave\Lighthouse\Schema\Directives\RenameDirective|null $renameDirective */
$renameDirective = $argument->directives->first(function ($directive) {
return $directive instanceof RenameDirective;
});
if ($renameDirective !== null) {
$argumentSet->arguments[$renameDirective->attributeArgValue()] = $argument;
} else {
$argumentSet->arguments[$name] = $argument;
}
}
return $argumentSet;
}
/**
* Apply ArgBuilderDirectives and scopes to the builder.
*
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
* @param array<string> $scopes
* @param \Closure $directiveFilter
*
* @return \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|\Laravel\Scout\Builder
*/
public function enhanceBuilder(object $builder, array $scopes, Closure $directiveFilter = null): object
{
$scoutEnhancer = new ScoutEnhancer($this, $builder);
if ($scoutEnhancer->hasSearchArguments()) {
return $scoutEnhancer->enhanceBuilder();
}
self::applyArgBuilderDirectives($this, $builder, $directiveFilter);
self::applyFieldBuilderDirectives($this, $builder);
foreach ($scopes as $scope) {
$builder->{$scope}($this->toArray());
}
return $builder;
}
/**
* Recursively apply the ArgBuilderDirectives onto the builder.
*
* TODO get rid of the reference passing in here. The issue is that @search makes a new builder instance,
* but we must special case that in some way anyhow, as only eq filters can be added on top of search.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
* @param (\Closure(\Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective): bool)|null $directiveFilter
*/
protected static function applyArgBuilderDirectives(self $argumentSet, object &$builder, Closure $directiveFilter = null): void
{
foreach ($argumentSet->arguments as $argument) {
$value = $argument->toPlain();
// 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;
}
$filteredDirectives = $argument
->directives
->filter(Utils::instanceofMatcher(ArgBuilderDirective::class));
if (null !== $directiveFilter) {
$filteredDirectives = $filteredDirectives->filter($directiveFilter);
}
$filteredDirectives->each(static function (ArgBuilderDirective $argBuilderDirective) use (&$builder, $value): void {
$builder = $argBuilderDirective->handleBuilder($builder, $value);
});
Utils::applyEach(
static function ($value) use (&$builder, $directiveFilter) {
if ($value instanceof self) {
self::applyArgBuilderDirectives($value, $builder, $directiveFilter);
}
},
$argument->value
);
}
}
/**
* Apply the FieldBuilderDirectives onto the builder.
*
* TODO get rid of the reference passing in here. The issue is that @search makes a new builder instance,
* but we must special case that in some way anyhow, as only eq filters can be added on top of search.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
*/
protected static function applyFieldBuilderDirectives(self $argumentSet, object &$builder): void
{
$argumentSet->directives
->filter(Utils::instanceofMatcher(FieldBuilderDirective::class))
->each(static function (FieldBuilderDirective $fieldBuilderDirective) use (&$builder): void {
$builder = $fieldBuilderDirective->handleFieldBuilder($builder);
});
}
/**
* Add a value at the dot-separated path.
*
* Works just like @see \Illuminate\Support\Arr::add().
*
* @param mixed $value Any value to inject.
* @return $this
*/
public function addValue(string $path, $value): self
{
$argumentSet = $this;
$keys = explode('.', $path);
while (count($keys) > 1) {
$key = array_shift($keys);
// If the key doesn't exist at this depth, we will just create an empty ArgumentSet
// to hold the next value, allowing us to create the ArgumentSet to hold a final
// value at the correct depth. Then we'll keep digging into the ArgumentSet.
if (! isset($argumentSet->arguments[$key])) {
$argument = new Argument();
$argument->value = new self();
$argumentSet->arguments[$key] = $argument;
}
$argumentSet = $argumentSet->arguments[$key]->value;
}
$argument = new Argument();
$argument->value = $value;
$argumentSet->arguments[array_shift($keys)] = $argument;
return $this;
}
/**
* The contained arguments, including all that were not passed.
*
* @return array<string, \Nuwave\Lighthouse\Execution\Arguments\Argument>
*/
public function argumentsWithUndefined(): array
{
return array_merge($this->arguments, $this->undefined);
}
}
@@ -0,0 +1,179 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputObjectTypeDefinitionNode;
use GraphQL\Language\AST\InputValueDefinitionNode;
use GraphQL\Language\AST\Node;
use GraphQL\Type\Definition\ResolveInfo;
use InvalidArgumentException;
use Nuwave\Lighthouse\Schema\AST\ASTBuilder;
use Nuwave\Lighthouse\Schema\DirectiveLocator;
class ArgumentSetFactory
{
/**
* @var \Nuwave\Lighthouse\Schema\AST\DocumentAST
*/
protected $documentAST;
/**
* @var \Nuwave\Lighthouse\Execution\Arguments\ArgumentTypeNodeConverter
*/
protected $argumentTypeNodeConverter;
/**
* @var \Nuwave\Lighthouse\Schema\DirectiveLocator
*/
protected $directiveLocator;
public function __construct(
ASTBuilder $astBuilder,
ArgumentTypeNodeConverter $argumentTypeNodeConverter,
DirectiveLocator $directiveLocator
) {
$this->documentAST = $astBuilder->documentAST();
$this->argumentTypeNodeConverter = $argumentTypeNodeConverter;
$this->directiveLocator = $directiveLocator;
}
/**
* Wrap client-given args with type information.
*
* @param array<mixed> $args
* @return \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
public function fromResolveInfo(array $args, ResolveInfo $resolveInfo): ArgumentSet
{
/**
* TODO handle programmatic types without an AST gracefully.
*
* @var \GraphQL\Language\AST\FieldDefinitionNode $definition
*/
$definition = $resolveInfo->fieldDefinition->astNode;
return $this->wrapArgs($definition, $args);
}
/**
* Wrap client-given args with type information.
*
* @param \GraphQL\Language\AST\FieldDefinitionNode|\GraphQL\Language\AST\InputObjectTypeDefinitionNode $definition
* @param array<mixed> $args
* @return \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
public function wrapArgs(Node $definition, array $args): ArgumentSet
{
$argumentSet = new ArgumentSet();
$argumentSet->directives = $this->directiveLocator->associated($definition);
if ($definition instanceof FieldDefinitionNode) {
$argDefinitions = $definition->arguments;
} elseif ($definition instanceof InputObjectTypeDefinitionNode) {
$argDefinitions = $definition->fields;
} else {
throw new InvalidArgumentException('Got unexpected node of type '.get_class($definition));
}
$argumentDefinitionMap = $this->makeDefinitionMap($argDefinitions);
foreach ($argumentDefinitionMap as $name => $definition) {
if (array_key_exists($name, $args)) {
$argumentSet->arguments[$name] = $this->wrapInArgument($args[$name], $definition);
} else {
$argumentSet->undefined[$name] = $this->wrapInArgument(null, $definition);
}
}
return $argumentSet;
}
/**
* Make a map with the name as keys.
*
* @param iterable<\GraphQL\Language\AST\InputValueDefinitionNode> $argumentDefinitions
* @return array<string, \GraphQL\Language\AST\InputValueDefinitionNode>
*/
protected function makeDefinitionMap($argumentDefinitions): array
{
$argumentDefinitionMap = [];
foreach ($argumentDefinitions as $definition) {
$argumentDefinitionMap[$definition->name->value] = $definition;
}
return $argumentDefinitionMap;
}
/**
* Wrap a single client-given argument with type information.
*
* @param mixed $value The client given value.
* @return \Nuwave\Lighthouse\Execution\Arguments\Argument
*/
protected function wrapInArgument($value, InputValueDefinitionNode $definition): Argument
{
$type = $this->argumentTypeNodeConverter->convert($definition->type);
$argument = new Argument();
$argument->directives = $this->directiveLocator->associated($definition);
$argument->type = $type;
$argument->value = $this->wrapWithType($value, $type);
return $argument;
}
/**
* Wrap a client-given value with information from a type.
*
* @param mixed|array<mixed> $valueOrValues
* @param \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType $type
* @return array|mixed|\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
protected function wrapWithType($valueOrValues, $type)
{
// No need to recurse down further if the value is null
if ($valueOrValues === null) {
return null;
}
// We have to do this conversion as we are resolving a client query
// because the incoming arguments put a bound on recursion depth
if ($type instanceof ListType) {
$typeInList = $type->type;
$values = [];
foreach ($valueOrValues as $singleValue) {
$values [] = $this->wrapWithType($singleValue, $typeInList);
}
return $values;
}
return $this->wrapWithNamedType($valueOrValues, $type);
}
/**
* Wrap a client-given value with information from a named type.
*
* @param mixed $value The client given value.
* @param \Nuwave\Lighthouse\Execution\Arguments\NamedType $namedType
* @return \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|mixed
*/
protected function wrapWithNamedType($value, NamedType $namedType)
{
// This might be null if the type is
// - created outside of the schema string
// - one of the built in types
$typeDef = $this->documentAST->types[$namedType->name] ?? null;
// We recurse down only if the type is an Input
if ($typeDef instanceof InputObjectTypeDefinitionNode) {
return $this->wrapArgs($typeDef, $value);
}
// Otherwise, we just return the value as is and are done with that subtree
return $value;
}
}
@@ -0,0 +1,36 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Schema\AST\TypeNodeConverter;
class ArgumentTypeNodeConverter extends TypeNodeConverter
{
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType $type
* @return \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType
*/
protected function nonNull($type): object
{
$type->nonNull = true;
return $type;
}
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\NamedType $type
* @return \Nuwave\Lighthouse\Execution\Arguments\ListType
*/
protected function listOf($type): object
{
return new ListType($type);
}
/**
* @return \Nuwave\Lighthouse\Execution\Arguments\NamedType
*/
protected function namedType(string $nodeName): NamedType
{
return new NamedType($nodeName);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
class ListType
{
/**
* The type contained within the list.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\NamedType|\Nuwave\Lighthouse\Execution\Arguments\ListType
*/
public $type;
/**
* Is the list itself defined to be non-nullable?
*
* @var bool
*/
public $nonNull = false;
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\NamedType|\Nuwave\Lighthouse\Execution\Arguments\ListType $type
*/
public function __construct($type)
{
$this->type = $type;
}
}
@@ -0,0 +1,25 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
class NamedType
{
/**
* The name of the type as defined in the schema.
*
* @var string
*/
public $name;
/**
* Is this type defined to be non-nullable?
*
* @var bool
*/
public $nonNull = false;
public function __construct(string $name)
{
$this->name = $name;
}
}
@@ -0,0 +1,88 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedBelongsTo implements ArgResolver
{
/**
* @var \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
protected $relation;
public function __construct(BelongsTo $relation)
{
$this->relation = $relation;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
if ($args->has('create')) {
$saveModel = new ResolveNested(new SaveModel($this->relation));
$related = $saveModel(
// @phpstan-ignore-next-line Unrecognized mixin
$this->relation->make(),
$args->arguments['create']->value
);
$this->relation->associate($related);
}
if ($args->has('connect')) {
$this->relation->associate($args->arguments['connect']->value);
}
if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($this->relation)));
$related = $updateModel(
// @phpstan-ignore-next-line Unrecognized mixin
$this->relation->make(),
$args->arguments['update']->value
);
$this->relation->associate($related);
}
if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($this->relation)));
$related = $upsertModel(
// @phpstan-ignore-next-line Unrecognized mixin
$this->relation->make(),
$args->arguments['upsert']->value
);
$this->relation->associate($related);
}
self::disconnectOrDelete($this->relation, $args);
}
public static function disconnectOrDelete(BelongsTo $relation, ArgumentSet $args): void
{
// 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 (
$args->has('disconnect')
&& $args->arguments['disconnect']->value
) {
$relation->dissociate();
}
if (
$args->has('delete')
&& $args->arguments['delete']->value
) {
$relation->dissociate();
// @phpstan-ignore-next-line Unrecognized mixin
$relation->delete();
}
}
}
@@ -0,0 +1,99 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedManyToMany implements ArgResolver
{
/**
* @var string
*/
protected $relationName;
public function __construct(string $relationName)
{
$this->relationName = $relationName;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
/** @var \Illuminate\Database\Eloquent\Relations\BelongsToMany|\Illuminate\Database\Eloquent\Relations\MorphToMany $relation */
$relation = $parent->{$this->relationName}();
if ($args->has('sync')) {
$relation->sync(
$this->generateRelationArray($args->arguments['sync'])
);
}
if ($args->has('syncWithoutDetaching')) {
$relation->syncWithoutDetaching(
$this->generateRelationArray($args->arguments['syncWithoutDetaching'])
);
}
NestedOneToMany::createUpdateUpsert($args, $relation);
if ($args->has('delete')) {
$ids = $args->arguments['delete']->toPlain();
$relation->detach($ids);
$relation->getRelated()::destroy($ids);
}
if ($args->has('connect')) {
$relation->attach(
$this->generateRelationArray($args->arguments['connect'])
);
}
if ($args->has('disconnect')) {
$relation->detach(
$args->arguments['disconnect']->toPlain()
);
}
}
/**
* Generate an array for passing into sync, syncWithoutDetaching or connect method.
*
* Those functions natively have the capability of passing additional
* data to store in the pivot table. That array expects passing the id's
* as keys, so we transform the passed arguments to match that.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\Argument $args
* @return array<mixed>
*/
protected function generateRelationArray(Argument $args): array
{
$values = $args->toPlain();
if (empty($values)) {
return [];
}
// Since GraphQL inputs are monomorphic, we can just look at the first
// given value and can deduce the value of all given args.
$exemplaryValue = $values[0];
// We assume that the values contain pivot information
if (is_array($exemplaryValue)) {
$relationArray = [];
foreach ($values as $value) {
$id = Arr::pull($value, 'id');
$relationArray[$id] = $value;
}
return $relationArray;
}
// The default case is simply a flat array of IDs which we don't have to transform
return $values;
}
}
@@ -0,0 +1,44 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedMorphTo implements ArgResolver
{
/**
* @var \Illuminate\Database\Eloquent\Relations\MorphTo
*/
protected $relation;
public function __construct(MorphTo $relation)
{
$this->relation = $relation;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
// TODO implement create and update once we figure out how to do polymorphic input types https://github.com/nuwave/lighthouse/issues/900
if ($args->has('connect')) {
$connectArgs = $args->arguments['connect']->value;
$morphToModel = $this->relation->createModelByType(
(string) $connectArgs->arguments['type']->value
);
$morphToModel->setAttribute(
$morphToModel->getKeyName(),
$connectArgs->arguments['id']->value
);
$this->relation->associate($morphToModel);
}
NestedBelongsTo::disconnectOrDelete($this->relation, $args);
}
}
@@ -0,0 +1,119 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Closure;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedOneToMany implements ArgResolver
{
/**
* @var string
*/
protected $relationName;
public function __construct(string $relationName)
{
$this->relationName = $relationName;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
/** @var \Illuminate\Database\Eloquent\Relations\HasMany|\Illuminate\Database\Eloquent\Relations\MorphMany $relation */
$relation = $parent->{$this->relationName}();
static::createUpdateUpsert($args, $relation);
static::connectDisconnect($args, $relation);
if ($args->has('delete')) {
$relation->getRelated()::destroy(
$args->arguments['delete']->toPlain()
);
}
}
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public static function createUpdateUpsert(ArgumentSet $args, Relation $relation): void
{
if ($args->has('create')) {
$saveModel = new ResolveNested(new SaveModel($relation));
foreach ($args->arguments['create']->value as $childArgs) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$saveModel($relation->make(), $childArgs);
}
}
if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation)));
foreach ($args->arguments['update']->value as $childArgs) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$updateModel($relation->make(), $childArgs);
}
}
if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation)));
foreach ($args->arguments['upsert']->value as $childArgs) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$upsertModel($relation->make(), $childArgs);
}
}
}
public static function connectDisconnect(ArgumentSet $args, HasOneOrMany $relation): void
{
if ($args->has('connect')) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$children = $relation
->make()
->whereIn(
self::getLocalKeyName($relation),
$args->arguments['connect']->value
)
->get();
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$relation->saveMany($children);
}
if ($args->has('disconnect')) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$relation
->make()
->whereIn(
self::getLocalKeyName($relation),
$args->arguments['disconnect']->value
)
->update([$relation->getForeignKeyName() => null]);
}
}
/**
* TODO remove this horrible hack when we no longer support Laravel 5.6.
*/
protected static function getLocalKeyName(HasOneOrMany $relation): string
{
$getLocalKeyName = Closure::bind(
function () {
/** @psalm-suppress InvalidScope */
// @phpstan-ignore-next-line This is a dirty hack
return $this->localKey;
},
$relation,
get_class($relation)
);
return $getLocalKeyName();
}
}
@@ -0,0 +1,52 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedOneToOne implements ArgResolver
{
/**
* @var string
*/
protected $relationName;
public function __construct(string $relationName)
{
$this->relationName = $relationName;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
/** @var \Illuminate\Database\Eloquent\Relations\HasOne|\Illuminate\Database\Eloquent\Relations\MorphOne $relation */
$relation = $parent->{$this->relationName}();
if ($args->has('create')) {
$saveModel = new ResolveNested(new SaveModel($relation));
$saveModel($relation->make(), $args->arguments['create']->value);
}
if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation)));
$updateModel($relation->make(), $args->arguments['update']->value);
}
if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation)));
$upsertModel($relation->make(), $args->arguments['upsert']->value);
}
if ($args->has('delete')) {
$relation->getRelated()::destroy(
$args->arguments['delete']->toPlain()
);
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class ResolveNested implements ArgResolver
{
/**
* @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver|null
*/
protected $previous;
/**
* @var callable
*/
protected $argPartitioner;
/**
* @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver|null $previous
*/
public function __construct(callable $previous = null, callable $argPartitioner = null)
{
$this->previous = $previous;
$this->argPartitioner = $argPartitioner ?? [ArgPartitioner::class, 'nestedArgResolvers'];
}
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($root, $args)
{
[$nestedArgs, $regularArgs] = ($this->argPartitioner)($args, $root);
if ($this->previous) {
$root = ($this->previous)($root, $regularArgs);
}
/** @var \Nuwave\Lighthouse\Execution\Arguments\Argument $nested */
foreach ($nestedArgs->arguments as $nested) {
// @phpstan-ignore-next-line we know the resolver is there because we partitioned for it
($nested->resolver)($root, $nested->value);
}
return $root;
}
}
@@ -0,0 +1,95 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class SaveModel implements ArgResolver
{
/**
* @var \Illuminate\Database\Eloquent\Relations\Relation|null
*/
protected $parentRelation;
public function __construct(?Relation $parentRelation = null)
{
$this->parentRelation = $parentRelation;
}
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($model, $args): Model
{
// Extract $morphTo first, as MorphTo extends BelongsTo
[$morphTo, $remaining] = ArgPartitioner::relationMethods(
$args,
$model,
MorphTo::class
);
[$belongsTo, $remaining] = ArgPartitioner::relationMethods(
$remaining,
$model,
BelongsTo::class
);
$argsToFill = $remaining->toArray();
// Use all the remaining attributes and fill the model
if (config('lighthouse.force_fill')) {
$model->forceFill($argsToFill);
} else {
$model->fill($argsToFill);
}
foreach ($belongsTo->arguments as $relationName => $nestedOperations) {
/** @var \Illuminate\Database\Eloquent\Relations\BelongsTo $belongsTo */
$belongsTo = $model->{$relationName}();
$belongsToResolver = new ResolveNested(new NestedBelongsTo($belongsTo));
$belongsToResolver($model, $nestedOperations->value);
}
foreach ($morphTo->arguments as $relationName => $nestedOperations) {
/** @var \Illuminate\Database\Eloquent\Relations\MorphTo $morphTo */
$morphTo = $model->{$relationName}();
$morphToResolver = new ResolveNested(new NestedMorphTo($morphTo));
$morphToResolver($model, $nestedOperations->value);
}
if ($this->parentRelation instanceof HasOneOrMany) {
// 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.
$this->parentRelation->save($model);
return $model;
}
$model->save();
if ($this->parentRelation instanceof BelongsTo) {
$parentModel = $this->parentRelation->associate($model);
// If the parent Model does not exist (still to be saved),
// a save could break any pending belongsTo relations that still
// needs to be created and associated with the parent model
if ($parentModel->exists) {
$parentModel->save();
}
}
if ($this->parentRelation instanceof BelongsToMany) {
$this->parentRelation->syncWithoutDetaching($model);
}
return $model;
}
}
@@ -0,0 +1,45 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use GraphQL\Error\Error;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class UpdateModel implements ArgResolver
{
const MISSING_PRIMARY_KEY_FOR_UPDATE = 'Missing primary key for update.';
/**
* @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver
*/
protected $previous;
/**
* @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous
*/
public function __construct(callable $previous)
{
$this->previous = $previous;
}
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($model, $args)
{
/** @var \Nuwave\Lighthouse\Execution\Arguments\Argument|null $id */
$id = Arr::pull($args->arguments, 'id')
?? Arr::pull($args->arguments, $model->getKeyName())
?? null;
if ($id === null) {
throw new Error(self::MISSING_PRIMARY_KEY_FOR_UPDATE);
}
$model = $model->newQuery()->findOrFail($id->value);
return ($this->previous)($model, $args);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class UpsertModel implements ArgResolver
{
/**
* @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver
*/
protected $previous;
/**
* @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous
*/
public function __construct(callable $previous)
{
$this->previous = $previous;
}
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($model, $args)
{
// TODO consider Laravel native ->upsert(), available from 8.10
$id = $args->arguments['id']
?? $args->arguments[$model->getKeyName()]
?? null;
if ($id !== null) {
$existingModel = $model
->newQuery()
->find($id->value);
if ($existingModel !== null) {
$model = $existingModel;
}
}
return ($this->previous)($model, $args);
}
}
-98
View File
@@ -1,98 +0,0 @@
<?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;
}
}
@@ -0,0 +1,69 @@
<?php
namespace Nuwave\Lighthouse\Execution\BatchLoader;
abstract class BatchLoaderRegistry
{
/**
* Active BatchLoader instances.
*
* @var array<string, object>
*/
protected static $instances = [];
/**
* Return an instance of a BatchLoader for a specific field.
*
* @param array<int|string> $pathToField Path to the GraphQL field from the root, is used as a key for BatchLoader instances
* @param callable(): object $makeInstance Function to instantiate the instance once
* @return object The result of calling makeInstance
*
* @throws \Exception
*/
public static function instance(array $pathToField, callable $makeInstance): object
{
// The path to the field serves as the unique key for the instance
$instanceKey = static::instanceKey($pathToField);
if (isset(self::$instances[$instanceKey])) {
return self::$instances[$instanceKey];
}
return self::$instances[$instanceKey] = $makeInstance();
}
/**
* Remove all stored BatchLoaders.
*
* This is called after Lighthouse has resolved a query, so multiple
* queries can be handled in a single request/session.
*/
public static function forgetInstances(): void
{
self::$instances = [];
}
/**
* Generate a unique key for the instance, using the path in the query.
*
* @param array<int|string> $path
*/
protected static function instanceKey(array $path): string
{
$significantPathSegments = array_filter(
$path,
static function ($segment): bool {
// Ignore numeric path entries, as those signify a list of fields.
// Combining the queries for those is the very purpose of the
// batch loader, so they must not be included.
return ! is_numeric($segment);
}
);
// Using . as the separator would combine relations in nested fields with
// higher up relations using dot notation, matching the field path.
// We might optimize this in the future to enable batching them anyways,
// but employ this solution for now, as it preserves correctness.
return implode('|', $significantPathSegments);
}
}
@@ -0,0 +1,85 @@
<?php
namespace Nuwave\Lighthouse\Execution\BatchLoader;
use GraphQL\Deferred;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Nuwave\Lighthouse\Execution\ModelsLoader\ModelsLoader;
use Nuwave\Lighthouse\Execution\Utils\ModelKey;
class RelationBatchLoader
{
/**
* @var \Nuwave\Lighthouse\Execution\ModelsLoader\ModelsLoader
*/
protected $relationLoader;
/**
* A map from unique keys to parent model instances.
*
* @var array<string, \Illuminate\Database\Eloquent\Model>
*/
protected $parents = [];
/**
* Marks when the actual batch loading happened.
*
* @var bool
*/
protected $hasResolved = false;
public function __construct(ModelsLoader $relationLoader)
{
$this->relationLoader = $relationLoader;
}
/**
* Schedule loading a relation off of a concrete model.
*
* This returns effectively a promise that will resolve to
* the result of loading the relation.
*
* As a side-effect, the model will then hold the relation.
*/
public function load(Model $model): Deferred
{
$modelKey = ModelKey::build($model);
$this->parents[$modelKey] = $model;
return new Deferred(function () use ($modelKey) {
if (! $this->hasResolved) {
$this->resolve();
}
// When we are deep inside a nested query, we can come across the
// same model in two different paths, so this might be another
// model instance then $model.
$parent = $this->parents[$modelKey];
return $this->relationLoader->extract($parent);
});
}
public function resolve(): void
{
$parentModels = new EloquentCollection($this->parents);
// Monomorphize the models to simplify eager loading relations onto them
$parentsGroupedByClass = $parentModels->groupBy(
/**
* @return class-string<\Illuminate\Database\Eloquent\Model>
*/
static function (Model $model): string {
return get_class($model);
},
true
);
foreach ($parentsGroupedByClass as $parentsOfSameClass) {
$this->relationLoader->load($parentsOfSameClass);
}
$this->hasResolved = true;
}
}
-79
View File
@@ -1,79 +0,0 @@
<?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;
}
}
@@ -9,12 +9,6 @@ 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);
@@ -2,29 +2,32 @@
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;
/**
* @deprecated implement your own batch loader instead.
* @see \Nuwave\Lighthouse\Execution\BatchLoader\BatchLoaderRegistry to resolve instances.
*/
abstract class BatchLoader
{
use HandlesCompositeKey;
/**
* Active BatchLoader instances.
*
* @var array<string, static>
*/
protected static $instances = [];
/**
* Keys to resolve.
* Map from keys to metainfo for resolving.
*
* @var array
* @var array<mixed, array<mixed>>
*/
protected $keys = [];
/**
* Map of loaded results.
* Map from keys to resolved values.
*
* [key => resolvedValue]
*
* @var mixed[]
* @var array<mixed, mixed>
*/
protected $results = [];
@@ -39,8 +42,8 @@ abstract class BatchLoader
* 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
* @param array<int|string> $pathToField Path to the GraphQL field from the root, is used as a key for BatchLoader instances
* @param array<mixed> $constructorArgs Those arguments are passed to the constructor of the new BatchLoader instance
* @return static
*
* @throws \Exception
@@ -50,60 +53,52 @@ abstract class BatchLoader
// 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}";
if (isset(self::$instances[$instanceName])) {
return self::$instances[$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;
return self::$instances[$instanceName] = app()->makeWith($loaderClass, $constructorArgs);
}
/**
* Generate a unique key for the instance, using the path in the query.
*
* @param mixed[] $path
* @return string
* @param array<int|string> $path
*/
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.
$significantPathSegments = array_filter(
$path,
function ($path): bool {
// Ignore numeric path entries, as those signify a list 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('_');
}
);
$pathIgnoringLists = implode('.', $significantPathSegments);
return 'nuwave/lighthouse/batchloader/'.$pathIgnoringLists;
}
/**
* Load object by key.
* Remove all stored BatchLoaders.
*
* @param mixed $key
* @param mixed[] $metaInfo
* @return \GraphQL\Deferred
* This is called after Lighthouse has resolved a query, so multiple
* queries can be handled in a single request/session.
*/
public function load($key, array $metaInfo = []): Deferred
public static function forgetInstances(): void
{
self::$instances = [];
}
/**
* Schedule a result to be loaded.
*
* @param array<mixed> $metaInfo
*/
public function load(string $key, array $metaInfo = []): Deferred
{
$key = $this->buildKey($key);
$this->keys[$key] = $metaInfo;
return new Deferred(function () use ($key) {
@@ -116,12 +111,29 @@ abstract class BatchLoader
});
}
/**
* Schedule multiple results to be loaded.
*
* @param array<mixed> $keys
* @param array<mixed> $metaInfo
* @return array<\GraphQL\Deferred>
*/
public function loadMany(array $keys, array $metaInfo = []): array
{
return array_map(
function ($key) use ($metaInfo): Deferred {
return $this->load($key, $metaInfo);
},
$keys
);
}
/**
* Resolve the keys.
*
* The result has to be an associative array: [key => result]
* The result has to be a map from keys to resolved values.
*
* @return mixed[]
* @return array<mixed, mixed>
*/
abstract public function resolve(): array;
}
@@ -1,410 +0,0 @@
<?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);
}
}
@@ -1,122 +0,0 @@
<?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
@@ -1,159 +0,0 @@
<?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;
}
}
+9 -7
View File
@@ -5,17 +5,19 @@ namespace Nuwave\Lighthouse\Execution;
use Closure;
use GraphQL\Error\Error;
/**
* Instantiated through the container, once per query.
*/
interface ErrorHandler
{
/**
* This function receives all GraphQL errors and may alter them or do something else with them.
* Called with each GraphQL error, allows doing anything with them.
*
* Always call $next($error) to keep the Pipeline going. Multiple such Handlers may be registered
* as an array in the config.
* Multiple such Handlers may be registered as an array in the config.
* Always call $next($error) to keep the Pipeline going.
* Returning null discards the error.
*
* @param \GraphQL\Error\Error $error
* @param \Closure $next
* @return array
* @return array<string, mixed>|null
*/
public static function handle(Error $error, Closure $next): array;
public function __invoke(?Error $error, Closure $next): ?array;
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Nuwave\Lighthouse\Execution;
use GraphQL\Error\Error;
use Throwable;
class ErrorPool
{
/**
* The buffered errors.
*
* @var array<int, \Throwable>
*/
protected $throwables = [];
/**
* Stores an error that will be added to the result.
*/
public function record(Throwable $throwable): void
{
$this->throwables [] = $throwable;
}
/**
* @return array<\GraphQL\Error\Error>
*/
public function errors(): array
{
return array_map(
function (Throwable $throwable): Error {
if ($throwable instanceof Error) {
return $throwable;
}
return new Error(
$throwable->getMessage(),
null,
null,
[],
null,
$throwable
);
},
$this->throwables
);
}
public function clear(): void
{
$this->throwables = [];
}
}
@@ -6,32 +6,32 @@ use Closure;
use GraphQL\Error\Error;
use Nuwave\Lighthouse\Exceptions\RendersErrorsExtensions;
/**
* 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.
*/
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
public function __invoke(?Error $error, Closure $next): ?array
{
$underlyingException = $error->getPrevious();
if ($error === null) {
return $next(null);
}
$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,
return $next(new Error(
$error->getMessage(),
// @phpstan-ignore-next-line graphql-php and phpstan disagree with themselves
$error->getNodes(),
$error->getSource(),
$error->getPositions(),
$error->getPath(),
$underlyingException,
$underlyingException->extensionsContent()
);
));
}
return $next($error);
@@ -3,8 +3,7 @@
namespace Nuwave\Lighthouse\Execution;
/**
* May be returned from listeners of the event:.
* @see \Nuwave\Lighthouse\Events\BuildExtensionsResponse
* May be returned from listeners of @see \Nuwave\Lighthouse\Events\BuildExtensionsResponse.
*/
class ExtensionsResponse
{
@@ -16,18 +15,12 @@ class ExtensionsResponse
protected $key;
/**
* JSON-encodable content of the extension.
*
* @var mixed
* @var mixed JSON-encodable content of the extension.
*/
protected $content;
/**
* ExtensionsResponse constructor.
*
* @param string $key
* @param mixed $content
* @return void
* @param mixed $content JSON-encodable content
*/
public function __construct(string $key, $content)
{
@@ -37,8 +30,6 @@ class ExtensionsResponse
/**
* Return the key of the extension.
*
* @return string
*/
public function key(): string
{
@@ -46,9 +37,7 @@ class ExtensionsResponse
}
/**
* Return the JSON-encodable content of the extension.
*
* @return mixed
* @return mixed JSON-encodable content of the extension.
*/
public function content()
{
@@ -1,50 +0,0 @@
<?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;
}

Some files were not shown because too many files have changed in this diff Show More