Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/master/digging-deeper/extending-lighthouse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
41 changes: 39 additions & 2 deletions src/LighthouseServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<string, mixed> $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};
}
Comment on lines +170 to +173
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we find an implementation that keeps all the tests happy? Or should we bite the bullet and let testPrefersAttributeAccessorNullThatShadowsPhpProperty fail?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I think having a php property and Laravel-access attribute of the same name is an anti-pattern. I'd even argue that php properties should have priority as that is the way php itself does it. Also they are less computationally expensive to check for presence via property_exists.
The only way to check this would involve checking all the Laravel ways you can define an attribute, not sure if that is worth the effort and extra computation for something that should be evaded in the first place.
Also I don't think such a check can be 100% reliable. While I cannot think of a reason someone would do this, the field could refer to a DB column that isn't selected so it does not appear in Model::$attributes.

Something that might be valuable is adding a check in the maintenance artisan commands that check for name collisions and warn the developer. If for some reason they really need to shadow a php property with a model attribute they can create an accessor with a different name and use @rename.

} else {
$property = Utils::extractKey($objectLikeValue, $fieldName);
}

return $property instanceof \Closure
? $property($objectLikeValue, $args, $contextValue, $info)
: $property;
};
}

protected function loadRoutesFrom($path): void
Expand Down
215 changes: 215 additions & 0 deletions tests/Integration/Models/PropertyAccessTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<?php declare(strict_types=1);

namespace Tests\Integration\Models;

use Tests\DBTestCase;
use Tests\Utils\Models\User;

final class PropertyAccessTest extends DBTestCase
{
public function testLaravelDatabaseProperty(): void
{
$name = 'foobar';

$user = factory(User::class)->make();
assert($user instanceof User);
$user->name = $name;
$user->save();

$this->schema = /** @lang GraphQL */ <<<GRAPHQL
type User {
id: ID!
name: String!
}

type Query {
user(id: ID! @eq): User @find
}
GRAPHQL;

$this->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
type User {
id: ID!
laravel_function_property: String!
}

type Query {
user(id: ID! @eq): User @find
}
GRAPHQL;

$this->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
type User {
id: ID!
php_property: String!
}

type Query {
user(id: ID! @eq): User @find
}
GRAPHQL;

$this->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
type User {
id: ID!
incrementing: String!
}

type Query {
user(id: ID! @eq): User @find
}
GRAPHQL;

$this->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
Comment on lines +148 to +149
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is failing with the current override of the default field resolver, whereas it used to work before.

{
$user = factory(User::class)->create();
assert($user instanceof User);

$this->schema = /** @lang GraphQL */ <<<GRAPHQL
type User {
id: ID!
exists: Boolean
}

type Query {
user(id: ID! @eq): User @find
}
GRAPHQL;

$this->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
Comment on lines +182 to +183
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we override the default field resolver, we should also consider this issue and fix it too.

{
$user = factory(User::class)->create();
assert($user instanceof User);

$this->schema = /** @lang GraphQL */ <<<GRAPHQL
type User {
id: ID!
expensive_property: Int!
}

type Query {
user(id: ID! @eq): User @find
}
GRAPHQL;

$this->graphQL(/** @lang GraphQL */ '
query ($id: ID!) {
user(id: $id) {
expensive_property
}
}
', [
'id' => $user->id,
])->assertJson([
'data' => [
'user' => [
'expensive_property' => 1,
],
],
]);
}
}
46 changes: 40 additions & 6 deletions tests/Utils/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, \Tests\Utils\Models\AlternateConnection> $alternateConnections
Expand All @@ -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.
*
Expand All @@ -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);
Expand Down Expand Up @@ -146,9 +157,7 @@ public function tasksCountLoaded(): bool
public function postsCommentsLoaded(): bool
{
return $this->relationLoaded('posts')
&& $this
->posts
->first()
&& $this->posts->first()
?->relationLoaded('comments');
}

Expand All @@ -161,9 +170,7 @@ public function tasksAndPostsCommentsLoaded(): bool
public function postsTaskLoaded(): bool
{
return $this->relationLoaded('posts')
&& $this
->posts
->first()
&& $this->posts->first()
?->relationLoaded('task');
}

Expand All @@ -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;
}
}
Loading