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

[11.x] afterQuery hook #50587

Merged
merged 12 commits into from Apr 10, 2024
Merged

Conversation

gdebrauwer
Copy link
Contributor

Explanation

Query scopes (or custom query builder methods) can be used to add constraints to a query, but in my projects, I use them a lot to hide (complex) code to add extra data to every queried model. Most of the time that code can completely live inside a scope, but there are some cases where some extra code needs to be executed after running the query. That extra code currently needs to live outside of the scope. That also means you have to remember to add that extra code after every query that uses that scope. Would it not be nice if we could keep that code in the scope itself? That is what this PR makes possible by adding an afterQuery method.

$query->afterQuery(function ($models) {
    // Make changes to the queried models ...
});

Use cases

Set attributes or relations in certain situations

Let's say that you have a withIsFavorite() scope that queries a boolean attribute on every model based on the authenticated user. But if there is no authenticated user, then that boolean should just be false on every model. Using the afterQuery() method, you can easily set that attribute on every model if there is no authenticated user.

// Before

public function scopeWithIsFavoriteOf($query, ?User $user = null) : void
{
    if ($user === null) {
        return $query;
    }

    $query->addSelect([
        // 'is_favorite' => some query ...
    ]);
}

$products = Product::withIsFavoriteOf(auth()->user())->get();

if (auth()->user() === null) {
    $products->each->setAttribute('is_favorite', false);
}
// After

public function scopeWithIsFavoriteOf($query, ?User $user = null) : void
{
    if ($user === null) {
        $query->afterQuery(fn ($products) => $products->each->setAttribute('is_favorite', false));

        return;
    }

    $query->addSelect([
        // 'is_favorite' => some query ...
    ]);
}

Product::withIsFavoriteOf(auth()->user())->get();

The example above could probably be done inside a query as well, but in the next example that would not be possible. Let's say we want to load the event booking of the authenticated user on every event, but if there is no authenticated user, we want to set the relation to null. We can easily achieve this with the afterQuery() method

// Before

public function scopeWithBookingOf($query, ?User $user = null) : void
{
    if ($user === null) {
        return $query;
    }

    $query->with([
        'booking' => function ($query) use ($user) {
            // ...
        }
    ]);
}

$events = Event::withBookingOf(auth()->user())->get();

if (auth()->user() === null) {
    $events->each->setRelation('booking', null);
}
// After

public function scopeWithBookingOf($query, ?User $user = null) : void
{
    if ($user === null) {
        $query->afterQuery(fn ($users) => $users->each->setRelation('booking', null));

        return;
    }

    $query->with([
        'booking' => function ($query) use ($user) {
            // ...
        }
    ]);
}

Event::withBookingOf(auth()->user())->get();

Make changes to data after it has been queried

Another example is when you load some extra data / some special relationshp in your query, but when the models are hydrated, you need to make some changes to the models and their releationships. Again, the afterQuery() makes it possible to keep that code together.

// Before

public function scopewithExtraData($query) : void
{
    $query->with('someRelation');
}

$models = Model::withExtraData()->get()->each(function ($model) {
    // making some changes to models and the loaded relation ...
});
// After

public function scopewithExtraData($query) : void
{
    $query->with('someRelation')->afterQuery(function (Collection $models) {
        $models->each(function ($model) {
            // making some changes to models and the loaded relation ...
        });
    });
}

Model::withExtraData()->get();

Only load data on the queried models instead of querying that data immediately

Another use case is where you want to load some data through a subselect on a paginated query, but you only want to do that after you have fetched the models of the current page to keep the pagination query performant. You could achieve this by creating a custom eloquent collection class for that model and adding that code to a method in that custom collection class, but that is a lot of extra code and an extra class that you might not need for anything else. The afterQuery() method makes that a lot simpler.

// Before

class UserCollection extends Collection
{
    public function loadHeadySubselectData()
    {
        // run a heavy query to get dat of selected users ...
    }
}

User::query()->paginate()->getCollection()-> loadHeadySubselectData();
// After

public function scopewithHeadySubselectData($query)
{
    $query->afterQuery(function ($users) {
        // run a heavy query on those $users ...
    })
}

User::withHeadySubselectData()->paginate();

@taylorotwell
Copy link
Member

There are some situations where I am curious if this would take affect, which may lead to inconsistency if it doesn't. Namely, BelongsToMany@get, HasManyThrough@get.

@taylorotwell taylorotwell marked this pull request as draft March 27, 2024 20:06
@gdebrauwer
Copy link
Contributor Author

I added support for those relationships. The other relationship types call the Builder@get method so those do not require any changes (If needed, I can also add tests for those relationships)

@gdebrauwer gdebrauwer marked this pull request as ready for review March 28, 2024 19:28
@taylorotwell
Copy link
Member

@gdebrauwer what do you think about cursor and pluck?

@taylorotwell taylorotwell marked this pull request as draft April 1, 2024 22:36
@gdebrauwer
Copy link
Contributor Author

@taylorotwell I added support for those methods. I also added some extra tests to cover the change you made to the applyAfterQueryCallbacks method

@gdebrauwer gdebrauwer marked this pull request as ready for review April 2, 2024 16:31
@gdebrauwer gdebrauwer marked this pull request as draft April 2, 2024 17:38
@gdebrauwer gdebrauwer marked this pull request as ready for review April 2, 2024 17:53
@taylorotwell
Copy link
Member

@gdebrauwer I'm not totally sure about the cursor implementation. The get implementation can filter models out of the result set and return a smaller set of models. But, since you're calling the callback for each individual model on cursor there is different behavior between get and cursor as cursor can not remove models from the result set or do any further filtering.

@taylorotwell taylorotwell marked this pull request as draft April 9, 2024 18:50
@gdebrauwer
Copy link
Contributor Author

@taylorotwell I added support for filtering when using a cursor

@gdebrauwer gdebrauwer marked this pull request as ready for review April 10, 2024 15:58
@taylorotwell taylorotwell merged commit 94f0192 into laravel:11.x Apr 10, 2024
27 of 28 checks passed
@taylorotwell
Copy link
Member

Thanks 👍

@hafezdivandari
Copy link
Contributor

Tests are failing on SQL Server.

@driesvints
Copy link
Member

@hafezdivandari fixed, thanks

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

4 participants