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