diff --git a/CHANGELOG-5.0.md b/CHANGELOG-5.0.md index 8deb29d340..8b08c11d46 100644 --- a/CHANGELOG-5.0.md +++ b/CHANGELOG-5.0.md @@ -32,6 +32,7 @@ - Added `Phalcon\Html\Helper\Script::beginInternal()` and `endInternal(array $attributes = [], int $pos = -1)` to capture inline JavaScript via output buffering and append it to the asset stack as a `` block [#16971](https://github.com/phalcon/cphalcon/issues/16971) - Added `Phalcon\Html\Helper\Tag` (open tag) and `Phalcon\Html\Helper\VoidTag` (self-closing tag) as escape hatches for arbitrary tag names without a dedicated helper; available via `TagFactory` as `tag` and `voidTag` [#16971](https://github.com/phalcon/cphalcon/issues/16971) - Added `Phalcon\Mvc\Router::getMethodRoutes(): array` — returns the internal HTTP-method index (routes bucketed by method string, unconstrained routes under `"*"`). `handle()` now builds a candidate list from only the matching-method bucket plus the unconstrained bucket and iterates that subset in reverse, eliminating the O(n) per-route HTTP-method check that previously ran against every registered route on each request [#17015](https://github.com/phalcon/cphalcon/issues/17015) +- Added `Phalcon\Paginator\Adapter\QueryBuilderCursor` — a keyset (cursor-based) pagination adapter that accepts a `QueryBuilder`, a `limit`, and a `cursorColumn` (the column used as the cursor key, typically a primary key). Each `paginate()` call fetches `limit + 1` rows using `cursorColumn > :cursor:` to skip already-seen rows; the extra row is used only to detect whether a next page exists and is never returned. `getNext()` returns the last visible row's cursor value, or `0` when no further page exists. `setCursor(int|null $cursor)` advances or resets the position. `getTotalItems()` and `getLast()` return `0` by design — no COUNT query is issued. Registered in `PaginatorFactory` as `"queryBuilderCursor"` [#14754](https://github.com/phalcon/cphalcon/issues/14754) - Added `Phalcon\Support\Collection:column(string $propertyOrMethod): array` (lift a single property/method off every item, keyed by the original collection key) [#17000](https://github.com/phalcon/cphalcon/issues/17000) - Added `Phalcon\Support\Collection:each(callable $callback)` (run the callback for side effects and return `$this` for chaining) [#17000](https://github.com/phalcon/cphalcon/issues/17000) - Added `Phalcon\Support\Collection:filter(callable $callback)` (new collection of items where the callback returns truthy) [#17000](https://github.com/phalcon/cphalcon/issues/17000) diff --git a/phalcon/Contracts/Paginator/Adapter.zep b/phalcon/Contracts/Paginator/Adapter.zep new file mode 100644 index 0000000000..80995a1cee --- /dev/null +++ b/phalcon/Contracts/Paginator/Adapter.zep @@ -0,0 +1,37 @@ + +/** + * This file is part of the Phalcon Framework. + * + * (c) Phalcon Team + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalcon\Contracts\Paginator; + +/** + * Interface for Phalcon\Paginator adapters + */ +interface Adapter +{ + /** + * Get current rows limit + */ + public function getLimit() -> int; + + /** + * Returns a slice of the resultset to show in the pagination + */ + public function paginate() -> ; + + /** + * Set the current page number + */ + public function setCurrentPage(int page); + + /** + * Set current rows limit + */ + public function setLimit(int limit); +} diff --git a/phalcon/Contracts/Paginator/Repository.zep b/phalcon/Contracts/Paginator/Repository.zep new file mode 100644 index 0000000000..82115692f3 --- /dev/null +++ b/phalcon/Contracts/Paginator/Repository.zep @@ -0,0 +1,106 @@ + +/** + * This file is part of the Phalcon Framework. + * + * (c) Phalcon Team + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalcon\Contracts\Paginator; + +/** + * Interface for the repository of current state + * Phalcon\Paginator\AdapterInterface::paginate() + */ +interface Repository +{ + /** + * @var string + */ + const PROPERTY_CURRENT_PAGE = "current"; + /** + * @var string + */ + const PROPERTY_FIRST_PAGE = "first"; + /** + * @var string + */ + const PROPERTY_ITEMS = "items"; + /** + * @var string + */ + const PROPERTY_LAST_PAGE = "last"; + /** + * @var string + */ + const PROPERTY_LIMIT = "limit"; + /** + * @var string + */ + const PROPERTY_NEXT_PAGE = "next"; + /** + * @var string + */ + const PROPERTY_PREVIOUS_PAGE = "previous"; + /** + * @var string + */ + const PROPERTY_TOTAL_ITEMS = "total_items"; + + /** + * Gets the aliases for properties repository + */ + public function getAliases() -> array; + + /** + * Gets number of the current page + */ + public function getCurrent() -> int; + + /** + * Gets number of the first page + */ + public function getFirst() -> int; + + /** + * Gets the items on the current page + */ + public function getItems() -> var; + + /** + * Gets number of the last page + */ + public function getLast() -> int; + + /** + * Gets current rows limit + */ + public function getLimit() -> int; + + /** + * Gets number of the next page + */ + public function getNext() -> int; + + /** + * Gets number of the previous page + */ + public function getPrevious() -> int; + + /** + * Gets the total number of items + */ + public function getTotalItems() -> int; + + /** + * Sets the aliases for properties repository + */ + public function setAliases(array aliases) -> ; + + /** + * Sets values for properties of the repository + */ + public function setProperties(array properties) -> ; +} diff --git a/phalcon/Paginator/Adapter/AdapterInterface.zep b/phalcon/Paginator/Adapter/AdapterInterface.zep index 98b27facf6..c3fcd480e6 100644 --- a/phalcon/Paginator/Adapter/AdapterInterface.zep +++ b/phalcon/Paginator/Adapter/AdapterInterface.zep @@ -10,32 +10,13 @@ namespace Phalcon\Paginator\Adapter; -use Phalcon\Paginator\RepositoryInterface; +use Phalcon\Contracts\Paginator\Adapter as AdapterContract; /** - * Phalcon\Paginator\AdapterInterface - * - * Interface for Phalcon\Paginator adapters + * @psalm-suppress DeprecatedInterface + * @deprecated Will be removed in a future major release. + * Use {@see \Phalcon\Contracts\Paginator\Adapter} instead. */ -interface AdapterInterface +interface AdapterInterface extends AdapterContract { - /** - * Get current rows limit - */ - public function getLimit() -> int; - - /** - * Returns a slice of the resultset to show in the pagination - */ - public function paginate() -> ; - - /** - * Set the current page number - */ - public function setCurrentPage(int page); - - /** - * Set current rows limit - */ - public function setLimit(int limit); } diff --git a/phalcon/Paginator/Adapter/QueryBuilderCursor.zep b/phalcon/Paginator/Adapter/QueryBuilderCursor.zep new file mode 100644 index 0000000000..3749cc0eb2 --- /dev/null +++ b/phalcon/Paginator/Adapter/QueryBuilderCursor.zep @@ -0,0 +1,264 @@ + +/** + * This file is part of the Phalcon Framework. + * + * (c) Phalcon Team + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +namespace Phalcon\Paginator\Adapter; + +use Phalcon\Mvc\Model\Query\Builder; +use Phalcon\Paginator\Exception; +use Phalcon\Paginator\RepositoryInterface; + +/** + * Phalcon\Paginator\Adapter\QueryBuilderCursor + * + * Cursor-based (keyset) pagination using a PHQL query builder as source of + * data. + * + * Unlike offset pagination, this adapter does not use an ever-growing OFFSET. + * It appends a WHERE condition on a unique, indexed cursor column so that each + * page is an O(1) index seek regardless of depth. + * + * Limitations: + * - No total count: `getTotalItems()` always returns 0. + * - No random access: `getLast()` always returns 0. Pages must be traversed + * in order by following the cursor value returned in `getNext()`. + * - The cursor column must be unique and indexed (e.g. a primary key). + * - Items are returned as an array of associative arrays (via + * `Resultset::toArray()`), not as model objects. + * - `cursorColumn` must match the PHQL-accessible column name exactly + * (e.g. `"inv_id"`). + * + * ```php + * use Phalcon\Paginator\Adapter\QueryBuilderCursor; + * + * $builder = $this->modelsManager->createBuilder() + * ->columns("inv_id, inv_title") + * ->from(Invoices::class) + * ->orderBy("inv_id"); + * + * $paginator = new QueryBuilderCursor( + * [ + * "builder" => $builder, + * "limit" => 20, + * "cursorColumn" => "inv_id", + * "cursor" => null, // first page; pass $page->getNext() for subsequent pages + * ] + * ); + * + * $page = $paginator->paginate(); + * // $page->getItems() — array of rows for this page + * // $page->getNext() — cursor value to pass for the next page (0 means no more pages) + * // $page->getCurrent() — cursor value used for this page (0 on first page) + * ``` + */ +class QueryBuilderCursor extends AbstractAdapter +{ + /** + * Paginator's data + * + * @var Builder + */ + protected builder; + + /** + * The cursor value for the current page (null = first page) + * + * @var mixed + */ + protected cursor = null; + + /** + * The column used as the cursor (must be unique and indexed) + * + * @var string + */ + protected cursorColumn; + + /** + * Phalcon\Paginator\Adapter\QueryBuilderCursor + * + * @param array config = [ + * 'limit' => 10, + * 'builder' => null, + * 'cursorColumn' => 'id', + * 'cursor' => null + * ] + */ + public function __construct(array config) + { + var builder, cursorColumn, cursor; + + if unlikely !isset config["limit"] { + throw new Exception("Parameter 'limit' is required"); + } + + if unlikely !fetch builder, config["builder"] { + throw new Exception("Parameter 'builder' is required"); + } + + if unlikely !(builder instanceof Builder) { + throw new Exception( + "Parameter 'builder' must be an instance " . + "of Phalcon\\Mvc\\Model\\Query\\Builder" + ); + } + + if unlikely !fetch cursorColumn, config["cursorColumn"] { + throw new Exception("Parameter 'cursorColumn' is required"); + } + + if unlikely typeof cursorColumn != "string" || empty cursorColumn { + throw new Exception( + "Parameter 'cursorColumn' must be a non-empty string" + ); + } + + let this->cursorColumn = cursorColumn; + + if fetch cursor, config["cursor"] { + let this->cursor = cursor; + } + + parent::__construct(config); + + this->setQueryBuilder(builder); + } + + /** + * Get the cursor value for the current page (null on first page) + */ + public function getCursor() -> var + { + return this->cursor; + } + + /** + * Get the cursor column name + */ + public function getCursorColumn() -> string + { + return this->cursorColumn; + } + + /** + * Get the current page number + * + * Returns the cursor value used for this page cast to int, or 0 for the + * first page. Use getCursor() to retrieve the raw cursor value. + */ + public function getCurrentPage() -> int + { + if this->cursor === null { + return 0; + } + + return (int) this->cursor; + } + + /** + * Get query builder object + */ + public function getQueryBuilder() -> + { + return this->builder; + } + + /** + * Returns a slice of the resultset to show in the pagination + * + * Fetches `limit + 1` rows from the builder. If the extra row is present + * a next page exists; it is discarded and the cursor value of the last + * included row is stored in the `next` repository property. + */ + public function paginate() -> + { + var builder, query, result, items, lastItem, nextCursor, currentCursor, + currentPage; + int limit; + + let builder = clone this->builder, + limit = (int) this->limitRows, + currentCursor = this->cursor; + + if currentCursor === null { + let currentPage = 0; + } else { + let currentPage = (int) currentCursor; + } + + /** + * Append the keyset WHERE condition (skipped on the first page) + */ + if currentCursor !== null { + builder->andWhere( + "[" . this->cursorColumn . "] > :cursor:", + ["cursor": currentCursor] + ); + } + + /** + * Fetch one extra row to detect whether a next page exists + */ + builder->limit(limit + 1); + + let query = builder->getQuery(), + result = query->execute(), + items = result->toArray(); + + /** + * If we received more rows than the page size a next page exists. + * Discard the lookahead row and record the cursor of the last + * included row so the caller can advance to the next page. + */ + if count(items) > limit { + array_pop(items); + + let lastItem = items[count(items) - 1], + nextCursor = (int) lastItem[this->cursorColumn]; + } else { + let nextCursor = 0; + } + + return this->getRepository( + [ + RepositoryInterface::PROPERTY_ITEMS : items, + RepositoryInterface::PROPERTY_TOTAL_ITEMS : 0, + RepositoryInterface::PROPERTY_LIMIT : this->limitRows, + RepositoryInterface::PROPERTY_FIRST_PAGE : 1, + RepositoryInterface::PROPERTY_PREVIOUS_PAGE : 0, + RepositoryInterface::PROPERTY_CURRENT_PAGE : currentPage, + RepositoryInterface::PROPERTY_NEXT_PAGE : nextCursor, + RepositoryInterface::PROPERTY_LAST_PAGE : 0 + ] + ); + } + + /** + * Set the cursor value for the next paginate() call + * + * Pass the value returned by Repository::getNext() to advance to the + * next page, or null to restart from the first page. + */ + public function setCursor(var cursor) -> + { + let this->cursor = cursor; + + return this; + } + + /** + * Set query builder object + */ + public function setQueryBuilder( builder) -> + { + let this->builder = builder; + + return this; + } +} diff --git a/phalcon/Paginator/PaginatorFactory.zep b/phalcon/Paginator/PaginatorFactory.zep index b31808c3fd..530f29618a 100644 --- a/phalcon/Paginator/PaginatorFactory.zep +++ b/phalcon/Paginator/PaginatorFactory.zep @@ -101,9 +101,10 @@ class PaginatorFactory extends AbstractFactory protected function getServices() -> array { return [ - "model" : "Phalcon\\Paginator\\Adapter\\Model", - "nativeArray" : "Phalcon\\Paginator\\Adapter\\NativeArray", - "queryBuilder" : "Phalcon\\Paginator\\Adapter\\QueryBuilder" + "model" : "Phalcon\\Paginator\\Adapter\\Model", + "nativeArray" : "Phalcon\\Paginator\\Adapter\\NativeArray", + "queryBuilder" : "Phalcon\\Paginator\\Adapter\\QueryBuilder", + "queryBuilderCursor" : "Phalcon\\Paginator\\Adapter\\QueryBuilderCursor" ]; } } diff --git a/phalcon/Paginator/RepositoryInterface.zep b/phalcon/Paginator/RepositoryInterface.zep index fc80ee7a85..e1ff8a6383 100644 --- a/phalcon/Paginator/RepositoryInterface.zep +++ b/phalcon/Paginator/RepositoryInterface.zep @@ -10,99 +10,13 @@ namespace Phalcon\Paginator; +use Phalcon\Contracts\Paginator\Repository as RepositoryContract; + /** - * Phalcon\Paginator\RepositoryInterface - * - * Interface for the repository of current state - * Phalcon\Paginator\AdapterInterface::paginate() + * @psalm-suppress DeprecatedInterface + * @deprecated Will be removed in a future major release. + * Use {@see \Phalcon\Contracts\Paginator\Repository} instead. */ -interface RepositoryInterface +interface RepositoryInterface extends RepositoryContract { - /** - * @var string - */ - const PROPERTY_CURRENT_PAGE = "current"; - /** - * @var string - */ - const PROPERTY_FIRST_PAGE = "first"; - /** - * @var string - */ - const PROPERTY_ITEMS = "items"; - /** - * @var string - */ - const PROPERTY_LAST_PAGE = "last"; - /** - * @var string - */ - const PROPERTY_LIMIT = "limit"; - /** - * @var string - */ - const PROPERTY_NEXT_PAGE = "next"; - /** - * @var string - */ - const PROPERTY_PREVIOUS_PAGE = "previous"; - /** - * @var string - */ - const PROPERTY_TOTAL_ITEMS = "total_items"; - - /** - * Gets the aliases for properties repository - */ - public function getAliases() -> array; - - /** - * Gets number of the current page - */ - public function getCurrent() -> int; - - /** - * Gets number of the first page - */ - public function getFirst() -> int; - - /** - * Gets the items on the current page - */ - public function getItems() -> var; - - /** - * Gets number of the last page - */ - public function getLast() -> int; - - /** - * Gets current rows limit - */ - public function getLimit() -> int; - - /** - * Gets number of the next page - */ - public function getNext() -> int; - - /** - * Gets number of the previous page - */ - public function getPrevious() -> int; - - /** - * Gets the total number of items - */ - public function getTotalItems() -> int; - - /** - * Sets the aliases for properties repository - */ - public function setAliases(array aliases) -> ; - - /** - * Sets values for properties of the repository - */ - public function setProperties(array properties) -> ; } diff --git a/tests/database/Paginator/Adapter/QueryBuilderCursor/ConstructTest.php b/tests/database/Paginator/Adapter/QueryBuilderCursor/ConstructTest.php new file mode 100644 index 0000000000..435f60fa6a --- /dev/null +++ b/tests/database/Paginator/Adapter/QueryBuilderCursor/ConstructTest.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Database\Paginator\Adapter\QueryBuilderCursor; + +use PDO; +use Phalcon\Paginator\Adapter\AdapterInterface; +use Phalcon\Paginator\Adapter\QueryBuilderCursor; +use Phalcon\Paginator\Exception; +use Phalcon\Tests\AbstractDatabaseTestCase; +use Phalcon\Tests\Support\Migrations\InvoicesMigration; +use Phalcon\Tests\Support\Models\Invoices; +use Phalcon\Tests\Support\Traits\DiTrait; +use stdClass; + +final class ConstructTest extends AbstractDatabaseTestCase +{ + use DiTrait; + + public function setUp(): void + { + $this->setNewFactoryDefault(); + $this->setDatabase(); + + /** @var PDO $connection */ + $connection = self::getConnection(); + (new InvoicesMigration($connection)); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: __construct() + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorConstruct(): void + { + $manager = $this->getService('modelsManager'); + + $builder = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + $this->assertInstanceOf(QueryBuilderCursor::class, $paginator); + $this->assertInstanceOf(AdapterInterface::class, $paginator); + $this->assertNull($paginator->getCursor()); + $this->assertSame('inv_id', $paginator->getCursorColumn()); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: __construct() - + * exception missing limit + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorConstructExceptionMissingLimit(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("Parameter 'limit' is required"); + + $manager = $this->getService('modelsManager'); + + new QueryBuilderCursor( + [ + 'builder' => $manager->createBuilder()->from(Invoices::class), + 'cursorColumn' => 'inv_id', + ] + ); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: __construct() - + * exception invalid builder type + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorConstructExceptionInvalidBuilder(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "Parameter 'builder' must be an instance of Phalcon\Mvc\Model\Query\Builder" + ); + + new QueryBuilderCursor( + [ + 'builder' => new stdClass(), + 'limit' => 10, + 'cursorColumn' => 'inv_id', + ] + ); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: __construct() - + * exception missing cursorColumn + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorConstructExceptionMissingCursorColumn(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("Parameter 'cursorColumn' is required"); + + $manager = $this->getService('modelsManager'); + + new QueryBuilderCursor( + [ + 'builder' => $manager->createBuilder()->from(Invoices::class), + 'limit' => 10, + ] + ); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: __construct() - + * exception empty cursorColumn + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorConstructExceptionEmptyCursorColumn(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "Parameter 'cursorColumn' must be a non-empty string" + ); + + $manager = $this->getService('modelsManager'); + + new QueryBuilderCursor( + [ + 'builder' => $manager->createBuilder()->from(Invoices::class), + 'limit' => 10, + 'cursorColumn' => '', + ] + ); + } +} diff --git a/tests/database/Paginator/Adapter/QueryBuilderCursor/GetSetQueryBuilderTest.php b/tests/database/Paginator/Adapter/QueryBuilderCursor/GetSetQueryBuilderTest.php new file mode 100644 index 0000000000..b9689f4721 --- /dev/null +++ b/tests/database/Paginator/Adapter/QueryBuilderCursor/GetSetQueryBuilderTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Database\Paginator\Adapter\QueryBuilderCursor; + +use PDO; +use Phalcon\Paginator\Adapter\QueryBuilderCursor; +use Phalcon\Tests\AbstractDatabaseTestCase; +use Phalcon\Tests\Database\Mvc\RecordsTrait; +use Phalcon\Tests\Support\Migrations\InvoicesMigration; +use Phalcon\Tests\Support\Models\Invoices; +use Phalcon\Tests\Support\Traits\DiTrait; + +final class GetSetQueryBuilderTest extends AbstractDatabaseTestCase +{ + use DiTrait; + use RecordsTrait; + + public function setUp(): void + { + $this->setNewFactoryDefault(); + $this->setDatabase(); + + /** @var PDO $connection */ + $connection = self::getConnection(); + (new InvoicesMigration($connection)); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: + * getQueryBuilder() / setQueryBuilder() + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorGetSetQueryBuilder(): void + { + $manager = $this->getService('modelsManager'); + $builder1 = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder1, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + $this->assertSame($builder1, $paginator->getQueryBuilder()); + + $builder2 = $manager + ->createBuilder() + ->from(Invoices::class) + ->where('inv_cst_id = :custId:', ['custId' => 1]) + ->orderBy('inv_id') + ; + + $result = $paginator->setQueryBuilder($builder2); + + $this->assertSame($builder2, $paginator->getQueryBuilder()); + $this->assertSame($paginator, $result); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: + * getCursor() / setCursor() + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorGetSetCursor(): void + { + $manager = $this->getService('modelsManager'); + $builder = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + $this->assertNull($paginator->getCursor()); + + $result = $paginator->setCursor(42); + $this->assertSame(42, $paginator->getCursor()); + $this->assertSame($paginator, $result); + + $paginator->setCursor(null); + $this->assertNull($paginator->getCursor()); + } +} diff --git a/tests/database/Paginator/Adapter/QueryBuilderCursor/PaginateTest.php b/tests/database/Paginator/Adapter/QueryBuilderCursor/PaginateTest.php new file mode 100644 index 0000000000..60140eb8ca --- /dev/null +++ b/tests/database/Paginator/Adapter/QueryBuilderCursor/PaginateTest.php @@ -0,0 +1,303 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Database\Paginator\Adapter\QueryBuilderCursor; + +use PDO; +use Phalcon\Paginator\Adapter\QueryBuilderCursor; +use Phalcon\Paginator\Repository; +use Phalcon\Tests\AbstractDatabaseTestCase; +use Phalcon\Tests\Database\Mvc\RecordsTrait; +use Phalcon\Tests\Support\Migrations\InvoicesMigration; +use Phalcon\Tests\Support\Models\Invoices; +use Phalcon\Tests\Support\Traits\DiTrait; + +/** + * @group phql + */ +final class PaginateTest extends AbstractDatabaseTestCase +{ + use DiTrait; + use RecordsTrait; + + public function setUp(): void + { + $this->setNewFactoryDefault(); + $this->setDatabase(); + + /** @var PDO $connection */ + $connection = self::getConnection(); + (new InvoicesMigration($connection)); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: paginate() - + * first page returns correct slice and a valid next cursor + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @issue https://github.com/phalcon/cphalcon/issues/14754 + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorPaginateFirstPage(): void + { + /** @var PDO $connection */ + $connection = self::getConnection(); + $migration = new InvoicesMigration($connection); + $invId = ('sqlite' === self::getDriver()) ? 'null' : 'default'; + + $this->insertDataInvoices($migration, 12, $invId, 1, 'aaa'); + + $manager = $this->getService('modelsManager'); + $builder = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + $page = $paginator->paginate(); + + $this->assertInstanceOf(Repository::class, $page); + $this->assertIsArray($page->getItems()); + $this->assertCount(5, $page->getItems()); + $this->assertSame(0, $page->getCurrent()); + $this->assertSame(5, $page->getLimit()); + $this->assertSame(0, $page->getTotalItems()); + $this->assertSame(0, $page->getLast()); + $this->assertGreaterThan(0, $page->getNext()); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: paginate() - + * traversing all pages forward yields every row exactly once + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @issue https://github.com/phalcon/cphalcon/issues/14754 + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorPaginateForwardTraversal(): void + { + /** @var PDO $connection */ + $connection = self::getConnection(); + $migration = new InvoicesMigration($connection); + $invId = ('sqlite' === self::getDriver()) ? 'null' : 'default'; + + $this->insertDataInvoices($migration, 11, $invId, 1, 'aaa'); + + $manager = $this->getService('modelsManager'); + $builder = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + // page 1 — 5 rows, next cursor set + $page1 = $paginator->paginate(); + $page1Items = $page1->getItems(); + $this->assertCount(5, $page1Items); + $this->assertGreaterThan(0, $page1->getNext()); + + // Capture cursor before the next paginate() call overwrites the shared + // repository object + $page1Next = $page1->getNext(); + + // page 2 — 5 rows, next cursor set + $paginator->setCursor($page1Next); + $page2 = $paginator->paginate(); + $page2Items = $page2->getItems(); + $this->assertCount(5, $page2Items); + $this->assertSame($page1Next, $page2->getCurrent()); + $this->assertGreaterThan(0, $page2->getNext()); + + // Capture before page 3 overwrites + $page2Next = $page2->getNext(); + + // page 3 — 1 remaining row, no further page + $paginator->setCursor($page2Next); + $page3 = $paginator->paginate(); + $page3Items = $page3->getItems(); + $this->assertCount(1, $page3Items); + $this->assertSame(0, $page3->getNext()); + + // Collect all IDs and verify no duplicates and correct count + $allIds = array_merge( + array_column($page1Items, 'inv_id'), + array_column($page2Items, 'inv_id'), + array_column($page3Items, 'inv_id') + ); + $this->assertCount(11, $allIds); + $this->assertCount(11, array_unique($allIds)); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: paginate() - + * exact multiple of limit has no next page on the last page + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @issue https://github.com/phalcon/cphalcon/issues/14754 + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorPaginateExactMultiple(): void + { + /** @var PDO $connection */ + $connection = self::getConnection(); + $migration = new InvoicesMigration($connection); + $invId = ('sqlite' === self::getDriver()) ? 'null' : 'default'; + + $this->insertDataInvoices($migration, 10, $invId, 1, 'bbb'); + + $manager = $this->getService('modelsManager'); + $builder = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + $page1 = $paginator->paginate(); + $page1Next = $page1->getNext(); + $this->assertCount(5, $page1->getItems()); + $this->assertGreaterThan(0, $page1Next); + + $paginator->setCursor($page1Next); + $page2 = $paginator->paginate(); + $this->assertCount(5, $page2->getItems()); + $this->assertSame(0, $page2->getNext()); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: paginate() - + * empty table returns empty items and zero cursor + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @issue https://github.com/phalcon/cphalcon/issues/14754 + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorPaginateEmpty(): void + { + $manager = $this->getService('modelsManager'); + $builder = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + $page = $paginator->paginate(); + + $this->assertInstanceOf(Repository::class, $page); + $this->assertIsArray($page->getItems()); + $this->assertCount(0, $page->getItems()); + $this->assertSame(0, $page->getNext()); + $this->assertSame(0, $page->getCurrent()); + } + + /** + * Tests Phalcon\Paginator\Adapter\QueryBuilderCursor :: paginate() - + * setCursor(null) resets to the first page + * + * @author Phalcon Team + * @since 2026-05-14 + * + * @issue https://github.com/phalcon/cphalcon/issues/14754 + * @group mysql + * @group pgsql + * @group sqlite + */ + public function testPaginatorAdapterQuerybuilderCursorPaginateResetCursor(): void + { + /** @var PDO $connection */ + $connection = self::getConnection(); + $migration = new InvoicesMigration($connection); + $invId = ('sqlite' === self::getDriver()) ? 'null' : 'default'; + + $this->insertDataInvoices($migration, 6, $invId, 1, 'ccc'); + + $manager = $this->getService('modelsManager'); + $builder = $manager + ->createBuilder() + ->from(Invoices::class) + ->orderBy('inv_id') + ; + + $paginator = new QueryBuilderCursor( + [ + 'builder' => $builder, + 'limit' => 5, + 'cursorColumn' => 'inv_id', + ] + ); + + $page1 = $paginator->paginate(); + $page1Items = $page1->getItems(); + $page1Next = $page1->getNext(); + $this->assertCount(5, $page1Items); + + // Advance to page 2 + $paginator->setCursor($page1Next); + $page2 = $paginator->paginate(); + $this->assertCount(1, $page2->getItems()); + + // Reset to first page + $paginator->setCursor(null); + $pageReset = $paginator->paginate(); + $this->assertCount(5, $pageReset->getItems()); + $this->assertSame(0, $pageReset->getCurrent()); + $this->assertSame($page1Items, $pageReset->getItems()); + } +}