diff --git a/src/Pagination/PaginateDirective.php b/src/Pagination/PaginateDirective.php index 53892e18d6..ef69c80758 100644 --- a/src/Pagination/PaginateDirective.php +++ b/src/Pagination/PaginateDirective.php @@ -2,17 +2,20 @@ namespace Nuwave\Lighthouse\Pagination; +use GraphQL\Error\Error; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\ObjectTypeDefinitionNode; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Arr; use Laravel\Scout\Builder as ScoutBuilder; use Nuwave\Lighthouse\Execution\ResolveInfo; use Nuwave\Lighthouse\Schema\AST\DocumentAST; use Nuwave\Lighthouse\Schema\Directives\BaseDirective; use Nuwave\Lighthouse\Schema\Values\FieldValue; +use Nuwave\Lighthouse\Select\SelectHelper; use Nuwave\Lighthouse\Support\Contracts\FieldManipulator; use Nuwave\Lighthouse\Support\Contracts\FieldResolver; use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; @@ -156,6 +159,45 @@ public function resolveField(FieldValue $fieldValue): FieldValue $this->directiveArgValue('scopes', []) ); + if (config('lighthouse.optimized_selects')) { + if ($query instanceof EloquentBuilder) { + $fieldSelection = $resolveInfo->getFieldSelection(2); + + if (($hasData = Arr::has($fieldSelection, 'data')) || Arr::has($fieldSelection, 'edges')) { + $data = $hasData + ? $fieldSelection['data'] + : $fieldSelection['edges']['node']; + + /** @var array $fieldSelection */ + $fieldSelection = array_keys($data); + + $model = $query->getModel(); + + $selectColumns = SelectHelper::getSelectColumns( + $this->definitionNode, + $fieldSelection, + get_class($model) + ); + + if (empty($selectColumns)) { + throw new Error('The select column is empty.'); + } + + $query = $query->select($selectColumns); + + /** @var string|string[] $keyName */ + $keyName = $model->getKeyName(); + if (is_string($keyName)) { + $keyName = [$keyName]; + } + + foreach ($keyName as $name) { + $query->orderBy($name); + } + } + } + } + $paginationArgs = PaginationArgs::extractArgs($args, $this->paginationType(), $this->paginateMaxCount()); $paginationArgs->type = $this->optimalPaginationType($resolveInfo); diff --git a/src/Pagination/PaginationManipulator.php b/src/Pagination/PaginationManipulator.php index 6109f43952..5c59d7eb5f 100644 --- a/src/Pagination/PaginationManipulator.php +++ b/src/Pagination/PaginationManipulator.php @@ -4,6 +4,7 @@ use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\NamedTypeNode; +use GraphQL\Language\AST\Node; use GraphQL\Language\AST\NonNullTypeNode; use GraphQL\Language\AST\ObjectTypeDefinitionNode; use GraphQL\Language\AST\TypeNode; @@ -15,6 +16,8 @@ use Nuwave\Lighthouse\Schema\AST\DocumentAST; use Nuwave\Lighthouse\Schema\Directives\ModelDirective; +use function Safe\preg_replace; + class PaginationManipulator { /** @@ -273,4 +276,14 @@ protected function paginationResultType(string $typeName): TypeNode return $typeNode; } + + public static function getReturnTypeName(Node $fieldDefinition): ?string + { + if (! ASTHelper::directiveDefinition($fieldDefinition, 'paginate')) { + return null; + } + $fieldTypeName = ASTHelper::getUnderlyingTypeName($fieldDefinition); + + return preg_replace('/(Connection|SimplePaginator|Paginator)$/', '', $fieldTypeName); + } } diff --git a/src/Schema/Directives/AllDirective.php b/src/Schema/Directives/AllDirective.php index 92e8958764..6872a54460 100644 --- a/src/Schema/Directives/AllDirective.php +++ b/src/Schema/Directives/AllDirective.php @@ -2,16 +2,19 @@ namespace Nuwave\Lighthouse\Schema\Directives; +use GraphQL\Error\Error; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Language\AST\ObjectTypeDefinitionNode; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Database\Query\Expression; use Illuminate\Support\Collection; use Laravel\Scout\Builder as ScoutBuilder; use Nuwave\Lighthouse\Execution\ResolveInfo; use Nuwave\Lighthouse\Schema\AST\DocumentAST; use Nuwave\Lighthouse\Schema\Values\FieldValue; +use Nuwave\Lighthouse\Select\SelectHelper; use Nuwave\Lighthouse\Support\Contracts\FieldManipulator; use Nuwave\Lighthouse\Support\Contracts\FieldResolver; use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; @@ -63,12 +66,57 @@ public function resolveField(FieldValue $fieldValue): FieldValue $query = $this->getModelClass()::query(); } - return $resolveInfo + $builder = $resolveInfo ->enhanceBuilder( $query, $this->directiveArgValue('scopes', []) - ) - ->get(); + ); + + if (config('lighthouse.optimized_selects')) { + if ($builder instanceof EloquentBuilder) { + $fieldSelection = array_keys($resolveInfo->getFieldSelection(1)); + + $model = $builder->getModel(); + + $selectColumns = SelectHelper::getSelectColumns( + $this->definitionNode, + $fieldSelection, + get_class($model) + ); + + if (empty($selectColumns)) { + throw new Error('The select column is empty.'); + } + + $query = $builder->getQuery(); + + if (null !== $query->columns) { + $bindings = $query->getRawBindings(); + + $expressions = array_filter($query->columns, function ($column) { + return $column instanceof Expression; + }); + + $builder = $builder->select(array_unique(array_merge($selectColumns, $expressions))); + + $builder = $builder->addBinding($bindings['select'], 'select'); + } else { + $builder = $builder->select($selectColumns); + } + + /** @var string|string[] $keyName */ + $keyName = $model->getKeyName(); + if (is_string($keyName)) { + $keyName = [$keyName]; + } + + foreach ($keyName as $name) { + $query->orderBy($name); + } + } + } + + return $builder->get(); }); return $fieldValue; diff --git a/src/Schema/Directives/FindDirective.php b/src/Schema/Directives/FindDirective.php index fcd2f8860e..e4b8a9f0ab 100644 --- a/src/Schema/Directives/FindDirective.php +++ b/src/Schema/Directives/FindDirective.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Nuwave\Lighthouse\Execution\ResolveInfo; use Nuwave\Lighthouse\Schema\Values\FieldValue; +use Nuwave\Lighthouse\Select\SelectHelper; use Nuwave\Lighthouse\Support\Contracts\FieldResolver; use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; @@ -35,12 +36,41 @@ public static function definition(): string public function resolveField(FieldValue $fieldValue): FieldValue { $fieldValue->setResolver(function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): ?Model { - $results = $resolveInfo + $builder = $resolveInfo ->enhanceBuilder( $this->getModelClass()::query(), $this->directiveArgValue('scopes', []) - ) - ->get(); + ); + + if (config('lighthouse.optimized_selects')) { + $fieldSelection = array_keys($resolveInfo->getFieldSelection(1)); + + $selectColumns = SelectHelper::getSelectColumns( + $this->definitionNode, + $fieldSelection, + $this->getModelClass() + ); + + if (empty($selectColumns)) { + throw new Error('The select column is empty.'); + } + + $builder = $builder->select($selectColumns); + + $model = $builder->getModel(); + + /** @var string|string[] $keyName */ + $keyName = $model->getKeyName(); + if (is_string($keyName)) { + $keyName = [$keyName]; + } + + foreach ($keyName as $name) { + $builder->orderBy($name); + } + } + + $results = $builder->get(); if ($results->count() > 1) { throw new Error('The query returned more than one result.'); diff --git a/src/Schema/Directives/SelectDirective.php b/src/Schema/Directives/SelectDirective.php new file mode 100644 index 0000000000..79259095dc --- /dev/null +++ b/src/Schema/Directives/SelectDirective.php @@ -0,0 +1,21 @@ + $fieldSelection + * + * @return array + * + * @reference https://github.com/nuwave/lighthouse/pull/1626 + */ + public static function getSelectColumns(Node $definitionNode, array $fieldSelection, string $modelName): array + { + if (! ($returnTypeName = PaginationManipulator::getReturnTypeName($definitionNode))) { + $returnTypeName = ASTHelper::getUnderlyingTypeName($definitionNode); + } + + $astBuilder = Container::getInstance()->make(ASTBuilder::class); + assert($astBuilder instanceof ASTBuilder); + + $documentAST = $astBuilder->documentAST(); + assert($documentAST instanceof DocumentAST); + + $type = $documentAST->types[$returnTypeName]; + + $fieldDefinitions = $type->fields; + assert($fieldDefinitions instanceof NodeList); + + $model = new $modelName(); + assert($model instanceof Model); + + $selectColumns = []; + + foreach ($fieldSelection as $field) { + $foundSelect = false; + $fieldDefinition = ASTHelper::firstByName($fieldDefinitions, $field); + + if ($fieldDefinition) { + // the priority of select directive is highest + if ($directive = ASTHelper::directiveDefinition($fieldDefinition, 'select')) { + // append selected columns in select directive to selection + $selectFields = ASTHelper::directiveArgValue($directive, 'columns', []); + $selectColumns = array_merge($selectColumns, $selectFields); + $foundSelect = true; + } + + foreach (self::DIRECTIVES as $directiveType) { + if ($directive = ASTHelper::directiveDefinition($fieldDefinition, $directiveType)) { + assert($directive instanceof DirectiveNode); + + $relationName = ASTHelper::directiveArgValue($directive, 'relation', $field); + if (method_exists($model, $relationName)) { + $relation = $model->{$relationName}(); + if (in_array($directiveType, self::DIRECTIVES_REQUIRING_LOCAL_KEY)) { + $selectColumns[] = self::getLocalKey($relation); + } elseif (in_array($directiveType, self::DIRECTIVES_REQUIRING_FOREIGN_KEY)) { + $selectColumns[] = self::getForeignKey($relation); + } elseif (in_array($directiveType, self::DIRECTIVES_REQUIRING_MORPH_KEY)) { + $selectColumns[] = self::getForeignKey($relation); + $selectColumns[] = $relation->getMorphType(); + } else { + $selectColumns[] = $model->getKeyName(); + } + } + + continue 2; + } + } + + if ($foundSelect) { + continue; + } + if ($directive = ASTHelper::directiveDefinition($fieldDefinition, 'rename')) { + // append renamed attribute to selection + $renamedAttribute = ASTHelper::directiveArgValue($directive, 'attribute'); + $selectColumns[] = $renamedAttribute; + } elseif (($directive = ASTHelper::directiveDefinition($fieldDefinition, 'method')) || method_exists($model, $field)) { + $relation = null !== $directive + ? $model->{ASTHelper::directiveArgValue($directive, 'name')}() + : $model->{$field}(); + if ($relation instanceof MorphTo) { + $selectColumns[] = self::getForeignKey($relation); + $selectColumns[] = $relation->getMorphType(); + } elseif ($relation instanceof BelongsTo) { + $selectColumns[] = self::getForeignKey($relation); + } elseif ($relation instanceof HasOneOrMany) { + $selectColumns[] = self::getLocalKey($relation); + } else { + $selectColumns[] = $model->getKeyName(); + } + } else { + // fallback to selecting the field name + $selectColumns[] = $field; + } + } + } + + return array_unique($selectColumns); + } + + /** + * Get the local key. + */ + protected static function getLocalKey(HasOneOrMany $relation): string + { + return AppVersion::below(5.7) + ? Utils::accessProtected($relation, 'localKey') + : $relation->getLocalKeyName(); + } + + /** + * Get the foreign key. + */ + protected static function getForeignKey(BelongsTo $relation): string + { + return AppVersion::below(5.8) + ? $relation->getForeignKey() // @phpstan-ignore-line only be executed on Laravel < 5.8 + : $relation->getForeignKeyName(); + } +} diff --git a/src/lighthouse.php b/src/lighthouse.php index ab19880f67..7d267f7df2 100644 --- a/src/lighthouse.php +++ b/src/lighthouse.php @@ -355,6 +355,18 @@ 'batchload_relations' => true, + /* + |-------------------------------------------------------------------------- + | Optimized Selects + |-------------------------------------------------------------------------- + | + | If set to true, Eloquent will only select the columns necessary to resolve a query. + | Use the @select directive to specify column dependencies of compound fields. + | + */ + + 'optimized_selects' => false, + /* |-------------------------------------------------------------------------- | Shortcut Foreign Key Selection diff --git a/tests/Integration/Pagination/PaginateDirectiveDBTest.php b/tests/Integration/Pagination/PaginateDirectiveDBTest.php index 8accd39926..3876d95594 100644 --- a/tests/Integration/Pagination/PaginateDirectiveDBTest.php +++ b/tests/Integration/Pagination/PaginateDirectiveDBTest.php @@ -265,6 +265,45 @@ public function testPaginateWithScopes(): void ]); } + public function testPaginateOptimizedSelect(): void + { + factory(User::class, 2)->create(); + + $this->schema = /** @lang GraphQL */ ' + type User { + id: ID! + } + + type Query { + users: [User!]! @paginate + } + '; + + self::trackQueries(); + + $this->graphQL(/** @lang GraphQL */ ' + { + users(first: 1) { + paginatorInfo { + count + total + currentPage + } + data { + id + } + } + } + ')->assertJsonCount(1, 'data.users.data'); + + $queries = self::getQueriesExecuted(); + + $this->assertStringContainsString( + 'select `id` from `users`', + $queries[1]['query'] + ); + } + public static function builder(): EloquentBuilder { return User::orderBy('id', 'DESC'); diff --git a/tests/Integration/Schema/Directives/AllDirectiveTest.php b/tests/Integration/Schema/Directives/AllDirectiveTest.php index de7a3cb6e4..a5d3bea750 100644 --- a/tests/Integration/Schema/Directives/AllDirectiveTest.php +++ b/tests/Integration/Schema/Directives/AllDirectiveTest.php @@ -302,6 +302,52 @@ public function testSpecifyCustomBuilderForScoutBuilder(): void ]); } + public function testGetAllOptimizedSelect(): void + { + factory(Post::class, 2)->create([ + // Do not create those, as they would create more users + 'task_id' => 1, + ]); + + $this->schema = /** @lang GraphQL */ ' + type User { + posts: [Post!]! @all + } + + type Post { + id: ID! + } + + type Query { + users: [User!]! @all + } + '; + + self::trackQueries(); + + $this->graphQL(/** @lang GraphQL */ ' + { + users { + posts { + id + } + } + } + '); + + $queries = self::getQueriesExecuted(); + + $this->assertStringContainsString( + 'select `id` from `posts`', + $queries[1]['query'] + ); + + $this->assertStringContainsString( + 'select `id` from `posts`', + $queries[2]['query'] + ); + } + public static function builder(): EloquentBuilder { return User::orderBy('id', 'DESC'); diff --git a/tests/Integration/Schema/Directives/BelongsToDirectiveTest.php b/tests/Integration/Schema/Directives/BelongsToDirectiveTest.php index 63d024c291..8140233681 100644 --- a/tests/Integration/Schema/Directives/BelongsToDirectiveTest.php +++ b/tests/Integration/Schema/Directives/BelongsToDirectiveTest.php @@ -179,6 +179,10 @@ public function testResolveBelongsToRelationshipWhenMainModelHasCompositePrimary } '; + $products = $products + ->sortBy(function ($product) {return $product->barcode; }) + ->values(); + $this->graphQL(/** @lang GraphQL */ ' { products(first: 2) { diff --git a/tests/Integration/Schema/Directives/CountDirectiveDBTest.php b/tests/Integration/Schema/Directives/CountDirectiveDBTest.php index 396d16a2b7..a44cec93eb 100644 --- a/tests/Integration/Schema/Directives/CountDirectiveDBTest.php +++ b/tests/Integration/Schema/Directives/CountDirectiveDBTest.php @@ -78,7 +78,7 @@ public function testCountRelationAndEagerLoadsTheCount(): void { $this->schema = /** @lang GraphQL */ ' type Query { - users: [User!] @all + users: [User!] @all @orderBy(column: "id") } type User { diff --git a/tests/Integration/Schema/Directives/FindDirectiveTest.php b/tests/Integration/Schema/Directives/FindDirectiveTest.php index 34dc436986..7f6d0ca668 100644 --- a/tests/Integration/Schema/Directives/FindDirectiveTest.php +++ b/tests/Integration/Schema/Directives/FindDirectiveTest.php @@ -170,7 +170,7 @@ public function testReturnsCustomAttributes(): void type User { id: ID! name: String! - companyName: String! + companyName: String! @select(columns: ["company_id"]) } type Query { @@ -196,4 +196,50 @@ public function testReturnsCustomAttributes(): void ], ]); } + + public function testReturnsOptimizedSelect(): void + { + $company = factory(Company::class)->create(); + + $user = factory(User::class)->make(); + assert($user instanceof User); + $user->company()->associate($company); + $user->save(); + + $this->schema = ' + type User { + id: ID! + companyName: String! @select(columns: ["company_id"]) + } + + type Query { + user(id: ID @eq): User @find(model: "User") + } + '; + + self::trackQueries(); + + $this->graphQL(" + { + user(id: {$user->id}) { + id + companyName + } + } + ")->assertJson([ + 'data' => [ + 'user' => [ + 'id' => (string) $user->id, + 'companyName' => $company->name, + ], + ], + ]); + + $queries = self::getQueriesExecuted(); + + $this->assertStringContainsString( + 'select `id`, `company_id` from `users`', + $queries[0]['query'] + ); + } } diff --git a/tests/Integration/Schema/Directives/HasManyDirectiveTest.php b/tests/Integration/Schema/Directives/HasManyDirectiveTest.php index 6d97d564d4..59cd794df8 100644 --- a/tests/Integration/Schema/Directives/HasManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/HasManyDirectiveTest.php @@ -302,7 +302,9 @@ public function testQueryPaginatedHasManyWithNonUniqueForeignKey(): void $this->schema /** @lang GraphQL */ = ' type Post { - roles: [RoleUser!]! @hasMany(relation: "roles", type: PAGINATOR, defaultCount: 10) + roles: [RoleUser!]! + @hasMany(relation: "roles", type: PAGINATOR, defaultCount: 10) + @select(columns: ["id"]) } type RoleUser { diff --git a/tests/TestCase.php b/tests/TestCase.php index afaab6dac8..701a719a14 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -176,6 +176,8 @@ protected function getEnvironmentSetUp($app): void $config->set('lighthouse.cache.enable', false); $config->set('lighthouse.unbox_bensampo_enum_enum_instances', true); + + $config->set('lighthouse.optimized_selects', true); } /**