Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support optimized select for top-level query #2235

Draft
wants to merge 46 commits into
base: master
Choose a base branch
from

Conversation

Lyrisbee
Copy link
Contributor

@Lyrisbee Lyrisbee commented Nov 21, 2022

  • Added or updated tests
  • Documented user facing changes
  • Updated CHANGELOG.md

I made some updates from #1626.

Tests passed directives

  1. @all
  2. @find
  3. @paginate

The query builder and scout builder are not optimized.

type Query {
  postsSearch(search: String @search): [Post!]! @paginate // scout builder
}

Tests passed relation types

  1. hasOne
  2. hasMany
  3. morphOne
  4. morphMany
  5. belongsTo
  6. belongsToMany

Only the root query will be optimized. The nested queries are not optimized.

type User {
  name: String!
  posts: [Post!]! @hasMany
}

type Post {
  id: ID!
  title: String!
}

type Query {
  users: [User!]! @all
}
{
  users {
    name
    posts {
      id
      title
    }
  }
}
# query logs
# optimized
select `id`, `name` from `users`
# the nested query is not optimized
select * from `posts` where `posts`.`user_id` in (1, 2) and `posts`.`deleted_at` is null

Changes
Only query the selection fields.

Breaking changes

  • Custom attribute needs to use @select
public function getCompanyNameAttribute(): string
{
    return $this->company->name;
}
type User {
    id: ID!
    companyName: String! @select(columns: ["company_id"])
}

Note
This PR was only tested in our own project, and there may be some edge cases that are not considered.

@Lyrisbee Lyrisbee marked this pull request as draft November 21, 2022 08:32
@spawnia spawnia added the enhancement A feature or improvement label Nov 21, 2022
Copy link
Collaborator

@spawnia spawnia left a comment

Choose a reason for hiding this comment

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

Great work on this, the tests look comprehensive and the overall direction looks right.

src/Pagination/PaginateDirective.php Outdated Show resolved Hide resolved
src/Pagination/PaginateDirective.php Outdated Show resolved Hide resolved
src/Pagination/PaginateDirective.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
tests/AssertsQueryCounts.php Outdated Show resolved Hide resolved
tests/Integration/Schema/Directives/FindDirectiveTest.php Outdated Show resolved Hide resolved
tests/Integration/Schema/Directives/LimitDirectiveTest.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
src/Select/SelectHelper.php Outdated Show resolved Hide resolved
Comment on lines 93 to 95
foreach ($bindings as $type => $binding) {
$builder = $builder->addBinding($binding, $type);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you mean it only needs to re-set the bindings['select'] but not the whole bindings?

That sounds reasonable then. Saves unnecessary work and is more explicit about what is happening and why.

|
*/

'optimized_selects' => true,
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should consider starting this setting with false and wait for reports of forgotten edge cases to come in.

tests/Integration/Schema/Directives/FindDirectiveTest.php Outdated Show resolved Hide resolved
@Lyrisbee Lyrisbee marked this pull request as ready for review December 19, 2022 02:13
@bepsvpt
Copy link
Contributor

bepsvpt commented Jan 27, 2023

@spawnia

Is there anything that needs to change for this pull request to be merged?

Comment on lines +166 to +169
if (($hasData = Arr::has($fieldSelection, 'data')) || Arr::has($fieldSelection, 'edges')) {
$data = $hasData
? $fieldSelection['data']
: $fieldSelection['edges']['node'];
Copy link
Collaborator

Choose a reason for hiding this comment

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

We can derive which field to check from $this->directiveArgValue('type'), no need to check both.

Comment on lines +188 to +196
/** @var string|string[] $keyName */
$keyName = $model->getKeyName();
if (is_string($keyName)) {
$keyName = [$keyName];
}

foreach ($keyName as $name) {
$query->orderBy($name);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems to be unrelated to optimizing the select.

}
$fieldTypeName = ASTHelper::getUnderlyingTypeName($fieldDefinition);

return preg_replace('/(Connection|SimplePaginator|Paginator)$/', '', $fieldTypeName);
Copy link
Collaborator

Choose a reason for hiding this comment

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

We can look at the argument of @paginate(type: ?) to know exactly which one to remove. That way, a model called ProductSimple of type PAGINATOR will not wrongly be seen as Product.

Comment on lines +107 to +115
/** @var string|string[] $keyName */
$keyName = $model->getKeyName();
if (is_string($keyName)) {
$keyName = [$keyName];
}

foreach ($keyName as $name) {
$query->orderBy($name);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Again, this seems like an unnecessary addition that is orthogonal to optimizing select.

Comment on lines +61 to +70

/** @var string|string[] $keyName */
$keyName = $model->getKeyName();
if (is_string($keyName)) {
$keyName = [$keyName];
}

foreach ($keyName as $name) {
$builder->orderBy($name);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

unnecessary

Comment on lines +91 to +93
$relationName = ASTHelper::directiveArgValue($directive, 'relation', $field);
if (method_exists($model, $relationName)) {
$relation = $model->{$relationName}();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Calling methods equivalent to the name of a field just because they exist is potentially dangerous.

type MyModel {
  launchTheNukes: Boolean! @method(name: "checkIfUserIsAllowedToLaunchTheNukesButNotActuallyLaunchThem")
}

Consider

/**
* 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 instanceof \ReflectionNamedType) {
return false;
}
if ($returnType->isBuiltin()) {
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);
}
}
for an actually safe check.

Comment on lines +115 to +116
$renamedAttribute = ASTHelper::directiveArgValue($directive, 'attribute');
$selectColumns[] = $renamedAttribute;
Copy link
Collaborator

Choose a reason for hiding this comment

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

There is no guarantee that the renamed attribute is actually a column, it could just as well be a virtual property.

Comment on lines +118 to +120
$relation = null !== $directive
? $model->{ASTHelper::directiveArgValue($directive, 'name')}()
: $model->{$field}();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Again, this is dangerously calling methods of the model. Due to the possibility of there being custom resolver directives, we don't know at all if they would be called during field resolution.

Even with @method(name: ?), there are still multiple ways this could go wrong:

  • the field might have required arguments that would be passed to the method when actually resolved
  • there might be middleware that prevents the current user from actually resolving the field

There are probably more, but once again this proves my point:

I don't see how we could reliably determine if a field is a column without explicit configuration.

It is not that I don't want to add this feature, I just need to make sure it is done in a way that does not cause random runtime crashes, security breaches, unintended side effects or a multitude of other problems. We really need to limit the magic to cases where we can be absolutely sure nothing can go wrong, otherwise we can only depend on explicit configuration.

Comment on lines +132 to +133
// fallback to selecting the field name
$selectColumns[] = $field;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This just goes wrong for the very simple case of there being a custom getter for the field - or any custom directive that we can not possibly know about. Consider the following:

type MyModel {
  foo: Int @someCustomDirectiveThatWeDoNotKnowAbout
  bar: ID @field(resolver: "SomeCustomClass@andAMethodWeCanNotPossiblyLookInto")
}

I don't think any amount of magic can help us here, any approach of trying to determine columns without explicit configuration is just fundamentally flawed.

|--------------------------------------------------------------------------
|
| 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.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Due to the problems I outlined in SelectHelper, I think we need to change the approach to this entirely. I believe we need to require that every field in a model that needs to select any columns has an @select configuration. I know that this is cumbersome, but perhaps we can allow iterative adoption by marking some models as being optimizable and only applying the optimization to those.

type Foo @select {
  id: ID @select(columns: ["id"])
}

@select on the model type signifies it can be considered for optimization.

@spawnia
Copy link
Collaborator

spawnia commented Jan 27, 2023

Thank you for your continued efforts on this. Unfortunately this pull request needs significant rework for the reasons I outlined in my review.

@spawnia spawnia marked this pull request as draft February 20, 2023 09:27
@spawnia
Copy link
Collaborator

spawnia commented Feb 22, 2023

You can add the following entry to CHANGELOG.md:

### Added

- Allow optimizing `SELECT` https://github.com/nuwave/lighthouse/pull/2235 by @Lyrisbee, @bepsvpt

@HelloAlexPan
Copy link

Thanks @spawnia, could we add a @storipress tag as well — we'll get back to this one sometime next month as we're working on some other features right now. We expect this to be done EO March

@spawnia
Copy link
Collaborator

spawnia commented Feb 22, 2023

Thanks @spawnia, could we add a @storipress tag as well — we'll get back to this one sometime next month as we're working on some other features right now. We expect this to be done EO March

Sure, that sounds fine to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement A feature or improvement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants