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

"...but could have been retrieved as a query" false positives or misleading suggestions #2045

Closed
panstromek opened this issue Sep 17, 2024 · 8 comments
Labels
bug Something isn't working

Comments

@panstromek
Copy link

  • Larastan Version: 2.9.8
  • Laravel Version: 10.48.20

Description

I regularly hit this error in cases involving limit clause. The error is pretty misleading and can be considered false positive (but it's debatable).

Laravel code where the issue was found

$isInTop3 = User::orderByDesc('score')
            ->limit(3)
            ->pluck('id')
            ->contains($userId)

This reports

phpstan: Called 'contains' on Laravel collection, but could have been retrieved as a query.

Another one is something like this.

$hasAtLeastTwoArticles = $user->articles()->limit(2)->get(['id'])->count() > 1

This reports:

phpstan: Called 'count' on Laravel collection, but could have been retrieved as a query.

The suggestion is technically correct, but the solution is often convoluted. What's worse, though, is that the suggestion is misleading - if you do the naive transformation that the error seems to suggest, for example:

$hasAtLeastTwoArticles = $user->articles()->limit(2)->count() > 1

You'll get the wrong result, because Eloquent will generate select count(*) from articles limit 2, which counts all articles, but what you want is select count(*) from (select id from articles limit 2) as first_two_articles

The correct solution is quite convoluted, because you have to make sure the Eloquent doesn't generate count(*) inside the subquery, which it often does (using loadCount or loadAggregate) so it's usually simpler to pull all results and count them in PHP (especially when it's about small counts like the examples above).

@panstromek panstromek added the bug Something isn't working label Sep 17, 2024
@calebdw
Copy link
Contributor

calebdw commented Sep 17, 2024

Why can't you just use ->exists() instead of count?

@panstromek
Copy link
Author

Why can't you just use ->exists() instead of count?

That will have the same problem, it will return whether result contains any rows, but not whether it has more than 1 row. Assuming you mean this:

$hasAtLeastTwoArticles = $user->articles()->limit(2)->exists()

@calebdw
Copy link
Contributor

calebdw commented Sep 17, 2024

Chatgpt suggests using skip which skips the first row and checks if the second exists (I haven't tested though):

YourModel::skip(1)->exists();

@panstromek
Copy link
Author

Chatgpt suggests using skip which skips the first row and checks if the second exists (I haven't tested though):

YourModel::skip(1)->exists();

Good point, that should work for the second example.

@calebdw
Copy link
Contributor

calebdw commented Sep 17, 2024

The problem here is that you need sub-queries to properly scope the count/exists methods:

$isInTop3 = User::query()
    ->whereIn('id', User::select('id')->orderByDesc('score')->limit(3))
    ->whereKey($userId)
    ->exists();

@panstromek
Copy link
Author

The problem here is that you need sub-queries to properly scope the count/exists methods:

Yes. I know, and that's the point of this issue. Both solutions are not obvious, and the lint suggestion makes it seem like you can just skip the ->get() call to fix it, which is wrong. In the second case, it's arguably not even better solution, though that's debatable.

@calebdw
Copy link
Contributor

calebdw commented Sep 17, 2024

Well what do you think should be done then? I'm not sure how well we'd be able to detect that you're trying to skirt around not using a subquery and an error shouldn't be raised. I don't think it's as simple as just detecting if limit/take was called earlier in the chain...

the lint suggestion makes it seem like you can just skip the ->get() call to fix it, which is wrong.

The error message never says to just remove the ->get() call---it just means there are better ways to accomplish what you are trying to do inside the database instead of needing to hydrate eloquent models just to count them in php land. Sure, removing the ->get() is often the only thing that needs to be done to convert the collection call to a query, but it's not always the case.

If you find yourself repeating this pattern around your codebase then that could be a great candidate for a scope or Builder macro.

You can always locally ignore that error or just disable the rule altogether if you like:

parameters:
    noUnnecessaryCollectionCall: false

@canvural
Copy link
Collaborator

Hi,

As you described the error is technically correct. So if you feel like it is a false positive, feel free to ignore the error. Rule itself can't detect each edge case.

Thank you!

@canvural canvural closed this as not planned Won't fix, can't repro, duplicate, stale Oct 14, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

3 participants