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
+40 -31
View File
@@ -1,6 +1,6 @@
<div align="center">
<a href="https://www.lighthouse-php.com">
<img src="logo.png" alt=lighthouse-logo" width="150" height="150">
<img src="./logo.png" alt=lighthouse-logo" width="150" height="150">
</a>
</div>
@@ -8,50 +8,59 @@
# Lighthouse
[![Build Status](https://travis-ci.org/nuwave/lighthouse.svg?branch=master)](https://travis-ci.org/nuwave/lighthouse)
[![codecov](https://codecov.io/gh/nuwave/lighthouse/branch/master/graph/badge.svg)](https://codecov.io/gh/nuwave/lighthouse)
[![Continuous Integration](https://github.com/nuwave/lighthouse/workflows/Continuous%20Integration/badge.svg)](https://github.com/nuwave/lighthouse/actions)
[![Code Coverage](https://codecov.io/gh/nuwave/lighthouse/branch/master/graph/badge.svg)](https://codecov.io/gh/nuwave/lighthouse)
[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat)](https://github.com/phpstan/phpstan)
[![StyleCI](https://github.styleci.io/repos/59965104/shield?branch=master)](https://github.styleci.io/repos/59965104)
[![Packagist](https://img.shields.io/packagist/dt/nuwave/lighthouse.svg)](https://packagist.org/packages/nuwave/lighthouse)
[![GitHub license](https://img.shields.io/github/license/nuwave/lighthouse.svg)](https://github.com/nuwave/lighthouse/blob/master/LICENSE)
[![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://join.slack.com/t/lighthouse-php/shared_invite/enQtMzc1NzQwNTUxMjk3LWI1ZDQ1YWM1NmM2MmQ0NTU0NGNjZWFkMTJhY2VjMDAwZmMyZDFlZTc1Mjc3ZGY0MWM1Y2Q5MWNjYmJmYWJkYmU)
[![StyleCI](https://github.styleci.io/repos/59965104/shield?branch=master&style=flat)](https://github.styleci.io/repos/59965104)
[![Packagist](https://img.shields.io/packagist/dt/nuwave/lighthouse.svg)](https://packagist.org/packages/nuwave/lighthouse)
[![Latest Stable Version](https://poser.pugx.org/nuwave/lighthouse/v/stable)](https://packagist.org/packages/nuwave/lighthouse)
[![GitHub license](https://img.shields.io/github/license/nuwave/lighthouse.svg)](https://github.com/nuwave/lighthouse/blob/master/LICENSE)
[![Ask on Stack Overflow](https://img.shields.io/badge/StackOverflow-ask-orange.svg)](https://stackoverflow.com/questions/tagged/laravel-lighthouse)
[![Get on Slack](https://img.shields.io/badge/Slack-join-blueviolet.svg)](https://join.slack.com/t/lighthouse-php/shared_invite/zt-4sm280w1-wu21r94f3kLRRtBXRbXVfw)
**A framework for serving GraphQL from Laravel**
**GraphQL Server for Laravel**
</div>
Lighthouse is a PHP package that allows you to serve a GraphQL endpoint from your
Laravel application. It greatly reduces the boilerplate required to create a schema,
it integrates well with any Laravel project, and it's highly customizable
giving you full control over your data.
Lighthouse is a GraphQL framework that integrates with your Laravel application.
It takes the best ideas of both and combines them to solve common tasks with ease
and offer flexibility when you need it.
## [Documentation](https://lighthouse-php.com/)
## Documentation
The documentation lives at [lighthouse-php.com](https://lighthouse-php.com/).
If you like reading plain markdown, you can also find the source files in the [docs folder](/docs).
The site includes the latest docs for each major version of Lighthouse.
You can find docs for specific versions by looking at the contents of [/docs/master](/docs/master)
at that point in the git history: `https://github.com/nuwave/lighthouse/tree/<SPECIFIC-TAG>/docs/master`.
## Get started
If you have an existing Laravel project, all you really need
to get up and running is a few steps:
1. Install via `composer require nuwave/lighthouse`
2. Publish the default schema `php artisan vendor:publish --provider="Nuwave\Lighthouse\LighthouseServiceProvider" --tag=schema`
3. Use something like [GraphQL Playground](https://github.com/mll-lab/laravel-graphql-playground) to explore your GraphQL endpoint
Check out [the docs](https://lighthouse-php.com/) to learn more.
A chinese translation is available at [lighthouse-php.cn](http://lighthouse-php.cn/) and is maintained
over at https://github.com/haxibiao/lighthouse.
## Get involved
We welcome contributions of any kind.
- Have a question? [Use the laravel-lighthouse tag on Stackoverflow](https://stackoverflow.com/questions/tagged/laravel-lighthouse)
- Talk to other users? [Hop into Slack](https://join.slack.com/t/lighthouse-php/shared_invite/enQtMzc1NzQwNTUxMjk3LWI1ZDQ1YWM1NmM2MmQ0NTU0NGNjZWFkMTJhY2VjMDAwZmMyZDFlZTc1Mjc3ZGY0MWM1Y2Q5MWNjYmJmYWJkYmU)
- Have a question? [Use the laravel-lighthouse tag on Stack Overflow](https://stackoverflow.com/questions/tagged/laravel-lighthouse)
- Talk to other users? [Hop into Slack](https://join.slack.com/t/lighthouse-php/shared_invite/zt-4sm280w1-wu21r94f3kLRRtBXRbXVfw)
- Found a bug? [Report a bug](https://github.com/nuwave/lighthouse/issues/new?template=bug_report.md)
- Need a feature? [Open a feature request](https://github.com/nuwave/lighthouse/issues/new?template=feature_request.md)
- Want to improve Lighthouse? [Read our contribution guidelines](https://github.com/nuwave/lighthouse/blob/master/.github/CONTRIBUTING.md)
- Have an idea? [Propose a feature](https://github.com/nuwave/lighthouse/issues/new?template=feature_proposal.md)
- Want to improve Lighthouse? [Read our contribution guidelines](https://github.com/nuwave/lighthouse/blob/master/CONTRIBUTING.md)
## Changelog
All notable changes to this project are documented in [`CHANGELOG.md`](CHANGELOG.md).
## Upgrade Guide
When upgrading between major versions of Lighthouse, consider [`UPGRADE.md`](UPGRADE.md).
## Contributing
We welcome contributions of any kind, see how in [`CONTRIBUTING.md`](CONTRIBUTING.md).
## Security Vulnerabilities
If you discover a security vulnerability within Lighthouse,
please email Benedikt Franke via [benedikt@franke.tech](mailto:benedikt@franke.tech).
please email Benedikt Franke via [benedikt@franke.tech](mailto:benedikt@franke.tech)
or visit https://tidelift.com/security.
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace Illuminate\Foundation\Testing {
class TestResponse
{
/**
* Asserts that the response contains an error from a given category.
*
* @param string $category The name of the expected error category.
* @return $this
*/
public function assertGraphQLErrorCategory(string $category): self
{
return $this;
}
/**
* Assert that the returned result contains exactly the given validation keys.
*
* @param array $keys The validation keys the result should have.
* @return $this
*/
public function assertGraphQLValidationKeys(array $keys): self
{
return $this;
}
/**
* Assert that a given validation error is present in the response.
*
* @param string $key The validation key that should be present.
* @param string $message The expected validation message.
* @return $this
*/
public function assertGraphQLValidationError(string $key, string $message): self
{
return $this;
}
/**
* Assert that no validation errors are present in the response.
*
* @return $this
*/
public function assertGraphQLValidationPasses(): self
{
return $this;
}
}
}
namespace Illuminate\Testing {
class TestResponse
{
/**
* Assert the response contains an error with the given message.
*
* @param string $message The expected error message.
* @return $this
*/
public function assertGraphQLErrorMessage(string $message): self
{
return $this;
}
/**
* Assert the response contains an error from the given category.
*
* @param string $category The name of the expected error category.
* @return $this
*/
public function assertGraphQLErrorCategory(string $category): self
{
return $this;
}
/**
* Assert the returned result contains exactly the given validation keys.
*
* @param array $keys The validation keys the result should have.
* @return $this
*/
public function assertGraphQLValidationKeys(array $keys): self
{
return $this;
}
/**
* Assert a given validation error is present in the response.
*
* @param string $key The validation key that should be present.
* @param string $message The expected validation message.
* @return $this
*/
public function assertGraphQLValidationError(string $key, string $message): self
{
return $this;
}
/**
* Assert no validation errors are present in the response.
*
* @return $this
*/
public function assertGraphQLValidationPasses(): self
{
return $this;
}
}
}
namespace GraphQL\Type\Definition {
class ResolveInfo
{
/**
* We monkey patch this onto here to pass it down the resolver chain.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
public $argumentSet;
}
}
+67 -44
View File
@@ -1,15 +1,14 @@
{
"name": "nuwave/lighthouse",
"description": "Lighthouse is a schema first GraphQL package for Laravel applications.",
"type": "library",
"description": "A framework for serving GraphQL from Laravel",
"keywords": [
"api",
"graphql",
"laravel",
"laravel-graphql"
],
"license": "MIT",
"homepage": "https://lighthouse-php.com",
"license": "MIT",
"authors": [
{
"name": "Christopher Moore",
@@ -22,38 +21,71 @@
"homepage": "https://franke.tech"
}
],
"support": {
"issues": "https://github.com/nuwave/lighthouse/issues",
"source": "https://github.com/nuwave/lighthouse"
},
"require": {
"php": ">= 7.1",
"php": ">= 7.2",
"ext-json": "*",
"illuminate/contracts": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0",
"illuminate/http": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0",
"illuminate/pagination": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0",
"illuminate/routing": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0",
"illuminate/support": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0",
"illuminate/validation": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0",
"webonyx/graphql-php": "^0.13.2"
"haydenpierce/class-finder": "^0.4",
"illuminate/auth": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/bus": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/contracts": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/http": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/pagination": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/queue": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/routing": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/support": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"illuminate/validation": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"laragraph/utils": "^1",
"thecodingmachine/safe": "^1",
"webonyx/graphql-php": "^14.7"
},
"require-dev": {
"bensampo/laravel-enum": "^1.22",
"laravel/lumen-framework": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0",
"laravel/scout": "^4.0",
"mll-lab/graphql-php-scalars": "^2.1",
"mockery/mockery": "^1.0",
"orchestra/database": "3.5.*|3.6.*|3.7.*|3.8.*|3.9.*",
"orchestra/testbench": "3.5.*|3.6.*|3.7.*|3.8.*|3.9.*",
"phpbench/phpbench": "@dev",
"pusher/pusher-php-server": "^3.2",
"haydenpierce/class-finder": "^0.3.3"
"bensampo/laravel-enum": "^1.28.3 || ^2 || ^3",
"ergebnis/composer-normalize": "^2.2.2",
"finwe/phpstan-faker": "^0.1.0",
"laravel/framework": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"laravel/legacy-factories": "^1",
"laravel/lumen-framework": "5.6.* || 5.7.* || 5.8.* || ^6 || ^7 || ^8",
"laravel/scout": "^7 || ^8",
"mll-lab/graphql-php-scalars": "^4",
"mockery/mockery": "^1",
"nunomaduro/larastan": "^0.6 || ^0.7",
"orchestra/testbench": "3.6.* || 3.7.* || 3.8.* || 3.9.* || ^4 || ^5 || ^6",
"phpbench/phpbench": "1.0.0-alpha4",
"phpstan/phpstan": "0.12.89",
"phpstan/phpstan-mockery": "^0.12.5",
"phpstan/phpstan-phpunit": "^0.12.17",
"phpunit/phpunit": "^7.5 || ^8.4 || ^9",
"predis/predis": "^1.1",
"pusher/pusher-php-server": "^4 || ^5",
"rector/rector": "^0.9",
"thecodingmachine/phpstan-safe-rule": "^1",
"vimeo/psalm": "^4.7"
},
"suggest": {
"bensampo/laravel-enum": "Convenient enum definitions that can easily be registered in your Schema",
"laravel/scout": "Required for the @search directive",
"mll-lab/graphql-php-scalars": "Useful scalar types, required for @whereConstraints",
"mll-lab/graphql-php-scalars": "Useful scalar types, required for @whereConditions",
"mll-lab/laravel-graphql-playground": "GraphQL IDE for better development workflow - integrated with Laravel",
"bensampo/laravel-enum": "Convenient enum definitions that can easily be registered in your Schema"
"pusher/pusher-php-server": "Required when using the Pusher Subscriptions driver"
},
"config": {
"sort-packages": true
},
"extra": {
"laravel": {
"aliases": {
"graphql": "Nuwave\\Lighthouse\\GraphQL"
},
"providers": [
"Nuwave\\Lighthouse\\LighthouseServiceProvider",
"Nuwave\\Lighthouse\\GlobalId\\GlobalIdServiceProvider",
"Nuwave\\Lighthouse\\OrderBy\\OrderByServiceProvider",
"Nuwave\\Lighthouse\\Pagination\\PaginationServiceProvider",
"Nuwave\\Lighthouse\\Scout\\ScoutServiceProvider",
"Nuwave\\Lighthouse\\SoftDeletes\\SoftDeletesServiceProvider",
"Nuwave\\Lighthouse\\Validation\\ValidationServiceProvider"
]
}
},
"autoload": {
"psr-4": {
@@ -67,24 +99,15 @@
}
},
"scripts": {
"test": "phpunit --colors=always",
"test:unit": "phpunit --colors=always --testsuite Unit",
"test:integration": "phpunit --colors=always --testsuite Integration",
"bench": "phpbench run",
"rector": "rector process -v src/ tests/",
"stan": "phpstan analyse --memory-limit 2048M",
"bench": "phpbench run"
"test": "phpunit --colors=always",
"test:integration": "phpunit --colors=always --testsuite Integration",
"test:unit": "phpunit --colors=always --testsuite Unit"
},
"extra": {
"laravel": {
"providers": [
"Nuwave\\Lighthouse\\LighthouseServiceProvider",
"Nuwave\\Lighthouse\\SoftDeletes\\SoftDeletesServiceProvider"
],
"aliases": {
"graphql": "Nuwave\\Lighthouse\\GraphQL"
}
}
},
"config": {
"sort-packages": true
"support": {
"issues": "https://github.com/nuwave/lighthouse/issues",
"source": "https://github.com/nuwave/lighthouse"
}
}
-240
View File
@@ -1,240 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Route Configuration
|--------------------------------------------------------------------------
|
| Controls the HTTP route that your GraphQL server responds to.
| You may set `route` => false, to disable the default route
| registration and take full control.
|
*/
'route' => [
/*
* The URI the endpoint responds to, e.g. mydomain.com/graphql.
*/
'uri' => 'graphql',
/*
* Lighthouse creates a named route for convenient URL generation and redirects.
*/
'name' => 'graphql',
/*
*
* Beware that middleware defined here runs before the GraphQL execution phase,
* so you have to take extra care to return spec-compliant error responses.
* To apply middleware on a field level, use the @middleware directive.
*/
'middleware' => [
\Nuwave\Lighthouse\Support\Http\Middleware\AcceptJson::class,
],
],
/*
|--------------------------------------------------------------------------
| Schema Declaration
|--------------------------------------------------------------------------
|
| This is a path that points to where your GraphQL schema is located
| relative to the app path. You should define your entire GraphQL
| schema in this file (additional files may be imported).
|
*/
'schema' => [
'register' => base_path('graphql/schema.graphql'),
],
/*
|--------------------------------------------------------------------------
| Schema Cache
|--------------------------------------------------------------------------
|
| A large part of schema generation consists of parsing and AST manipulation.
| This operation is very expensive, so it is highly recommended to enable
| caching of the final schema to optimize performance of large schemas.
|
*/
'cache' => [
'enable' => env('LIGHTHOUSE_CACHE_ENABLE', true),
'key' => env('LIGHTHOUSE_CACHE_KEY', 'lighthouse-schema'),
'ttl' => env('LIGHTHOUSE_CACHE_TTL', null),
],
/*
|--------------------------------------------------------------------------
| Namespaces
|--------------------------------------------------------------------------
|
| These are the default namespaces where Lighthouse looks for classes
| that extend functionality of the schema. You may pass either a string
| or an array, they are tried in order and the first match is used.
|
*/
'namespaces' => [
'models' => ['App', 'App\\Models'],
'queries' => 'App\\GraphQL\\Queries',
'mutations' => 'App\\GraphQL\\Mutations',
'subscriptions' => 'App\\GraphQL\\Subscriptions',
'interfaces' => 'App\\GraphQL\\Interfaces',
'unions' => 'App\\GraphQL\\Unions',
'scalars' => 'App\\GraphQL\\Scalars',
'directives' => ['App\\GraphQL\\Directives'],
],
/*
|--------------------------------------------------------------------------
| Security
|--------------------------------------------------------------------------
|
| Control how Lighthouse handles security related query validation.
| This configures the options from http://webonyx.github.io/graphql-php/security/
|
*/
'security' => [
'max_query_complexity' => \GraphQL\Validator\Rules\QueryComplexity::DISABLED,
'max_query_depth' => \GraphQL\Validator\Rules\QueryDepth::DISABLED,
'disable_introspection' => \GraphQL\Validator\Rules\DisableIntrospection::DISABLED,
],
/*
|--------------------------------------------------------------------------
| Pagination
|--------------------------------------------------------------------------
|
| Limits the maximum "count" that users may pass as an argument
| to fields that are paginated with the @paginate directive.
| A setting of "null" means the count is unrestricted.
|
*/
'paginate_max_count' => null,
/*
|--------------------------------------------------------------------------
| Pagination Amount Argument
|--------------------------------------------------------------------------
|
| Set the name to use for the generated argument on paginated fields
| that controls how many results are returned.
| This setting will be removed in v5.
|
*/
'pagination_amount_argument' => 'first',
/*
|--------------------------------------------------------------------------
| Debug
|--------------------------------------------------------------------------
|
| Control the debug level as described in http://webonyx.github.io/graphql-php/error-handling/
| Debugging is only applied if the global Laravel debug config is set to true.
|
*/
'debug' => \GraphQL\Error\Debug::INCLUDE_DEBUG_MESSAGE | \GraphQL\Error\Debug::INCLUDE_TRACE,
/*
|--------------------------------------------------------------------------
| Error Handlers
|--------------------------------------------------------------------------
|
| Register error handlers that receive the Errors that occur during execution
| and handle them. You may use this to log, filter or format the errors.
| The classes must implement \Nuwave\Lighthouse\Execution\ErrorHandler
|
*/
'error_handlers' => [
\Nuwave\Lighthouse\Execution\ExtensionErrorHandler::class,
],
/*
|--------------------------------------------------------------------------
| Global ID
|--------------------------------------------------------------------------
|
| The name that is used for the global id field on the Node interface.
| When creating a Relay compliant server, this must be named "id".
|
*/
'global_id_field' => 'id',
/*
|--------------------------------------------------------------------------
| Batched Queries
|--------------------------------------------------------------------------
|
| GraphQL query batching means sending multiple queries to the server in one request,
| You may set this flag to either process or deny batched queries.
|
*/
'batched_queries' => true,
/*
|--------------------------------------------------------------------------
| Transactional Mutations
|--------------------------------------------------------------------------
|
| Sets default setting for transactional mutations.
| You may set this flag to have @create|@update mutations transactional or not.
|
*/
'transactional_mutations' => true,
/*
|--------------------------------------------------------------------------
| GraphQL Subscriptions
|--------------------------------------------------------------------------
|
| Here you can define GraphQL subscription "broadcasters" and "storage" drivers
| as well their required configuration options.
|
*/
'subscriptions' => [
/*
* Determines if broadcasts should be queued by default.
*/
'queue_broadcasts' => env('LIGHTHOUSE_QUEUE_BROADCASTS', true),
/*
* Default subscription storage.
*
* Any Laravel supported cache driver options are available here.
*/
'storage' => env('LIGHTHOUSE_SUBSCRIPTION_STORAGE', 'redis'),
/*
* Default subscription broadcaster.
*/
'broadcaster' => env('LIGHTHOUSE_BROADCASTER', 'pusher'),
/*
* Subscription broadcasting drivers with config options.
*/
'broadcasters' => [
'log' => [
'driver' => 'log',
],
'pusher' => [
'driver' => 'pusher',
'routes' => \Nuwave\Lighthouse\Subscriptions\SubscriptionRouter::class.'@pusher',
'connection' => 'pusher',
],
],
],
];
+29
View File
@@ -0,0 +1,29 @@
<?php
use Rector\Core\Configuration\Option;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedParameterRector;
use Rector\Set\ValueObject\SetList;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$parameters = $containerConfigurator->parameters();
$parameters->set(Option::SETS, [
SetList::CODE_QUALITY,
SetList::DEAD_CODE,
SetList::PHPUNIT_EXCEPTION,
SetList::PHPUNIT_SPECIFIC_METHOD,
SetList::PHPUNIT_YIELD_DATA_PROVIDER,
]);
$parameters->set(Option::SKIP, [
// Does not fit autoloading standards
__DIR__.'/tests/database/migrations',
// Gets stuck on WhereConditionsBaseDirective for some reason
__DIR__.'/src/WhereConditions',
// Having unused parameters can increase clarity, e.g. in event handlers
RemoveUnusedParameterRector::class,
]);
};
@@ -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;
}
}

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