Compare commits

...

33 Commits
3.6.1 ... 3.7.x

Author SHA1 Message Date
Grégoire Paris
b87781f65e Merge pull request #12364 from seb-jean/cursor-pagination
Add cursor-based pagination
2026-03-15 14:20:20 +00:00
seb-jean
c0ff86ef69 Add cursor pagination 2026-03-13 12:04:50 +01:00
Alexander M. Turek
77b579287c Merge branch '3.6.x' into 3.7.x
* 3.6.x:
  Make the data provider static
  Raise proper exception for invalid arguments in Base::add() (#12394)
2026-03-12 13:26:47 +01:00
Alexander M. Turek
27c33cf88d Merge branch '2.20.x' into 3.6.x
* 2.20.x:
  Make the data provider static
  Raise proper exception for invalid arguments in Base::add() (#12394)
2026-03-12 09:31:56 +01:00
Alexander M. Turek
6068b61a0d Make the data provider static 2026-03-12 09:24:03 +01:00
Alexander M. Turek
00024f7d88 Raise proper exception for invalid arguments in Base::add() (#12394) 2026-03-12 09:05:27 +01:00
Alexander M. Turek
255612a1ff Merge branch '3.6.x' into 3.7.x
* 3.6.x:
  Fix code style (#12395)
  Bump actions/upload-artifact from 6 to 7 (#12387)
  Bump actions/download-artifact from 7 to 8 (#12386)
2026-03-11 16:55:34 +01:00
Alexander M. Turek
331f8b52cb Merge branch '2.20.x' into 3.6.x
* 2.20.x:
  Fix code style (#12395)
  Bump actions/upload-artifact from 6 to 7 (#12387)
  Bump actions/download-artifact from 7 to 8 (#12386)
2026-03-11 16:55:13 +01:00
Alexander M. Turek
b2faba62b7 Fix code style (#12395) 2026-03-11 16:51:15 +01:00
dependabot[bot]
da426a0036 Bump actions/upload-artifact from 6 to 7 (#12387)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 00:34:23 +01:00
dependabot[bot]
1891a76f13 Bump actions/download-artifact from 7 to 8 (#12386)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 00:33:50 +01:00
Grégoire Paris
14bb034fe4 Merge pull request #12341 from greg0ire/compat-coll3
Implement compatibility with collections 3
2026-02-02 18:29:56 +01:00
Grégoire Paris
afc0aab61a Implement compatiblity with collections 3 2026-02-01 23:19:23 +01:00
Grégoire Paris
e1d7a13a5e Merge pull request #12368 from doctrine/3.6.x-merge-up-into-3.7.x_4EGww1uJ
Merge release 3.6.2 into 3.7.x
2026-01-30 22:55:20 +01:00
vvaswani
4262eb495b fix: update index to be serialized in __sleep() (#12366)
Signed-off-by: Vikram Vaswani <2571660+vvaswani@users.noreply.github.com`>
2026-01-30 22:41:41 +01:00
Grégoire Paris
fe6e5a67f8 Merge pull request #12362 from greg0ire/leverage-evm-interface
Leverage event manager interfaces
2026-01-30 07:51:38 +01:00
Grégoire Paris
b20a66dcdd Leverage event manager interfaces
Note that this involves dropping support for doctrine/event-manager 1.x,
and given that v2 only requires PHP 8.1, I think that is fine.
2026-01-29 22:37:51 +01:00
Grégoire Paris
dc46af27ed Merge pull request #12358 from doctrine/3.6.x
Merge 3.6.x up into 3.7.x
2026-01-26 22:40:24 +01:00
Grégoire Paris
05ab22710b Merge pull request #12349 from greg0ire/remove-has-listeners-call
Remove unnecessary check
2026-01-26 09:03:10 +01:00
Grégoire Paris
d3b47d2cbb Merge pull request #12355 from doctrine/2.20.x
Merge 2.20.x up into 3.6.x
2026-01-25 12:48:28 +01:00
Grégoire Paris
026f5bfe1b Merge pull request #12350 from greg0ire/missing-order-by
Add missing ORDER BY clause
2026-01-18 10:41:46 +01:00
Grégoire Paris
6af7de38e1 Remove unnecessary check
EventManager::dispatchEvent() already performs a similar check. So does
EventManager::getListeners()
2026-01-17 13:57:15 +01:00
Grégoire Paris
0b0f2f4d86 Add missing ORDER BY clause
This causes transient failures with PostgreSQL. Order is not guaranteed.
2026-01-17 13:39:44 +01:00
Grégoire Paris
63d9a898ec Merge pull request #12347 from doctrine/3.6.x
Merge 3.6.x up into 3.7.x
2026-01-16 18:28:50 +01:00
Grégoire Paris
0bd839a720 Merge pull request #12345 from greg0ire/3.6.x
Merge 2.20.x up into 3.6.x
2026-01-16 18:27:03 +01:00
Grégoire Paris
b65004fc26 Merge remote-tracking branch 'origin/2.20.x' into 3.6.x 2026-01-16 18:24:38 +01:00
Grégoire Paris
d2418ab074 Merge pull request #12344 from greg0ire/update-baseline
Update PHPStan baseline
2026-01-15 23:38:15 +01:00
Grégoire Paris
39a05e31c9 Update PHPStan baseline
This is caused by the release of doctrine/collections 2.7.0. The error
message is a bit shorter now.
2026-01-15 20:13:05 +01:00
sasezaki
ab156a551c Update phpstan-dbal2 to phpstan-dbal3 in .gitattributes (#12343) 2026-01-12 12:53:04 +01:00
Grégoire Paris
0fc9208d71 Merge pull request #12340 from greg0ire/add-missing-td
Add missing return type declaration
2026-01-10 23:18:33 +01:00
Grégoire Paris
fd9e572424 Add missing return type declaration
The class is final, so this is backward-compatible.
2026-01-10 13:01:03 +01:00
Grégoire Paris
76490f2c99 Merge pull request #12338 from doctrine/3.6.x-merge-up-into-3.7.x_8wGpvJ0m
Merge release 3.6.1 into 3.7.x
2026-01-09 10:09:17 +01:00
Carlos Fernandes
f8bbdc40b0 Add dispatchPreFlushEvent() method and avoid calling getConnection() twice 2025-12-30 18:35:45 +01:00
71 changed files with 2291 additions and 241 deletions

2
.gitattributes vendored
View File

@@ -15,6 +15,6 @@ phpcs.xml.dist export-ignore
phpbench.json export-ignore
phpstan.neon export-ignore
phpstan-baseline.neon export-ignore
phpstan-dbal2.neon export-ignore
phpstan-dbal3.neon export-ignore
phpstan-params.neon export-ignore
phpstan-persistence2.neon export-ignore

View File

@@ -150,7 +150,7 @@ jobs:
ENABLE_NATIVE_LAZY_OBJECTS: ${{ matrix.native_lazy }}
- name: "Upload coverage file"
uses: "actions/upload-artifact@v6"
uses: "actions/upload-artifact@v7"
with:
name: "phpunit-${{ matrix.extension }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-${{ matrix.deps }}-${{ matrix.stability }}-${{ matrix.native_lazy }}-coverage"
path: "coverage*.xml"
@@ -265,7 +265,7 @@ jobs:
run: "vendor/bin/phpunit -c ci/github/phpunit/pdo_pgsql.xml --coverage-clover=coverage.xml"
- name: "Upload coverage file"
uses: "actions/upload-artifact@v6"
uses: "actions/upload-artifact@v7"
with:
name: "${{ github.job }}-${{ matrix.postgres-version }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-${{ matrix.extension }}-coverage"
path: "coverage.xml"
@@ -339,7 +339,7 @@ jobs:
run: "vendor/bin/phpunit -c ci/github/phpunit/${{ matrix.extension }}.xml --coverage-clover=coverage.xml"
- name: "Upload coverage file"
uses: "actions/upload-artifact@v6"
uses: "actions/upload-artifact@v7"
with:
name: "${{ github.job }}-${{ matrix.mariadb-version }}-${{ matrix.extension }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-coverage"
path: "coverage.xml"
@@ -442,7 +442,7 @@ jobs:
ENABLE_SECOND_LEVEL_CACHE: 1
- name: "Upload coverage files"
uses: "actions/upload-artifact@v6"
uses: "actions/upload-artifact@v7"
with:
name: "${{ github.job }}-${{ matrix.mysql-version }}-${{ matrix.extension }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-coverage"
path: "coverage*.xml"
@@ -465,7 +465,7 @@ jobs:
fetch-depth: 2
- name: "Download coverage files"
uses: "actions/download-artifact@v7"
uses: "actions/download-artifact@v8"
with:
path: "reports"

View File

@@ -27,6 +27,43 @@ At this point, we recommend upgrading to PHP 8.4 first and then directly from
ORM 2.19 to 3.5 and up so that you can skip the lazy ghost proxy generation
and directly start using native lazy objects.
# Upgrade to 3.7
## Conditional breaking changes
3.7 adds support for `doctrine/collections` 3. If you upgrade to that version
of `doctrine/collections`, there are breaking changes in `doctrine/orm` as well,
because of cross-package inheritance and type declarations.
Most notably, `Doctrine\ORM\PersistentCollection::add` no longer returns a boolean:
```diff
- public function add(mixed $value): bool
+ public function add(mixed $value): void
```
That method always returned `true`, so you can safely stop using the return
value before upgrading.
Also, if you extend `Doctrine\ORM\Persisters\SqlValueVisitor`, you need to
ensure the following methods have a return type in your subclasses:
- `walkComparison()`
- `walkCompositeExpression()`
- `walkValue()`
## Deprecate `EventManager` return type in `EntityManager` methods
The return type of the following methods has been changed from
`Doctrine\Common\EventManager` to `Doctrine\Common\EventManagerInterface`:
- `Doctrine\ORM\Decorator\EntityManagerDecorator::getEventManager()`
- `Doctrine\ORM\EntityManager::getEventManager()`
- `Doctrine\ORM\EntityManagerInterface::getEventManager()`
All three methods continue to return an instance of `EventManager`, however
relying on that is deprecated and will no longer be the guaranteed in 4.0.
# Upgrade to 3.6
## Deprecate using string expression for default values in mappings

View File

@@ -34,10 +34,10 @@
"php": "^8.1",
"ext-ctype": "*",
"composer-runtime-api": "^2",
"doctrine/collections": "^2.2",
"doctrine/collections": "^2.2 || ^3",
"doctrine/dbal": "^3.8.2 || ^4",
"doctrine/deprecations": "^0.5.3 || ^1",
"doctrine/event-manager": "^1.2 || ^2",
"doctrine/event-manager": "^2.1.1",
"doctrine/inflector": "^1.4 || ^2.0",
"doctrine/instantiator": "^1.3 || ^2",
"doctrine/lexer": "^3",

View File

@@ -1,6 +1,41 @@
Pagination
==========
Doctrine ORM provides two pagination strategies for DQL queries. Both handle
the low-level SQL plumbing, but they make different trade-offs:
.. list-table::
:header-rows: 1
* - Feature
- Offset ``Paginator``
- ``CursorPaginator``
* - Total count
- Yes
- No
* - Random access to page N
- Yes
- No
* - Stable under concurrent inserts/deletes
- No
- Yes
* - Performance on deep pages
- Degrades (OFFSET scan)
- Constant (index range scan)
* - Requires deterministic ORDER BY
- No
- Yes
Choose the **Offset Paginator** when you need a total page count or want to
let users jump to an arbitrary page number.
Choose the **Cursor Paginator** when you need stable, high-performance
pagination on large datasets and a simple previous/next navigation is
sufficient.
Offset-Based Pagination
-----------------------
Doctrine ORM ships with a Paginator for DQL queries. It
has a very simple API and implements the SPL interfaces ``Countable`` and
``IteratorAggregate``.
@@ -58,3 +93,178 @@ In this way the `DISTINCT` keyword will be omitted and can bring important perfo
->setHint(Paginator::HINT_ENABLE_DISTINCT, false)
->setFirstResult(0)
->setMaxResults(100);
Cursor-Based Pagination
-----------------------
Doctrine ORM ships with a ``CursorPaginator`` for cursor-based pagination of DQL queries.
Unlike offset-based pagination, cursor pagination uses opaque pointers (cursors) derived
from the last seen row to fetch the next or previous page. This makes it stable and
performant on large datasets — no matter how deep you paginate, the database always uses
an index range scan instead of skipping rows.
.. note::
Cursor pagination requires a **deterministic ``ORDER BY`` clause**. Every column
combination used for sorting must uniquely identify a position in the result set.
A common pattern is to sort by a timestamp and then by primary key as a tie-breaker.
Basic Usage
~~~~~~~~~~~
The ``$cursor`` parameter is an opaque string produced by a previous call to
``getNextCursorAsString()`` or ``getPreviousCursorAsString()``. On the first request
it is ``null`` or an empty string ``''`` — both are treated identically as the first
page. It is typically read from the incoming HTTP query string:
.. code-block:: php
$cursor = $_GET['cursor'] ?? null; // null or '' on the first page
.. code-block:: php
<?php
use Doctrine\ORM\Tools\CursorPagination\CursorPaginator;
$dql = 'SELECT p FROM BlogPost p ORDER BY p.createdAt DESC, p.id DESC';
$query = $entityManager->createQuery($dql);
$paginator = (new CursorPaginator($query))
->paginate(cursor: $cursor, limit: 15);
foreach ($paginator as $post) {
echo $post->getTitle() . "\n";
}
echo $paginator->getPreviousCursorAsString(); // previous encoded cursor string
echo $paginator->getNextCursorAsString(); // next encoded cursor string
Navigating Pages
~~~~~~~~~~~~~~~~
Pass the encoded cursor back on subsequent requests to move forward or backward:
.. code-block:: php
<?php
// Next page
$paginator->paginate(15, $nextCursor);
// Previous page
$paginator->paginate(15, $previousCursor);
The cursor is an encoded string containing the location at which the next query should begin fetching results, along with the navigation direction.
API Reference
~~~~~~~~~~~~~
``CursorPaginator::paginate(?string $cursor, int $limit): self``
Executes the query and stores the results. Fetches ``$limit + 1`` rows to
detect whether a further page exists, then trims the extra row. Returns
``$this`` for chaining.
``CursorPaginator::getNextCursor(): Cursor``
Returns the ``Cursor`` object for the next page. Throws a ``LogicException``
if there is no next page — call ``hasNextPage()`` first.
``CursorPaginator::getPreviousCursor(): Cursor``
Returns the ``Cursor`` object for the previous page. Throws a ``LogicException``
if there is no previous page — call ``hasPreviousPage()`` first.
``CursorPaginator::getNextCursorAsString(): string``
Returns the encoded cursor to retrieve the next page. Throws a
``LogicException`` if there is no next page — call ``hasNextPage()`` first.
``CursorPaginator::getPreviousCursorAsString(): string``
Returns the encoded cursor to retrieve the previous page. Throws a
``LogicException`` if there is no previous page — call ``hasPreviousPage()`` first.
``CursorPaginator::hasNextPage(): bool``
Returns whether a next page is available.
``CursorPaginator::hasPreviousPage(): bool``
Returns whether a previous page is available.
``CursorPaginator::hasToPaginate(): bool``
Returns whether either a next or previous page exists (i.e. the result
set spans more than one page).
``CursorPaginator::getValues(): array``
Returns the raw entity array for the current page.
``CursorPaginator::getItems(): array``
Returns an array of ``CursorItem`` objects, each wrapping an entity and its
individual ``Cursor``. Useful when you need per-row cursors.
``CursorPaginator::getCursorForItem(mixed $item, bool $isNext = true): Cursor``
Builds a ``Cursor`` pointing at a specific entity. ``$isNext = true`` means
"start *after* this item"; ``false`` means "start *before* this item".
``CursorPaginator::count(): int``
Returns the number of items on the current page (implements ``Countable``).
**Next page**
.. code-block:: sql
SELECT ...
FROM post p
WHERE (p.created_at < :cursor_val_0)
OR (p.created_at = :cursor_val_0 AND p.id < :cursor_id_1)
ORDER BY p.created_at DESC, p.id DESC
LIMIT 16 -- limit + 1
**Previous page**
.. code-block:: sql
SELECT ...
FROM post p
WHERE (p.created_at > :cursor_val_0)
OR (p.created_at = :cursor_val_0 AND p.id > :cursor_id_1)
ORDER BY p.created_at ASC, p.id ASC -- reversed
LIMIT 16
HTML Template Example
~~~~~~~~~~~~~~~~~~~~~
The following example shows how to render a paginated list with previous/next
navigation links using the ``CursorPaginator`` in a PHP template:
.. literalinclude:: pagination/cursor-pagination.php
:language: php
Cursor Encoding
~~~~~~~~~~~~~~~
A cursor is serialized to a URL-safe string via ``Cursor::encodeToString()`` and
deserialized back via the static ``Cursor::fromEncodedString()``. The format is a
JSON object encoded with URL-safe Base64 (no padding):
.. code-block:: json
{
"p.createdAt": "2024-01-15T10:30:00+00:00",
"p.id": 42,
"_isNext": true
}
The ``_isNext`` flag distinguishes next-page cursors from previous-page cursors.
All other keys are the DQL path expressions (``alias.field``) of the ``ORDER BY``
columns, and their values are the database representations of the pivot row's
field values.
If you need a different serialization format (e.g. encryption), build it on top of
a ``Cursor`` instance: call ``$cursor->toArray()`` to get the raw data, apply your
own encoding, and reconstruct with ``new Cursor($parameters, $isNext)``.
Limitations
~~~~~~~~~~~
- Every ``ORDER BY`` column must map to an entity field. Raw SQL expressions or
computed columns in ``ORDER BY`` are not supported.
- ``COUNT`` queries are not available; cursor pagination does not know the total
number of results by design. If you need a total count, use the
offset-based ``Paginator`` described above.
- The query must have at least one ``ORDER BY`` item; the paginator throws a
``LogicException`` otherwise.

View File

@@ -0,0 +1,31 @@
<?php
use Doctrine\ORM\Tools\CursorPagination\CursorPaginator;
$cursor = $_GET['cursor'] ?? null;
$query = $entityManager->createQuery('SELECT p FROM BlogPost p ORDER BY p.createdAt DESC, p.id DESC');
/** @var CursorPaginator<BlogPost> $paginator */
$paginator = (new CursorPaginator($query))
->paginate(cursor: $cursor, limit: 15);
?>
<p><?= $paginator->count() ?> result(s) on this page.</p>
<ul>
<?php foreach ($paginator as $post): ?>
<li><?= escape($post->getTitle()) ?></li>
<?php endforeach ?>
</ul>
<?php if ($paginator->hasToPaginate()): ?>
<nav>
<?php if ($paginator->hasPreviousPage()): ?>
<a href="?cursor=<?= escape($paginator->getPreviousCursorAsString()) ?>">Previous</a>
<?php endif ?>
<?php if ($paginator->hasNextPage()): ?>
<a href="?cursor=<?= escape($paginator->getNextCursorAsString()) ?>">Next</a>
<?php endif ?>
</nav>
<?php endif ?>

View File

@@ -52,6 +52,8 @@
<rule ref="PSR1.Classes.ClassDeclaration.MultipleClasses">
<exclude-pattern>src/Mapping/Driver/LoadMappingFileImplementation.php</exclude-pattern>
<exclude-pattern>src/Mapping/GetReflectionClassImplementation.php</exclude-pattern>
<exclude-pattern>src/Persisters/SqlValueVisitorImplementation.php</exclude-pattern>
<exclude-pattern>src/PersistentCollectionImplementation.php</exclude-pattern>
<exclude-pattern>tests/*</exclude-pattern>
</rule>

View File

@@ -619,7 +619,7 @@ parameters:
path: src/EntityRepository.php
-
message: '#^Method Doctrine\\ORM\\EntityRepository\:\:matching\(\) should return Doctrine\\Common\\Collections\\AbstractLazyCollection\<int, T of object\>&Doctrine\\Common\\Collections\\Selectable\<int, T of object\> but returns Doctrine\\ORM\\LazyCriteriaCollection\<\(int\|string\), object\>\.$#'
message: '#^Method Doctrine\\ORM\\EntityRepository\:\:matching\(\) should return Doctrine\\Common\\Collections\\AbstractLazyCollection\<int, T of object\> but returns Doctrine\\ORM\\LazyCriteriaCollection\<\(int\|string\), object\>\.$#'
identifier: return.type
count: 1
path: src/EntityRepository.php
@@ -1681,7 +1681,7 @@ parameters:
path: src/PersistentCollection.php
-
message: '#^Method Doctrine\\ORM\\PersistentCollection\:\:matching\(\) should return Doctrine\\Common\\Collections\\Collection\<TKey of \(int\|string\), T\> but returns Doctrine\\Common\\Collections\\ReadableCollection\<TKey of \(int\|string\), T\>&Doctrine\\Common\\Collections\\Selectable\<TKey of \(int\|string\), T\>\.$#'
message: '#^Method Doctrine\\ORM\\PersistentCollection\:\:matching\(\) should return Doctrine\\Common\\Collections\\Collection\<TKey of \(int\|string\), T\> but returns Doctrine\\Common\\Collections\\ReadableCollection\<TKey of \(int\|string\), T\>\.$#'
identifier: return.type
count: 1
path: src/PersistentCollection.php
@@ -1693,7 +1693,7 @@ parameters:
path: src/PersistentCollection.php
-
message: '#^Parameter \#2 \$callback of function array_walk expects callable\(object, int\)\: mixed, array\{Doctrine\\Common\\Collections\\Collection\<TKey of \(int\|string\), T\>&Doctrine\\Common\\Collections\\Selectable\<TKey of \(int\|string\), T\>, ''add''\} given\.$#'
message: '#^Parameter \#2 \$callback of function array_walk expects callable\(object, int\)\: mixed, array\{Doctrine\\Common\\Collections\\Collection\<TKey of \(int\|string\), T\>, ''add''\} given\.$#'
identifier: argument.type
count: 1
path: src/PersistentCollection.php
@@ -1818,6 +1818,12 @@ parameters:
count: 1
path: src/Persisters/Collection/ManyToManyPersister.php
-
message: '#^Method Doctrine\\ORM\\Persisters\\Collection\\ManyToManyPersister\:\:count\(\) should return int\<0, max\> but returns int\.$#'
identifier: return.type
count: 1
path: src/Persisters/Collection/ManyToManyPersister.php
-
message: '#^Method Doctrine\\ORM\\Persisters\\Collection\\ManyToManyPersister\:\:delete\(\) has parameter \$collection with generic class Doctrine\\ORM\\PersistentCollection but does not specify its types\: TKey, T$#'
identifier: missingType.generics
@@ -2580,30 +2586,12 @@ parameters:
count: 1
path: src/Query/Exec/SingleTableDeleteUpdateExecutor.php
-
message: '#^PHPDoc type array\<string\> of property Doctrine\\ORM\\Query\\Expr\\Andx\:\:\$allowedClasses is not covariant with PHPDoc type list\<class\-string\> of overridden property Doctrine\\ORM\\Query\\Expr\\Base\:\:\$allowedClasses\.$#'
identifier: property.phpDocType
count: 1
path: src/Query/Expr/Andx.php
-
message: '#^Method Doctrine\\ORM\\Query\\Expr\\Func\:\:getArguments\(\) should return list\<mixed\> but returns array\<mixed\>\.$#'
identifier: return.type
count: 1
path: src/Query/Expr/Func.php
-
message: '#^PHPDoc type array\<string\> of property Doctrine\\ORM\\Query\\Expr\\Orx\:\:\$allowedClasses is not covariant with PHPDoc type list\<class\-string\> of overridden property Doctrine\\ORM\\Query\\Expr\\Base\:\:\$allowedClasses\.$#'
identifier: property.phpDocType
count: 1
path: src/Query/Expr/Orx.php
-
message: '#^PHPDoc type array\<string\> of property Doctrine\\ORM\\Query\\Expr\\Select\:\:\$allowedClasses is not covariant with PHPDoc type list\<class\-string\> of overridden property Doctrine\\ORM\\Query\\Expr\\Base\:\:\$allowedClasses\.$#'
identifier: property.phpDocType
count: 1
path: src/Query/Expr/Select.php
-
message: '#^Method Doctrine\\ORM\\Query\\ParameterTypeInferer\:\:inferType\(\) never returns int so it can be removed from the return type\.$#'
identifier: return.unusedType
@@ -3447,12 +3435,6 @@ parameters:
count: 2
path: src/UnitOfWork.php
-
message: '#^Parameter \#3 \$collection of class Doctrine\\ORM\\PersistentCollection constructor expects Doctrine\\Common\\Collections\\Collection\<\(int\|string\), mixed\>&Doctrine\\Common\\Collections\\Selectable\<\(int\|string\), mixed\>, Doctrine\\Common\\Collections\\Collection given\.$#'
identifier: argument.type
count: 1
path: src/UnitOfWork.php
-
message: '#^Parameter \#5 \$invoke of method Doctrine\\ORM\\Event\\ListenersInvoker\:\:invoke\(\) expects int\<0, 7\>, int\<min, \-1\>\|int\<1, max\> given\.$#'
identifier: argument.type

View File

@@ -4,6 +4,12 @@ includes:
parameters:
reportUnmatchedIgnoredErrors: false # Some errors in the baseline only apply to DBAL 4
excludePaths:
# Compatibility shims for Collections 2 vs Collections 3
# These have intentional signature mismatches that cannot be resolved
- src/PersistentCollectionImplementation.php
- src/Persisters/SqlValueVisitorImplementation.php
ignoreErrors:
# Symfony cache supports passing a key prefix to the clear method.
- '/^Method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) invoked with 1 parameter, 0 required\.$/'
@@ -160,3 +166,10 @@ parameters:
-
message: '~inferType.*never returns~'
path: src/Query/ParameterTypeInferer.php
# Methods used by excluded compatibility shim traits
-
message: '#^Method .* is unused\.$#'
paths:
- src/PersistentCollection.php
- src/Persisters/SqlValueVisitor.php

View File

@@ -3,6 +3,12 @@ includes:
- phpstan-params.neon
parameters:
excludePaths:
# Compatibility shims for Collections 2 vs Collections 3
# These have intentional signature mismatches that cannot be resolved
- src/PersistentCollectionImplementation.php
- src/Persisters/SqlValueVisitorImplementation.php
ignoreErrors:
# Symfony cache supports passing a key prefix to the clear method.
- '/^Method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) invoked with 1 parameter, 0 required\.$/'
@@ -54,3 +60,10 @@ parameters:
-
message: '#Expression on left side of \?\? is not nullable.#'
path: src/Mapping/Driver/AttributeDriver.php
# Methods used by excluded compatibility shim traits
-
message: '#^Method .* is unused\.$#'
paths:
- src/PersistentCollection.php
- src/Persisters/SqlValueVisitor.php

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Doctrine\ORM\Decorator;
use DateTimeInterface;
use Doctrine\Common\EventManager;
use Doctrine\Common\EventManagerInterface;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\Cache;
@@ -122,7 +122,7 @@ abstract class EntityManagerDecorator extends ObjectManagerDecorator implements
$this->wrapped->refresh($object, $lockMode);
}
public function getEventManager(): EventManager
public function getEventManager(): EventManagerInterface
{
return $this->wrapped->getEventManager();
}

View File

@@ -7,6 +7,7 @@ namespace Doctrine\ORM;
use BackedEnum;
use DateTimeInterface;
use Doctrine\Common\EventManager;
use Doctrine\Common\EventManagerInterface;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\Exception\EntityManagerClosed;
@@ -511,7 +512,7 @@ class EntityManager implements EntityManagerInterface
&& ! $this->unitOfWork->isScheduledForDelete($object);
}
public function getEventManager(): EventManager
public function getEventManager(): EventManagerInterface
{
return $this->eventManager;
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Doctrine\ORM;
use DateTimeInterface;
use Doctrine\Common\EventManager;
use Doctrine\Common\EventManagerInterface;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\Exception\ORMException;
@@ -180,9 +180,9 @@ interface EntityManagerInterface extends ObjectManager
public function lock(object $entity, LockMode|int $lockMode, DateTimeInterface|int|null $lockVersion = null): void;
/**
* Gets the EventManager used by the EntityManager.
* Gets the EventManagerInterface used by the EntityManager.
*/
public function getEventManager(): EventManager;
public function getEventManager(): EventManagerInterface;
/**
* Gets the Configuration used by the EntityManager.

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Doctrine\ORM\Event;
use Doctrine\Common\EventArgs;
use Doctrine\Common\EventManager;
use Doctrine\Common\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\EntityListenerResolver;
@@ -23,13 +23,13 @@ class ListenersInvoker
/** The Entity listener resolver. */
private readonly EntityListenerResolver $resolver;
/** The EventManager used for dispatching events. */
private readonly EventManager $eventManager;
/** The EventDispatcher used for dispatching events. */
private readonly EventDispatcher $eventDispatcher;
public function __construct(EntityManagerInterface $em)
{
$this->eventManager = $em->getEventManager();
$this->resolver = $em->getConfiguration()->getEntityListenerResolver();
$this->eventDispatcher = $em->getEventManager();
$this->resolver = $em->getConfiguration()->getEntityListenerResolver();
}
/**
@@ -52,7 +52,7 @@ class ListenersInvoker
$invoke |= self::INVOKE_LISTENERS;
}
if ($this->eventManager->hasListeners($eventName)) {
if ($this->eventDispatcher->hasListeners($eventName)) {
$invoke |= self::INVOKE_MANAGER;
}
@@ -92,7 +92,7 @@ class ListenersInvoker
}
if ($invoke & self::INVOKE_MANAGER) {
$this->eventManager->dispatchEvent($eventName, $event);
$this->eventDispatcher->dispatchEvent($eventName, $event);
}
}
}

View File

@@ -11,8 +11,6 @@ use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Persisters\Entity\EntityPersister;
use function assert;
/**
* A lazy collection that allows a fast count when using criteria object
* Once count gets executed once without collection being initialized, result
@@ -26,6 +24,7 @@ use function assert;
*/
class LazyCriteriaCollection extends AbstractLazyCollection implements Selectable
{
/** @var non-negative-int|null */
private int|null $count = null;
public function __construct(
@@ -83,7 +82,6 @@ class LazyCriteriaCollection extends AbstractLazyCollection implements Selectabl
public function matching(Criteria $criteria): ReadableCollection&Selectable
{
$this->initialize();
assert($this->collection instanceof Selectable);
return $this->collection->matching($criteria);
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Doctrine\ORM\Mapping;
use Doctrine\Common\EventManager;
use Doctrine\Common\EventDispatcher;
use Doctrine\DBAL\Platforms;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\Deprecations\Deprecation;
@@ -55,7 +55,7 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory
private EntityManagerInterface|null $em = null;
private AbstractPlatform|null $targetPlatform = null;
private MappingDriver|null $driver = null;
private EventManager|null $evm = null;
private EventDispatcher|null $eventDispatcher = null;
/** @var mixed[] */
private array $embeddablesActiveNesting = [];
@@ -109,20 +109,16 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory
protected function initialize(): void
{
$this->driver = $this->em->getConfiguration()->getMetadataDriverImpl();
$this->evm = $this->em->getEventManager();
$this->initialized = true;
$this->driver = $this->em->getConfiguration()->getMetadataDriverImpl();
$this->eventDispatcher = $this->em->getEventManager();
$this->initialized = true;
}
protected function onNotFoundMetadata(string $className): ClassMetadata|null
{
if (! $this->evm->hasListeners(Events::onClassMetadataNotFound)) {
return null;
}
$eventArgs = new OnClassMetadataNotFoundEventArgs($className, $this->em);
$this->evm->dispatchEvent(Events::onClassMetadataNotFound, $eventArgs);
$this->eventDispatcher->dispatchEvent(Events::onClassMetadataNotFound, $eventArgs);
$classMetadata = $eventArgs->getFoundMetadata();
assert($classMetadata instanceof ClassMetadata || $classMetadata === null);
@@ -245,10 +241,10 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory
// During the following event, there may also be updates to the discriminator map as per GH-1257/GH-8402.
// So, we must not discover the missing subclasses before that.
if ($this->evm->hasListeners(Events::loadClassMetadata)) {
$eventArgs = new LoadClassMetadataEventArgs($class, $this->em);
$this->evm->dispatchEvent(Events::loadClassMetadata, $eventArgs);
}
$this->eventDispatcher->dispatchEvent(
Events::loadClassMetadata,
new LoadClassMetadataEventArgs($class, $this->em),
);
$this->findAbstractEntityClassesNotListedInDiscriminatorMap($class);

View File

@@ -142,7 +142,7 @@ final class FieldMapping implements ArrayAccess
{
$serialized = ['type', 'fieldName', 'columnName'];
foreach (['nullable', 'notInsertable', 'notUpdatable', 'id', 'unique', 'version', 'quoted'] as $boolKey) {
foreach (['nullable', 'notInsertable', 'notUpdatable', 'id', 'unique', 'version', 'quoted', 'index'] as $boolKey) {
if ($this->$boolKey) {
$serialized[] = $boolKey;
}

View File

@@ -42,6 +42,8 @@ use function strtoupper;
*/
final class PersistentCollection extends AbstractLazyCollection implements Selectable
{
use PersistentCollectionImplementation;
/**
* A snapshot of the collection at the moment it was fetched from the database.
* This is used to create a diff of the collection at commit time.
@@ -402,7 +404,7 @@ final class PersistentCollection extends AbstractLazyCollection implements Selec
}
}
public function add(mixed $value): bool
private function doAdd(mixed $value): void
{
$this->unwrap()->add($value);
@@ -411,8 +413,6 @@ final class PersistentCollection extends AbstractLazyCollection implements Selec
if (is_object($value) && $this->em) {
$this->getUnitOfWork()->cancelOrphanRemoval($value);
}
return true;
}
public function offsetExists(mixed $offset): bool
@@ -504,10 +504,8 @@ final class PersistentCollection extends AbstractLazyCollection implements Selec
$this->em = null;
}
/**
* {@inheritDoc}
*/
public function first()
/** {@inheritDoc} */
public function first(): mixed
{
if (! $this->initialized && ! $this->isDirty && $this->getMapping()->fetch === ClassMetadata::FETCH_EXTRA_LAZY) {
$persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping());
@@ -618,7 +616,6 @@ final class PersistentCollection extends AbstractLazyCollection implements Selec
public function unwrap(): Selectable&Collection
{
assert($this->collection instanceof Collection);
assert($this->collection instanceof Selectable);
return $this->collection;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM;
use Doctrine\Common\Collections\Criteria;
use function defined;
if (defined(Criteria::class . '::ASC')) {
// collections 2
/** @internal */
trait PersistentCollectionImplementation
{
abstract private function doAdd(mixed $value): void;
public function add(mixed $value): bool
{
$this->doAdd($value);
return true;
}
}
} else {
// collections 3
/** @internal */
trait PersistentCollectionImplementation
{
abstract private function doAdd(mixed $value): void;
public function add(mixed $value): void
{
$this->doAdd($value);
}
}
}

View File

@@ -25,6 +25,8 @@ interface CollectionPersister
/**
* Counts the size of this persistent collection.
*
* @return non-negative-int
*/
public function count(PersistentCollection $collection): int;

View File

@@ -106,6 +106,7 @@ class ManyToManyPersister extends AbstractCollectionPersister
);
}
/** @return non-negative-int */
public function count(PersistentCollection $collection): int
{
$conditions = [];

View File

@@ -20,6 +20,7 @@ use function array_reverse;
use function array_values;
use function assert;
use function count;
use function defined;
use function implode;
use function is_int;
use function is_string;
@@ -86,10 +87,13 @@ class OneToManyPersister extends AbstractCollectionPersister
$mapping = $this->getMapping($collection);
$persister = $this->uow->getEntityPersister($mapping->targetEntity);
// Doctrine Collections 2.x support
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
// only works with single id identifier entities. Will throw an
// exception in Entity Persisters if that is not the case for the
// 'mappedBy' field.
$criteria = Criteria::create(true)->where(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
$criteria = $criteria->where(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
return $persister->count($criteria);
}
@@ -118,7 +122,8 @@ class OneToManyPersister extends AbstractCollectionPersister
// only works with single id identifier entities. Will throw an
// exception in Entity Persisters if that is not the case for the
// 'mappedBy' field.
$criteria = Criteria::create(true);
// Doctrine Collections 2.x support
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->andWhere(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
$criteria->andWhere(Criteria::expr()->eq($mapping->indexBy(), $key));
@@ -135,10 +140,12 @@ class OneToManyPersister extends AbstractCollectionPersister
$mapping = $this->getMapping($collection);
$persister = $this->uow->getEntityPersister($mapping->targetEntity);
// Doctrine Collections 2.x support
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
// only works with single id identifier entities. Will throw an
// exception in Entity Persisters if that is not the case for the
// 'mappedBy' field.
$criteria = Criteria::create(true)->where(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
$criteria = $criteria->where(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
return $persister->exists($element, $criteria);
}

View File

@@ -7,25 +7,21 @@ namespace Doctrine\ORM\Persisters;
use Doctrine\Common\Collections\Expr\Comparison;
use Doctrine\Common\Collections\Expr\CompositeExpression;
use Doctrine\Common\Collections\Expr\ExpressionVisitor;
use Doctrine\Common\Collections\Expr\Value;
/**
* Extract the values from a criteria/expression
*/
class SqlValueVisitor extends ExpressionVisitor
{
use SqlValueVisitorImplementation;
/** @var mixed[] */
private array $values = [];
/** @var mixed[][] */
private array $types = [];
/**
* Converts a comparison expression into the target query language output.
*
* {@inheritDoc}
*/
public function walkComparison(Comparison $comparison)
private function doWalkComparison(Comparison $comparison): mixed
{
$value = $this->getValueFromComparison($comparison);
@@ -35,12 +31,7 @@ class SqlValueVisitor extends ExpressionVisitor
return null;
}
/**
* Converts a composite expression into the target query language output.
*
* {@inheritDoc}
*/
public function walkCompositeExpression(CompositeExpression $expr)
private function doWalkCompositeExpression(CompositeExpression $expr): mixed
{
foreach ($expr->getExpressionList() as $child) {
$this->dispatch($child);
@@ -49,16 +40,6 @@ class SqlValueVisitor extends ExpressionVisitor
return null;
}
/**
* Converts a value expression into the target query language part.
*
* {@inheritDoc}
*/
public function walkValue(Value $value)
{
return null;
}
/**
* Returns the Parameters and Types necessary for matching the last visited expression.
*

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Persisters;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Expr\Comparison;
use Doctrine\Common\Collections\Expr\CompositeExpression;
use Doctrine\Common\Collections\Expr\Value;
use function defined;
if (defined(Criteria::class . '::ASC')) {
// collections 2
/** @internal */
trait SqlValueVisitorImplementation
{
abstract private function doWalkComparison(Comparison $comparison): mixed;
abstract private function doWalkCompositeExpression(CompositeExpression $comparison): mixed;
/**
* Converts a comparison expression into the target query language output.
*
* {@inheritDoc}
*
* @phpstan-ignore missingType.return
*/
public function walkComparison(Comparison $comparison)
{
return $this->doWalkComparison($comparison);
}
/**
* Converts a value expression into the target query language part.
*
* {@inheritDoc}
*
* @phpstan-ignore missingType.return
*/
public function walkValue(Value $value)
{
return null;
}
/**
* Converts a composite expression into the target query language output.
*
* {@inheritDoc}
*
* @phpstan-ignore missingType.return
*/
public function walkCompositeExpression(CompositeExpression $expr)
{
return $this->doWalkCompositeExpression($expr);
}
}
} else {
// collections 3
/** @internal */
trait SqlValueVisitorImplementation
{
abstract private function doWalkComparison(Comparison $comparison): mixed;
abstract private function doWalkCompositeExpression(CompositeExpression $comparison): mixed;
/** Converts a comparison expression into the target query language output. */
public function walkComparison(Comparison $comparison): mixed
{
return $this->doWalkComparison($comparison);
}
/** Converts a value expression into the target query language part. */
public function walkValue(Value $value): mixed
{
return null;
}
/** Converts a composite expression into the target query language output. */
public function walkCompositeExpression(CompositeExpression $expr): mixed
{
return $this->doWalkCompositeExpression($expr);
}
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Doctrine\ORM\Query\Expr;
use Stringable;
/**
* Expression class for building DQL and parts.
*
@@ -13,7 +15,7 @@ class Andx extends Composite
{
protected string $separator = ' AND ';
/** @var string[] */
/** @var list<class-string<Stringable>> */
protected array $allowedClasses = [
Comparison::class,
Func::class,

View File

@@ -13,6 +13,7 @@ use function get_debug_type;
use function implode;
use function in_array;
use function is_array;
use function is_object;
use function is_string;
use function sprintf;
@@ -27,7 +28,7 @@ abstract class Base implements Stringable
protected string $separator = ', ';
protected string $postSeparator = ')';
/** @var list<class-string> */
/** @var list<class-string<Stringable>> */
protected array $allowedClasses = [];
/** @var list<string|Stringable> */
@@ -58,6 +59,8 @@ abstract class Base implements Stringable
}
/**
* @param string|Stringable|null $arg
*
* @return $this
*
* @throws InvalidArgumentException
@@ -66,7 +69,8 @@ abstract class Base implements Stringable
{
if ($arg !== null && (! $arg instanceof self || $arg->count() > 0)) {
// If we decide to keep Expr\Base instances, we can use this check
if (! is_string($arg) && ! in_array($arg::class, $this->allowedClasses, true)) {
// @phpstan-ignore function.alreadyNarrowedType (input validation)
if (! is_string($arg) && ! (is_object($arg) && in_array($arg::class, $this->allowedClasses, true))) {
throw new InvalidArgumentException(sprintf(
"Expression of type '%s' not allowed in this context.",
get_debug_type($arg),

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Doctrine\ORM\Query\Expr;
use Stringable;
/**
* Expression class for building DQL OR clauses.
*
@@ -13,7 +15,7 @@ class Orx extends Composite
{
protected string $separator = ' OR ';
/** @var string[] */
/** @var list<class-string<Stringable>> */
protected array $allowedClasses = [
Comparison::class,
Func::class,

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Doctrine\ORM\Query\Expr;
use Stringable;
/**
* Expression class for building DQL select statements.
*
@@ -14,7 +16,7 @@ class Select extends Base
protected string $preSeparator = '';
protected string $postSeparator = '';
/** @var string[] */
/** @var list<class-string<Stringable>> */
protected array $allowedClasses = [Func::class];
/** @phpstan-var list<string|Func> */

View File

@@ -62,7 +62,7 @@ EOT);
ksort($allListeners);
} else {
$listeners = $eventManager->hasListeners($eventName) ? $eventManager->getListeners($eventName) : [];
$listeners = $eventManager->getListeners($eventName);
if (! $listeners) {
$io->info(sprintf('No listeners are configured for the "%s" event.', $eventName));

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\CursorPagination;
use Doctrine\ORM\Tools\CursorPagination\Exception\InvalidCursor;
use JsonException;
use function base64_decode;
use function base64_encode;
use function json_decode;
use function json_encode;
use function rtrim;
use function strtr;
use const JSON_THROW_ON_ERROR;
/**
* Represents a cursor for cursor-based pagination.
*
* A cursor contains the parameters needed to fetch the next or previous page of results.
*/
final class Cursor
{
/** @param array<string, scalar> $parameters */
public function __construct(
private readonly array $parameters,
private readonly bool $isNext = true,
) {
}
/**
* @internal
*
* @return array<string, scalar>
*/
public function getParameters(): array
{
return $this->parameters;
}
/**
* Returns whether the cursor is for navigating to the next page.
*/
public function isNext(): bool
{
return $this->isNext;
}
/**
* Returns whether the cursor is for navigating to the previous page.
*/
public function isPrevious(): bool
{
return ! $this->isNext;
}
/** @return array<string, scalar> */
public function toArray(): array
{
return [...$this->parameters, '_isNext' => $this->isNext];
}
/**
* Encodes the cursor to a URL-safe Base64 JSON string.
*/
public function encodeToString(): string
{
return rtrim(strtr(base64_encode((string) json_encode($this->toArray())), '+/', '-_'), '=');
}
/**
* Decodes a cursor from an encoded string.
*
* @see CursorWalker::buildCursorCondition() for the security model around cursor manipulation.
*
* @throws InvalidCursor If decoding fails.
*/
public static function fromEncodedString(string $encodedString): self
{
$decoded = base64_decode(strtr($encodedString, '-_', '+/'), strict: true);
if ($decoded === false) {
throw new InvalidCursor($encodedString);
}
try {
$parameters = json_decode($decoded, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new InvalidCursor($encodedString, $e);
}
$isNext = $parameters['_isNext'] ?? true;
unset($parameters['_isNext']);
return new self($parameters, $isNext);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\CursorPagination;
/**
* Represents a paginated item with its associated cursor.
*
* @template T
*/
final class CursorItem
{
/** @param T $value */
public function __construct(
private readonly mixed $value,
private readonly Cursor $cursor,
) {
}
/** @return T */
public function getValue(): mixed
{
return $this->value;
}
public function getCursor(): Cursor
{
return $this->cursor;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\CursorPagination;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\AST\PathExpression;
/** @internal */
final class CursorOrderByItem
{
/** @param ClassMetadata<object>|null $metadata */
public function __construct(
public readonly PathExpression|string $expression,
public readonly OrderDirection $direction,
public readonly string $paramKey,
public readonly ClassMetadata|null $metadata = null,
) {
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\CursorPagination;
use Countable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Exception;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\AST\PathExpression;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Utility\PersisterHelper;
use IteratorAggregate;
use LogicException;
use Traversable;
use function array_map;
use function array_reverse;
/**
* The cursor paginator handles cursor-based pagination for DQL queries.
*
* @template T
* @implements IteratorAggregate<mixed, T>
*/
final class CursorPaginator implements IteratorAggregate, Countable
{
private readonly Query $query;
/** @var Collection<int, T>|null */
private Collection|null $items = null;
/** @var list<CursorOrderByItem>|null */
private array|null $orderByItems = null;
private bool $hasMore = false;
private Cursor|null $cursor = null;
public function __construct(Query|QueryBuilder $query)
{
if ($query instanceof QueryBuilder) {
$query = $query->getQuery();
}
$this->query = $query;
}
/**
* Returns the query.
*/
public function getQuery(): Query
{
return $this->query;
}
/**
* Paginates the query with the given limit and optional cursor.
*
* @param string|null $cursor The encoded cursor string, null or empty string for the first page.
* @param int $limit The maximum number of results to return.
*
* @return $this
*/
public function paginate(string|null $cursor, int $limit): self
{
$this->cursor = ! empty($cursor) ? Cursor::fromEncodedString($cursor) : null;
$shouldReverse = $this->cursor?->isPrevious() ?? false;
$query = $this->cloneQuery($this->query);
$this->appendTreeWalker($query);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, $shouldReverse);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, $this->cursor?->getParameters() ?? []);
$query->setMaxResults($limit + 1);
$this->items = new ArrayCollection($query->getResult());
$this->hasMore = $this->items->count() > $limit;
$this->items = new ArrayCollection($this->items->slice(0, $limit));
$this->orderByItems = $query->getHint(CursorWalker::HINT_CURSOR_ORDER_BY_ITEMS) ?: [];
if ($this->cursor !== null && $this->cursor->isPrevious()) {
$this->items = new ArrayCollection(array_reverse($this->items->toArray(), true));
}
return $this;
}
private function cloneQuery(Query $query): Query
{
$cloneQuery = clone $query;
$cloneQuery->setParameters(clone $query->getParameters());
$cloneQuery->setCacheable(false);
foreach ($query->getHints() as $name => $value) {
$cloneQuery->setHint($name, $value);
}
return $cloneQuery;
}
/**
* Appends a custom tree walker to the tree walkers hint.
*/
private function appendTreeWalker(Query $query): void
{
$hints = $query->getHint(Query::HINT_CUSTOM_TREE_WALKERS);
if ($hints === false) {
$hints = [];
}
$hints[] = CursorWalker::class;
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, $hints);
}
/**
* {@inheritDoc}
*
* @return Traversable<mixed, T>
*/
public function getIterator(): Traversable
{
return $this->items->getIterator();
}
public function count(): int
{
return $this->items->count();
}
/**
* Returns whether there is a previous page.
*/
public function hasPreviousPage(): bool
{
return $this->cursor !== null && ($this->cursor->isNext() || $this->hasMore);
}
/**
* Returns whether there is a next page.
*/
public function hasNextPage(): bool
{
return $this->hasMore || ($this->cursor !== null && $this->cursor->isPrevious());
}
/**
* Returns the cursor object for the next page.
*
* @throws LogicException If there is no next page. Check {@see hasNextPage()} first.
*/
public function getNextCursor(): Cursor
{
if ($this->items->isEmpty() || ! $this->hasNextPage()) {
throw new LogicException('There is no next page. Call hasNextPage() before getNextCursor().');
}
return $this->getCursorForItem($this->items->last());
}
/**
* Returns the cursor object for the previous page.
*
* @throws LogicException If there is no previous page. Check {@see hasPreviousPage()} first.
*/
public function getPreviousCursor(): Cursor
{
if ($this->items->isEmpty() || ! $this->hasPreviousPage()) {
throw new LogicException('There is no previous page. Call hasPreviousPage() before getPreviousCursor().');
}
return $this->getCursorForItem($this->items->first(), false);
}
/**
* Returns the encoded cursor string for the next page.
*
* @throws LogicException If there is no next page. Check {@see hasNextPage()} first.
*/
public function getNextCursorAsString(): string
{
return $this->getNextCursor()->encodeToString();
}
/**
* Returns the encoded cursor string for the previous page.
*
* @throws LogicException If there is no previous page. Check {@see hasPreviousPage()} first.
*/
public function getPreviousCursorAsString(): string
{
return $this->getPreviousCursor()->encodeToString();
}
/**
* Returns the cursor for a given item.
*
* @param mixed $item The item to create a cursor for.
* @param bool $isNext Whether the cursor is for the next page.
*
* @throws Exception
* @throws QueryException
*/
public function getCursorForItem(mixed $item, bool $isNext = true): Cursor
{
return new Cursor($this->getParametersForItem($item), $isNext);
}
/**
* Returns items wrapped with their associated cursors.
*
* @return array<int, CursorItem<T>>
*
* @throws Exception
* @throws QueryException
*/
public function getItems(): array
{
return array_map(
fn (mixed $item) => new CursorItem($item, $this->getCursorForItem($item)),
$this->items->toArray(),
);
}
/**
* Returns the raw entity values.
*
* @return list<T>
*/
public function getValues(): array
{
return $this->items->getValues();
}
/**
* Returns whether pagination is needed.
*/
public function hasToPaginate(): bool
{
return $this->hasPreviousPage() || $this->hasNextPage();
}
/**
* @return array<string, mixed>
*
* @throws Query\QueryException
* @throws Exception
*/
private function getParametersForItem(mixed $item): array
{
$em = $this->query->getEntityManager();
$connection = $em->getConnection();
$metadata = $em->getMetadataFactory()->hasMetadataFor($item::class)
? $em->getClassMetadata($item::class)
: null;
$result = [];
foreach ($this->orderByItems as $orderByItem) {
if (! $orderByItem->expression instanceof PathExpression) {
continue;
}
$fieldName = $orderByItem->expression->field;
$orderMetadata = $orderByItem->metadata ?? $metadata;
$value = $metadata?->getFieldValue($item, $fieldName) ?? $item->$fieldName;
$type = PersisterHelper::getTypeOfField($fieldName, $orderMetadata, $em)[0];
$result[$orderByItem->paramKey] = $connection->convertToDatabaseValue($value, $type);
}
return $result;
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\CursorPagination;
use Doctrine\ORM\Query\AST\ComparisonExpression;
use Doctrine\ORM\Query\AST\ConditionalExpression;
use Doctrine\ORM\Query\AST\ConditionalPrimary;
use Doctrine\ORM\Query\AST\ConditionalTerm;
use Doctrine\ORM\Query\AST\InputParameter;
use Doctrine\ORM\Query\AST\OrderByClause;
use Doctrine\ORM\Query\AST\OrderByItem;
use Doctrine\ORM\Query\AST\PathExpression;
use Doctrine\ORM\Query\AST\SelectStatement;
use Doctrine\ORM\Query\AST\WhereClause;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\Query\TreeWalkerAdapter;
use LogicException;
use function count;
use function str_replace;
/**
* @internal
*
* TreeWalker for cursor-based pagination.
*
* Responsibilities:
* - Extract ORDER BY columns from AST
* - Inject WHERE conditions for cursor navigation
* - Reverse ORDER BY direction for previous page navigation
*/
class CursorWalker extends TreeWalkerAdapter
{
public const HINT_CURSOR_PARAMETERS = 'doctrine.cursor.parameters';
public const HINT_CURSOR_REVERSE = 'doctrine.cursor.reverse';
public const HINT_CURSOR_ORDER_BY_ITEMS = 'doctrine.cursor.order_by_items';
public function walkSelectStatement(SelectStatement $selectStatement): void
{
$query = $this->_getQuery();
$shouldReverse = $query->getHint(self::HINT_CURSOR_REVERSE) === true;
$cursorParameters = $query->getHint(self::HINT_CURSOR_PARAMETERS);
if (! isset($selectStatement->orderByClause)) {
throw new LogicException('No ORDER BY clause found. Cursor pagination requires a deterministic sort order.');
}
$orderByItems = [];
$newOrderByItems = [];
foreach ($selectStatement->orderByClause->orderByItems as $orderByItem) {
$direction = OrderDirection::fromOrderByItem($orderByItem, $shouldReverse);
$paramKey = $this->getParameterKey($orderByItem->expression);
$metadata = $orderByItem->expression instanceof PathExpression
? $this->getMetadataForDqlAlias($orderByItem->expression->identificationVariable)
: null;
$orderByItems[] = new CursorOrderByItem($orderByItem->expression, $direction, $paramKey, $metadata);
$newItem = new OrderByItem($orderByItem->expression);
$newItem->type = $direction->value;
$newOrderByItems[] = $newItem;
}
$selectStatement->orderByClause = new OrderByClause($newOrderByItems);
$query->setHint(self::HINT_CURSOR_ORDER_BY_ITEMS, $orderByItems);
if (empty($cursorParameters)) {
return;
}
$condition = $this->buildCursorCondition($orderByItems, $cursorParameters);
if ($condition === null) {
return;
}
$conditionalPrimary = new ConditionalPrimary();
$conditionalPrimary->conditionalExpression = $condition;
if ($selectStatement->whereClause !== null) {
if ($selectStatement->whereClause->conditionalExpression instanceof ConditionalTerm) {
$selectStatement->whereClause->conditionalExpression->conditionalFactors[] = $conditionalPrimary;
} elseif ($selectStatement->whereClause->conditionalExpression instanceof ConditionalPrimary) {
$selectStatement->whereClause->conditionalExpression = new ConditionalExpression(
[
new ConditionalTerm(
[
$selectStatement->whereClause->conditionalExpression,
$conditionalPrimary,
],
),
],
);
} else {
$existingPrimary = new ConditionalPrimary();
$existingPrimary->conditionalExpression = $selectStatement->whereClause->conditionalExpression;
$selectStatement->whereClause->conditionalExpression = new ConditionalTerm(
[
$existingPrimary,
$conditionalPrimary,
],
);
}
} else {
$selectStatement->whereClause = new WhereClause(
new ConditionalExpression(
[new ConditionalTerm([$conditionalPrimary])],
),
);
}
}
/**
* Recursively builds a cursor condition:
* (col1 > :val1) OR (col1 = :val1 AND col2 > :val2) OR ...
*
* @param list<CursorOrderByItem> $orderByItems
* @param array<string, mixed> $cursorParameters
*
* @throws QueryException
*/
private function buildCursorCondition(array $orderByItems, array $cursorParameters, int $index = 0): ConditionalExpression|null
{
if (! isset($orderByItems[$index])) {
return null;
}
$orderByItem = $orderByItems[$index];
$expression = $orderByItem->expression;
$direction = $orderByItem->direction;
$paramKey = $orderByItem->paramKey;
$operator = $direction->operator();
$paramName = str_replace('.', '_', $paramKey) . '_' . $index;
$paramValue = $cursorParameters[$paramKey] ?? null;
// Security note: $paramKey is derived from the DQL ORDER BY AST, not from the
// cursor payload. A tampered cursor can only influence the *values* used as pivot
// points, not the columns being filtered. All values are bound via setParameter(),
// so SQL injection is not possible. The worst a user can do is navigate to an
// arbitrary position in the result set, while remaining bound by the original
// query's WHERE constraints.
$this->_getQuery()->setParameter($paramName, $paramValue);
$comparisonExpr = new ComparisonExpression(
$expression,
$operator,
new InputParameter(':' . $paramName),
);
$comparisonPrimary = new ConditionalPrimary();
$comparisonPrimary->simpleConditionalExpression = $comparisonExpr;
if ($index === count($orderByItems) - 1) {
return new ConditionalExpression([new ConditionalTerm([$comparisonPrimary])]);
}
$nextCondition = $this->buildCursorCondition($orderByItems, $cursorParameters, $index + 1);
$equalityExpr = new ComparisonExpression(
$expression,
'=',
new InputParameter(':' . $paramName),
);
$equalityPrimary = new ConditionalPrimary();
$equalityPrimary->simpleConditionalExpression = $equalityExpr;
$nextPrimary = new ConditionalPrimary();
$nextPrimary->conditionalExpression = $nextCondition;
$andPrimary = new ConditionalPrimary();
$andPrimary->conditionalExpression = new ConditionalExpression([
new ConditionalTerm([$equalityPrimary, $nextPrimary]),
]);
return new ConditionalExpression([
new ConditionalTerm([$comparisonPrimary]),
new ConditionalTerm([$andPrimary]),
]);
}
/**
* Returns the parameter key for the given expression.
*/
private function getParameterKey(mixed $expression): string
{
if ($expression instanceof PathExpression) {
return $expression->identificationVariable . '.' . $expression->field;
}
return (string) $expression;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\CursorPagination\Exception;
use InvalidArgumentException;
use Throwable;
use function sprintf;
final class InvalidCursor extends InvalidArgumentException
{
public function __construct(string $cursor, Throwable|null $previous = null)
{
parent::__construct(
sprintf(
'The cursor "%s" could not be decoded.',
$cursor,
),
previous: $previous,
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\CursorPagination;
use Doctrine\ORM\Query\AST\OrderByItem;
/** @internal */
enum OrderDirection: string
{
case Ascending = 'ASC';
case Descending = 'DESC';
public static function fromOrderByItem(OrderByItem $item, bool $reverse = false): self
{
$direction = $item->isAsc() ? self::Ascending : self::Descending;
return $reverse ? $direction->reversed() : $direction;
}
public function operator(): string
{
return match ($this) {
self::Ascending => '>',
self::Descending => '<',
};
}
public function reversed(): self
{
return match ($this) {
self::Ascending => self::Descending,
self::Descending => self::Ascending,
};
}
}

View File

@@ -389,21 +389,17 @@ class SchemaTool
}
}
if ($eventManager->hasListeners(ToolEvents::postGenerateSchemaTable)) {
$eventManager->dispatchEvent(
ToolEvents::postGenerateSchemaTable,
new GenerateSchemaTableEventArgs($class, $schema, $table),
);
}
}
if ($eventManager->hasListeners(ToolEvents::postGenerateSchema)) {
$eventManager->dispatchEvent(
ToolEvents::postGenerateSchema,
new GenerateSchemaEventArgs($this->em, $schema),
ToolEvents::postGenerateSchemaTable,
new GenerateSchemaTableEventArgs($class, $schema, $table),
);
}
$eventManager->dispatchEvent(
ToolEvents::postGenerateSchema,
new GenerateSchemaEventArgs($this->em, $schema),
);
return $schema;
}

View File

@@ -8,7 +8,7 @@ use BackedEnum;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventManager;
use Doctrine\Common\EventDispatcher;
use Doctrine\DBAL;
use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
use Doctrine\DBAL\LockMode;
@@ -261,9 +261,9 @@ class UnitOfWork implements PropertyChangedListener
private array $collectionPersisters = [];
/**
* The EventManager used for dispatching events.
* The EventDispatcher used for dispatching events.
*/
private readonly EventManager $evm;
private readonly EventDispatcher $eventDispatcher;
/**
* The ListenersInvoker used for dispatching events.
@@ -314,7 +314,7 @@ class UnitOfWork implements PropertyChangedListener
public function __construct(
private readonly EntityManagerInterface $em,
) {
$this->evm = $em->getEventManager();
$this->eventDispatcher = $em->getEventManager();
$this->listenersInvoker = new ListenersInvoker($em);
$this->hasCache = $em->getConfiguration()->isSecondLevelCacheEnabled();
$this->identifierFlattener = new IdentifierFlattener($this, $em->getMetadataFactory());
@@ -344,10 +344,7 @@ class UnitOfWork implements PropertyChangedListener
$connection->ensureConnectedToPrimary();
}
// Raise preFlush
if ($this->evm->hasListeners(Events::preFlush)) {
$this->evm->dispatchEvent(Events::preFlush, new PreFlushEventArgs($this->em));
}
$this->dispatchPreFlushEvent();
// Compute changes done since last commit.
$this->computeChangeSets();
@@ -378,8 +375,7 @@ class UnitOfWork implements PropertyChangedListener
$this->dispatchOnFlushEvent();
$conn = $this->em->getConnection();
$conn->beginTransaction();
$connection->beginTransaction();
$successful = false;
@@ -430,7 +426,7 @@ class UnitOfWork implements PropertyChangedListener
$commitFailed = false;
try {
if ($conn->commit() === false) {
if ($connection->commit() === false) {
$commitFailed = true;
}
} catch (DBAL\Exception $e) {
@@ -446,8 +442,8 @@ class UnitOfWork implements PropertyChangedListener
if (! $successful) {
$this->em->close();
if ($conn->isTransactionActive()) {
$conn->rollBack();
if ($connection->isTransactionActive()) {
$connection->rollBack();
}
$this->afterTransactionRolledBack();
@@ -2299,9 +2295,7 @@ class UnitOfWork implements PropertyChangedListener
$this->eagerLoadingCollections =
$this->orphanRemovals = [];
if ($this->evm->hasListeners(Events::onClear)) {
$this->evm->dispatchEvent(Events::onClear, new OnClearEventArgs($this->em));
}
$this->eventDispatcher->dispatchEvent(Events::onClear, new OnClearEventArgs($this->em));
}
/**
@@ -3147,18 +3141,19 @@ class UnitOfWork implements PropertyChangedListener
}
}
private function dispatchPreFlushEvent(): void
{
$this->eventDispatcher->dispatchEvent(Events::preFlush, new PreFlushEventArgs($this->em));
}
private function dispatchOnFlushEvent(): void
{
if ($this->evm->hasListeners(Events::onFlush)) {
$this->evm->dispatchEvent(Events::onFlush, new OnFlushEventArgs($this->em));
}
$this->eventDispatcher->dispatchEvent(Events::onFlush, new OnFlushEventArgs($this->em));
}
private function dispatchPostFlushEvent(): void
{
if ($this->evm->hasListeners(Events::postFlush)) {
$this->evm->dispatchEvent(Events::postFlush, new PostFlushEventArgs($this->em));
}
$this->eventDispatcher->dispatchEvent(Events::postFlush, new PostFlushEventArgs($this->em));
}
/**

View File

@@ -23,6 +23,8 @@ use Doctrine\Tests\OrmTestCase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\MockObject;
use function defined;
#[Group('DDC-2183')]
abstract class EntityPersisterTestCase extends OrmTestCase
{
@@ -141,7 +143,7 @@ abstract class EntityPersisterTestCase extends OrmTestCase
public function testInvokeExpandCriteriaParameters(): void
{
$persister = $this->createPersisterDefault();
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$this->entityPersister->expects(self::once())
->method('expandCriteriaParameters')
@@ -320,7 +322,7 @@ abstract class EntityPersisterTestCase extends OrmTestCase
$rsm = new ResultSetMappingBuilder($this->em);
$persister = $this->createPersisterDefault();
$entity = new Country('Foo');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$this->em->getUnitOfWork()->registerManaged($entity, ['id' => 1], ['id' => 1, 'name' => 'Foo']);
$rsm->addEntityResult(Country::class, 'c');

View File

@@ -19,6 +19,7 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\DoesNotPerformAssertions;
use PHPUnit\Framework\Attributes\Group;
use function defined;
use function get_debug_type;
use function sprintf;
@@ -494,13 +495,13 @@ class ClassTableInheritanceTest extends OrmFunctionalTestCase
$this->_em->flush();
$repository = $this->_em->getRepository(CompanyEmployee::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('department', 'IT'),
));
self::assertCount(1, $users);
$repository = $this->_em->getRepository(CompanyManager::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('department', 'IT'),
));
self::assertCount(1, $users);

View File

@@ -12,6 +12,8 @@ use Doctrine\Tests\Models\Tweet\Tweet;
use Doctrine\Tests\Models\Tweet\User;
use Doctrine\Tests\OrmFunctionalTestCase;
use function defined;
class EntityRepositoryCriteriaTest extends OrmFunctionalTestCase
{
protected function setUp(): void
@@ -66,7 +68,7 @@ class EntityRepositoryCriteriaTest extends OrmFunctionalTestCase
$this->loadFixture();
$repository = $this->_em->getRepository(DateTimeModel::class);
$dates = $repository->matching(Criteria::create(true)->where(
$dates = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->lte('datetime', new DateTime('today')),
));
@@ -98,7 +100,7 @@ class EntityRepositoryCriteriaTest extends OrmFunctionalTestCase
$this->loadNullFieldFixtures();
$repository = $this->_em->getRepository(DateTimeModel::class);
$dates = $repository->matching(Criteria::create(true)->where(
$dates = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->isNull('time'),
));
@@ -110,7 +112,7 @@ class EntityRepositoryCriteriaTest extends OrmFunctionalTestCase
$this->loadNullFieldFixtures();
$repository = $this->_em->getRepository(DateTimeModel::class);
$dates = $repository->matching(Criteria::create(true)->where(
$dates = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('time', null),
));
@@ -122,7 +124,7 @@ class EntityRepositoryCriteriaTest extends OrmFunctionalTestCase
$this->loadNullFieldFixtures();
$repository = $this->_em->getRepository(DateTimeModel::class);
$dates = $repository->matching(Criteria::create(true)->where(
$dates = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->neq('time', null),
));
@@ -134,14 +136,14 @@ class EntityRepositoryCriteriaTest extends OrmFunctionalTestCase
$this->loadFixture();
$repository = $this->_em->getRepository(DateTimeModel::class);
$dates = $repository->matching(Criteria::create(true));
$dates = $repository->matching(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create());
self::assertFalse($dates->isInitialized());
self::assertCount(3, $dates);
self::assertFalse($dates->isInitialized());
// Test it can work even with a constraint
$dates = $repository->matching(Criteria::create(true)->where(
$dates = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->lte('datetime', new DateTime('today')),
));
@@ -169,7 +171,7 @@ class EntityRepositoryCriteriaTest extends OrmFunctionalTestCase
$this->_em->clear();
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->andWhere($criteria->expr()->contains('content', 'Criteria'));
$user = $this->_em->find(User::class, $user->id);

View File

@@ -29,6 +29,7 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function array_values;
use function defined;
use function reset;
class EntityRepositoryTest extends OrmFunctionalTestCase
@@ -661,7 +662,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true));
$users = $repository->matching(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create());
self::assertCount(4, $users);
}
@@ -672,7 +673,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('username', 'beberlei'),
));
@@ -685,7 +686,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->neq('username', 'beberlei'),
));
@@ -698,7 +699,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->in('username', ['beberlei', 'gblanco']),
));
@@ -711,7 +712,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->notIn('username', ['beberlei', 'gblanco', 'asm89']),
));
@@ -724,7 +725,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$firstUserId = $this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->lt('id', $firstUserId + 1),
));
@@ -737,7 +738,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$firstUserId = $this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->lte('id', $firstUserId + 1),
));
@@ -750,7 +751,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$firstUserId = $this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->gt('id', $firstUserId),
));
@@ -763,7 +764,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$firstUserId = $this->loadFixture();
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->gte('id', $firstUserId),
));
@@ -777,7 +778,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$user = $this->_em->find(CmsUser::class, $userId);
$criteria = Criteria::create(true)->where(
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('user', $user),
);
@@ -798,7 +799,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$user = $this->_em->find(CmsUser::class, $userId);
$criteria = Criteria::create(true)->where(
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->in('user', [$user]),
);
@@ -818,13 +819,13 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(Criteria::expr()->contains('name', 'Foobar')));
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->contains('name', 'Foobar')));
self::assertCount(0, $users);
$users = $repository->matching(Criteria::create(true)->where(Criteria::expr()->contains('name', 'Rom')));
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->contains('name', 'Rom')));
self::assertCount(1, $users);
$users = $repository->matching(Criteria::create(true)->where(Criteria::expr()->contains('status', 'dev')));
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->contains('status', 'dev')));
self::assertCount(2, $users);
}
@@ -834,17 +835,17 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->startsWith('name', 'Foo'),
));
self::assertCount(0, $users);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->startsWith('name', 'R'),
));
self::assertCount(1, $users);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->startsWith('status', 'de'),
));
self::assertCount(2, $users);
@@ -856,17 +857,17 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$repository = $this->_em->getRepository(CmsUser::class);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->endsWith('name', 'foo'),
));
self::assertCount(0, $users);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->endsWith('name', 'oman'),
));
self::assertCount(1, $users);
$users = $repository->matching(Criteria::create(true)->where(
$users = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->endsWith('status', 'ev'),
));
self::assertCount(2, $users);
@@ -878,8 +879,8 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$fixtures = $this->loadFixtureUserEmail();
$user = $this->_em->find(CmsUser::class, $fixtures[0]->id);
$repository = $this->_em->getRepository(CmsUser::class);
$criteriaIsNull = Criteria::create(true)->where(Criteria::expr()->isNull('email'));
$criteriaEqNull = Criteria::create(true)->where(Criteria::expr()->eq('email', null));
$criteriaIsNull = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->isNull('email'));
$criteriaEqNull = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->eq('email', null));
$user->setEmail(null);
$this->_em->persist($user);
@@ -936,7 +937,7 @@ class EntityRepositoryTest extends OrmFunctionalTestCase
$this->expectExceptionMessage('Unrecognized field: ');
$repository = $this->_em->getRepository(CmsUser::class);
$result = $repository->matching(Criteria::create(true)->where(
$result = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('username = ?; DELETE FROM cms_users; SELECT 1 WHERE 1', 'beberlei'),
));

View File

@@ -35,6 +35,7 @@ use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use function class_exists;
use function defined;
use function sprintf;
use function uniqid;
@@ -559,7 +560,7 @@ EXCEPTION
$library = $this->_em->find(Library::class, $library->id);
self::assertFalse($library->books->isInitialized(), 'Pre-condition: lazy collection');
$result = $library->books->matching(Criteria::create(true)->where($comparison));
$result = $library->books->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where($comparison));
self::assertCount(1, $result);
self::assertSame($nonfictionBook->id, $result[0]->id);
@@ -588,7 +589,7 @@ EXCEPTION
$category = $this->_em->find(BookCategory::class, $category->id);
self::assertFalse($category->books->isInitialized(), 'Pre-condition: lazy collection');
$result = $category->books->matching(Criteria::create(true)->where($comparison));
$result = $category->books->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where($comparison));
self::assertCount(1, $result);
self::assertSame($nonfictionBook->id, $result[0]->id);

View File

@@ -17,6 +17,7 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function assert;
use function defined;
use function get_class;
/**
@@ -436,7 +437,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$user = $this->_em->find($user::class, $user->id);
$criteria = Criteria::create(true)
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())
->orderBy(['name' => Order::Ascending]);
self::assertEquals(
@@ -476,7 +477,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$user = $this->_em->find($user::class, $user->id);
$criteria = Criteria::create(true)
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())
->orderBy(['name' => Order::Ascending]);
self::assertEquals(
@@ -499,7 +500,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$groups = $user->groups;
self::assertFalse($user->groups->isInitialized(), 'Pre-condition: lazy collection');
$criteria = Criteria::create(true)->setMaxResults(1);
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->setMaxResults(1);
$result = $groups->matching($criteria);
self::assertCount(1, $result);
@@ -517,7 +518,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$groups = $user->groups;
self::assertFalse($user->groups->isInitialized(), 'Pre-condition: lazy collection');
$criteria = Criteria::create(true)->setFirstResult(1);
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->setFirstResult(1);
$result = $groups->matching($criteria);
self::assertCount(1, $result);
@@ -538,7 +539,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$groups = $user->groups;
self::assertFalse($user->groups->isInitialized(), 'Pre-condition: lazy collection');
$criteria = Criteria::create(true)->setFirstResult(1)->setMaxResults(3);
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->setFirstResult(1)->setMaxResults(3);
$result = $groups->matching($criteria);
self::assertCount(3, $result);
@@ -562,7 +563,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$groups = $user->groups;
self::assertFalse($user->groups->isInitialized(), 'Pre-condition: lazy collection');
$criteria = Criteria::create(true)->where(Criteria::expr()->eq('name', (string) 'Developers_0'));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->eq('name', (string) 'Developers_0'));
$result = $groups->matching($criteria);
self::assertCount(1, $result);
@@ -583,7 +584,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$groups = $user->groups;
self::assertFalse($user->groups->isInitialized(), 'Pre-condition: lazy collection');
$criteria = Criteria::create(true)->where(Criteria::expr()->in('name', ['Developers_1']));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->in('name', ['Developers_1']));
$result = $groups->matching($criteria);
self::assertCount(1, $result);
@@ -602,7 +603,7 @@ class ManyToManyBasicAssociationTest extends OrmFunctionalTestCase
$groups = $user->groups;
self::assertFalse($user->groups->isInitialized(), 'Pre-condition: lazy collection');
$criteria = Criteria::create(true)->where(Criteria::expr()->notIn('name', ['Developers_0']));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->notIn('name', ['Developers_0']));
$result = $groups->matching($criteria);
self::assertCount(1, $result);

View File

@@ -11,6 +11,8 @@ use Doctrine\Tests\Models\ECommerce\ECommerceProduct;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function defined;
/**
* Tests a bidirectional one-to-one association mapping (without inheritance).
*/
@@ -164,14 +166,14 @@ class OneToManyBidirectionalAssociationTest extends OrmFunctionalTestCase
$product = $this->_em->find(ECommerceProduct::class, $this->product->getId());
$features = $product->getFeatures();
$results = $features->matching(Criteria::create(true)->where(
$results = $features->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('description', 'Model writing tutorial'),
));
self::assertInstanceOf(Collection::class, $results);
self::assertCount(1, $results);
$results = $features->matching(Criteria::create(true));
$results = $features->matching(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create());
self::assertInstanceOf(Collection::class, $results);
self::assertCount(2, $results);
@@ -190,7 +192,7 @@ class OneToManyBidirectionalAssociationTest extends OrmFunctionalTestCase
$features = $product->getFeatures();
$features->add($thirdFeature);
$results = $features->matching(Criteria::create(true)->where(
$results = $features->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('description', 'Model writing tutorial'),
));
@@ -208,14 +210,14 @@ class OneToManyBidirectionalAssociationTest extends OrmFunctionalTestCase
$thirdFeature->setDescription('Third feature');
$product->addFeature($thirdFeature);
$results = $features->matching(Criteria::create(true)->where(
$results = $features->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('description', 'Third feature'),
));
self::assertInstanceOf(Collection::class, $results);
self::assertCount(1, $results);
$results = $features->matching(Criteria::create(true));
$results = $features->matching(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create());
self::assertInstanceOf(Collection::class, $results);
self::assertCount(3, $results);

View File

@@ -678,7 +678,7 @@ SQL,
public function testDifferentResultLengthsDoNotRequireExtraQueryCacheEntries(): void
{
$dql = 'SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u WHERE u.id >= :id';
$dql = 'SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u WHERE u.id >= :id ORDER BY u.id';
$query = $this->_em->createQuery($dql);
$query->setMaxResults(10);

View File

@@ -15,6 +15,8 @@ use Doctrine\Tests\Models\ValueConversionType\InversedManyToManyExtraLazyEntity;
use Doctrine\Tests\Models\ValueConversionType\OwningManyToManyExtraLazyEntity;
use Doctrine\Tests\OrmFunctionalTestCase;
use function defined;
class PersistentCollectionCriteriaTest extends OrmFunctionalTestCase
{
protected function setUp(): void
@@ -80,7 +82,7 @@ class PersistentCollectionCriteriaTest extends OrmFunctionalTestCase
$repository = $this->_em->getRepository(User::class);
$user = $repository->findOneBy(['name' => 'ngal']);
$tweets = $user->tweets->matching(Criteria::create(true));
$tweets = $user->tweets->matching(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create());
self::assertInstanceOf(LazyCriteriaCollection::class, $tweets);
self::assertFalse($tweets->isInitialized());
@@ -88,7 +90,7 @@ class PersistentCollectionCriteriaTest extends OrmFunctionalTestCase
self::assertFalse($tweets->isInitialized());
// Make sure it works with constraints
$tweets = $user->tweets->matching(Criteria::create(true)->where(
$tweets = $user->tweets->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('content', 'Foo'),
));
@@ -117,7 +119,7 @@ class PersistentCollectionCriteriaTest extends OrmFunctionalTestCase
$parent = $this->_em->find(OwningManyToManyExtraLazyEntity::class, $parent->id2);
$criteria = Criteria::create(true)->where(Criteria::expr()->eq('id1', 'Bob'));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->eq('id1', 'Bob'));
$result = $parent->associatedEntities->matching($criteria);

View File

@@ -12,6 +12,7 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function class_exists;
use function defined;
class PersistentCollectionTest extends OrmFunctionalTestCase
{
@@ -89,7 +90,7 @@ class PersistentCollectionTest extends OrmFunctionalTestCase
$this->_em->flush();
$this->_em->clear();
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$collectionHolder = $this->_em->find(PersistentCollectionHolder::class, $collectionHolder->getId());
$collectionHolder->getCollection()->matching($criteria);

View File

@@ -10,6 +10,8 @@ use Doctrine\Tests\Models\Cache\Country;
use Doctrine\Tests\Models\Cache\State;
use PHPUnit\Framework\Attributes\Group;
use function defined;
#[Group('DDC-2183')]
class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
{
@@ -26,7 +28,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
$repository = $this->_em->getRepository(Country::class);
$this->getQueryLog()->reset()->enable();
$name = $this->countries[0]->getName();
$result1 = $repository->matching(Criteria::create(true)->where(
$result1 = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $name),
));
@@ -41,7 +43,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
$this->_em->clear();
$result2 = $repository->matching(Criteria::create(true)->where(
$result2 = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $name),
));
@@ -65,7 +67,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
$repository = $this->_em->getRepository(Country::class);
$this->getQueryLog()->reset()->enable();
$result1 = $repository->matching(Criteria::create(true)->where(
$result1 = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $this->countries[0]->getName()),
));
@@ -79,7 +81,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
$this->_em->clear();
$result2 = $repository->matching(Criteria::create(true)->where(
$result2 = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $this->countries[0]->getName()),
));
@@ -94,7 +96,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
self::assertEquals($this->countries[0]->getId(), $result2[0]->getId());
self::assertEquals($this->countries[0]->getName(), $result2[0]->getName());
$result3 = $repository->matching(Criteria::create(true)->where(
$result3 = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $this->countries[1]->getName()),
));
@@ -109,7 +111,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
self::assertEquals($this->countries[1]->getId(), $result3[0]->getId());
self::assertEquals($this->countries[1]->getName(), $result3[0]->getName());
$result4 = $repository->matching(Criteria::create(true)->where(
$result4 = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $this->countries[1]->getName()),
));
@@ -134,7 +136,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
$itemName = $this->states[0]->getCities()->get(0)->getName();
$this->getQueryLog()->reset()->enable();
$collection = $entity->getCities();
$matching = $collection->matching(Criteria::create(true)->where(
$matching = $collection->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $itemName),
));
@@ -147,7 +149,7 @@ class SecondLevelCacheCriteriaTest extends SecondLevelCacheFunctionalTestCase
$entity = $this->_em->find(State::class, $this->states[0]->getId());
$this->getQueryLog()->reset()->enable();
$collection = $entity->getCities();
$matching = $collection->matching(Criteria::create(true)->where(
$matching = $collection->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('name', $itemName),
));

View File

@@ -16,6 +16,7 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function array_map;
use function defined;
use function sort;
class SingleTableInheritanceTest extends OrmFunctionalTestCase
@@ -354,13 +355,13 @@ class SingleTableInheritanceTest extends OrmFunctionalTestCase
$this->loadFullFixture();
$repository = $this->_em->getRepository(CompanyContract::class);
$contracts = $repository->matching(Criteria::create(true)->where(
$contracts = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('salesPerson', $this->salesPerson),
));
self::assertCount(3, $contracts);
$repository = $this->_em->getRepository(CompanyFixContract::class);
$contracts = $repository->matching(Criteria::create(true)->where(
$contracts = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('salesPerson', $this->salesPerson),
));
self::assertCount(1, $contracts);
@@ -376,7 +377,7 @@ class SingleTableInheritanceTest extends OrmFunctionalTestCase
$this->expectException(MatchingAssociationFieldRequiresObject::class);
$this->expectExceptionMessage('annot match on Doctrine\Tests\Models\Company\CompanyContract::salesPerson with a non-object value.');
$contracts = $repository->matching(Criteria::create(true)->where(
$contracts = $repository->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->eq('salesPerson', $this->salesPerson->getId()),
));

View File

@@ -50,7 +50,7 @@ class DDC1452Test extends OrmFunctionalTestCase
$this->_em->flush();
$this->_em->clear();
$dql = 'SELECT a, b, ba FROM ' . __NAMESPACE__ . '\DDC1452EntityA AS a LEFT JOIN a.entitiesB AS b LEFT JOIN b.entityATo AS ba';
$dql = 'SELECT a, b, ba FROM ' . __NAMESPACE__ . '\DDC1452EntityA AS a LEFT JOIN a.entitiesB AS b LEFT JOIN b.entityATo AS ba ORDER BY a.id';
$results = $this->_em->createQuery($dql)->setMaxResults(1)->getResult();
self::assertSame($results[0], $results[0]->entitiesB[0]->entityAFrom);

View File

@@ -16,6 +16,8 @@ use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function defined;
#[Group('DDC-2106')]
class DDC2106Test extends OrmFunctionalTestCase
{
@@ -39,7 +41,7 @@ class DDC2106Test extends OrmFunctionalTestCase
$entityWithoutId = new DDC2106Entity();
$this->_em->persist($entityWithoutId);
$criteria = Criteria::create(true)->where(Criteria::expr()->eq('parent', $entityWithoutId));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->eq('parent', $entityWithoutId));
self::assertCount(0, $entity->children->matching($criteria));
}

View File

@@ -10,6 +10,8 @@ use Doctrine\Tests\Models\Company\CompanyManager;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function defined;
class DDC3719Test extends OrmFunctionalTestCase
{
protected function setUp(): void
@@ -44,7 +46,7 @@ class DDC3719Test extends OrmFunctionalTestCase
$contracts = $manager->managedContracts;
self::assertCount(2, $contracts);
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where(Criteria::expr()->eq('completed', true));
$completedContracts = $contracts->matching($criteria);

View File

@@ -10,6 +10,8 @@ use Doctrine\Tests\Models\ECommerce\ECommerceProduct;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function defined;
final class GH6740Test extends OrmFunctionalTestCase
{
private int $productId;
@@ -49,7 +51,7 @@ final class GH6740Test extends OrmFunctionalTestCase
public function testCollectionFilteringLteOperator(): void
{
$product = $this->_em->find(ECommerceProduct::class, $this->productId);
$criteria = Criteria::create(true)->where(Criteria::expr()->lte('id', $this->secondCategoryId));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->lte('id', $this->secondCategoryId));
self::assertCount(2, $product->getCategories()->matching($criteria));
}
@@ -58,7 +60,7 @@ final class GH6740Test extends OrmFunctionalTestCase
public function testCollectionFilteringLtOperator(): void
{
$product = $this->_em->find(ECommerceProduct::class, $this->productId);
$criteria = Criteria::create(true)->where(Criteria::expr()->lt('id', $this->secondCategoryId));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->lt('id', $this->secondCategoryId));
self::assertCount(1, $product->getCategories()->matching($criteria));
}
@@ -67,7 +69,7 @@ final class GH6740Test extends OrmFunctionalTestCase
public function testCollectionFilteringGteOperator(): void
{
$product = $this->_em->find(ECommerceProduct::class, $this->productId);
$criteria = Criteria::create(true)->where(Criteria::expr()->gte('id', $this->firstCategoryId));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->gte('id', $this->firstCategoryId));
self::assertCount(2, $product->getCategories()->matching($criteria));
}
@@ -76,7 +78,7 @@ final class GH6740Test extends OrmFunctionalTestCase
public function testCollectionFilteringGtOperator(): void
{
$product = $this->_em->find(ECommerceProduct::class, $this->productId);
$criteria = Criteria::create(true)->where(Criteria::expr()->gt('id', $this->firstCategoryId));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->gt('id', $this->firstCategoryId));
self::assertCount(1, $product->getCategories()->matching($criteria));
}
@@ -85,7 +87,7 @@ final class GH6740Test extends OrmFunctionalTestCase
public function testCollectionFilteringEqualsOperator(): void
{
$product = $this->_em->find(ECommerceProduct::class, $this->productId);
$criteria = Criteria::create(true)->where(Criteria::expr()->eq('id', $this->firstCategoryId));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->eq('id', $this->firstCategoryId));
self::assertCount(1, $product->getCategories()->matching($criteria));
}

View File

@@ -10,6 +10,8 @@ use Doctrine\Tests\Models\GH7717\GH7717Child;
use Doctrine\Tests\Models\GH7717\GH7717Parent;
use Doctrine\Tests\OrmFunctionalTestCase;
use function defined;
final class GH7717Test extends OrmFunctionalTestCase
{
public function setUp(): void
@@ -37,7 +39,7 @@ final class GH7717Test extends OrmFunctionalTestCase
$parent = $this->_em->find(GH7717Parent::class, 1);
$this->assertCount(1, $parent->children->matching(Criteria::create(true)->where(
$this->assertCount(1, $parent->children->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(
Criteria::expr()->isNull('nullableProperty'),
)));
}

View File

@@ -17,6 +17,8 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use function defined;
#[Group('GH7737')]
class GH7737Test extends OrmFunctionalTestCase
{
@@ -43,7 +45,7 @@ class GH7737Test extends OrmFunctionalTestCase
$query = $this->_em->createQueryBuilder()
->select('person')
->from(GH7737Person::class, 'person')
->addCriteria(Criteria::create(true)->where(Criteria::expr()->memberOf(':group', 'person.groups')))
->addCriteria((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->memberOf(':group', 'person.groups')))
->getQuery();
$group1 = $this->_em->find(GH7737Group::class, 1);

View File

@@ -20,6 +20,7 @@ use PHPUnit\Framework\Attributes\Group;
use function assert;
use function class_exists;
use function defined;
#[Group('GH7767')]
class GH7767Test extends OrmFunctionalTestCase
@@ -45,7 +46,7 @@ class GH7767Test extends OrmFunctionalTestCase
$parent = $this->_em->find(GH7767ParentEntity::class, 1);
assert($parent instanceof GH7767ParentEntity);
$children = $parent->getChildren()->matching(Criteria::create(true));
$children = $parent->getChildren()->matching(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create());
self::assertEquals(100, $children[0]->position);
self::assertEquals(200, $children[1]->position);
@@ -58,7 +59,7 @@ class GH7767Test extends OrmFunctionalTestCase
assert($parent instanceof GH7767ParentEntity);
$children = $parent->getChildren()->matching(
Criteria::create(true)->orderBy(['position' => class_exists(Order::class) ? Order::Descending : 'DESC']),
(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->orderBy(['position' => class_exists(Order::class) ? Order::Descending : 'DESC']),
);
self::assertEquals(300, $children[0]->position);

View File

@@ -20,6 +20,7 @@ use PHPUnit\Framework\Attributes\Group;
use function assert;
use function class_exists;
use function defined;
#[Group('GH7836')]
class GH7836Test extends OrmFunctionalTestCase
@@ -45,7 +46,7 @@ class GH7836Test extends OrmFunctionalTestCase
$parent = $this->_em->find(GH7836ParentEntity::class, 1);
assert($parent instanceof GH7836ParentEntity);
$children = $parent->getChildren()->matching(Criteria::create(true));
$children = $parent->getChildren()->matching(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create());
self::assertSame(100, $children[0]->position);
self::assertSame('bar', $children[0]->name);
@@ -61,7 +62,7 @@ class GH7836Test extends OrmFunctionalTestCase
assert($parent instanceof GH7836ParentEntity);
$children = $parent->getChildren()->matching(
Criteria::create(true)->orderBy(
(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->orderBy(
class_exists(Order::class)
? ['position' => Order::Descending, 'name' => Order::Ascending]
: ['position' => 'DESC', 'name' => 'ASC'],
@@ -82,7 +83,7 @@ class GH7836Test extends OrmFunctionalTestCase
assert($parent instanceof GH7836ParentEntity);
$children = $parent->getChildren()->matching(
Criteria::create(true)->orderBy(
(defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->orderBy(
class_exists(Order::class)
? ['name' => Order::Ascending, 'position' => Order::Ascending]
: ['name' => 'ASC', 'position' => 'ASC'],

View File

@@ -15,6 +15,8 @@ use Doctrine\ORM\Mapping\ManyToMany;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
use function defined;
#[Group('GH-9109')]
class GH9109Test extends OrmFunctionalTestCase
{
@@ -67,7 +69,7 @@ class GH9109Test extends OrmFunctionalTestCase
self::assertEquals($userLastName, $user->getLastName());
// assert NOT QUOTED will WORK with Criteria
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('lastName', $userLastName));
$user = $persistedProduct->getBuyers()->matching($criteria)->first();
self::assertInstanceOf(GH9109User::class, $user);
@@ -79,7 +81,7 @@ class GH9109Test extends OrmFunctionalTestCase
self::assertEquals($userFirstName, $user->getFirstName());
// assert QUOTED will WORK with Criteria
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('firstName', $userFirstName));
$user = $persistedProduct->getBuyers()->matching($criteria)->first();
self::assertInstanceOf(GH9109User::class, $user);

View File

@@ -12,6 +12,8 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use function defined;
class ManyToManyCriteriaMatchingTest extends OrmFunctionalTestCase
{
protected function setUp(): void
@@ -48,7 +50,7 @@ class ManyToManyCriteriaMatchingTest extends OrmFunctionalTestCase
$associated = $this->_em->find(InversedManyToManyEntity::class, 'associated');
self::assertFalse($associated->associatedEntities->isInitialized(), 'Pre-condition: lazy collection');
$result = $associated->associatedEntities->matching(Criteria::create(true)->where($comparison));
$result = $associated->associatedEntities->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where($comparison));
$l = $this->getQueryLog();

View File

@@ -12,6 +12,8 @@ use Doctrine\Tests\OrmFunctionalTestCase;
use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use function defined;
class OneToManyCriteriaMatchingTest extends OrmFunctionalTestCase
{
protected function setUp(): void
@@ -48,7 +50,7 @@ class OneToManyCriteriaMatchingTest extends OrmFunctionalTestCase
$entityWithCollection = $this->_em->find(InversedOneToManyEntity::class, 'associated');
self::assertFalse($entityWithCollection->associatedEntities->isInitialized(), 'Pre-condition: lazy collection');
$result = $entityWithCollection->associatedEntities->matching(Criteria::create(true)->where($comparison));
$result = $entityWithCollection->associatedEntities->matching((defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where($comparison));
$l = $this->getQueryLog();

View File

@@ -12,6 +12,8 @@ use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use function defined;
#[CoversClass(LazyCriteriaCollection::class)]
class LazyCriteriaCollectionTest extends TestCase
{
@@ -22,7 +24,7 @@ class LazyCriteriaCollectionTest extends TestCase
protected function setUp(): void
{
$this->persister = $this->createMock(EntityPersister::class);
$this->criteria = Criteria::create(true);
$this->criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$this->lazyCriteriaCollection = new LazyCriteriaCollection($this->persister, $this->criteria);
}
@@ -78,7 +80,7 @@ class LazyCriteriaCollectionTest extends TestCase
->with($this->criteria)
->willReturn([$foo, $bar, $baz]);
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->andWhere($criteria->expr()->eq('val', 'foo'));

View File

@@ -33,6 +33,7 @@ final class FieldMappingTest extends TestCase
$mapping->precision = 10;
$mapping->scale = 2;
$mapping->unique = true;
$mapping->index = true;
$mapping->inherited = self::class;
$mapping->originalClass = self::class;
$mapping->originalField = 'id';
@@ -57,6 +58,7 @@ final class FieldMappingTest extends TestCase
self::assertSame(10, $resurrectedMapping->precision);
self::assertSame(2, $resurrectedMapping->scale);
self::assertTrue($resurrectedMapping->unique);
self::assertTrue($resurrectedMapping->index);
self::assertSame(self::class, $resurrectedMapping->inherited);
self::assertSame(self::class, $resurrectedMapping->originalClass);
self::assertSame('id', $resurrectedMapping->originalField);

View File

@@ -12,6 +12,8 @@ use Doctrine\Tests\Models\GeoNames\Admin1AlternateName;
use Doctrine\Tests\Models\GeoNames\Country;
use Doctrine\Tests\OrmTestCase;
use function defined;
class BasicEntityPersisterCompositeTypeParametersTest extends OrmTestCase
{
protected BasicEntityPersister $persister;
@@ -46,7 +48,7 @@ class BasicEntityPersisterCompositeTypeParametersTest extends OrmTestCase
$country = new Country('IT', 'Italy');
$admin1 = new Admin1(10, 'Rome', $country);
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->andWhere(Criteria::expr()->eq('admin1', $admin1));
[$values, $types] = $this->persister->expandCriteriaParameters($criteria);

View File

@@ -24,6 +24,7 @@ use PHPUnit\Framework\Attributes\Group;
use ReflectionMethod;
use function array_slice;
use function defined;
use function enum_exists;
class BasicEntityPersisterTypeValueSqlTest extends OrmTestCase
@@ -185,7 +186,7 @@ class BasicEntityPersisterTypeValueSqlTest extends OrmTestCase
self::assertEquals('SELECT COUNT(*) FROM "not-a-simple-entity" t0 WHERE t0."simple-entity-value" = ?', $statement);
// Using a criteria object
$criteria = Criteria::create(true)->where(Criteria::expr()->eq('value', 'bar'));
$criteria = (defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create())->where(Criteria::expr()->eq('value', 'bar'));
$statement = $persister->getCountSQL($criteria);
self::assertEquals('SELECT COUNT(*) FROM "not-a-simple-entity" t0 WHERE t0."simple-entity-value" = ?', $statement);
}

View File

@@ -374,11 +374,27 @@ class ExprTest extends OrmTestCase
public function testAddThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$orExpr = $this->expr->orX();
$this->expectException(InvalidArgumentException::class);
$orExpr->add($this->expr->quot(5, 2));
}
#[DataProvider('provideInvalidTypesForAdd')]
public function testAddThrowsExceptionOnInvalidType(mixed $arg): void
{
$orExpr = $this->expr->orX();
$this->expectException(InvalidArgumentException::class);
$orExpr->add($arg);
}
/** @return Generator<string, array{mixed}> */
public static function provideInvalidTypesForAdd(): Generator
{
yield 'integer 1' => [1];
yield 'object' => [(object) ['foo' => 'bar']];
yield 'array' => [['foo' => 'bar']];
}
#[Group('DDC-1683')]
public function testBooleanLiteral(): void
{

View File

@@ -31,6 +31,7 @@ use PHPUnit\Framework\TestCase;
use RuntimeException;
use function array_filter;
use function defined;
/**
* Test case for the QueryBuilder class used to build DQL query string in a
@@ -513,7 +514,7 @@ class QueryBuilderTest extends OrmTestCase
$qb->select('u')
->from(CmsUser::class, 'u');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('field', 'value'));
$qb->addCriteria($criteria);
@@ -527,7 +528,7 @@ class QueryBuilderTest extends OrmTestCase
$qb = $this->entityManager->createQueryBuilder();
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->andX(
$criteria->expr()->eq('field', 'value1'),
$criteria->expr()->eq('field', 'value2'),
@@ -546,7 +547,7 @@ class QueryBuilderTest extends OrmTestCase
$qb = $this->entityManager->createQueryBuilder();
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('field', 'value1'));
$criteria->andWhere($criteria->expr()->gt('field', 'value2'));
@@ -563,7 +564,7 @@ class QueryBuilderTest extends OrmTestCase
$qb = $this->entityManager->createQueryBuilder();
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('field1', 'value1'));
$criteria->andWhere($criteria->expr()->gt('field2', 'value2'));
@@ -580,7 +581,7 @@ class QueryBuilderTest extends OrmTestCase
$qb = $this->entityManager->createQueryBuilder();
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('field1', 'value1'));
$criteria->andWhere($criteria->expr()->gt('field2', 'value2'));
@@ -597,7 +598,7 @@ class QueryBuilderTest extends OrmTestCase
$qb = $this->entityManager->createQueryBuilder();
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('field1', 'value1'));
$criteria->andWhere($criteria->expr()->gt('field1', 'value2'));
@@ -614,7 +615,7 @@ class QueryBuilderTest extends OrmTestCase
$qb->select('u')
->from(CmsUser::class, 'u');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->orderBy(['field' => Order::Descending]);
$qb->addCriteria($criteria);
@@ -631,7 +632,7 @@ class QueryBuilderTest extends OrmTestCase
->from(CmsUser::class, 'u')
->join('u.article', 'a');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->orderBy(['a.field' => Order::Descending]);
$qb->addCriteria($criteria);
@@ -646,7 +647,7 @@ class QueryBuilderTest extends OrmTestCase
$qb->select('u')
->from(CmsUser::class, 'u');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->setFirstResult(2);
$criteria->setMaxResults(10);
@@ -664,7 +665,7 @@ class QueryBuilderTest extends OrmTestCase
->setFirstResult(2)
->setMaxResults(10);
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$qb->addCriteria($criteria);
@@ -954,7 +955,7 @@ class QueryBuilderTest extends OrmTestCase
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$qb->join('alias1.articles', 'alias2');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('field', 'value1'));
$criteria->andWhere($criteria->expr()->gt('alias2.field', 'value2'));
@@ -972,7 +973,7 @@ class QueryBuilderTest extends OrmTestCase
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$qb->join('alias1.articles', 'alias2');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('alias1.field', 'value1'));
$criteria->andWhere($criteria->expr()->gt('alias2.field', 'value2'));
@@ -990,7 +991,7 @@ class QueryBuilderTest extends OrmTestCase
$qb->select('alias1')->from(CmsUser::class, 'alias1');
$qb->join('alias1.articles', 'alias2');
$criteria = Criteria::create(true);
$criteria = defined(Criteria::class . '::ASC') ? Criteria::create(true) : Criteria::create();
$criteria->where($criteria->expr()->eq('alias1.field', 'value1'));
$criteria->andWhere($criteria->expr()->gt('alias2.field', 'value2'));
$criteria->andWhere($criteria->expr()->lt('alias2.field', 'value3'));

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Tools\CursorPagination;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\InverseJoinColumn;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\JoinTable;
use Doctrine\ORM\Mapping\ManyToMany;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\MappedSuperclass;
use Doctrine\ORM\Mapping\OneToOne;
use Doctrine\ORM\Mapping\Table;
use Doctrine\Tests\OrmTestCase;
abstract class CursorPaginationTestCase extends OrmTestCase
{
/** @var EntityManagerInterface */
public $entityManager;
protected function setUp(): void
{
$this->entityManager = $this->getTestEntityManager();
}
}
#[Entity]
class MyBlogPost
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
/** @var Author */
#[ManyToOne(targetEntity: 'Author')]
public $author;
/** @var Category */
#[ManyToOne(targetEntity: 'Category')]
public $category;
/** @var string */
#[Column(type: 'string', length: 255)]
public $title;
}
#[Entity]
class MyAuthor
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
}
#[Entity]
class MyCategory
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
}
#[Entity]
class BlogPost
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
/** @var Author */
#[ManyToOne(targetEntity: 'Author')]
public $author;
/** @var Category */
#[ManyToOne(targetEntity: 'Category')]
public $category;
}
#[Entity]
class Author
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
/** @var string */
#[Column(type: 'string', length: 255)]
public $name;
}
#[Entity]
class Person
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
/** @var string */
#[Column(type: 'string', length: 255)]
public $name;
/** @var string */
#[Column(type: 'string', length: 255)]
public $biography;
}
#[Entity]
class Category
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
}
#[Table(name: 'groups')]
#[Entity]
class Group
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
/** @phpstan-var Collection<int, User> */
#[ManyToMany(targetEntity: 'User', mappedBy: 'groups')]
public $users;
}
#[Entity]
class User
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
/** @phpstan-var Collection<int, Group> */
#[JoinTable(name: 'user_group')]
#[JoinColumn(name: 'user_id', referencedColumnName: 'id')]
#[InverseJoinColumn(name: 'group_id', referencedColumnName: 'id')]
#[ManyToMany(targetEntity: 'Group', inversedBy: 'users')]
public $groups;
/** @var Avatar */
#[OneToOne(targetEntity: 'Avatar', mappedBy: 'user')]
public $avatar;
}
#[Entity]
class Avatar
{
/** @var int */
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public $id;
/** @var User */
#[OneToOne(targetEntity: 'User', inversedBy: 'avatar')]
#[JoinColumn(name: 'user_id', referencedColumnName: 'id')]
public $user;
/** @var string */
#[Column(type: 'string', length: 255)]
public $image;
/** @var int */
#[Column(type: 'integer')]
public $imageHeight;
/** @var int */
#[Column(type: 'integer')]
public $imageWidth;
/** @var string */
#[Column(type: 'string', length: 255)]
public $imageAltDesc;
}
#[MappedSuperclass]
abstract class Identified
{
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
private int $id;
public function getId(): int
{
return $this->id;
}
}
#[Entity]
class Banner extends Identified
{
/** @var string */
#[Column(type: 'string', length: 255)]
public $name;
}

View File

@@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Tools\CursorPagination;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Schema\Name\UnquotedIdentifierFolding;
use Doctrine\ORM\Decorator\EntityManagerDecorator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Internal\Hydration\AbstractHydrator;
use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\CursorPagination\Cursor;
use Doctrine\ORM\Tools\CursorPagination\CursorItem;
use Doctrine\ORM\Tools\CursorPagination\CursorPaginator;
use Doctrine\Tests\OrmTestCase;
use LogicException;
use PHPUnit\Framework\MockObject\MockObject;
use function enum_exists;
class CursorPaginatorTest extends OrmTestCase
{
private Connection&MockObject $connection;
private EntityManagerInterface&MockObject $em;
private AbstractHydrator&MockObject $hydrator;
protected function setUp(): void
{
$platform = $this->getMockBuilder(AbstractPlatform::class)
->setConstructorArgs(enum_exists(UnquotedIdentifierFolding::class) ? [UnquotedIdentifierFolding::UPPER] : [])
->getMock();
$platform->method('supportsIdentityColumns')
->willReturn(true);
$driver = $this->createMock(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->createMock(AbstractHydrator::class);
$this->em->method('newHydrator')->willReturn($this->hydrator);
}
public function testPaginatorAcceptsQueryBuilder(): void
{
$qb = $this->em->createQueryBuilder()
->select('p')
->from(BlogPost::class, 'p')
->orderBy('p.id', 'ASC');
$paginator = new CursorPaginator($qb);
self::assertInstanceOf(Query::class, $paginator->getQuery());
}
public function testHasNextPageWhenMoreResultsExist(): void
{
$items = [
(object) ['id' => 1],
(object) ['id' => 2],
(object) ['id' => 3],
(object) ['id' => 4],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 3);
self::assertTrue($paginator->hasNextPage());
self::assertFalse($paginator->hasPreviousPage());
self::assertCount(3, $paginator);
}
public function testHasNoPagesOnFirstPageWithoutMoreResults(): void
{
$items = [(object) ['id' => 1]];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
self::assertFalse($paginator->hasPreviousPage());
self::assertFalse($paginator->hasNextPage());
self::assertFalse($paginator->hasToPaginate());
}
public function testGetNextCursorAsStringThrowsWhenNoNextPage(): void
{
$items = [(object) ['id' => 1]];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
$this->expectException(LogicException::class);
$paginator->getNextCursorAsString();
}
public function testGetPreviousCursorAsStringThrowsWhenNoPreviousPage(): void
{
$items = [(object) ['id' => 1]];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
$this->expectException(LogicException::class);
$paginator->getPreviousCursorAsString();
}
public function testGetNextCursorThrowsWhenNoNextPage(): void
{
$items = [(object) ['id' => 1]];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
$this->expectException(LogicException::class);
$paginator->getNextCursor();
}
public function testGetPreviousCursorThrowsWhenNoPreviousPage(): void
{
$items = [(object) ['id' => 1]];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
$this->expectException(LogicException::class);
$paginator->getPreviousCursor();
}
public function testGetNextCursorWhenMoreResultsExist(): void
{
$items = [
(object) ['id' => 1],
(object) ['id' => 2],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 1);
$nextCursor = $paginator->getNextCursor();
self::assertTrue($nextCursor->isNext());
}
public function testGetNextCursorAsStringReturnsStringWhenNextPageExists(): void
{
$items = [
(object) ['id' => 1],
(object) ['id' => 2],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 1);
self::assertIsString($paginator->getNextCursorAsString());
}
public function testGetPreviousCursorAsStringReturnsStringWhenPreviousPageExists(): void
{
$items = [
(object) ['id' => 1],
(object) ['id' => 2],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$cursor = (new Cursor(['p__id' => 1], true))->encodeToString();
$paginator = new CursorPaginator($query);
$paginator->paginate($cursor, 10);
self::assertIsString($paginator->getPreviousCursorAsString());
}
public function testEmptyResultSet(): void
{
$this->hydrator->method('hydrateAll')->willReturn([]);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
self::assertCount(0, $paginator);
self::assertFalse($paginator->hasNextPage());
self::assertFalse($paginator->hasPreviousPage());
}
public function testIteratorReturnsItems(): void
{
$items = [
(object) ['id' => 1],
(object) ['id' => 2],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
$iteratedItems = [];
foreach ($paginator as $item) {
$iteratedItems[] = $item;
}
self::assertCount(2, $iteratedItems);
}
public function testGetValuesReturnsRawEntities(): void
{
$items = [
(object) ['id' => 1],
(object) ['id' => 2],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
$values = $paginator->getValues();
self::assertCount(2, $values);
self::assertSame($items[0], $values[0]);
self::assertSame($items[1], $values[1]);
}
public function testGetItemsReturnsCursorItems(): void
{
$items = [
(object) ['id' => 1],
(object) ['id' => 2],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
$cursorItems = $paginator->getItems();
self::assertCount(2, $cursorItems);
self::assertInstanceOf(CursorItem::class, $cursorItems[0]);
self::assertSame($items[0], $cursorItems[0]->getValue());
self::assertInstanceOf(Cursor::class, $cursorItems[0]->getCursor());
self::assertSame($items[1], $cursorItems[1]->getValue());
}
public function testGetItemsReturnsEmptyArrayWhenNoResults(): void
{
$this->hydrator->method('hydrateAll')->willReturn([]);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
self::assertSame([], $paginator->getItems());
self::assertSame([], $paginator->getValues());
}
public function testPaginateWithEmptyCursorTreatedAsFirstPage(): void
{
$items = [(object) ['id' => 1]];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$paginator = new CursorPaginator($query);
$paginator->paginate('', 10);
self::assertFalse($paginator->hasPreviousPage());
self::assertFalse($paginator->hasNextPage());
}
public function testPaginateWithPreviousCursorReversesItems(): void
{
$items = [
(object) ['id' => 3],
(object) ['id' => 2],
(object) ['id' => 1],
];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$previousCursor = (new Cursor(['p__id' => 3], false))->encodeToString();
$paginator = new CursorPaginator($query);
$paginator->paginate($previousCursor, 10);
$values = $paginator->getValues();
self::assertCount(3, $values);
self::assertSame(1, $values[0]->id);
self::assertSame(2, $values[1]->id);
self::assertSame(3, $values[2]->id);
}
public function testCloneQueryCopiesExistingHints(): void
{
$items = [(object) ['id' => 1]];
$this->hydrator->method('hydrateAll')->willReturn($items);
$result = $this->createMock(Result::class);
$this->connection->method('executeQuery')->willReturn($result);
$query = new Query($this->em);
$query->setDQL('SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC');
$query->setHint('custom.hint', 'custom_value');
$paginator = new CursorPaginator($query);
$paginator->paginate(null, 10);
self::assertCount(1, $paginator);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Tools\CursorPagination;
use Doctrine\ORM\Tools\CursorPagination\Cursor;
use Doctrine\ORM\Tools\CursorPagination\Exception\InvalidCursor;
use JsonException;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use function base64_encode;
use function json_encode;
use function rtrim;
use function strtr;
class CursorTest extends TestCase
{
public function testCursorIsFinal(): void
{
$reflection = new ReflectionClass(Cursor::class);
self::assertTrue($reflection->isFinal());
}
public function testEncodeAndDecode(): void
{
$parameters = ['id' => 10, 'name' => 'test'];
$cursor = new Cursor($parameters, true);
$decoded = Cursor::fromEncodedString($cursor->encodeToString());
self::assertSame($parameters, $decoded->getParameters());
self::assertTrue($decoded->isNext());
}
public function testEncodeAndDecodeWithPreviousCursor(): void
{
$cursor = new Cursor(['id' => 10], false);
$decoded = Cursor::fromEncodedString($cursor->encodeToString());
self::assertTrue($decoded->isPrevious());
}
public function testEncodeProducesUrlSafeString(): void
{
$cursor = new Cursor(['id' => 123456789, 'foo' => 'bar/baz+test']);
$encoded = $cursor->encodeToString();
self::assertStringNotContainsString('+', $encoded);
self::assertStringNotContainsString('/', $encoded);
self::assertStringNotContainsString('=', $encoded);
}
public function testFromEncodedStringThrowsForInvalidBase64(): void
{
$this->expectException(InvalidCursor::class);
Cursor::fromEncodedString('not-valid-base64!@#$');
}
public function testFromEncodedStringThrowsForInvalidJson(): void
{
$this->expectException(InvalidCursor::class);
Cursor::fromEncodedString(rtrim(strtr(base64_encode('not-json'), '+/', '-_'), '='));
}
public function testFromEncodedStringChainsPreviousExceptionForInvalidJson(): void
{
try {
Cursor::fromEncodedString(rtrim(strtr(base64_encode('not-json'), '+/', '-_'), '='));
self::fail('Expected InvalidCursor to be thrown.');
} catch (InvalidCursor $e) {
self::assertInstanceOf(JsonException::class, $e->getPrevious());
}
}
public function testFromEncodedStringDefaultsToNextWhenIsNextMissing(): void
{
$json = json_encode(['id' => 10]);
$encoded = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
$cursor = Cursor::fromEncodedString($encoded);
self::assertTrue($cursor->isNext());
}
public function testToArray(): void
{
$cursor = new Cursor(['id' => 1], true);
self::assertSame(['id' => 1, '_isNext' => true], $cursor->toArray());
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Tools\CursorPagination;
use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\CursorPagination\CursorWalker;
use LogicException;
class CursorWalkerTest extends CursorPaginationTestCase
{
public function testThrowsExceptionWithoutOrderBy(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$this->expectException(LogicException::class);
$this->expectExceptionMessage('No ORDER BY clause found. Cursor pagination requires a deterministic sort order.');
$query->getSQL();
}
public function testBasicQueryWithOrderBy(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, []);
self::assertEquals(
'SELECT b0_.id AS id_0, b0_.author_id AS author_id_1, b0_.category_id AS category_id_2 FROM BlogPost b0_ ORDER BY b0_.id ASC',
$query->getSQL(),
);
}
public function testQueryWithReversedOrderBy(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, true);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, []);
self::assertEquals(
'SELECT b0_.id AS id_0, b0_.author_id AS author_id_1, b0_.category_id AS category_id_2 FROM BlogPost b0_ ORDER BY b0_.id DESC',
$query->getSQL(),
);
}
public function testQueryWithMultipleOrderByColumnsReversed(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\MyBlogPost p ORDER BY p.title ASC, p.id DESC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, true);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, []);
self::assertEquals(
'SELECT m0_.id AS id_0, m0_.title AS title_1, m0_.author_id AS author_id_2, m0_.category_id AS category_id_3 FROM MyBlogPost m0_ ORDER BY m0_.title DESC, m0_.id ASC',
$query->getSQL(),
);
}
public function testCursorConditionSingleColumnAsc(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id ASC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, ['p.id' => 10]);
self::assertEquals(
'SELECT b0_.id AS id_0, b0_.author_id AS author_id_1, b0_.category_id AS category_id_2 FROM BlogPost b0_ WHERE (b0_.id > ?) ORDER BY b0_.id ASC',
$query->getSQL(),
);
}
public function testCursorConditionSingleColumnDesc(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p ORDER BY p.id DESC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, ['p.id' => 10]);
self::assertEquals(
'SELECT b0_.id AS id_0, b0_.author_id AS author_id_1, b0_.category_id AS category_id_2 FROM BlogPost b0_ WHERE (b0_.id < ?) ORDER BY b0_.id DESC',
$query->getSQL(),
);
}
public function testCursorConditionWithExistingWhere(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p WHERE p.id > 5 ORDER BY p.id ASC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, ['p.id' => 10]);
self::assertEquals(
'SELECT b0_.id AS id_0, b0_.author_id AS author_id_1, b0_.category_id AS category_id_2 FROM BlogPost b0_ WHERE b0_.id > 5 AND (b0_.id > ?) ORDER BY b0_.id ASC',
$query->getSQL(),
);
}
public function testQueryWithJoinAndOrderByJoinedEntity(): void
{
$query = $this->entityManager->createQuery(
'SELECT p, a FROM Doctrine\Tests\ORM\Tools\CursorPagination\BlogPost p JOIN p.author a ORDER BY a.name ASC, p.id ASC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, []);
self::assertEquals(
'SELECT b0_.id AS id_0, a1_.id AS id_1, a1_.name AS name_2, b0_.author_id AS author_id_3, b0_.category_id AS category_id_4 FROM BlogPost b0_ INNER JOIN Author a1_ ON b0_.author_id = a1_.id ORDER BY a1_.name ASC, b0_.id ASC',
$query->getSQL(),
);
}
public function testCursorConditionMultipleColumnsDesc(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\MyBlogPost p ORDER BY p.title DESC, p.id DESC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, ['p.title' => 'Test', 'p.id' => 10]);
self::assertEquals(
'SELECT m0_.id AS id_0, m0_.title AS title_1, m0_.author_id AS author_id_2, m0_.category_id AS category_id_3 FROM MyBlogPost m0_ WHERE (m0_.title < ? OR (m0_.title = ? AND (m0_.id < ?))) ORDER BY m0_.title DESC, m0_.id DESC',
$query->getSQL(),
);
}
public function testCursorConditionMultipleColumnsAsc(): void
{
$query = $this->entityManager->createQuery(
'SELECT a FROM Doctrine\Tests\ORM\Tools\CursorPagination\Author a ORDER BY a.name ASC, a.id ASC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, ['a.name' => 'John', 'a.id' => 5]);
self::assertEquals(
'SELECT a0_.id AS id_0, a0_.name AS name_1 FROM Author a0_ WHERE (a0_.name > ? OR (a0_.name = ? AND (a0_.id > ?))) ORDER BY a0_.name ASC, a0_.id ASC',
$query->getSQL(),
);
}
public function testCursorConditionThreeColumnsDesc(): void
{
$query = $this->entityManager->createQuery(
'SELECT p FROM Doctrine\Tests\ORM\Tools\CursorPagination\Person p ORDER BY p.biography DESC, p.name DESC, p.id DESC',
);
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [CursorWalker::class]);
$query->setHint(CursorWalker::HINT_CURSOR_REVERSE, false);
$query->setHint(CursorWalker::HINT_CURSOR_PARAMETERS, [
'p.biography' => '2019-11-04',
'p.name' => 'test@example.com',
'p.id' => 529,
]);
self::assertEquals(
'SELECT p0_.id AS id_0, p0_.name AS name_1, p0_.biography AS biography_2 FROM Person p0_ WHERE (p0_.biography < ? OR (p0_.biography = ? AND (p0_.name < ? OR (p0_.name = ? AND (p0_.id < ?))))) ORDER BY p0_.biography DESC, p0_.name DESC, p0_.id DESC',
$query->getSQL(),
);
}
}