From e6246fac004544f7017e7f18708aacb546983750 Mon Sep 17 00:00:00 2001 From: Meindert <89913092+AIC-BV@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:17:34 +0200 Subject: [PATCH] Read record navigation keys without the list's display columns prepareQuery() builds the display query, and Query\Builder::pluck() only fills in the column list when none is set, so every useRelationCount and select: expression was evaluated for each row of the list and then thrown away -- the navigation needs nothing but the keys. On a 67k-row list with ten relation-count columns that measured 6.7s and a full-row fetch of the table, against 0.23s for the keys alone. Move the read into Lists::getRecordKeys() and reduce the select list to the key, leaving it intact when the active sort resolves against one of those aliases, since ORDER BY relies on them being selected. Co-Authored-By: Claude Opus 5 --- modules/backend/behaviors/FormController.php | 12 ++--- modules/backend/tests/widgets/ListsTest.php | 55 ++++++++++++++++++++ modules/backend/widgets/Lists.php | 45 ++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/modules/backend/behaviors/FormController.php b/modules/backend/behaviors/FormController.php index d26664c7a9..5609e60f9c 100644 --- a/modules/backend/behaviors/FormController.php +++ b/modules/backend/behaviors/FormController.php @@ -687,11 +687,11 @@ public function formRenderRecordNavigation(): string * Resolves the position of the current record within the controller's list * and the neighboring record keys used for previous/next navigation. * - * The sibling set comes from the ListController's prepared query, so it + * The sibling set comes from the ListController's list widget, so it * reflects the active filters, search and sorting exactly as the user left - * the list. Ordered keys are read with a single portable `pluck` and the - * position is resolved in PHP — no driver-specific SQL — so it behaves - * identically across every database Winter supports. + * the list. Ordered keys are read with a single portable, key-only query + * and the position is resolved in PHP — no driver-specific SQL — so it + * behaves identically across every database Winter supports. * * @param \Winter\Storm\Database\Model|null $model * @return array{previous: mixed, next: mixed, current: int|null, total: int}|null @@ -717,9 +717,7 @@ public function formGetRecordNavigation($model = null): ?array return null; } - $keys = $listWidget->prepareQuery()->pluck($model->getQualifiedKeyName())->all(); - - return static::resolveRecordPosition($keys, $model->getKey()); + return static::resolveRecordPosition($listWidget->getRecordKeys(), $model->getKey()); } /** diff --git a/modules/backend/tests/widgets/ListsTest.php b/modules/backend/tests/widgets/ListsTest.php index 43e194d332..4622e9216f 100644 --- a/modules/backend/tests/widgets/ListsTest.php +++ b/modules/backend/tests/widgets/ListsTest.php @@ -2,6 +2,7 @@ namespace Backend\Tests\Widgets; +use Db; use System\Tests\Bootstrap\PluginTestCase; use Winter\Storm\Exception\ApplicationException; use Backend\Tests\Fixtures\Models\UserFixture; @@ -120,6 +121,60 @@ public function testRestrictedColumnSinglePermissionWithUserWithRightPermissions $this->assertNotNull($list->getColumn('email')); } + public function testRecordKeysAreReadWithoutTheDisplayColumns() + { + $this->actingAs((new UserFixture)->asSuperUser()); + + $sql = $this->recordKeysQuery(['column' => 'id', 'direction' => 'desc']); + $key = Db::connection()->getQueryGrammar()->wrap((new User)->getQualifiedKeyName()); + + $this->assertStringStartsWith('select ' . $key . ' from', $sql); + $this->assertStringNotContainsString('groups_count', $sql); + } + + public function testRecordKeysKeepTheDisplayColumnsTheSortResolvesAgainst() + { + $this->actingAs((new UserFixture)->asSuperUser()); + + $sql = $this->recordKeysQuery(['column' => 'groups', 'direction' => 'desc']); + + $this->assertStringContainsString('groups_count', $sql); + } + + /** + * Returns the SQL of the query Lists::getRecordKeys() runs for the given sort. + */ + protected function recordKeysQuery(array $defaultSort): string + { + $list = new Lists(null, [ + 'model' => new User, + 'arrayName' => 'array', + 'defaultSort' => $defaultSort, + 'columns' => [ + 'id' => [ + 'type' => 'text', + 'label' => 'ID', + 'sortable' => true + ], + 'groups' => [ + 'label' => 'Groups', + 'relation' => 'groups', + 'useRelationCount' => true, + 'sortable' => true + ] + ] + ]); + + $sql = ''; + Db::listen(function ($query) use (&$sql) { + $sql = $query->sql; + }); + + $list->getRecordKeys(); + + return $sql; + } + protected function restrictedListsFixture(bool $singlePermission = false) { return new Lists(null, [ diff --git a/modules/backend/widgets/Lists.php b/modules/backend/widgets/Lists.php index 1ba55a6a70..bff09d2e72 100644 --- a/modules/backend/widgets/Lists.php +++ b/modules/backend/widgets/Lists.php @@ -733,6 +733,51 @@ public function prepareQuery() return $query; } + /** + * Returns the primary key of every record in the list, in the list's current + * order, honouring the active search, filters and sorting. + * + * Only the key is read: `prepareQuery()` selects every visible column, so on + * a list carrying `useRelationCount` or custom `select:` columns each row + * would evaluate a correlated subquery whose value is then thrown away. The + * select list is reduced to the key unless the active sort resolves against + * one of its aliases, since ORDER BY relies on those being selected. + * + * @return array + */ + public function getRecordKeys(): array + { + $query = $this->prepareQuery(); + $keyName = $this->model->getQualifiedKeyName(); + + if (!$this->sortsBySelectedExpression()) { + $baseQuery = $query->getQuery(); + $baseQuery->columns = [$keyName]; + + // The select bindings belong to the expressions just discarded. + $baseQuery->bindings['select'] = []; + } + + return $query->pluck($keyName)->all(); + } + + /** + * Determines whether the active sort refers to a column that exists only as + * an alias in the select list, which is the case for columns using + * `select:` or `useRelationCount`, as well as related columns sorted by + * `valueFrom`. + */ + protected function sortsBySelectedExpression(): bool + { + if ($this->showTree || !($sortColumn = $this->getSortColumn())) { + return false; + } + + $column = array_get($this->allColumns, $sortColumn); + + return $column && (isset($column->sqlSelect) || isset($column->relation)); + } + /** * Calculate the totals for the summable columns */