From 48c30335d1bc680fb86ef366a49564386f284b67 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 2 Dec 2025 07:56:16 +0000
Subject: [PATCH 1/3] Initial plan
From e1d2d81c3286c963f9b414b701759b49fe2a5cb3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 2 Dec 2025 08:48:42 +0000
Subject: [PATCH 2/3] Implement keyset/cursor pagination for users list page
Co-authored-by: samdark <47294+samdark@users.noreply.github.com>
---
components/KeysetDataProvider.php | 295 ++++++++++++++++++++++++++++++
components/KeysetPagination.php | 196 ++++++++++++++++++++
controllers/UserController.php | 64 +------
views/user/index.php | 105 ++++++-----
4 files changed, 562 insertions(+), 98 deletions(-)
create mode 100644 components/KeysetDataProvider.php
create mode 100644 components/KeysetPagination.php
diff --git a/components/KeysetDataProvider.php b/components/KeysetDataProvider.php
new file mode 100644
index 00000000..a01146eb
--- /dev/null
+++ b/components/KeysetDataProvider.php
@@ -0,0 +1,295 @@
+query === null) {
+ throw new InvalidConfigException('The "query" property must be set.');
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected function prepareModels()
+ {
+ $query = clone $this->query;
+ $pagination = $this->getPagination();
+
+ if ($pagination === false) {
+ return $query->all();
+ }
+
+ $pageSize = $pagination->pageSize;
+ $cursor = KeysetPagination::decodeCursor($pagination->cursor);
+ $direction = $pagination->direction;
+
+ // Build the keyset condition
+ if ($cursor !== null && isset($cursor['key'], $cursor['id'])) {
+ $keyValue = $cursor['key'];
+ $idValue = $cursor['id'];
+
+ if ($direction === 'prev') {
+ // Going backwards: get items before the cursor
+ if ($this->keySort === SORT_ASC) {
+ $query->andWhere([
+ 'or',
+ ['<', $this->keyColumn, $keyValue],
+ [
+ 'and',
+ [$this->keyColumn => $keyValue],
+ ['<', $this->secondaryKeyColumn, $idValue]
+ ]
+ ]);
+ // Reverse sort to get the last N items before cursor
+ $query->orderBy([
+ $this->keyColumn => SORT_DESC,
+ $this->secondaryKeyColumn => SORT_DESC
+ ]);
+ } else {
+ $query->andWhere([
+ 'or',
+ ['>', $this->keyColumn, $keyValue],
+ [
+ 'and',
+ [$this->keyColumn => $keyValue],
+ ['>', $this->secondaryKeyColumn, $idValue]
+ ]
+ ]);
+ $query->orderBy([
+ $this->keyColumn => SORT_ASC,
+ $this->secondaryKeyColumn => SORT_ASC
+ ]);
+ }
+ } else {
+ // Going forward: get items after the cursor
+ if ($this->keySort === SORT_ASC) {
+ $query->andWhere([
+ 'or',
+ ['>', $this->keyColumn, $keyValue],
+ [
+ 'and',
+ [$this->keyColumn => $keyValue],
+ ['>', $this->secondaryKeyColumn, $idValue]
+ ]
+ ]);
+ $query->orderBy([
+ $this->keyColumn => SORT_ASC,
+ $this->secondaryKeyColumn => SORT_ASC
+ ]);
+ } else {
+ $query->andWhere([
+ 'or',
+ ['<', $this->keyColumn, $keyValue],
+ [
+ 'and',
+ [$this->keyColumn => $keyValue],
+ ['<', $this->secondaryKeyColumn, $idValue]
+ ]
+ ]);
+ $query->orderBy([
+ $this->keyColumn => SORT_DESC,
+ $this->secondaryKeyColumn => SORT_DESC
+ ]);
+ }
+ }
+ } else {
+ // No cursor - start from beginning
+ if ($this->keySort === SORT_ASC) {
+ $query->orderBy([
+ $this->keyColumn => SORT_ASC,
+ $this->secondaryKeyColumn => SORT_ASC
+ ]);
+ } else {
+ $query->orderBy([
+ $this->keyColumn => SORT_DESC,
+ $this->secondaryKeyColumn => SORT_DESC
+ ]);
+ }
+ }
+
+ // Fetch one extra to determine if there are more pages
+ $query->limit($pageSize + 1);
+ $models = $query->all();
+
+ // Check if there are more items
+ $hasMore = count($models) > $pageSize;
+ if ($hasMore) {
+ // Remove the extra item
+ array_pop($models);
+ }
+
+ // If we went backwards, reverse the results to maintain correct order
+ if ($direction === 'prev' && $cursor !== null) {
+ $models = array_reverse($models);
+ }
+
+ // Update pagination cursors
+ if (!empty($models)) {
+ $firstModel = reset($models);
+ $lastModel = end($models);
+
+ // Set cursors for next/prev navigation
+ $pagination->nextCursor = KeysetPagination::encodeCursor([
+ 'key' => $lastModel->{$this->keyColumn},
+ 'id' => $lastModel->{$this->secondaryKeyColumn}
+ ]);
+
+ $pagination->prevCursor = KeysetPagination::encodeCursor([
+ 'key' => $firstModel->{$this->keyColumn},
+ 'id' => $firstModel->{$this->secondaryKeyColumn}
+ ]);
+
+ if ($direction === 'prev') {
+ $pagination->hasPrevPage = $hasMore;
+ // Check if there's a next page by seeing if we have a cursor we came from
+ $pagination->hasNextPage = ($cursor !== null);
+ } else {
+ $pagination->hasNextPage = $hasMore;
+ // We have a previous page if we started from a cursor
+ $pagination->hasPrevPage = ($cursor !== null);
+ }
+ } else {
+ $pagination->hasNextPage = false;
+ $pagination->hasPrevPage = ($cursor !== null);
+ }
+
+ return $models;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected function prepareKeys($models)
+ {
+ $keys = [];
+ if ($this->key !== null) {
+ foreach ($models as $model) {
+ if (is_string($this->key)) {
+ $keys[] = $model[$this->key];
+ } else {
+ $keys[] = call_user_func($this->key, $model);
+ }
+ }
+ return $keys;
+ }
+
+ if ($this->query instanceof ActiveQueryInterface) {
+ /* @var $class \yii\db\ActiveRecordInterface */
+ $class = $this->query->modelClass;
+ $pks = $class::primaryKey();
+ if (count($pks) === 1) {
+ $pk = $pks[0];
+ foreach ($models as $model) {
+ $keys[] = $model[$pk];
+ }
+ } else {
+ foreach ($models as $model) {
+ $kk = [];
+ foreach ($pks as $pk) {
+ $kk[$pk] = $model[$pk];
+ }
+ $keys[] = $kk;
+ }
+ }
+ return $keys;
+ }
+
+ return array_keys($models);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected function prepareTotalCount()
+ {
+ if ($this->_totalCount !== null) {
+ return $this->_totalCount;
+ }
+
+ // Note: This is expensive for large tables.
+ // Consider caching or not using total count with keyset pagination.
+ $query = clone $this->query;
+ return (int) $query->limit(-1)->offset(-1)->orderBy([])->count('*');
+ }
+
+ /**
+ * Sets the total count manually to avoid expensive COUNT query.
+ *
+ * @param int $count
+ */
+ public function setTotalCount($count)
+ {
+ $this->_totalCount = $count;
+ }
+
+ /**
+ * Returns the pagination object.
+ *
+ * @return KeysetPagination|false
+ */
+ public function getPagination()
+ {
+ if ($this->pagination === null) {
+ $this->pagination = new KeysetPagination();
+ } elseif (is_array($this->pagination)) {
+ $config = $this->pagination;
+ $config['class'] = KeysetPagination::class;
+ $this->pagination = \Yii::createObject($config);
+ }
+
+ return $this->pagination;
+ }
+}
diff --git a/components/KeysetPagination.php b/components/KeysetPagination.php
new file mode 100644
index 00000000..ed141ab6
--- /dev/null
+++ b/components/KeysetPagination.php
@@ -0,0 +1,196 @@
+getRequest();
+ if ($request instanceof Request) {
+ if ($this->cursor === null) {
+ $this->cursor = $request->getQueryParam($this->cursorParam);
+ }
+ $dir = $request->getQueryParam($this->directionParam, 'next');
+ if (in_array($dir, ['next', 'prev'], true)) {
+ $this->direction = $dir;
+ }
+ }
+ }
+
+ /**
+ * Encodes cursor values for URL usage.
+ *
+ * @param array $values the values to encode
+ * @return string the encoded cursor
+ */
+ public static function encodeCursor(array $values): string
+ {
+ return base64_encode(json_encode($values));
+ }
+
+ /**
+ * Decodes a cursor string back to values.
+ *
+ * @param string|null $cursor the cursor to decode
+ * @return array|null the decoded values or null if invalid
+ */
+ public static function decodeCursor(?string $cursor): ?array
+ {
+ if ($cursor === null || $cursor === '') {
+ return null;
+ }
+
+ $decoded = base64_decode($cursor, true);
+ if ($decoded === false) {
+ return null;
+ }
+
+ $values = json_decode($decoded, true);
+ if (!is_array($values)) {
+ return null;
+ }
+
+ return $values;
+ }
+
+ /**
+ * Creates the URL for the next page.
+ *
+ * @return string|null the URL or null if no next page
+ */
+ public function getNextPageUrl(): ?string
+ {
+ if (!$this->hasNextPage || $this->nextCursor === null) {
+ return null;
+ }
+
+ return $this->createUrl($this->nextCursor, 'next');
+ }
+
+ /**
+ * Creates the URL for the previous page.
+ *
+ * @return string|null the URL or null if no previous page
+ */
+ public function getPrevPageUrl(): ?string
+ {
+ if (!$this->hasPrevPage || $this->prevCursor === null) {
+ return null;
+ }
+
+ return $this->createUrl($this->prevCursor, 'prev');
+ }
+
+ /**
+ * Creates a pagination URL.
+ *
+ * @param string $cursor the cursor value
+ * @param string $direction the direction
+ * @return string the URL
+ */
+ protected function createUrl(string $cursor, string $direction): string
+ {
+ $params = $this->params;
+ $params[$this->cursorParam] = $cursor;
+ $params[$this->directionParam] = $direction;
+
+ if ($this->route !== null) {
+ $route = $this->route;
+ $route[0] = $route[0] ?? '';
+ return Yii::$app->urlManager->createUrl(array_merge($route, $params));
+ }
+
+ return Yii::$app->urlManager->createUrl(array_merge([''], $params));
+ }
+
+ /**
+ * Creates the URL for the first page (no cursor).
+ *
+ * @return string the URL
+ */
+ public function getFirstPageUrl(): string
+ {
+ $params = $this->params;
+ unset($params[$this->cursorParam], $params[$this->directionParam]);
+
+ if ($this->route !== null) {
+ $route = $this->route;
+ $route[0] = $route[0] ?? '';
+ return Yii::$app->urlManager->createUrl(array_merge($route, $params));
+ }
+
+ return Yii::$app->urlManager->createUrl(array_merge([''], $params));
+ }
+}
diff --git a/controllers/UserController.php b/controllers/UserController.php
index cc341cd5..12a6f8af 100644
--- a/controllers/UserController.php
+++ b/controllers/UserController.php
@@ -2,6 +2,7 @@
namespace app\controllers;
+use app\components\KeysetDataProvider;
use app\components\mailers\EmailVerificationMailer;
use app\models\Badge;
use app\models\ChangeEmailForm;
@@ -13,7 +14,6 @@
use app\models\Wiki;
use Yii;
use app\models\User;
-use yii\data\ActiveDataProvider;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\helpers\ArrayHelper;
@@ -57,69 +57,25 @@ public function behaviors()
}
/**
- * Lists all User models.
+ * Lists all User models using keyset/cursor pagination.
+ * This is more efficient than offset pagination for large datasets.
*/
public function actionIndex()
{
// temporarily
return $this->redirect(['site/index']);
- $dataProvider = new ActiveDataProvider([
+ // Using keyset pagination instead of offset pagination
+ // to reduce MySQL load on large user tables
+ $dataProvider = new KeysetDataProvider([
'query' => User::find()->active(),
+ 'keyColumn' => 'rank',
+ 'secondaryKeyColumn' => 'id',
+ 'keySort' => SORT_ASC,
'pagination' => [
'pageSize' => 50,
+ 'route' => ['user/index'],
],
- 'sort' => [
- 'defaultOrder' => ['rank' => SORT_ASC],
- 'attributes' => [
- 'rank'=> [
- 'asc'=>['rank' => SORT_ASC],
- 'desc'=>['rank' => SORT_DESC],
- 'label'=>'Rank',
- ],
- 'display_name'=> [
- 'asc'=>['display_name' => SORT_ASC],
- 'desc'=>['display_name' => SORT_DESC],
- 'label'=>'User',
- ],
- 'joined'=> [
- 'asc'=>['created_at' => SORT_ASC],
- 'desc'=>['created_at' => SORT_DESC],
- 'label'=>'Member Since',
- 'default'=>SORT_DESC,
- ],
- 'rating'=> [
- 'asc'=>['rating' => SORT_ASC],
- 'desc'=>['rating' => SORT_DESC],
- 'label'=>'Overall Rating',
- 'default'=>SORT_DESC,
- ],
- 'extensions'=> [
- 'asc'=>['extension_count' => SORT_ASC],
- 'desc'=>['extension_count' => SORT_DESC],
- 'label'=>'Extensions',
- 'default'=>SORT_DESC,
- ],
- 'wiki'=> [
- 'asc'=>['wiki_count' => SORT_ASC],
- 'desc'=>['wiki_count' => SORT_DESC],
- 'label'=>'Wiki Articles',
- 'default'=>SORT_DESC,
- ],
- 'comments'=> [
- 'asc'=>['comment_count' => SORT_ASC],
- 'desc'=>['comment_count' => SORT_DESC],
- 'label'=>'Comments',
- 'default'=>SORT_DESC,
- ],
- 'posts'=> [
- 'asc'=>['post_count' => SORT_ASC],
- 'desc'=>['post_count' => SORT_DESC],
- 'label'=>'Forum Posts',
- 'default'=>SORT_DESC,
- ],
- ],
- ]
]);
return $this->render('index', [
diff --git a/views/user/index.php b/views/user/index.php
index b6d89692..ebda1883 100644
--- a/views/user/index.php
+++ b/views/user/index.php
@@ -1,12 +1,12 @@
title = 'Members';
@@ -23,51 +23,68 @@
$this->registerMetaTag(['name' => 'keywords', 'value' => 'yii framework, community, members']);
+$models = $dataProvider->getModels();
+$pagination = $dataProvider->getPagination();
+
?>
Members
- = GridView::widget([
- 'dataProvider' => $dataProvider,
- 'summary' => 'Showing
{begin, number}-{end, number} of
{totalCount, number} {totalCount, plural, one{member} other{members}}.',
- 'columns' => [
- [
- 'attribute' => 'rank',
- 'value' => static function($model) {
- return $model->rank == 999999 ? 'not ranked' : $model->rank;
- },
- ],
- [
- 'attribute' => 'display_name',
- 'content' => static function($model) {
- return $model->rankLink;
- },
- ],
- [
- 'attribute' => 'joined',
- 'value' => 'created_at',
- 'format' => 'date',
- 'label'=>'Member Since',
- ],
- 'rating',
- [
- 'attribute' => 'extensions',
- 'value' => 'extension_count',
- ],
- [
- 'attribute' => 'wiki',
- 'value' => 'wiki_count',
- ],
- [
- 'attribute' => 'comments',
- 'value' => 'comment_count',
- ],
- [
- 'attribute' => 'posts',
- 'value' => 'post_count',
- ],
- ],
- ]) ?>
+
+
+
+
+ | Rank |
+ User |
+ Member Since |
+ Overall Rating |
+ Extensions |
+ Wiki Articles |
+ Comments |
+ Forum Posts |
+
+
+
+
+
+ | = $model->rank == 999999 ? 'not ranked' : Html::encode($model->rank) ?> |
+ = $model->rankLink ?> |
+ = Yii::$app->formatter->asDate($model->created_at) ?> |
+ = Html::encode($model->rating) ?> |
+ = Html::encode($model->extension_count) ?> |
+ = Html::encode($model->wiki_count) ?> |
+ = Html::encode($model->comment_count) ?> |
+ = Html::encode($model->post_count) ?> |
+
+
+
+
+
+
+
+
From cc704ef3c9d6e6c5053130f6893c9a43224b4fc7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 4 Dec 2025 18:48:59 +0000
Subject: [PATCH 3/3] Remove temporary redirect from user index action
Co-authored-by: samdark <47294+samdark@users.noreply.github.com>
---
controllers/UserController.php | 3 ---
1 file changed, 3 deletions(-)
diff --git a/controllers/UserController.php b/controllers/UserController.php
index 12a6f8af..3719fe2f 100644
--- a/controllers/UserController.php
+++ b/controllers/UserController.php
@@ -62,9 +62,6 @@ public function behaviors()
*/
public function actionIndex()
{
- // temporarily
- return $this->redirect(['site/index']);
-
// Using keyset pagination instead of offset pagination
// to reduce MySQL load on large user tables
$dataProvider = new KeysetDataProvider([