diff --git a/docs/master/digging-deeper/extending-lighthouse.md b/docs/master/digging-deeper/extending-lighthouse.md index 45df26493c..1272b64896 100644 --- a/docs/master/digging-deeper/extending-lighthouse.md +++ b/docs/master/digging-deeper/extending-lighthouse.md @@ -35,15 +35,17 @@ final class SomePackageServiceProvider extends ServiceProvider ## Changing the default resolver -Lighthouse will fall back to using [webonyx's default resolver](https://webonyx.github.io/graphql-php/data-fetching/#default-field-resolver) +Lighthouse overrides [webonyx's default resolver](https://webonyx.github.io/graphql-php/data-fetching#default-field-resolver) for non-root fields, [see resolver precedence](../the-basics/fields.md#resolver-precedence). -You may overwrite this by passing a `callable` to `GraphQL\Executor\Executor::setDefaultFieldResolver()`. +See `Nuwave\Lighthouse\LighthouseServiceProvider::defaultFieldResolver()` for the implementation. + +You may override this by calling `GraphQL\Executor\Executor::setDefaultFieldResolver()` in your service provider's `boot()` method. ## Use a custom `GraphQLContext` The context is the third argument of any resolver function. -You may replace the default `\Nuwave\Lighthouse\Schema\Context` with your own +You may replace the default `Nuwave\Lighthouse\Schema\Context` with your own implementation of the interface `Nuwave\Lighthouse\Support\Contracts\GraphQLContext`. The following example is just a starting point of what you can do: diff --git a/src/LighthouseServiceProvider.php b/src/LighthouseServiceProvider.php index 46fd630391..68f79f6181 100644 --- a/src/LighthouseServiceProvider.php +++ b/src/LighthouseServiceProvider.php @@ -6,9 +6,13 @@ use GraphQL\Error\Error; use GraphQL\Error\ProvidesExtensions; use GraphQL\Executor\ExecutionResult; +use GraphQL\Executor\Executor; +use GraphQL\Type\Definition\ResolveInfo; +use GraphQL\Utils\Utils; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Illuminate\Contracts\Events\Dispatcher as EventsDispatcher; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Http\JsonResponse; use Illuminate\Support\ServiceProvider; @@ -99,14 +103,14 @@ public function provideSubscriptionResolver(FieldValue $fieldValue): \Closure }); $this->app->bind(ProvidesValidationRules::class, CacheableValidationRulesProvider::class); - - $this->commands(self::COMMANDS); } public function boot(ConfigRepository $configRepository, EventsDispatcher $dispatcher): void { $dispatcher->listen(RegisterDirectiveNamespaces::class, static fn (): string => __NAMESPACE__ . '\\Schema\\Directives'); + $this->commands(self::COMMANDS); + $this->publishes([ __DIR__ . '/lighthouse.php' => $this->app->configPath() . '/lighthouse.php', ], 'lighthouse-config'); @@ -142,6 +146,39 @@ public function boot(ConfigRepository $configRepository, EventsDispatcher $dispa return new JsonResponse($serializableResult); }); } + + Executor::setDefaultFieldResolver([static::class, 'defaultFieldResolver']); + } + + /** + * The default field resolver for GraphQL queries. + * + * This method is used to resolve fields on the object-like value returned by a resolver. + * It checks if the value is an Eloquent model and retrieves the attribute or property accordingly. + * Otherwise, it falls back to the default behavior from webonyx/graphql-php's default field resolver. + * + * @see \GraphQL\Executor\Executor::defaultFieldResolver() + * + * @return callable(mixed $objectLikeValue, array $args, mixed $contextValue, ResolveInfo $info): mixed + */ + public static function defaultFieldResolver(): callable + { + return static function ($objectLikeValue, array $args, $contextValue, ResolveInfo $info): mixed { + $fieldName = $info->fieldName; + + if ($objectLikeValue instanceof Model) { + $property = $objectLikeValue->getAttribute($fieldName); + if ($property === null && property_exists($objectLikeValue, $fieldName)) { + $property = $objectLikeValue->{$fieldName}; + } + } else { + $property = Utils::extractKey($objectLikeValue, $fieldName); + } + + return $property instanceof \Closure + ? $property($objectLikeValue, $args, $contextValue, $info) + : $property; + }; } protected function loadRoutesFrom($path): void diff --git a/tests/Integration/Models/PropertyAccessTest.php b/tests/Integration/Models/PropertyAccessTest.php new file mode 100644 index 0000000000..6d3cbc18f6 --- /dev/null +++ b/tests/Integration/Models/PropertyAccessTest.php @@ -0,0 +1,215 @@ +make(); + assert($user instanceof User); + $user->name = $name; + $user->save(); + + $this->schema = /** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(id: $id) { + name + } + } + ', [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'name' => $name, + ], + ], + ]); + } + + public function testLaravelFunctionProperty(): void + { + $user = factory(User::class)->create(); + assert($user instanceof User); + + $this->schema = /** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(id: $id) { + laravel_function_property + } + } + ', [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'laravel_function_property' => User::FUNCTION_PROPERTY_ATTRIBUTE_VALUE, + ], + ], + ]); + } + + /** @see https://github.com/nuwave/lighthouse/issues/2687 */ + public function testPhpProperty(): void + { + $user = factory(User::class)->create(); + assert($user instanceof User); + + $this->schema = /** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(id: $id) { + php_property + } + } + ', [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'php_property' => User::PHP_PROPERTY_VALUE, + ], + ], + ]); + } + + /** @see https://github.com/nuwave/lighthouse/issues/2687 */ + public function testPrefersAttributeAccessorThatShadowsPhpProperty(): void + { + $user = factory(User::class)->create(); + assert($user instanceof User); + + $this->schema = /** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(id: $id) { + incrementing + } + } + ', [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'incrementing' => User::INCREMENTING_ATTRIBUTE_VALUE, + ], + ], + ]); + } + + /** @see https://github.com/nuwave/lighthouse/issues/2687 */ + public function testPrefersAttributeAccessorNullThatShadowsPhpProperty(): void + { + $user = factory(User::class)->create(); + assert($user instanceof User); + + $this->schema = /** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(id: $id) { + exists + } + } + ', [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'exists' => null, + ], + ], + ]); + } + + /** @see https://github.com/nuwave/lighthouse/issues/1671 */ + public function testExpensivePropertyIsOnlyCalledOnce(): void + { + $user = factory(User::class)->create(); + assert($user instanceof User); + + $this->schema = /** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(id: $id) { + expensive_property + } + } + ', [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'expensive_property' => 1, + ], + ], + ]); + } +} diff --git a/tests/Utils/Models/User.php b/tests/Utils/Models/User.php index 721b11c5dc..2cacff91af 100644 --- a/tests/Utils/Models/User.php +++ b/tests/Utils/Models/User.php @@ -37,6 +37,8 @@ * * Virtual * @property-read string|null $company_name + * @property-read string $laravel_function_property @see \Tests\Integration\Models\PropertyAccessTest + * @property-read int $expensive_property @see \Tests\Integration\Models\PropertyAccessTest * * Relations * @property-read \Illuminate\Database\Eloquent\Collection $alternateConnections @@ -51,6 +53,12 @@ */ final class User extends Authenticatable { + public const INCREMENTING_ATTRIBUTE_VALUE = 'value of the incrementing attribute'; + + public const FUNCTION_PROPERTY_ATTRIBUTE_VALUE = 'value of the virtual property'; + + public const PHP_PROPERTY_VALUE = 'value of the PHP property'; + /** * Ensure that this is functionally equivalent to leaving this as null. * @@ -63,6 +71,9 @@ final class User extends Authenticatable 'email_verified_at' => 'datetime', ]; + /** @see \Tests\Integration\Models\PropertyAccessTest */ + public string $php_property = self::PHP_PROPERTY_VALUE; + public function newEloquentBuilder($query): UserBuilder { return new UserBuilder($query); @@ -146,9 +157,7 @@ public function tasksCountLoaded(): bool public function postsCommentsLoaded(): bool { return $this->relationLoaded('posts') - && $this - ->posts - ->first() + && $this->posts->first() ?->relationLoaded('comments'); } @@ -161,9 +170,7 @@ public function tasksAndPostsCommentsLoaded(): bool public function postsTaskLoaded(): bool { return $this->relationLoaded('posts') - && $this - ->posts - ->first() + && $this->posts->first() ?->relationLoaded('task'); } @@ -177,4 +184,31 @@ public function nonRelationPrimitive(): string { return 'foo'; } + + /** @see \Tests\Integration\Models\PropertyAccessTest */ + public function getLaravelFunctionPropertyAttribute(): string + { + return self::FUNCTION_PROPERTY_ATTRIBUTE_VALUE; + } + + /** @see \Tests\Integration\Models\PropertyAccessTest */ + public function getExpensivePropertyAttribute(): int + { + static $counter = 0; + ++$counter; + + return $counter; + } + + /** @see \Tests\Integration\Models\PropertyAccessTest */ + public function getIncrementingAttribute(): string + { + return self::INCREMENTING_ATTRIBUTE_VALUE; + } + + /** @see \Tests\Integration\Models\PropertyAccessTest */ + public function getExistsAttribute(): ?bool // @phpstan-ignore return.unusedType + { + return null; + } }