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

[9.x] Prevent queries during response preparation #45603

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented Jan 11, 2023

Related PR of Octane: laravel/octane#631

The problem

In many contexts, queries within a response object can be problematic and hard to detect. The response object may be a view(), a JsonResource, or something else entirely.

Two database queries are run in the following view. For some applications this may be totally fine, while others may find this problematic from a performance / resources / conventions perspective.

@foreach(['users' => 'Users', 'teams' => 'Teams'] as $table => $title)
  <dl>
      <dt>Total {{ $title }}</dt>
      <dd>{{ DB::table($table)->count() }}</dd>
  </dl>
@endforeach

The solution

To help applications detect these issues, I propose we introduce a new opt-in mechanism that will throw exceptions when a query is run while the response is being prepared.

// in a service provider or middleware

App::preventQueriesWhilePreparingResponse();

Response preparation involves the following code:

public static function toResponse($request, $response)
{
if ($response instanceof Responsable) {
$response = $response->toResponse($request);
}
if ($response instanceof PsrResponseInterface) {
$response = (new HttpFoundationFactory)->createResponse($response);
} elseif ($response instanceof Model && $response->wasRecentlyCreated) {
$response = new JsonResponse($response, 201);
} elseif ($response instanceof Stringable) {
$response = new Response($response->__toString(), 200, ['Content-Type' => 'text/html']);
} elseif (! $response instanceof SymfonyResponse &&
($response instanceof Arrayable ||
$response instanceof Jsonable ||
$response instanceof ArrayObject ||
$response instanceof JsonSerializable ||
$response instanceof stdClass ||
is_array($response))) {
$response = new JsonResponse($response);
} elseif (! $response instanceof SymfonyResponse) {
$response = new Response($response, 200, ['Content-Type' => 'text/html']);
}
if ($response->getStatusCode() === Response::HTTP_NOT_MODIFIED) {
$response->setNotModified();
}
return $response->prepare($request);
}

If an exception is thrown while the response is passing through this method, an exception will be thrown.

This means apps can throw if a query occurs in a blade view, as seen above, or while a JsonResource is being resolved. The following resource would throw an exception.

class TeamResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'name' => $this->name,
            'member_count' => $this->members()->count(), // query
        ];
    }
}

As soon as this resource is a collection, like in a UserResource that includes all the users teams, this will trigger a query for every team included in the resource.

There are other places where "response preparation" occurs, such as the exception handler, but this feature does not concern itself with that. It is only related to values returned from a Controller.

public function show(Team $team)
{
    return TeamResource::make($team);

    return view('teams.show', ['team' => $team]);
}

Doing it manually

As part of this main feature is a lower level feature for preventing queries for certain parts of an application.

DB::preventQueries(function () {
   // queries here will throw an exception.
});

// queries can run again.

DB::preventQueries();

// queries here will throw an exception.

DB::allowQueries();

// queries can run again.

This manual mechanism is handy when an application wants to prevent queries while a view is being prepared manually. Take the following example:

public function update(UpdateTeamRequest $request, Team $team)
{
    $team->update($request->validated());

    return response()->json($team);
}

This will allow queries to run when the toArray method is called even with preventQueriesWhilePreparingResponse in use. In this type of scenario where you are preparing the response yourself you may opt-in to prevent queries:

public function update(UpdateTeamRequest $request, Team $team)
{
    $team->update($request->validated());

    return DB::preventQueries(fn () => response()->json($team));
}

Note This could impact things like dispatching jobs to the queue. This is a sharp knife. Use wisely.

@timacdonald timacdonald reopened this Jan 11, 2023
@timacdonald timacdonald force-pushed the restrict-response-resolution-queries branch 6 times, most recently from 020dd19 to 67c4653 Compare January 11, 2023 05:58
@timacdonald timacdonald changed the title Restrict response resolution queries [9.x] Restrict response resolution queries Jan 11, 2023
@timacdonald timacdonald changed the title [9.x] Restrict response resolution queries [9.x] Prevent queries during response preparation Jan 11, 2023
@bastien-phi
Copy link
Contributor

Maybe a App::allowQueriesWhilePreparingResponse() is missing to rollback the behaviour.

@timacdonald timacdonald force-pushed the restrict-response-resolution-queries branch from 1a1ed92 to ee14a8c Compare January 11, 2023 23:10
@imanghafoori1
Copy link
Contributor

imanghafoori1 commented Jan 11, 2023

App::preventBadPractices([
    'queriesInBlades',
    'envCallsOutsideConfig',
    'fullModelPathInPolymorphicRelation',
     ...
]);

@Rizky92
Copy link

Rizky92 commented Jan 12, 2023

Question, will this prevent unwanted extra queries in blade too? Something like calling aggregate function from relationship for example:

<ul>
    @foreach($someModelCollection as $model)
        <li>{{ $model->relationship()->count() }}</li>
    @endforeach
</ul>

@timacdonald
Copy link
Member Author

@Rizky92 yes it will.

Given your example, if you had App::preventQueriesWhilePreparingResponse() in a service provider an exception would be thrown when $model->relationship()->count() is invoked.

@imanghafoori1 I wouldn't say queries in the view are inherently a bad practice. In certain teams, application sizes, and contexts they are perfectly fine. In others, not so much.

@timacdonald timacdonald force-pushed the restrict-response-resolution-queries branch from ee14a8c to d733414 Compare January 12, 2023 00:58
@timacdonald timacdonald marked this pull request as ready for review January 12, 2023 00:59
@taylorotwell taylorotwell marked this pull request as draft January 16, 2023 21:24
@garygreen
Copy link
Contributor

Does this prevent relationships from lazily loading? E.g.

@foreach ($user->invoices as $invoice)
   ...
@endforeach

Would that throw if that relation wasn't eagerly loaded?

It is an interesting approach because it'll bring more awareness of db performance considerations when rendering views. I hope that this won't be frustrating to debug if it's throwing errors and you can't easily decipher where it came from exactly. This PR kinda reminds me of https://github.com/beyondcode/laravel-query-detector

@timacdonald timacdonald closed this Apr 3, 2023
@timacdonald timacdonald deleted the restrict-response-resolution-queries branch September 13, 2023 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants