Organization and mail datagrids: closure columns query the database once per row
Bug report
Issue Description
Two datagrid columns query the database inside their closure, which the grid runs once
per rendered row. Both listings therefore cost two extra queries per row — up to 100 on a
page of 50, which the toolbar offers (DataGrid::$perPageOptions).
1. OrganizationDataGrid, column persons_count
(packages/Webkul/Admin/src/DataGrids/Contact/OrganizationDataGrid.php:71)
'closure' => function ($row) {
$personsCount = $this->personRepository->findWhere(['organization_id' => $row->id])->count();
return $personsCount;
},One SELECT * FROM persons WHERE organization_id = ? per row — and because
Person::$with = 'organization' (Person.php:35), each of those also re-selects the
organization it came from. Two queries per row, to produce a number the database can return
as one column.
2. EmailDataGrid, column tags
(packages/Webkul/Admin/src/DataGrids/Mail/EmailDataGrid.php:109-110)
'closure' => function ($row) {
if ($email = app(EmailRepository::class)->find($row->id)) {
return $email->tags;
}
return '--';
},The row was just selected by the grid's own query; the closure selects it again by primary
key, then lazy-loads its tags.
Worth noting: the grid already has that value. prepareQueryBuilder() selects
'tags.name as tags' (line 35) through the email_tags / tags joins on lines 39-40, so
$row->tags is populated before the closure runs. The closure discards it and re-fetches.
Measured (MySQL 8.0, queries per grid request):
| rows on the page | organizations | |
|---|---|---|
| 5 | 14 | 14 |
| 10 | 23 | 23 |
| 20 | 43 | 43 |
Preconditions
- Krayin
master@2c12091(Core::KRAYIN_VERSION2.2.6), also present in v2.2.5. - PHP 8.3.33, Laravel 12, MySQL 8.0.
Steps to reproduce
- Create 20 organizations, each with one person; and 20 inbox emails, each with a tag.
- Count the queries each listing request makes — Laravel Debugbar's Queries tab on the grid XHR, or from a feature test:
DB::enableQueryLog();
$this->actingAs(User::find(1), 'user')
->getJson('/admin/contacts/organizations?pagination[per_page]=20',
['X-Requested-With' => 'XMLHttpRequest']);
count(DB::getQueryLog());- Repeat with
per_page=10and compare: the difference is two queries per row. Same for/admin/mail/inbox?pagination[per_page]=20.
Expected result
- A listing costs a constant number of queries regardless of the page size.
For persons_count, counting in the query the grid already runs, e.g. in
prepareQueryBuilder():
$queryBuilder->selectSub(
DB::table('persons')->selectRaw('count(*)')->whereColumn('persons.organization_id', 'organizations.id'),
'persons_count'
);and dropping the closure — the column index is already persons_count.
For the mail grid, the closure can go entirely: $row->tags already carries the joined
value. The filterable_options block on the same column is unaffected.
Actual result
Both listings issue two extra queries for every row displayed. On the largest page size the toolbar offers that is about 100 queries spent on data the grid either already has (mail) or could get as one subquery (organizations).
How this was found
Found with query-guard — a PHPUnit extension
that traces the SQL a test run produces, fingerprints every query by call site and shape,
and flags a call site when the same-shaped query runs 3+ times with differing bound values
inside one test (batched IN (...) lookups are excluded, since those are the cure rather
than the disease).
The Pest suite could not surface this on its own, since these screens are covered by the Playwright suite rather than by PHPUnit. So the numbers above come from driving the two grid endpoints through Laravel's HTTP kernel — real route, middleware, controller and datagrid — with the page size varied.
You do not need that tool to check any of this: the reproduction above uses nothing but
DB::enableQueryLog(), and Laravel Debugbar's Queries tab shows the same counts in the
browser.
Source: krayin/laravel-crm