From f0f273a08b16d6978c64bb8fae71e0e4a9be71f9 Mon Sep 17 00:00:00 2001 From: seb-jean Date: Sun, 30 Aug 2026 12:21:19 +0200 Subject: [PATCH] Remove deprecated Paginator Use `OffsetPaginator` instead. `Paginator::HINT_ENABLE_DISTINCT`, deprecated in 3.7 in favor of `PaginatorInterface::HINT_ENABLE_DISTINCT`, goes away with it. --- UPGRADE.md | 55 +++++++ docs/en/tutorials/pagination.rst | 29 ---- phpcs.xml.dist | 1 - phpstan-baseline.neon | 6 - phpstan.neon | 9 -- src/Tools/Pagination/OffsetPaginator.php | 4 +- src/Tools/Pagination/Paginator.php | 115 --------------- src/Tools/Pagination/PaginatorQuery.php | 13 +- ...ovariant.php => window-page-covariant.php} | 20 +-- .../ORM/Tools/Pagination/PaginatorTest.php | 134 ------------------ 10 files changed, 72 insertions(+), 314 deletions(-) delete mode 100644 src/Tools/Pagination/Paginator.php rename tests/StaticAnalysis/Tools/Pagination/{paginator-covariant.php => window-page-covariant.php} (52%) delete mode 100644 tests/Tests/ORM/Tools/Pagination/PaginatorTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 602696099af..630ec6d456a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,61 @@ awareness about deprecated code. # Upgrade to 4.0 +## Removed `Doctrine\ORM\Tools\Pagination\Paginator` + +The class deprecated in 3.7 has been removed. Use +`Doctrine\ORM\Tools\Pagination\OffsetPaginator` instead: the first result and the +maximum number of results are no longer read implicitly from the query, they are +passed together as a `Window` value object to `paginate()`, which returns an +immutable, iterable `WindowPage`. + +```diff +-use Doctrine\ORM\Tools\Pagination\Paginator; ++use Doctrine\ORM\Tools\Pagination\OffsetPaginator; ++use Doctrine\ORM\Tools\Pagination\Window; + +-$query = $entityManager->createQuery($dql) +- ->setFirstResult(0) +- ->setMaxResults(25); ++$query = $entityManager->createQuery($dql); + +-$paginator = new Paginator($query, fetchJoinCollection: true); ++$page = (new OffsetPaginator(fetchJoinCollection: true)) ++ ->paginate($query, new Window(0, 25)); + +-$total = count($paginator); ++$total = $page->getTotalCount(); + +-foreach ($paginator as $post) { ++foreach ($page as $post) { + // ... + } +``` + +`Paginator::setUseOutputWalkers()` becomes the `useOutputWalkers` constructor +argument of `OffsetPaginator`. + +A `Window` always carries a page size, so paginating without a limit is no longer +possible: run the query itself when you want every row. + +`WindowPage::getItems()` returns a list, whereas iterating the legacy `Paginator` +preserved the result keys of a DQL `INDEX BY` clause. + +## Removed `Paginator::HINT_ENABLE_DISTINCT` + +The constant deprecated in 3.7 has been removed along with its class. Use +`Doctrine\ORM\Tools\Pagination\PaginatorInterface::HINT_ENABLE_DISTINCT` instead, which +applies to both `OffsetPaginator` and `CursorPaginator`. Its value is unchanged, +so hints set as a raw string keep working. + +```diff +-use Doctrine\ORM\Tools\Pagination\Paginator; ++use Doctrine\ORM\Tools\Pagination\PaginatorInterface; + +-$query->setHint(Paginator::HINT_ENABLE_DISTINCT, false); ++$query->setHint(PaginatorInterface::HINT_ENABLE_DISTINCT, false); +``` + ## Forbid using the `WITH` keyword for arbitrary DQL joins Using the `WITH` keyword to specify the condition for an arbitrary DQL join is diff --git a/docs/en/tutorials/pagination.rst b/docs/en/tutorials/pagination.rst index 8c105dca9f5..557168dc80d 100644 --- a/docs/en/tutorials/pagination.rst +++ b/docs/en/tutorials/pagination.rst @@ -168,35 +168,6 @@ API Reference ``Window::getPageNumber(): int`` Returns the 1-based page number the window points at. -Legacy ``Paginator`` -~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 3.7 - - The ``Paginator`` class is deprecated in favor of ``OffsetPaginator`` and - will be removed in 4.0. - -The legacy ``Paginator`` reads the offset implicitly from the query -(``setFirstResult()`` / ``setMaxResults()``) and implements the SPL interfaces -``Countable`` and ``IteratorAggregate``: - -.. code-block:: php - - createQuery($dql) - ->setFirstResult(0) - ->setMaxResults(100); - - $paginator = new Paginator($query, fetchJoinCollection: true); - - $c = count($paginator); - foreach ($paginator as $post) { - echo $post->getHeadline() . "\n"; - } - Cursor-Based Pagination ----------------------- diff --git a/phpcs.xml.dist b/phpcs.xml.dist index b61b7b5a567..2da2c5d1077 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -155,7 +155,6 @@ src/EntityManagerInterface.php - src/Tools/Pagination/PaginatorInterface.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 584c4428d9f..07c02bf2bdd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2682,12 +2682,6 @@ parameters: count: 1 path: src/Tools/Pagination/LimitSubqueryOutputWalker.php - - - message: '#^Method Doctrine\\ORM\\Tools\\Pagination\\Paginator\:\:count\(\) should return int\<0, max\> but returns int\.$#' - identifier: return.type - count: 1 - path: src/Tools/Pagination/Paginator.php - - message: '#^Method Doctrine\\ORM\\Tools\\ResolveTargetEntityListener\:\:remapAssociation\(\) has parameter \$classMetadata with generic class Doctrine\\ORM\\Mapping\\ClassMetadata but does not specify its types\: T$#' identifier: missingType.generics diff --git a/phpstan.neon b/phpstan.neon index f971a7269a7..892f930ef31 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,15 +6,6 @@ parameters: checkMissingOverrideMethodAttribute: true ignoreErrors: - # Paginator is deprecated in favor of OffsetPaginator, but the static-analysis - # fixture still legitimately references it until its removal in 4.0. - - - identifier: return.deprecatedClass - path: tests/StaticAnalysis/Tools/Pagination/paginator-covariant.php - - - identifier: parameter.deprecatedClass - path: tests/StaticAnalysis/Tools/Pagination/paginator-covariant.php - # Symfony cache supports passing a key prefix to the clear method. - '/^Method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) invoked with 1 parameter, 0 required\.$/' diff --git a/src/Tools/Pagination/OffsetPaginator.php b/src/Tools/Pagination/OffsetPaginator.php index bc3af8d3527..fab69fa2a46 100644 --- a/src/Tools/Pagination/OffsetPaginator.php +++ b/src/Tools/Pagination/OffsetPaginator.php @@ -24,9 +24,7 @@ * which returns an immutable, iterable {@see WindowPage} rather than the * paginator itself. The paginator therefore holds no state beyond its * configuration: a single instance can be shared as a service and reused for - * any query and any page. This avoids the implicit offset handling and the - * stateful API of the legacy {@see Paginator}, which this class is intended to - * replace. + * any query and any page. * * @template-covariant T * @implements PaginatorInterface diff --git a/src/Tools/Pagination/Paginator.php b/src/Tools/Pagination/Paginator.php deleted file mode 100644 index d84773d5146..00000000000 --- a/src/Tools/Pagination/Paginator.php +++ /dev/null @@ -1,115 +0,0 @@ - - */ -class Paginator implements Countable, IteratorAggregate -{ - use SQLResultCasing; - use PaginatorQuery; - - /** @deprecated Use {@see PaginatorInterface::HINT_ENABLE_DISTINCT} instead. */ - final public const string HINT_ENABLE_DISTINCT = PaginatorInterface::HINT_ENABLE_DISTINCT; - - private readonly Query $query; - private int|null $count = null; - - /** @param bool $fetchJoinCollection Whether the query joins a collection (true by default). */ - public function __construct( - Query|QueryBuilder $query, - private readonly bool $fetchJoinCollection = true, - ) { - $this->query = $this->resolveQuery($query); - } - - /** - * Returns the query. - */ - public function getQuery(): Query - { - return $this->query; - } - - /** - * Returns whether the query joins a collection. - * - * @return bool Whether the query joins a collection. - */ - public function getFetchJoinCollection(): bool - { - return $this->fetchJoinCollection; - } - - /** - * Returns whether the paginator will use an output walker. - */ - public function getUseOutputWalkers(): bool|null - { - return $this->useOutputWalkers; - } - - /** - * Sets whether the paginator will use an output walker. - * - * @return $this - */ - public function setUseOutputWalkers(bool|null $useOutputWalkers): static - { - $this->useOutputWalkers = $useOutputWalkers; - - return $this; - } - - #[Override] - public function count(): int - { - if ($this->count === null) { - try { - $this->count = (int) array_sum(array_map('current', $this->getCountQuery($this->query)->getScalarResult())); - } catch (NoResultException) { - $this->count = 0; - } - } - - return $this->count; - } - - /** - * {@inheritDoc} - * - * @phpstan-return Traversable - */ - #[Override] - public function getIterator(): Traversable - { - return new ArrayIterator($this->getResultForOffset( - $this->query, - $this->query->getFirstResult(), - $this->query->getMaxResults(), - $this->fetchJoinCollection, - )); - } -} diff --git a/src/Tools/Pagination/PaginatorQuery.php b/src/Tools/Pagination/PaginatorQuery.php index 37118a82d1a..ab1ab365223 100644 --- a/src/Tools/Pagination/PaginatorQuery.php +++ b/src/Tools/Pagination/PaginatorQuery.php @@ -19,8 +19,8 @@ use function is_string; /** - * Provides the implementation shared by {@see Paginator}, {@see OffsetPaginator} - * and {@see CursorPaginator}. + * Provides the implementation shared by {@see OffsetPaginator} and + * {@see CursorPaginator}. * * Every method takes the query it works on as an argument, so that the * stateless paginators can serve any query. @@ -31,8 +31,7 @@ trait PaginatorQuery { /** * Whether to force the use of an output walker. Null lets the paginator - * decide. The legacy {@see Paginator} exposes a setter for it, the - * stateless paginators take it as a constructor argument. + * decide. Paginators take it as a constructor argument. */ private bool|null $useOutputWalkers = null; @@ -128,9 +127,9 @@ private function getCountQuery(Query $query): Query /** * Executes the query for the given offset window and returns the entities. * - * Shared by {@see Paginator} and {@see OffsetPaginator}: when a to-many - * collection is fetch-joined, it uses the ID subquery + WHERE IN strategy to - * return the correct number of root entities despite duplicate rows. + * Used by {@see OffsetPaginator}: when a to-many collection is fetch-joined, + * it uses the ID subquery + WHERE IN strategy to return the correct number + * of root entities despite duplicate rows. * * The result keys are preserved (e.g. for DQL ``INDEX BY``); callers that * need a list should apply {@see array_values()} themselves. diff --git a/tests/StaticAnalysis/Tools/Pagination/paginator-covariant.php b/tests/StaticAnalysis/Tools/Pagination/window-page-covariant.php similarity index 52% rename from tests/StaticAnalysis/Tools/Pagination/paginator-covariant.php rename to tests/StaticAnalysis/Tools/Pagination/window-page-covariant.php index 0211a605033..7c2e01e7fff 100644 --- a/tests/StaticAnalysis/Tools/Pagination/paginator-covariant.php +++ b/tests/StaticAnalysis/Tools/Pagination/window-page-covariant.php @@ -4,10 +4,10 @@ namespace Doctrine\StaticAnalysis\Tools\Pagination; -use Doctrine\ORM\Tools\Pagination\Paginator; +use Doctrine\ORM\Tools\Pagination\WindowPage; /** @template-covariant T of object */ -abstract class PaginatorFactory +abstract class PageFactory { /** @var class-string */ private $class; @@ -24,8 +24,8 @@ public function getClass(): string return $this->class; } - /** @phpstan-return Paginator */ - abstract public function createPaginator(): Paginator; + /** @phpstan-return WindowPage */ + abstract public function createPage(): WindowPage; } interface Animal @@ -36,18 +36,18 @@ class Cat implements Animal { } -/** @param Paginator $paginator */ -function getFirstAnimal(Paginator $paginator): Animal|null +/** @param WindowPage $page */ +function getFirstAnimal(WindowPage $page): Animal|null { - foreach ($paginator as $result) { + foreach ($page as $result) { return $result; } return null; } -/** @param PaginatorFactory $catPaginatorFactory */ -function test(PaginatorFactory $catPaginatorFactory): Animal|null +/** @param PageFactory $catPageFactory */ +function test(PageFactory $catPageFactory): Animal|null { - return getFirstAnimal($catPaginatorFactory->createPaginator()); + return getFirstAnimal($catPageFactory->createPage()); } diff --git a/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php b/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php deleted file mode 100644 index 9635a12a8ad..00000000000 --- a/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php +++ /dev/null @@ -1,134 +0,0 @@ -getMockBuilder(AbstractPlatform::class) - ->setConstructorArgs(enum_exists(UnquotedIdentifierFolding::class) ? [UnquotedIdentifierFolding::UPPER] : []) - ->getMock(); - $platform->method('supportsIdentityColumns') - ->willReturn(true); - - $driver = $this->createStub(Driver::class); - $driver->method('getDatabasePlatform') - ->willReturn($platform); - - $this->connection = $this->getMockBuilder(Connection::class) - ->onlyMethods(['executeQuery']) - ->setConstructorArgs([[], $driver]) - ->getMock(); - - $this->em = $this->getMockBuilder(EntityManagerDecorator::class) - ->onlyMethods(['newHydrator']) - ->setConstructorArgs([$this->createTestEntityManagerWithConnection($this->connection)]) - ->getMock(); - - $this->hydrator = $this->createStub(AbstractHydrator::class); - $this->em->method('newHydrator')->willReturn($this->hydrator); - } - - #[AllowMockObjectsWithoutExpectations] - public function testExtraParametersAreStrippedWhenWalkerRemovingOriginalSelectElementsIsUsed(): void - { - $paramInWhere = 1; - $paramInSubSelect = 2; - $returnedIds = [10]; - - $this->hydrator->method('hydrateAll')->willReturn([$returnedIds]); - - $query = new Query($this->em); - $query->setDQL( - 'SELECT u, - ( - SELECT MAX(a.version) - FROM Doctrine\\Tests\\Models\\CMS\\CmsArticle a - WHERE a.user = u AND 1 = :paramInSubSelect - ) AS HIDDEN max_version - FROM Doctrine\\Tests\\Models\\CMS\\CmsUser u - WHERE u.id = :paramInWhere', - ); - $query->setParameters(['paramInWhere' => $paramInWhere, 'paramInSubSelect' => $paramInSubSelect]); - $query->setMaxResults(1); - $paginator = (new Paginator($query, true))->setUseOutputWalkers(false); - - $receivedParams = []; - $resultStub = $this->createStub(Result::class); - $this->connection - ->method('executeQuery') - ->willReturnCallback(static function (string $sql, array $params) use (&$receivedParams, $resultStub): Result { - $receivedParams[] = $params; - - return $resultStub; - }); - - $paginator->count(); - $paginator->getIterator(); - - self::assertSame([ - [$paramInWhere], - [$paramInWhere], - [$paramInSubSelect, $paramInWhere, $returnedIds], - ], $receivedParams); - } - - public function testPaginatorNotCaringAboutExtraParametersWithoutOutputWalkers(): void - { - $result = $this->getMockBuilder(Result::class)->disableOriginalConstructor()->getMock(); - $this->connection->expects(self::exactly(3))->method('executeQuery')->willReturn($result); - - $this->createPaginatorWithExtraParametersWithoutOutputWalkers([])->count(); - $this->createPaginatorWithExtraParametersWithoutOutputWalkers([[10]])->count(); - $this->createPaginatorWithExtraParametersWithoutOutputWalkers([])->getIterator(); - } - - public function testgetIteratorDoesCareAboutExtraParametersWithoutOutputWalkersWhenResultIsNotEmpty(): void - { - $result = $this->getMockBuilder(Result::class)->disableOriginalConstructor()->getMock(); - $this->connection->expects(self::exactly(1))->method('executeQuery')->willReturn($result); - $this->expectException(QueryException::class); - $this->expectExceptionMessage('Too many parameters: the query defines 1 parameters and you bound 2'); - - $this->createPaginatorWithExtraParametersWithoutOutputWalkers([[10]])->getIterator(); - } - - /** @param int[][] $willReturnRows */ - private function createPaginatorWithExtraParametersWithoutOutputWalkers(array $willReturnRows): Paginator - { - $this->hydrator->method('hydrateAll')->willReturn($willReturnRows); - $this->connection->method('executeQuery')->with(self::anything(), []); - - $query = new Query($this->em); - $query->setDQL('SELECT u FROM Doctrine\\Tests\\Models\\CMS\\CmsUser u'); - $query->setParameters(['paramInWhere' => 1]); - $query->setMaxResults(1); - - return (new Paginator($query, true))->setUseOutputWalkers(false); - } -}