diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 18a9ab970..059f7132d 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -104,6 +104,8 @@ Represents [JSON](https://json.org) string. [Scout](https://laravel.com/docs/scout) is also supported 🤩. You just need to add [`@search`](https://lighthouse-php.com/master/api-reference/directives.html#search) directive to an argument. Please note that available operators depend on [Scout itself](https://laravel.com/docs/scout#where-clauses). +Please note that if the [`@search`](https://lighthouse-php.com/master/api-reference/directives.html#search) directive added, the generated query will expect the Scout builder only. So recommended using non-nullable `String!` type to avoid using the Eloquent builder (it will happen if the search argument missed or `null`; see also [lighthouse#2465](https://github.com/nuwave/lighthouse/issues/2465). + # Input type auto-generation The type used with the Builder directives like `@searchBy`/`@sortBy` may be Explicit (when you specify the `input` name `field(where: InputTypeName @searchBy): [Object!]!`) or Implicit (when the `_` used, `field(where: _ @searchBy): [Object!]!`). They are processing a bit differently. diff --git a/packages/graphql/UPGRADE.md b/packages/graphql/UPGRADE.md index 54ea3bdc6..8bb326a49 100644 --- a/packages/graphql/UPGRADE.md +++ b/packages/graphql/UPGRADE.md @@ -188,3 +188,7 @@ This section is actual only if you are extending the package. Please review and * [ ] `@sortByOperatorProperty` => `@sortByOperatorChild` (and class too) * [ ] `@sortByOperatorField` => `@sortByOperatorSort` (and class too) + +* [ ] `LastDragon_ru\LaraASP\GraphQL\Stream\Contracts\StreamFactory::enhance()` removed + +* [ ] `LastDragon_ru\LaraASP\GraphQL\Stream\Directives\Directive` diff --git a/packages/graphql/src/Builder/Contracts/Enhancer.php b/packages/graphql/src/Builder/Contracts/Enhancer.php new file mode 100644 index 000000000..adb1535ec --- /dev/null +++ b/packages/graphql/src/Builder/Contracts/Enhancer.php @@ -0,0 +1,23 @@ +definitionNode instanceof InputValueDefinitionNode) { - $argument = !($value instanceof Argument) + $argument = !($value instanceof Argument) ? $this->getFactory()->getArgument($this->definitionNode, $value) : $value; - $isList = $this->definitionNode->type instanceof ListTypeNode; - $conditions = $isList && is_array($argument->value) - ? $argument->value - : [$argument->value]; - - foreach ($conditions as $condition) { - if ($condition instanceof ArgumentSet) { - $builder = $this->handle($builder, new Field(), $condition, $context ?? new Context()); - } else { - throw new HandlerInvalidConditions($this); - } + $builder = $this->enhance($builder, $argument, $field, $context); + } + + return $builder; + } + + #[Override] + public function enhance( + object $builder, + ArgumentSet|Argument $value, + Field $field = null, + ContextContract $context = null, + ): object { + $field ??= new Field(); + $context ??= new Context(); + $conditions = match (true) { + $value instanceof ArgumentSet => [$value], + !is_array($value->value) => [$value->value], + default => $value->value, + }; + + foreach ($conditions as $condition) { + if ($condition instanceof ArgumentSet) { + $builder = $this->handle($builder, $field, $condition, $context); + } elseif ($condition === null) { + // nothing to do, skip + } else { + throw new HandlerInvalidConditions($this); } } diff --git a/packages/graphql/src/Stream/Contracts/StreamFactory.php b/packages/graphql/src/Stream/Contracts/StreamFactory.php index 36eddde43..4006a69df 100644 --- a/packages/graphql/src/Stream/Contracts/StreamFactory.php +++ b/packages/graphql/src/Stream/Contracts/StreamFactory.php @@ -3,8 +3,6 @@ namespace LastDragon_ru\LaraASP\GraphQL\Stream\Contracts; use LastDragon_ru\LaraASP\GraphQL\Stream\Offset; -use Nuwave\Lighthouse\Execution\ResolveInfo; -use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; /** * @template TBuilder of object @@ -17,20 +15,6 @@ interface StreamFactory { */ public function isSupported(object|string $builder): bool; - /** - * @param TBuilder $builder - * @param array $args - * - * @return TBuilder - */ - public function enhance( - object $builder, - mixed $root, - array $args, - GraphQLContext $context, - ResolveInfo $info, - ): object; - /** * @param TBuilder $builder * @param int<1, max> $limit diff --git a/packages/graphql/src/Stream/Directives/Directive.php b/packages/graphql/src/Stream/Directives/Directive.php index 198701649..223d29c9c 100644 --- a/packages/graphql/src/Stream/Directives/Directive.php +++ b/packages/graphql/src/Stream/Directives/Directive.php @@ -12,6 +12,8 @@ use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\Eloquent\Relations\Relation as EloquentRelation; +use Illuminate\Database\Query\Builder as QueryBuilder; use Laravel\Scout\Builder as ScoutBuilder; use LastDragon_ru\LaraASP\Core\Utils\Cast; use LastDragon_ru\LaraASP\Eloquent\ModelHelper; @@ -19,6 +21,7 @@ use LastDragon_ru\LaraASP\GraphQL\Builder\BuilderInfoDetector; use LastDragon_ru\LaraASP\GraphQL\Builder\Context; use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\BuilderInfoProvider; +use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\Enhancer; use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\TypeSource; use LastDragon_ru\LaraASP\GraphQL\Builder\Sources\InterfaceFieldArgumentSource; use LastDragon_ru\LaraASP\GraphQL\Builder\Sources\InterfaceFieldSource; @@ -98,10 +101,10 @@ class Directive extends BaseDirective implements FieldResolver, FieldManipulator final public const ArgKey = 'key'; /** - * @param StreamFactory $streamFactory + * @param StreamFactory $factory */ public function __construct( - private readonly StreamFactory $streamFactory, + protected readonly StreamFactory $factory, ) { // empty } @@ -177,16 +180,6 @@ public static function definition(): string { } // - // - // ========================================================================= - /** - * @return StreamFactory - */ - protected function getStreamFactory(): StreamFactory { - return $this->streamFactory; - } - // - // // ========================================================================= #[Override] @@ -412,7 +405,7 @@ static function (mixed $argument, AstManipulator $manipulator): bool { } // Not supported? - if ($type !== null && !$this->getStreamFactory()->isSupported($type)) { + if ($type !== null && !$this->factory->isSupported($type)) { throw new BuilderUnsupported($source, $type); } } catch (ReflectionException) { @@ -441,31 +434,96 @@ public function resolveField(FieldValue $fieldValue): callable { $offset = $this->getFieldValue(StreamOffsetDirective::class, $manipulator, $source, $info, $args); // Builder - $factory = $this->getStreamFactory(); $resolver = $this->getResolver($source); $builder = $resolver !== null && is_callable($resolver) ? $resolver($root, $args, $context, $info) : null; + $builder = is_object($builder) + ? $this->getBuilder($builder, $root, $args, $context, $info) + : null; if (!is_object($builder)) { throw new BuilderInvalid($source, gettype($builder)); - } elseif (!$factory->isSupported($builder)) { + } elseif (!$this->factory->isSupported($builder)) { throw new BuilderUnsupported($source, $builder::class); } else { // ok } // Stream - $key = $this->getArgKey($manipulator, $source); - $limit = $this->getFieldValue(StreamLimitDirective::class, $manipulator, $source, $info, $args); - $builder = $factory->enhance($builder, $root, $args, $context, $info); - $stream = $factory->create($builder, $key, $limit, $offset); - $stream = new StreamValue($stream); + $key = $this->getArgKey($manipulator, $source); + $limit = $this->getFieldValue(StreamLimitDirective::class, $manipulator, $source, $info, $args); + $stream = $this->factory->create($builder, $key, $limit, $offset); + $stream = new StreamValue($stream); return $stream; }; } + /** + * @param array $args + */ + protected function getBuilder( + object $builder, + mixed $root, + array $args, + GraphQLContext $context, + ResolveInfo $info, + ): object { + // Scout? + if ($builder instanceof EloquentBuilder) { + $enhancer = new class ($info->argumentSet, $builder) extends ScoutEnhancer { + #[Override] + public function enhanceEloquentBuilder(): ScoutBuilder { + return parent::enhanceEloquentBuilder(); + } + }; + + if ($enhancer->canEnhanceBuilder()) { + $builder = $enhancer->enhanceEloquentBuilder(); + } + } + + // Lighthouse's enhancer is slower because it processes all nested fields + // of the input value, and then we need to recreate Argument from the + // plain value. So we are skipping it if possible. + $lighthouse = false; + $filter = static function (object $directive): bool { + return !($directive instanceof Enhancer) + && !($directive instanceof Offset) + && !($directive instanceof Limit) + && !($directive instanceof SearchDirective); + }; + + foreach ($info->argumentSet->arguments as $argument) { + if ($argument->directives->contains(static fn ($directive) => !$filter($directive))) { + foreach ($argument->directives as $directive) { + if ($directive instanceof Enhancer) { + $builder = $directive->enhance($builder, $argument); + } + } + } else { + $lighthouse = true; + } + } + + // Not possible? + if ( + $lighthouse + && ( + $builder instanceof EloquentBuilder + || $builder instanceof EloquentRelation + || $builder instanceof QueryBuilder + || $builder instanceof ScoutBuilder + ) + ) { + $builder = $info->enhanceBuilder($builder, [], $root, $args, $context, $info, $filter); + } + + // Return + return $builder; + } + /** * @return Closure(mixed, array, GraphQLContext, ResolveInfo):mixed|array{class-string, string}|null */ @@ -493,13 +551,15 @@ protected function getResolver(ObjectFieldSource|InterfaceFieldSource $source): } } else { $parent = $source->getParent()->getTypeName(); - $resolver = $this->getResolverQuery($parent, $source->getName()) ?? ( - RootType::isRootType($parent) + $resolver = $this->getResolverQuery($parent, $source->getName()); + + if (!$resolver) { + $resolver = RootType::isRootType($parent) ? $this->getResolverModel( Container::getInstance()->make(StreamType::class)->getOriginalTypeName($source->getTypeName()), ) - : $this->getResolverRelation($parent, $source->getName()) - ); + : $this->getResolverRelation($parent, $source->getName()); + } } return $resolver; diff --git a/packages/graphql/src/Stream/Directives/DirectiveTest.php b/packages/graphql/src/Stream/Directives/DirectiveTest.php index ae2b95fa6..54ef9a9c3 100644 --- a/packages/graphql/src/Stream/Directives/DirectiveTest.php +++ b/packages/graphql/src/Stream/Directives/DirectiveTest.php @@ -25,6 +25,7 @@ use LastDragon_ru\LaraASP\GraphQL\Builder\Traits\WithManipulator; use LastDragon_ru\LaraASP\GraphQL\Exceptions\ArgumentAlreadyDefined; use LastDragon_ru\LaraASP\GraphQL\Package; +use LastDragon_ru\LaraASP\GraphQL\SearchBy\Definitions\SearchByDirective; use LastDragon_ru\LaraASP\GraphQL\SearchBy\Definitions\SearchByOperatorEqualDirective; use LastDragon_ru\LaraASP\GraphQL\SearchBy\Operators; use LastDragon_ru\LaraASP\GraphQL\Stream\Contracts\FieldArgumentDirective; @@ -42,6 +43,7 @@ use LastDragon_ru\LaraASP\GraphQL\Stream\Offset as StreamOffset; use LastDragon_ru\LaraASP\GraphQL\Stream\Streams\Stream; use LastDragon_ru\LaraASP\GraphQL\Testing\Package\Data\Models\TestObject; +use LastDragon_ru\LaraASP\GraphQL\Testing\Package\Data\Models\TestObjectSearchable; use LastDragon_ru\LaraASP\GraphQL\Testing\Package\Data\Models\WithTestObject; use LastDragon_ru\LaraASP\GraphQL\Testing\Package\Data\Queries\Query; use LastDragon_ru\LaraASP\GraphQL\Testing\Package\Data\Types\CustomType; @@ -57,6 +59,8 @@ use LastDragon_ru\LaraASP\Testing\Constraints\Response\Response; use LastDragon_ru\LaraASP\Testing\Constraints\Response\StatusCodes\Ok; use Mockery; +use Mockery\MockInterface; +use Nuwave\Lighthouse\Execution\Arguments\ArgumentSetFactory; use Nuwave\Lighthouse\Execution\ResolveInfo; use Nuwave\Lighthouse\Schema\AST\DocumentAST; use Nuwave\Lighthouse\Schema\DirectiveLocator; @@ -467,6 +471,228 @@ public function testGetBuilderInfo( self::assertEquals($expected, $directive->getBuilderInfo($source)); } + public function testGetBuilder(): void { + config([ + 'lighthouse.namespaces.models' => [ + (new ReflectionClass(Car::class))->getNamespaceName(), + ], + ]); + + $this->useGraphQLSchema( + <<<'GRAPHQL' + type Query { + field: [Car] @stream + } + + type Car { + id: ID! + } + GRAPHQL, + ); + + $type = $this->getGraphQLSchema()->getQueryType(); + $field = $type?->findField('field'); + + self::assertNotNull($type); + self::assertNotNull($field); + self::assertNotNull($field->astNode); + + $this->override(SearchByDirective::class, static function (MockInterface $mock): void { + $mock + ->shouldReceive('hydrate') + ->once() + ->andReturns(); + $mock + ->shouldReceive('enhance') + ->once() + ->andReturnUsing( + static fn (object $builder) => $builder, + ); + }); + + $info = Mockery::mock(ResolveInfo::class); + $info->path = ['field']; + $info->parentType = $type; + $info->fieldDefinition = $field; + $info->argumentSet = Container::getInstance()->make(ArgumentSetFactory::class)->fromResolveInfo( + [ + 'limit' => 10, + 'where' => [ + 'field' => [ + 'id' => [ + 'equal' => 123, + ], + ], + ], + ], + $info, + ); + $info + ->shouldReceive('enhanceBuilder') + ->never(); + + $root = 123; + $args = $info->argumentSet->toArray(); + $context = Mockery::mock(GraphQLContext::class); + $builder = Car::query(); + $directive = Mockery::mock(Directive::class); + $directive->shouldAllowMockingProtectedMethods(); + $directive->makePartial(); + + $directive->hydrate( + Parser::directive('@stream'), + $field->astNode, + ); + + self::assertInstanceOf($builder::class, $directive->getBuilder($builder, $root, $args, $context, $info)); + } + + #[RequiresLaravelScout] + public function testGetBuilderScoutBuilder(): void { + config([ + 'lighthouse.namespaces.models' => [ + (new ReflectionClass(TestObjectSearchable::class))->getNamespaceName(), + ], + ]); + + $this->useGraphQLSchema( + <<<'GRAPHQL' + type Query { + field(search: String! @search): [TestObject] @stream + } + + type TestObject { + id: ID! + } + GRAPHQL, + ); + + $type = $this->getGraphQLSchema()->getQueryType(); + $field = $type?->findField('field'); + + self::assertNotNull($type); + self::assertNotNull($field); + self::assertNotNull($field->astNode); + + $this->override(SearchByDirective::class, static function (MockInterface $mock): void { + $mock + ->shouldReceive('hydrate') + ->once() + ->andReturns(); + $mock + ->shouldReceive('enhance') + ->never(); + }); + + $info = Mockery::mock(ResolveInfo::class); + $info->path = ['field']; + $info->parentType = $type; + $info->fieldDefinition = $field; + $info->argumentSet = Container::getInstance()->make(ArgumentSetFactory::class)->fromResolveInfo( + [ + 'search' => '*', + ], + $info, + ); + $info + ->shouldReceive('enhanceBuilder') + ->never(); + + $root = 123; + $args = $info->argumentSet->toArray(); + $context = Mockery::mock(GraphQLContext::class); + $builder = TestObjectSearchable::query(); + $directive = Mockery::mock(Directive::class); + $directive->shouldAllowMockingProtectedMethods(); + $directive->makePartial(); + + $directive->hydrate( + Parser::directive('@stream'), + $field->astNode, + ); + + self::assertInstanceOf(ScoutBuilder::class, $directive->getBuilder($builder, $root, $args, $context, $info)); + } + + public function testGetBuilderLighthouseEnhancer(): void { + config([ + 'lighthouse.namespaces.models' => [ + (new ReflectionClass(Car::class))->getNamespaceName(), + ], + ]); + + $this->useGraphQLSchema( + <<<'GRAPHQL' + type Query { + field(id: String! @eq): [Car] @stream + } + + type Car { + id: ID! + } + GRAPHQL, + ); + + $type = $this->getGraphQLSchema()->getQueryType(); + $field = $type?->findField('field'); + + self::assertNotNull($type); + self::assertNotNull($field); + self::assertNotNull($field->astNode); + + $this->override(SearchByDirective::class, static function (MockInterface $mock): void { + $mock + ->shouldReceive('hydrate') + ->once() + ->andReturns(); + $mock + ->shouldReceive('enhance') + ->once() + ->andReturnUsing( + static fn (object $builder) => $builder, + ); + }); + + $info = Mockery::mock(ResolveInfo::class); + $info->path = ['field']; + $info->parentType = $type; + $info->fieldDefinition = $field; + $info->argumentSet = Container::getInstance()->make(ArgumentSetFactory::class)->fromResolveInfo( + [ + 'id' => 123, + 'where' => [ + 'field' => [ + 'id' => [ + 'equal' => 123, + ], + ], + ], + ], + $info, + ); + $info + ->shouldReceive('enhanceBuilder') + ->once() + ->andReturnUsing( + static fn (object $builder) => $builder, + ); + + $root = 123; + $args = $info->argumentSet->toArray(); + $context = Mockery::mock(GraphQLContext::class); + $builder = Car::query(); + $directive = Mockery::mock(Directive::class); + $directive->shouldAllowMockingProtectedMethods(); + $directive->makePartial(); + + $directive->hydrate( + Parser::directive('@stream'), + $field->astNode, + ); + + self::assertInstanceOf($builder::class, $directive->getBuilder($builder, $root, $args, $context, $info)); + } + /** * @dataProvider dataProviderGetBuilderInfoScoutBuilder * @@ -901,15 +1127,13 @@ public function testResolveField(): void { $info->path = ['field']; $info->parentType = $type; $info->fieldDefinition = $field; + $info->argumentSet = Container::getInstance()->make(ArgumentSetFactory::class)->fromResolveInfo([], $info); $info ->shouldReceive('enhanceBuilder') - ->once() - ->andReturnUsing( - static fn (mixed $builder) => $builder, - ); + ->never(); $root = 123; - $args = ['a' => 'a']; + $args = $info->argumentSet->toArray(); $value = Mockery::mock(FieldValue::class); $context = Mockery::mock(GraphQLContext::class); $builder = Mockery::mock(EloquentBuilder::class); @@ -1067,6 +1291,12 @@ public function testResolveFieldBuilderUnsupported(): void { ->andReturn( static fn () => new stdClass(), ); + $directive + ->shouldReceive('getBuilder') + ->once() + ->andReturnUsing( + static fn (object $builder) => $builder, + ); $factory ->shouldReceive('isSupported') ->once() diff --git a/packages/graphql/src/Stream/StreamFactory.php b/packages/graphql/src/Stream/StreamFactory.php index c75d02df4..e81ce65c0 100644 --- a/packages/graphql/src/Stream/StreamFactory.php +++ b/packages/graphql/src/Stream/StreamFactory.php @@ -4,15 +4,12 @@ use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model as EloquentModel; -use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; use Laravel\Scout\Builder as ScoutBuilder; use LastDragon_ru\LaraASP\GraphQL\Stream\Contracts\Stream as StreamContract; use LastDragon_ru\LaraASP\GraphQL\Stream\Contracts\StreamFactory as StreamFactoryContract; use LastDragon_ru\LaraASP\GraphQL\Stream\Streams\Database; use LastDragon_ru\LaraASP\GraphQL\Stream\Streams\Scout; -use Nuwave\Lighthouse\Execution\ResolveInfo; -use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; use Override; use function is_a; @@ -33,25 +30,6 @@ public function isSupported(object|string $builder): bool { || is_a($builder, ScoutBuilder::class, true); } - /** - * @inheritDoc - */ - #[Override] - public function enhance( - object $builder, - mixed $root, - array $args, - GraphQLContext $context, - ResolveInfo $info, - ): object { - $builder = $info->enhanceBuilder($builder, [], $root, $args, $context, $info); - $builder = $builder instanceof Relation - ? $builder->getQuery() - : $builder; - - return $builder; - } - #[Override] public function create(object $builder, string $key, int $limit, Offset $offset): StreamContract { return $builder instanceof ScoutBuilder diff --git a/phpstan-baseline-well-known.neon b/phpstan-baseline-well-known.neon index d15087e50..37fe7bff0 100644 --- a/phpstan-baseline-well-known.neon +++ b/phpstan-baseline-well-known.neon @@ -67,6 +67,10 @@ parameters: message: "#^Call to protected method getNulls\\(\\) of class LastDragon_ru\\\\LaraASP\\\\GraphQL\\\\SortBy\\\\Operators\\\\Sort\\.$#" paths: - packages/graphql/src/SortBy/Operators/SortTest.php + - + message: "#^Call to protected method getBuilder\\(\\) of class LastDragon_ru\\\\LaraASP\\\\GraphQL\\\\Stream\\\\Directives\\\\Directive\\.$#" + paths: + - packages/graphql/src/Stream/Directives/DirectiveTest.php # PHPStan doesn't allow use `@var` and `assert()` for `$this` inside Closure yet # https://github.com/phpstan/phpstan/issues/149