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(); + ?> 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([