Compare commits

...

106 Commits
3.5.6 ... 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
Grégoire Paris
2148940290 Merge pull request #12335 from greg0ire/gh-12166
Avoid lazy object initialization when initializing read-only property
2026-01-09 06:28:15 +01:00
Grégoire Paris
d3538095fd Merge pull request #12337 from greg0ire/3.6.x
Merge 2.20.x up into 3.6.x
2026-01-09 06:26:06 +01:00
Grégoire Paris
0c1bf14729 Merge remote-tracking branch 'origin/2.20.x' into 3.6.x 2026-01-08 08:55:21 +01:00
Grégoire Paris
3b8c23c51d Merge pull request #12336 from greg0ire/dmwydo
Stop mocking EventManager
2026-01-08 07:19:16 +01:00
Grégoire Paris
60d4ea694a Stop mocking EventManager
It is defined outside this repository, so let us not mock what we do not
own.
2026-01-07 21:52:47 +01:00
Grégoire Paris
e923bbc932 Avoid lazy object initialization when initializing read-only property
Initializing e.g. a readonly ID does not require loading any data from
the database. However, calling isInitialized() on the reflection of a
readonly property triggers the native lazy object initialization.
If we have a lazy property at hand, then the property cannot be initialized
already, so it is safe to skip the call.
2026-01-07 13:04:14 +01:00
n0099
8cbd34c666 Merge pull request #12060 from n0099/patch-1
Update doc `dql-custom-walkers.rst` with an output walker to interpolate parameters into SQL
2026-01-01 18:27:48 +01:00
Carlos Fernandes
f8bbdc40b0 Add dispatchPreFlushEvent() method and avoid calling getConnection() twice 2025-12-30 18:35:45 +01:00
Vladislav Sultanov
8bdefef6d1 Handle int-backed enums for values stored as string values in MySQL ENUM columns (#12275)
* FIX: Handle int-backed enums for values stored as string values in MySQL ENUM columns

Related issue: doctrine#12274

* FIX: Apply coding standard changes

Related issue: doctrine#12274

* FIX: Add unit test cases

Related issue: doctrine#12274
2025-12-30 18:21:10 +01:00
Grégoire Paris
0f8730a6e5 Merge pull request #12331 from greg0ire/a-the
Fix grammatical errors
2025-12-24 11:39:26 +01:00
Grégoire Paris
62477b5d42 Update branch metadata (#12327)
* Update branch metadata

3.6.0 has been released. As a consequence:

- 3.7.x is the next minor branch;
- 3.6.x is the current branch;
- 3.5.x is no longer maintained.

* doc: drop old releases

We should reduce the number of versions we have, so let's remove docs
for versions that have less than 2k downloads per day.
2025-12-21 00:29:57 +01:00
HypeMC
12116aa3c2 Fix docs regarding query hints (#12328) 2025-12-21 00:29:08 +01:00
Grégoire Paris
0aeddd0592 Fix grammatical errors 2025-12-20 15:34:16 +01:00
Grégoire Paris
2491c4b20d Merge pull request #12329 from HypeMC/fix-result-cache-examples
Fix result cache examples in docs
2025-12-20 09:14:39 +01:00
HypeMC
08d6167243 Fix result cache examples in docs 2025-12-20 01:21:11 +01:00
Grégoire Paris
d4e9276e79 Merge pull request #12325 from doctrine/3.5.x
Merge 3.5.x up into 3.6.x
2025-12-19 21:36:14 +01:00
Grégoire Paris
cee74faa97 Merge remote-tracking branch 'origin/2.20.x' into 3.5.x 2025-12-19 20:59:05 +01:00
Grégoire Paris
9ae2181185 Merge pull request #12165 from HypeMC/debug-events-commands
Add commands for inspecting configured listeners
2025-12-19 08:31:44 +01:00
HypeMC
3e25efd72b One table 2025-12-17 23:17:36 +01:00
HypeMC
47496ed882 Fixes 2025-12-17 18:52:17 +01:00
Sadetdin EYILI
492745d710 docs: add xml example for Single Table Inheritance mapping (#12169) 2025-12-17 10:40:06 +01:00
dependabot[bot]
67419cf951 Bump actions/download-artifact from 6 to 7 (#12321) 2025-12-15 07:56:29 +01:00
dependabot[bot]
1237f5c909 Bump actions/upload-artifact from 5 to 6 (#12322) 2025-12-15 07:55:38 +01:00
Grégoire Paris
609e616f2d Merge pull request #12279 from greg0ire/deprecate-conversion
Deprecate string default expressions
2025-12-10 15:05:54 +01:00
Grégoire Paris
4016d6ba4b Deprecate string default expressions
Right now, the ORM handles the conversion of strings that happen to be
default expressions for date, time and datetime columns into the
corresponding value objects.

Let us allow users to specify these value objects directly, and
deprecate relying on the aforementioned conversion.
2025-12-10 12:08:30 +01:00
dependabot[bot]
dcdd46251e Bump doctrine/.github/.github/workflows/release-on-milestone-closed.yml (#12315)
Bumps [doctrine/.github/.github/workflows/release-on-milestone-closed.yml](https://github.com/doctrine/.github) from 13.0.0 to 13.1.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/13.0.0...13.1.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/release-on-milestone-closed.yml
  dependency-version: 13.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 10:23:27 +01:00
dependabot[bot]
3d98b43561 Bump doctrine/.github/.github/workflows/composer-lint.yml (#12317)
Bumps [doctrine/.github/.github/workflows/composer-lint.yml](https://github.com/doctrine/.github) from 13.0.0 to 13.1.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/13.0.0...13.1.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/composer-lint.yml
  dependency-version: 13.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 10:22:42 +01:00
dependabot[bot]
9f3f70944a Bump doctrine/.github/.github/workflows/coding-standards.yml (#12316)
Bumps [doctrine/.github/.github/workflows/coding-standards.yml](https://github.com/doctrine/.github) from 13.0.0 to 13.1.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/13.0.0...13.1.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/coding-standards.yml
  dependency-version: 13.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 10:22:15 +01:00
dependabot[bot]
05e07c0ae0 Bump doctrine/.github/.github/workflows/documentation.yml (#12318)
Bumps [doctrine/.github/.github/workflows/documentation.yml](https://github.com/doctrine/.github) from 13.0.0 to 13.1.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/13.0.0...13.1.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/documentation.yml
  dependency-version: 13.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 10:21:12 +01:00
Grégoire Paris
fea42ab984 Merge pull request #12299 from alexislefebvre/chore-show-parameters-in-name-of-CI-jobs
chore: show parameters in name of CI jobs
2025-11-30 11:14:14 +01:00
Alexis Lefebvre
7c347b85c1 doc: do not mention InverseJoinColumn since it’s only with PHP 8.0 (#12313) 2025-11-30 00:49:09 +01:00
Alexander M. Turek
458b040d93 Remove obsolete PHPStan ignore rules 2025-11-30 00:34:10 +01:00
Alexander M. Turek
396636a2c2 Merge branch '3.5.x' into 3.6.x
* 3.5.x:
  Remove obsolete VarExporter feature detection (#12309)
  Allow Symfony 8 (#12308)
  Explicitly set a cache in testDisablingXmlValidationIsPossible (#12307)
  Removes Guides from our dependencies (#12303)
  Fix PHPStan and test errors after DBAL 4.4 and Symfony 7.4 releases (#12301)
  Support Symfony Console 8 (#12300)
  Bump doctrine/.github/.github/workflows/composer-lint.yml (#12288)
  Bump doctrine/.github/.github/workflows/documentation.yml (#12289)
  Bump doctrine/.github/.github/workflows/coding-standards.yml (#12290)
  Bump doctrine/.github/.github/workflows/release-on-milestone-closed.yml (#12291)
  Bump actions/checkout from 5 to 6 (#12292)
2025-11-30 00:29:59 +01:00
Alexander M. Turek
78dd074266 Remove obsolete VarExporter feature detection (#12309) 2025-11-30 00:11:02 +01:00
Alexander M. Turek
ff22a00fcf Allow Symfony 8 (#12308) 2025-11-30 00:10:09 +01:00
Alexander M. Turek
02e8ff9663 Explicitly set a cache in testDisablingXmlValidationIsPossible (#12307) 2025-11-29 23:16:54 +01:00
Alexis Lefebvre
01fd55e9ea chore: show parameters in name of CI jobs 2025-11-29 23:12:27 +01:00
Alexander M. Turek
2e75a7f1c1 Merge branch '2.20.x' into 3.5.x
* 2.20.x:
  Support Symfony Console 8 (#12300)
  Bump doctrine/.github/.github/workflows/composer-lint.yml (#12288)
  Bump doctrine/.github/.github/workflows/documentation.yml (#12289)
  Bump doctrine/.github/.github/workflows/coding-standards.yml (#12290)
  Bump doctrine/.github/.github/workflows/release-on-milestone-closed.yml (#12291)
  Bump actions/checkout from 5 to 6 (#12292)
2025-11-29 22:07:13 +01:00
Alexander M. Turek
152b0e3d65 Removes Guides from our dependencies (#12303) 2025-11-29 21:55:58 +01:00
Alexander M. Turek
9d11fdd3da Fix PHPStan and test errors after DBAL 4.4 and Symfony 7.4 releases (#12301)
* Fix PHPStan errors after DBAL 4.4 and Symfony 7.4 releases

* Fix PHPStan and test errors after DBAL 4.4 and Symfony 7.4 releases
2025-11-29 18:04:54 +01:00
Alexander M. Turek
87f1ba74e0 Support Symfony Console 8 (#12300) 2025-11-29 15:03:56 +01:00
Grégoire Paris
f357a33d23 Merge pull request #12293 from doctrine/3.5.x
Merge 3.5.x. up into 3.6.x
2025-11-24 20:25:07 +01:00
Grégoire Paris
ee70178314 Merge pull request #12287 from greg0ire/more-order-by
Add ORDER BY clause to more test cases
2025-11-24 09:40:48 +01:00
dependabot[bot]
ab148d3d9d Bump doctrine/.github/.github/workflows/composer-lint.yml (#12288)
Bumps [doctrine/.github/.github/workflows/composer-lint.yml](https://github.com/doctrine/.github) from 12.2.0 to 13.0.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/v12.2.0...13.0.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/composer-lint.yml
  dependency-version: 13.0.0
  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>
2025-11-24 09:25:08 +01:00
dependabot[bot]
3924c38fab Bump doctrine/.github/.github/workflows/documentation.yml (#12289)
Bumps [doctrine/.github/.github/workflows/documentation.yml](https://github.com/doctrine/.github) from 12.2.0 to 13.0.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/v12.2.0...13.0.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/documentation.yml
  dependency-version: 13.0.0
  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>
2025-11-24 09:24:42 +01:00
dependabot[bot]
9814078a2c Bump doctrine/.github/.github/workflows/coding-standards.yml (#12290)
Bumps [doctrine/.github/.github/workflows/coding-standards.yml](https://github.com/doctrine/.github) from 12.2.0 to 13.0.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/v12.2.0...13.0.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/coding-standards.yml
  dependency-version: 13.0.0
  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>
2025-11-24 09:24:14 +01:00
dependabot[bot]
6de5684fd9 Bump doctrine/.github/.github/workflows/release-on-milestone-closed.yml (#12291)
Bumps [doctrine/.github/.github/workflows/release-on-milestone-closed.yml](https://github.com/doctrine/.github) from 12.2.0 to 13.0.0.
- [Release notes](https://github.com/doctrine/.github/releases)
- [Commits](https://github.com/doctrine/.github/compare/v12.2.0...13.0.0)

---
updated-dependencies:
- dependency-name: doctrine/.github/.github/workflows/release-on-milestone-closed.yml
  dependency-version: 13.0.0
  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>
2025-11-24 09:23:46 +01:00
dependabot[bot]
c142503a52 Bump actions/checkout from 5 to 6 (#12292) 2025-11-24 07:27:27 +01:00
Grégoire Paris
01c178b297 Add ORDER BY clause to more test cases
In https://github.com/doctrine/orm/pull/12222, I thought wrongly thought
the issue I was fixing only affected one test.
2025-11-23 19:18:20 +01:00
Grégoire Paris
ffa50a777f Merge pull request #12286 from greg0ire/3.5.x
Merge 2.20.x up into 3.5.x
2025-11-21 20:24:51 +01:00
Grégoire Paris
649048f745 Merge remote-tracking branch 'origin/2.20.x' into 3.5.x 2025-11-21 19:46:45 +01:00
Grégoire Paris
15537bc218 Merge pull request #12285 from HypeMC/fix-is-foreign-key-composite
Fix check for composite foreign key
2025-11-21 19:29:05 +01:00
HypeMC
bc95c7c08d Fix check for composite foreign key 2025-11-21 07:27:53 +01:00
Grégoire Paris
6982c8ab9d Merge pull request #12284 from doctrine/3.5.x
Merge 3.5.x up into 3.6.x
2025-11-20 22:49:20 +01:00
Grégoire Paris
3df11d518c Merge pull request #12283 from doctrine/2.20.x
Merge 2.20.x up into 3.5.x
2025-11-20 21:31:19 +01:00
Grégoire Paris
c1becd54e6 Merge pull request #12281 from greg0ire/document-default-expressions
Fix documentation about default values
2025-11-20 17:58:48 +01:00
Grégoire Paris
e4d7df29c2 Fix documentation about default values
Saying it is not possible to get Doctrine to use the `DEFAULT` SQL
keyword is wrong.
2025-11-19 23:17:53 +01:00
Grégoire Paris
608705427e Merge pull request #12277 from greg0ire/3.5.x
Merge 2.20.x up into 3.5.x
2025-11-19 07:13:23 +01:00
Grégoire Paris
f0562f4120 Merge pull request #12273 from greg0ire/deprecate-default
Deprecate FieldMapping::$default
2025-11-19 06:50:43 +01:00
Grégoire Paris
9f19310f27 Merge remote-tracking branch 'origin/2.20.x' into 3.5.x 2025-11-19 00:01:30 +01:00
Adrian Brajkovic
e38278bfca Fix eager fetch composite foreign key (#11397)
I think #11289 did not completely fix problem for eager fetch.
Change in that PR checked if primary key of target class is composite but that does not matter when loading collection by foreign key.
It should check if foreign key on target class is composite.

Fix from that PR did not work for me because i had entity with regular autogenerated id (single column), but foreign key referenced entity with composite primary key, like SecondLevelWithoutCompositePrimaryKey in this PR.

Checking if foreign key is composite fixed the problem for me.
2025-11-18 21:40:50 +01:00
Grégoire Paris
62f2cff218 Merge pull request #12268 from pmaasz/querybuilder-hints
Add hints to QueryBuilder
2025-11-15 09:32:24 +01:00
pmaasz
cdd774906b add member variable hints to the querybuilder for hints to be added to the query
This adds the membervariable hints to the QueryBuilder to enable setting hints
that will be applied to the query when it is created. This can help trigger
custom walker classes when the query is not adressable driectly e.g. in
Symfony Form Extensions where the quer_builder normalizer is handed the querybuilder
directly. Also see #11849
The feature mirrors the hint feature from the Query class.
This also adds tests for the hints in the QueryBuilder to ensure that those are added
correctly and applied to the query itself on creation
2025-11-14 09:11:48 +01:00
Grégoire Paris
96776e091d Deprecate FieldMapping::$default
Its purpose is unclear since there is FieldMapping::$options['default']
already.
2025-11-14 08:46:08 +01:00
Grégoire Paris
f7470d8a3f Merge pull request #12271 from greg0ire/3.6.x
Merge 3.5.x up into 3.6.x
2025-11-11 19:31:00 +01:00
Grégoire Paris
2c41cc7f1c Merge remote-tracking branch 'origin/3.5.x' into 3.6.x 2025-11-11 19:28:47 +01:00
Grégoire Paris
f18de9d569 Merge pull request #12269 from greg0ire/3.5.x
Undo merge from 3.6.x into 3.5.x
2025-11-11 19:27:40 +01:00
Grégoire Paris
37f76a8381 Undo merge from 3.6.x into 3.5.x 2025-11-11 19:11:32 +01:00
Grégoire Paris
a6c1e63a60 Merge pull request #12266 from doctrine/3.5.x-merge-up-into-3.6.x_hSSiOXm0
Merge release 3.5.6 into 3.6.x
2025-11-10 22:27:19 +01:00
Grégoire Paris
6881cdff4c Merge pull request #12264 from doctrine/3.5.x-merge-up-into-3.6.x_9GolPzTd
Merge release 3.5.5 into 3.6.x
2025-11-10 21:27:36 +01:00
Grégoire Paris
9e5442a892 Merge pull request #12251 from doctrine/3.5.x
Merge 3.5.x up into 3.6.x
2025-10-29 20:41:29 +01:00
Grégoire Paris
01774c035c Merge pull request #12065 from whataboutpereira/fix-enum-discriminator-column
Use enum values from enumType in DiscriminatorColumn and check DiscriminatorMap values against it
2025-10-29 07:28:16 +01:00
Reio Remma
6f83166266 Extract enum cases from enumType in DiscriminatorColumn
Check DiscriminatorMap keys match enum cases.
Test values are populated from enum cases and mismatched values throw an exception.
Fixes #11794
2025-10-28 20:36:11 +02:00
HypeMC
cb8a76ba3a Add commands for inspecting configured listeners 2025-09-15 15:03:52 +02:00
127 changed files with 4156 additions and 486 deletions

View File

@@ -12,42 +12,17 @@
"upcoming": true
},
{
"name": "3.6",
"branchName": "3.6.x",
"slug": "3.6",
"name": "3.7",
"branchName": "3.7.x",
"slug": "3.7",
"upcoming": true
},
{
"name": "3.5",
"branchName": "3.5.x",
"slug": "3.5",
"name": "3.6",
"branchName": "3.6.x",
"slug": "3.6",
"current": true
},
{
"name": "3.4",
"slug": "3.4",
"maintained": false
},
{
"name": "3.3",
"slug": "3.3",
"maintained": false
},
{
"name": "3.2",
"slug": "3.2",
"maintained": false
},
{
"name": "3.1",
"slug": "3.1",
"maintained": false
},
{
"name": "3.0",
"slug": "3.0",
"maintained": false
},
{
"name": "2.21",
"branchName": "2.21.x",
@@ -89,26 +64,6 @@
"name": "2.14",
"slug": "2.14",
"maintained": false
},
{
"name": "2.13",
"slug": "2.13",
"maintained": false
},
{
"name": "2.12",
"slug": "2.12",
"maintained": false
},
{
"name": "2.11",
"slug": "2.11",
"maintained": false
},
{
"name": "2.10",
"slug": "2.10",
"maintained": false
}
]
}

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

@@ -24,4 +24,4 @@ on:
jobs:
coding-standards:
uses: "doctrine/.github/.github/workflows/coding-standards.yml@v12.2.0"
uses: "doctrine/.github/.github/workflows/coding-standards.yml@13.1.0"

View File

@@ -17,4 +17,4 @@ on:
jobs:
composer-lint:
name: "Composer Lint"
uses: "doctrine/.github/.github/workflows/composer-lint.yml@v12.2.0"
uses: "doctrine/.github/.github/workflows/composer-lint.yml@13.1.0"

View File

@@ -1,4 +1,4 @@
name: "CI"
name: "CI: PHPUnit"
on:
pull_request:
@@ -25,7 +25,14 @@ env:
jobs:
phpunit-smoke-check:
name: "PHPUnit with SQLite"
name: >
SQLite -
${{ format('PHP {0} - DBAL {1} - ext. {2} - proxy {3}',
matrix.php-version || 'Ø',
matrix.dbal-version || 'Ø',
matrix.extension || 'Ø',
matrix.proxy || 'Ø'
) }}
runs-on: "ubuntu-22.04"
strategy:
@@ -80,7 +87,7 @@ jobs:
steps:
- name: "Checkout"
uses: "actions/checkout@v5"
uses: "actions/checkout@v6"
with:
fetch-depth: 2
@@ -104,6 +111,10 @@ jobs:
run: "composer require doctrine/dbal ^${{ matrix.dbal-version }} --no-update"
if: "${{ matrix.dbal-version != 'default' }}"
- name: "Downgrade VarExporter"
run: 'composer require --no-update "symfony/var-exporter:^6.4 || ^7.4"'
if: "${{ matrix.native_lazy == '0' }}"
- name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v3"
with:
@@ -139,7 +150,7 @@ jobs:
ENABLE_NATIVE_LAZY_OBJECTS: ${{ matrix.native_lazy }}
- name: "Upload coverage file"
uses: "actions/upload-artifact@v5"
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"
@@ -180,7 +191,13 @@ jobs:
phpunit-postgres:
name: "PHPUnit with PostgreSQL"
name: >
${{ format('PostgreSQL {0} - PHP {1} - DBAL {2} - ext. {3}',
matrix.postgres-version || 'Ø',
matrix.php-version || 'Ø',
matrix.dbal-version || 'Ø',
matrix.extension || 'Ø'
) }}
runs-on: "ubuntu-22.04"
needs: "phpunit-smoke-check"
@@ -223,7 +240,7 @@ jobs:
steps:
- name: "Checkout"
uses: "actions/checkout@v5"
uses: "actions/checkout@v6"
with:
fetch-depth: 2
@@ -248,14 +265,20 @@ jobs:
run: "vendor/bin/phpunit -c ci/github/phpunit/pdo_pgsql.xml --coverage-clover=coverage.xml"
- name: "Upload coverage file"
uses: "actions/upload-artifact@v5"
uses: "actions/upload-artifact@v7"
with:
name: "${{ github.job }}-${{ matrix.postgres-version }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-${{ matrix.extension }}-coverage"
path: "coverage.xml"
phpunit-mariadb:
name: "PHPUnit with MariaDB"
name: >
${{ format('MariaDB {0} - PHP {1} - DBAL {2} - ext. {3}',
matrix.mariadb-version || 'Ø',
matrix.php-version || 'Ø',
matrix.dbal-version || 'Ø',
matrix.extension || 'Ø'
) }}
runs-on: "ubuntu-22.04"
needs: "phpunit-smoke-check"
@@ -291,7 +314,7 @@ jobs:
steps:
- name: "Checkout"
uses: "actions/checkout@v5"
uses: "actions/checkout@v6"
with:
fetch-depth: 2
@@ -316,14 +339,20 @@ jobs:
run: "vendor/bin/phpunit -c ci/github/phpunit/${{ matrix.extension }}.xml --coverage-clover=coverage.xml"
- name: "Upload coverage file"
uses: "actions/upload-artifact@v5"
uses: "actions/upload-artifact@v7"
with:
name: "${{ github.job }}-${{ matrix.mariadb-version }}-${{ matrix.extension }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-coverage"
path: "coverage.xml"
phpunit-mysql:
name: "PHPUnit with MySQL"
name: >
${{ format('MySQL {0} - PHP {1} - DBAL {2} - ext. {3}',
matrix.mysql-version || 'Ø',
matrix.php-version || 'Ø',
matrix.dbal-version || 'Ø',
matrix.extension || 'Ø'
) }}
runs-on: "ubuntu-22.04"
needs: "phpunit-smoke-check"
@@ -367,7 +396,7 @@ jobs:
steps:
- name: "Checkout"
uses: "actions/checkout@v5"
uses: "actions/checkout@v6"
with:
fetch-depth: 2
@@ -413,7 +442,7 @@ jobs:
ENABLE_SECOND_LEVEL_CACHE: 1
- name: "Upload coverage files"
uses: "actions/upload-artifact@v5"
uses: "actions/upload-artifact@v7"
with:
name: "${{ github.job }}-${{ matrix.mysql-version }}-${{ matrix.extension }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-coverage"
path: "coverage*.xml"
@@ -431,12 +460,12 @@ jobs:
steps:
- name: "Checkout"
uses: "actions/checkout@v5"
uses: "actions/checkout@v6"
with:
fetch-depth: 2
- name: "Download coverage files"
uses: "actions/download-artifact@v6"
uses: "actions/download-artifact@v8"
with:
path: "reports"

View File

@@ -17,4 +17,4 @@ on:
jobs:
documentation:
name: "Documentation"
uses: "doctrine/.github/.github/workflows/documentation.yml@v12.2.0"
uses: "doctrine/.github/.github/workflows/documentation.yml@13.1.0"

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Checkout"
uses: "actions/checkout@v5"
uses: "actions/checkout@v6"
with:
fetch-depth: 2

View File

@@ -7,7 +7,7 @@ on:
jobs:
release:
uses: "doctrine/.github/.github/workflows/release-on-milestone-closed.yml@v12.2.0"
uses: "doctrine/.github/.github/workflows/release-on-milestone-closed.yml@13.1.0"
secrets:
GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }}

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: "Checkout code"
uses: "actions/checkout@v5"
uses: "actions/checkout@v6"
- name: Install PHP
uses: shivammathur/setup-php@v2

View File

@@ -1,7 +1,7 @@
| [4.0.x][4.0] | [3.6.x][3.6] | [3.5.x][3.5] | [2.21.x][2.21] | [2.20.x][2.20] |
| [4.0.x][4.0] | [3.7.x][3.7] | [3.6.x][3.6] | [2.21.x][2.21] | [2.20.x][2.20] |
|:------------------------------------------------------:|:------------------------------------------------------:|:------------------------------------------------------:|:--------------------------------------------------------:|:--------------------------------------------------------:|
| [![Build status][4.0 image]][4.0 workflow] | [![Build status][3.6 image]][3.6 workflow] | [![Build status][3.5 image]][3.5 workflow] | [![Build status][2.21 image]][2.21 workflow] | [![Build status][2.20 image]][2.20 workflow] |
| [![Coverage Status][4.0 coverage image]][4.0 coverage] | [![Coverage Status][3.6 coverage image]][3.6 coverage] | [![Coverage Status][3.5 coverage image]][3.5 coverage] | [![Coverage Status][2.21 coverage image]][2.21 coverage] | [![Coverage Status][2.20 coverage image]][2.20 coverage] |
| [![Build status][4.0 image]][4.0 workflow] | [![Build status][3.7 image]][3.7 workflow] | [![Build status][3.6 image]][3.6 workflow] | [![Build status][2.21 image]][2.21 workflow] | [![Build status][2.20 image]][2.20 workflow] |
| [![Coverage Status][4.0 coverage image]][4.0 coverage] | [![Coverage Status][3.7 coverage image]][3.7 coverage] | [![Coverage Status][3.6 coverage image]][3.6 coverage] | [![Coverage Status][2.21 coverage image]][2.21 coverage] | [![Coverage Status][2.20 coverage image]][2.20 coverage] |
Doctrine ORM is an object-relational mapper for PHP 8.1+ that provides transparent persistence
for PHP objects. It sits on top of a powerful database abstraction layer (DBAL). One of its key features
@@ -21,16 +21,16 @@ without requiring unnecessary code duplication.
[4.0 workflow]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml?query=branch%3A4.0.x
[4.0 coverage image]: https://codecov.io/gh/doctrine/orm/branch/4.0.x/graph/badge.svg
[4.0 coverage]: https://codecov.io/gh/doctrine/orm/branch/4.0.x
[3.7 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.7.x
[3.7]: https://github.com/doctrine/orm/tree/3.7.x
[3.7 workflow]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml?query=branch%3A3.7.x
[3.7 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.7.x/graph/badge.svg
[3.7 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.7.x
[3.6 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.6.x
[3.6]: https://github.com/doctrine/orm/tree/3.6.x
[3.6 workflow]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml?query=branch%3A3.6.x
[3.6 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.6.x/graph/badge.svg
[3.6 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.6.x
[3.5 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.5.x
[3.5]: https://github.com/doctrine/orm/tree/3.5.x
[3.5 workflow]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml?query=branch%3A3.5.x
[3.5 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.5.x/graph/badge.svg
[3.5 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.5.x
[2.21 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.21.x
[2.21]: https://github.com/doctrine/orm/tree/2.21.x
[2.21 workflow]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml?query=branch%3A2.21.x

View File

@@ -27,8 +27,139 @@ 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
Using a string expression for default values in field mappings is deprecated.
Use `Doctrine\DBAL\Schema\DefaultExpression` instances instead.
Here is how to address this deprecation when mapping entities using PHP attributes:
```diff
use DateTime;
+use Doctrine\DBAL\Schema\DefaultExpression\CurrentDate;
+use Doctrine\DBAL\Schema\DefaultExpression\CurrentTime;
+use Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
final class TimeEntity
{
#[ORM\Id]
#[ORM\Column]
public int $id;
- #[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'], insertable: false, updatable: false)]
+ #[ORM\Column(options: ['default' => new CurrentTimestamp()], insertable: false, updatable: false)]
public DateTime $createdAt;
- #[ORM\Column(options: ['default' => 'CURRENT_TIME'], insertable: false, updatable: false)]
+ #[ORM\Column(options: ['default' => new CurrentTime()], insertable: false, updatable: false)]
public DateTime $createdTime;
- #[ORM\Column(options: ['default' => 'CURRENT_DATE'], insertable: false, updatable: false)]
+ #[ORM\Column(options: ['default' => new CurrentDate()], insertable: false, updatable: false)]
public DateTime $createdDate;
}
```
Here is how to do the same when mapping entities using XML:
```diff
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="Doctrine\Tests\ORM\Functional\XmlTimeEntity">
<id name="id" type="integer" column="id">
<generator strategy="AUTO"/>
</id>
<field name="createdAt" type="datetime" insertable="false" updatable="false">
<options>
- <option name="default">CURRENT_TIMESTAMP</option>
+ <option name="default">
+ <object class="Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp"/>
+ </option>
</options>
</field>
<field name="createdAtImmutable" type="datetime_immutable" insertable="false" updatable="false">
<options>
- <option name="default">CURRENT_TIMESTAMP</option>
+ <option name="default">
+ <object class="Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp"/>
+ </option>
</options>
</field>
<field name="createdTime" type="time" insertable="false" updatable="false">
<options>
- <option name="default">CURRENT_TIME</option>
+ <option name="default">
+ <object class="Doctrine\DBAL\Schema\DefaultExpression\CurrentTime"/>
+ </option>
</options>
</field>
<field name="createdDate" type="date" insertable="false" updatable="false">
<options>
- <option name="default">CURRENT_DATE</option>
+ <option name="default">
+ <object class="Doctrine\DBAL\Schema\DefaultExpression\CurrentDate"/>
+ </option>
</options>
</field>
</entity>
</doctrine-mapping>
```
## Deprecate `FieldMapping::$default`
The `default` property of `Doctrine\ORM\Mapping\FieldMapping` is deprecated and
will be removed in 4.0. Instead, use `FieldMapping::$options['default']`.
## Deprecate specifying `nullable` on columns that end up being used in a primary key
Specifying `nullable` on join columns that are part of a primary key is
@@ -2207,7 +2338,7 @@ from 2.0 have to configure the annotation driver if they don't use `Configuratio
## Scalar mappings can now be omitted from DQL result
You are now allowed to mark scalar SELECT expressions as HIDDEN an they are not hydrated anymore.
You are now allowed to mark scalar SELECT expressions as HIDDEN and they are not hydrated anymore.
Example:
SELECT u, SUM(a.id) AS HIDDEN numArticles FROM User u LEFT JOIN u.Articles a ORDER BY numArticles DESC HAVING numArticles > 10

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",
@@ -49,7 +49,6 @@
"require-dev": {
"doctrine/coding-standard": "^14.0",
"phpbench/phpbench": "^1.0",
"phpdocumentor/guides-cli": "^1.4",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "2.1.23",
"phpstan/phpstan-deprecation-rules": "^2",

View File

@@ -40,7 +40,8 @@ Now this is all awfully technical, so let me come to some use-cases
fast to keep you motivated. Using walker implementation you can for
example:
- Modify the Output walker to get the raw SQL via ``Query->getSQL()``
with interpolated parameters.
- Modify the AST to generate a Count Query to be used with a
paginator for any given DQL query.
- Modify the Output Walker to generate vendor-specific SQL
@@ -50,7 +51,7 @@ example:
- Modify the Output walker to pretty print the SQL for debugging
purposes.
In this cookbook-entry I will show examples of the first two
In this cookbook-entry I will show examples of the first three
points. There are probably much more use-cases.
Generic count query for pagination
@@ -223,3 +224,39 @@ huge benefits with using vendor specific features. This would still
allow you write DQL queries instead of NativeQueries to make use of
vendor specific features.
Modifying the Output Walker to get the raw SQL with interpolated parameters
---------------------------------------------------------------------------
Sometimes we may want to log or trace the raw SQL being generated from its DQL
for profiling slow queries afterwards or audit queries that changed many rows
``$query->getSQL()`` will give us the prepared statement being passed to database
with all values of SQL parameters being replaced by positional ``?`` or named ``:name``
as parameters are interpolated into prepared statements by the database while executing the SQL.
``$query->getParameters()`` will give us details about SQL parameters that we've provided.
So we can create an output walker to interpolate all SQL parameters that will be
passed into prepared statement in PHP before database handle them internally:
.. literalinclude:: dql-custom-walkers/InterpolateParametersSQLOutputWalker.php
:language: php
Then you may get the raw SQL with this output walker:
.. code-block:: php
<?php
$query
->where('t.int IN (:ints)')->setParameter(':ints', [1, 2])
->orWhere('t.string IN (?0)')->setParameter(0, ['3', '4'])
->orWhere("t.bool = ?1")->setParameter('?1', true)
->orWhere("t.string = :string")->setParameter(':string', 'ABC')
->setHint(\Doctrine\ORM\Query::HINT_CUSTOM_OUTPUT_WALKER, InterpolateParametersSQLOutputWalker::class)
->getSQL();
The where clause of the returned SQL should be like:
.. code-block:: sql
WHERE t0_.int IN (1, 2)
OR t0_.string IN ('3', '4')
OR t0_.bool = 1
OR t0_.string = 'ABC'

View File

@@ -0,0 +1,47 @@
<?php
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\BooleanType;
use Doctrine\DBAL\Types\Exception\ValueNotConvertible;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Query\AST;
use Doctrine\ORM\Query\SqlOutputWalker;
class InterpolateParametersSQLOutputWalker extends SqlOutputWalker
{
/** {@inheritdoc} */
public function walkInputParameter(AST\InputParameter $inputParam): string
{
$parameter = $this->getQuery()->getParameter($inputParam->name);
if ($parameter === null) {
return '?';
}
$value = $parameter->getValue();
/** @var ParameterType|ArrayParameterType|int|string $typeName */
/** @see \Doctrine\ORM\Query\ParameterTypeInferer::inferType() */
$typeName = $parameter->getType();
$platform = $this->getConnection()->getDatabasePlatform();
$processParameterType = static fn(ParameterType $type) => static fn($value): string =>
(match ($type) { /** @see Type::getBindingType() */
ParameterType::NULL => 'NULL',
ParameterType::INTEGER => $value,
ParameterType::BOOLEAN => (new BooleanType())->convertToDatabaseValue($value, $platform),
ParameterType::STRING, ParameterType::ASCII => $platform->quoteStringLiteral($value),
default => throw new ValueNotConvertible($value, $type->name)
});
if (is_string($typeName) && Type::hasType($typeName)) {
return Type::getType($typeName)->convertToDatabaseValue($value, $platform);
}
if ($typeName instanceof ParameterType) {
return $processParameterType($typeName)($value);
}
if ($typeName instanceof ArrayParameterType && is_array($value)) {
$type = ArrayParameterType::toElementParameterType($typeName);
return implode(', ', array_map($processParameterType($type), $value));
}
throw new ValueNotConvertible($value, $typeName);
}
}

View File

@@ -668,11 +668,6 @@ and in the Context of a :ref:`#[ManyToMany] <attrref_manytomany>`. If this attri
are missing they will be computed considering the field's name and the current
:doc:`naming strategy <namingstrategy>`.
The ``#[InverseJoinColumn]`` is the same as ``#[JoinColumn]`` and is used in the context
of a ``#[ManyToMany]`` attribute declaration to specifiy the details of the join table's
column information used for the join to the inverse entity. This is only required
on PHP 8.0, where nested attributes are not yet supported.
Optional parameters:
- **name**: Column name that holds the foreign key identifier for

View File

@@ -182,6 +182,37 @@ Here is a complete list of ``Column``s attributes (all optional):
- ``options``: Key-value pairs of options that get passed
to the underlying database platform when generating DDL statements.
Specifying default values
~~~~~~~~~~~~~~~~~~~~~~~~~
While it is possible to specify default values for properties in your
PHP class, Doctrine also allows you to specify default values for
database columns using the ``default`` key in the ``options`` array of
the ``Column`` attribute.
When using XML, you can specify object instances using the ``<object>``
element:
.. code-block:: xml
<field name="createdAt" type="datetime" insertable="false" updatable="false">
<options>
<option name="default">
<object class="Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp"/>
</option>
</options>
</field>
The ``<object>`` element requires a ``class`` attribute specifying the
fully qualified class name to instantiate.
.. configuration-block::
.. literalinclude:: basic-mapping/DefaultValues.php
:language: attribute
.. literalinclude:: basic-mapping/default-values.xml
:language: xml
.. _reference-php-mapping-types:
PHP Types Mapping

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use DateTime;
use Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
#[Entity]
class Message
{
#[Column(options: ['default' => 'Hello World!'])]
private string $text;
#[Column(options: ['default' => new CurrentTimestamp()], insertable: false, updatable: false)]
private DateTime $createdAt;
}

View File

@@ -0,0 +1,16 @@
<doctrine-mapping>
<entity name="Message">
<field name="text">
<options>
<option name="default">Hello World!</option>
</options>
</field>
<field name="createdAt" insertable="false" updatable="false">
<options>
<option name="default">
<object class="Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp"/>
</option>
</options>
</field>
</entity>
</doctrine-mapping>

View File

@@ -1411,8 +1411,7 @@ Result Cache API:
$query->setResultCacheDriver(new ApcCache());
$query->useResultCache(true)
->setResultCacheLifeTime(3600);
$query->enableResultCache(3600);
$result = $query->getResult(); // cache miss
@@ -1422,8 +1421,8 @@ Result Cache API:
$query->setResultCacheId('my_query_result');
$result = $query->getResult(); // saved in given result cache id.
// or call useResultCache() with all parameters:
$query->useResultCache(true, 3600, 'my_query_result');
// or call enableResultCache() with all parameters:
$query->enableResultCache(3600, 'my_query_result');
$result = $query->getResult(); // cache hit!
// Introspection

View File

@@ -18,30 +18,6 @@ In your mapping configuration, the column definition (for example, the
the ``charset`` and ``collation``. The default values are ``utf8`` and
``utf8_unicode_ci``, respectively.
Entity Classes
--------------
How can I add default values to a column?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Doctrine does not support to set the default values in columns through the "DEFAULT" keyword in SQL.
This is not necessary however, you can just use your class properties as default values. These are then used
upon insert:
.. code-block:: php
class User
{
private const STATUS_DISABLED = 0;
private const STATUS_ENABLED = 1;
private string $algorithm = "sha1";
/** @var self::STATUS_* */
private int $status = self::STATUS_DISABLED;
}
.
Mapping
-------

View File

@@ -208,6 +208,22 @@ Example:
// ...
}
.. code-block:: xml
<doctrine-mapping>
<entity name="MyProject\Model\Person" inheritance-type="SINGLE_TABLE">
<discriminator-column name="discr" type="string" />
<discriminator-map>
<discriminator-mapping value="person" class="MyProject\Model\Person"/>
<discriminator-mapping value="employee" class="MyProject\Model\Employee"/>
</discriminator-map>
</entity>
</doctrine-mapping>
<doctrine-mapping>
<entity name="MyProject\Model\Employee">
</entity>
</doctrine-mapping>
In this example, the ``#[DiscriminatorMap]`` specifies that in the
discriminator column, a value of "person" identifies a row as being of type

View File

@@ -344,10 +344,10 @@ the Query object which can be retrieved from ``EntityManager#createQuery()``.
Executing a Query
^^^^^^^^^^^^^^^^^
The QueryBuilder is a builder object only - it has no means of actually
executing the Query. Additionally a set of parameters such as query hints
cannot be set on the QueryBuilder itself. This is why you always have to convert
a querybuilder instance into a Query object:
The QueryBuilder is only a builder object - it has no means of actually
executing the Query. Additional functionality, such as enabling the result cache,
cannot be set on the QueryBuilder itself. This is why you must always convert
a QueryBuilder instance into a Query object:
.. code-block:: php
@@ -355,9 +355,8 @@ a querybuilder instance into a Query object:
// $qb instanceof QueryBuilder
$query = $qb->getQuery();
// Set additional Query options
$query->setQueryHint('foo', 'bar');
$query->useResultCache('my_cache_id');
// Enable the result cache
$query->enableResultCache(3600, 'my_custom_id');
// Execute Query
$result = $query->getResult();
@@ -555,6 +554,24 @@ using ``addCriteria``:
$qb->addCriteria($criteria);
// then execute your query like normal
Adding hints to a Query
^^^^^^^^^^^^^^^^^^^^^^^
You can also set query hints to a QueryBuilder by using ``setHint``:
.. code-block:: php
<?php
// ...
// $qb instanceof QueryBuilder
$qb->setHint('hintName', 'hintValue');
// then execute your query like normal
The query hint can hold anything the usual query hints can hold
except null. Those hints will be applied to the query when the
query is created.
Low Level API
^^^^^^^^^^^^^

View File

@@ -133,7 +133,7 @@ Caching mode
* Read Write cache employs locks before update/delete.
* Use if data needs to be updated.
* Slowest strategy.
* To use it a the cache region implementation must support locking.
* To use it the cache region implementation must support locking.
Built-in cached persisters

View File

@@ -22,7 +22,7 @@ have to register them yourself.
All the commands of the Doctrine Console require access to the
``EntityManager``. You have to inject it into the console application.
Here is an example of a the project-specific ``bin/doctrine`` binary.
Here is an example of a project-specific ``bin/doctrine`` binary.
.. code-block:: php
@@ -96,6 +96,10 @@ The following Commands are currently available:
- ``orm:schema-tool:update`` Processes the schema and either
update the database schema of EntityManager Storage Connection or
generate the SQL output.
- ``orm:debug:event-manager`` Lists event listeners for an entity
manager, optionally filtered by event name.
- ``orm:debug:entity-listeners`` Lists entity listeners for a given
entity, optionally filtered by event name.
The following alias is defined:

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

@@ -155,10 +155,20 @@
</xs:restriction>
</xs:simpleType>
<xs:complexType name="object">
<xs:attribute name="class" type="xs:string" use="required"/>
<xs:anyAttribute namespace="##other"/>
</xs:complexType>
<xs:complexType name="option" mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="option" type="orm:option"/>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="object" type="orm:object"/>
<xs:sequence>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="option" type="orm:option"/>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
</xs:sequence>
</xs:choice>
<xs:attribute name="name" type="xs:NMTOKEN" use="required"/>
<xs:anyAttribute namespace="##other"/>

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
@@ -1410,12 +1410,6 @@ parameters:
count: 2
path: src/Mapping/Driver/DatabaseDriver.php
-
message: '#^Parameter \#1 \$asset of static method Doctrine\\ORM\\Mapping\\Driver\\DatabaseDriver\:\:getAssetName\(\) expects Doctrine\\DBAL\\Schema\\AbstractAsset, Doctrine\\DBAL\\Schema\\Column\|false given\.$#'
identifier: argument.type
count: 1
path: src/Mapping/Driver/DatabaseDriver.php
-
message: '#^Parameter \#2 \$columnName of method Doctrine\\ORM\\Mapping\\Driver\\DatabaseDriver\:\:getFieldNameForColumn\(\) expects string, string\|false given\.$#'
identifier: argument.type
@@ -1447,7 +1441,7 @@ parameters:
path: src/Mapping/Driver/XmlDriver.php
-
message: '#^Parameter \#1 \$columnDef of method Doctrine\\ORM\\Mapping\\ClassMetadata\<T of object\>\:\:setDiscriminatorColumn\(\) expects array\{name\: string\|null, fieldName\?\: string\|null, type\?\: string\|null, length\?\: int\|null, columnDefinition\?\: string\|null, enumType\?\: class\-string\<BackedEnum\>\|null, options\?\: array\<string, mixed\>\|null\}\|Doctrine\\ORM\\Mapping\\DiscriminatorColumnMapping\|null, array\{name\: string\|null, type\: string, length\: int, columnDefinition\: string\|null, enumType\: string\|null, options\?\: array\<int\|string, array\<int\|string, mixed\>\|bool\|string\>\} given\.$#'
message: '#^Parameter \#1 \$columnDef of method Doctrine\\ORM\\Mapping\\ClassMetadata\<T of object\>\:\:setDiscriminatorColumn\(\) expects array\{name\: string\|null, fieldName\?\: string\|null, type\?\: string\|null, length\?\: int\|null, columnDefinition\?\: string\|null, enumType\?\: class\-string\<BackedEnum\>\|null, options\?\: array\<string, mixed\>\|null\}\|Doctrine\\ORM\\Mapping\\DiscriminatorColumnMapping\|null, array\{name\: string\|null, type\: string, length\: int, columnDefinition\: string\|null, enumType\: string\|null, options\?\: array\<int\|string, array\<int\|string, mixed\>\|bool\|object\|string\>\} given\.$#'
identifier: argument.type
count: 1
path: src/Mapping/Driver/XmlDriver.php
@@ -1477,7 +1471,7 @@ parameters:
path: src/Mapping/Driver/XmlDriver.php
-
message: '#^Property Doctrine\\ORM\\Mapping\\ClassMetadata\<T of object\>\:\:\$table \(array\{name\: string, schema\?\: string, indexes\?\: array, uniqueConstraints\?\: array, options\?\: array\<string, mixed\>, quoted\?\: bool\}\) does not accept array\{name\: string, schema\?\: string, indexes\?\: array, uniqueConstraints\?\: array, options\: array\<int\|string, array\<int\|string, mixed\>\|bool\|string\>, quoted\?\: bool\}\.$#'
message: '#^Property Doctrine\\ORM\\Mapping\\ClassMetadata\<T of object\>\:\:\$table \(array\{name\: string, schema\?\: string, indexes\?\: array, uniqueConstraints\?\: array, options\?\: array\<string, mixed\>, quoted\?\: bool\}\) does not accept array\{name\: string, schema\?\: string, indexes\?\: array, uniqueConstraints\?\: array, options\: array\<int\|string, array\<int\|string, mixed\>\|bool\|object\|string\>, quoted\?\: bool\}\.$#'
identifier: assign.propertyType
count: 1
path: src/Mapping/Driver/XmlDriver.php
@@ -1687,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
@@ -1699,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
@@ -1824,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
@@ -2586,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
@@ -3345,6 +3327,12 @@ parameters:
count: 1
path: src/UnitOfWork.php
-
message: '#^Access to an undefined property Doctrine\\ORM\\Mapping\\AssociationMapping\:\:\$joinColumns\.$#'
identifier: property.notFound
count: 1
path: src/UnitOfWork.php
-
message: '#^Access to an undefined property Doctrine\\ORM\\Mapping\\ManyToManyInverseSideMapping\|Doctrine\\ORM\\Mapping\\ManyToManyOwningSideMapping\|Doctrine\\ORM\\Mapping\\ManyToOneAssociationMapping\|Doctrine\\ORM\\Mapping\\OneToManyAssociationMapping\|Doctrine\\ORM\\Mapping\\OneToOneInverseSideMapping\|Doctrine\\ORM\\Mapping\\OneToOneOwningSideMapping\:\:\$inversedBy\.$#'
identifier: property.notFound
@@ -3360,7 +3348,7 @@ parameters:
-
message: '#^Access to an undefined property Doctrine\\ORM\\Mapping\\ManyToManyInverseSideMapping\|Doctrine\\ORM\\Mapping\\ManyToManyOwningSideMapping\|Doctrine\\ORM\\Mapping\\ManyToOneAssociationMapping\|Doctrine\\ORM\\Mapping\\OneToManyAssociationMapping\|Doctrine\\ORM\\Mapping\\OneToOneInverseSideMapping\|Doctrine\\ORM\\Mapping\\OneToOneOwningSideMapping\:\:\$mappedBy\.$#'
identifier: property.notFound
count: 1
count: 3
path: src/UnitOfWork.php
-
@@ -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\.$/'
@@ -98,6 +104,17 @@ parameters:
identifier: argument.unresolvableType
path: src/Mapping/Driver/DatabaseDriver.php
-
message: '#^Parameter \#1 \$asset of static method Doctrine\\ORM\\Mapping\\Driver\\DatabaseDriver\:\:getAssetName\(\) expects Doctrine\\DBAL\\Schema\\AbstractAsset, Doctrine\\DBAL\\Schema\\Column\|false given\.$#'
identifier: argument.type
path: src/Mapping/Driver/DatabaseDriver.php
-
message: '#^Instantiated class Doctrine\\DBAL\\Schema\\DefaultExpression\\\w+ not found\.$#'
identifier: class.notFound
path: src/Tools/SchemaTool.php
# To be removed in 4.0
-
message: '#Negated boolean expression is always false\.#'
@@ -150,7 +167,9 @@ parameters:
message: '~inferType.*never returns~'
path: src/Query/ParameterTypeInferer.php
# Compatibility with Symfony 8
# Methods used by excluded compatibility shim traits
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\VarExporter\\\\ProxyHelper'' and ''generateLazyGhost'' will always evaluate to true\.$#'
path: src/Proxy/ProxyFactory.php
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\.$/'
@@ -55,7 +61,9 @@ parameters:
message: '#Expression on left side of \?\? is not nullable.#'
path: src/Mapping/Driver/AttributeDriver.php
# Compatibility with Symfony 8
# Methods used by excluded compatibility shim traits
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\VarExporter\\\\ProxyHelper'' and ''generateLazyGhost'' will always evaluate to true\.$#'
path: src/Proxy/ProxyFactory.php
message: '#^Method .* is unused\.$#'
paths:
- src/PersistentCollection.php
- src/Persisters/SqlValueVisitor.php

View File

@@ -1065,7 +1065,7 @@ abstract class AbstractQuery
}
/**
* Executes the query and returns a the resulting Statement object.
* Executes the query and returns the resulting Statement object.
*
* @return Result|int The executed database statement that holds
* the results, or an integer indicating how

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

@@ -17,6 +17,7 @@ use Doctrine\ORM\UnitOfWork;
use Generator;
use LogicException;
use ReflectionClass;
use ReflectionEnum;
use function array_key_exists;
use function array_keys;
@@ -597,13 +598,18 @@ abstract class AbstractHydrator
*/
final protected function buildEnum(mixed $value, string $enumType): BackedEnum|array
{
$reflection = new ReflectionEnum($enumType);
$isIntBacked = $reflection->isBacked() && $reflection->getBackingType()->getName() === 'int';
if (is_array($value)) {
return array_map(
static fn ($value) => $enumType::from($value),
static fn ($value) => $enumType::from($isIntBacked ? (int) $value : $value),
$value,
);
}
$value = $isIntBacked ? (int) $value : $value;
return $enumType::from($value);
}
}

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

@@ -2204,6 +2204,20 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
throw MappingException::invalidDiscriminatorColumnType($this->name, $columnDef['type']);
}
if (isset($columnDef['enumType'])) {
if (! enum_exists($columnDef['enumType'])) {
throw MappingException::nonEnumTypeMapped($this->name, $columnDef['fieldName'], $columnDef['enumType']);
}
if (
defined('Doctrine\DBAL\Types\Types::ENUM')
&& $columnDef['type'] === Types::ENUM
&& ! isset($columnDef['options']['values'])
) {
$columnDef['options']['values'] = array_column($columnDef['enumType']::cases(), 'value');
}
}
$this->discriminatorColumn = DiscriminatorColumnMapping::fromMappingArray($columnDef);
}
}
@@ -2222,6 +2236,8 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
* Used for JOINED and SINGLE_TABLE inheritance mapping strategies.
*
* @param array<int|string, string> $map
*
* @throws MappingException
*/
public function setDiscriminatorMap(array $map): void
{
@@ -2241,6 +2257,16 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
);
}
$values = $this->discriminatorColumn->options['values'] ?? null;
if ($values !== null) {
$diff = array_diff(array_keys($map), $values);
if ($diff !== []) {
throw MappingException::invalidEntriesInDiscriminatorMap(array_values($diff), $this->name, $this->discriminatorColumn->enumType);
}
}
foreach ($map as $value => $className) {
$this->addDiscriminatorMapClass($value, $className);
}
@@ -2454,9 +2480,9 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
if (! isset($mapping['default'])) {
if (in_array($mapping['type'], ['integer', 'bigint', 'smallint'], true)) {
$mapping['default'] = 1;
$mapping['options']['default'] = 1;
} elseif ($mapping['type'] === 'datetime') {
$mapping['default'] = 'CURRENT_TIMESTAMP';
$mapping['options']['default'] = 'CURRENT_TIMESTAMP';
} else {
throw MappingException::unsupportedOptimisticLockingType($this->name, $mapping['fieldName'], $mapping['type']);
}

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

@@ -16,6 +16,7 @@ use LogicException;
use SimpleXMLElement;
use function assert;
use function class_exists;
use function constant;
use function count;
use function defined;
@@ -656,15 +657,30 @@ class XmlDriver extends FileDriver
* Parses (nested) option elements.
*
* @return mixed[] The options array.
* @phpstan-return array<int|string, array<int|string, mixed|string>|bool|string>
* @phpstan-return array<int|string, array<int|string, mixed|string>|bool|string|object>
*/
private function parseOptions(SimpleXMLElement|null $options): array
{
$array = [];
foreach ($options ?? [] as $option) {
$value = null;
if ($option->count()) {
$value = $this->parseOptions($option->children());
// Check if this option contains an <object> element
$children = $option->children();
$hasObjectElement = false;
foreach ($children as $child) {
if ($child->getName() === 'object') {
$value = $this->parseObjectElement($child);
$hasObjectElement = true;
break;
}
}
if (! $hasObjectElement) {
$value = $this->parseOptions($children);
}
} else {
$value = (string) $option;
}
@@ -684,6 +700,33 @@ class XmlDriver extends FileDriver
return $array;
}
/**
* Parses an <object> element and returns the instantiated object.
*
* @param SimpleXMLElement $objectElement The XML element.
*
* @return object The instantiated object.
*
* @throws MappingException If the object specification is invalid.
* @throws InvalidArgumentException If the class does not exist.
*/
private function parseObjectElement(SimpleXMLElement $objectElement): object
{
$attributes = $objectElement->attributes();
if (! isset($attributes->class)) {
throw MappingException::missingRequiredOption('object', 'class');
}
$className = (string) $attributes->class;
if (! class_exists($className)) {
throw new InvalidArgumentException(sprintf('Class "%s" does not exist', $className));
}
return new $className();
}
/**
* Constructs a joinColumn mapping array based on the information
* found in the given SimpleXMLElement.

View File

@@ -71,7 +71,9 @@ final class FieldMapping implements ArrayAccess
public string|null $declaredField = null;
public array|null $options = null;
public bool|null $version = null;
public string|int|null $default = null;
/** @deprecated Use options with 'default' key instead */
public string|int|null $default = null;
/**
* @param string $type The type name of the mapped field. Can be one of
@@ -140,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

@@ -179,7 +179,7 @@ class MappingException extends PersistenceMappingException implements ORMExcepti
public static function joinTableRequired(string $fieldName): self
{
return new self(sprintf("The mapping of field '%s' requires an the 'joinTable' attribute.", $fieldName));
return new self(sprintf("The mapping of field '%s' requires the 'joinTable' attribute.", $fieldName));
}
/**
@@ -329,6 +329,24 @@ class MappingException extends PersistenceMappingException implements ORMExcepti
);
}
/**
* Returns an exception that indicates that discriminator entries used in a discriminator map
* does not exist in the backed enum provided by enumType option.
*
* @param array<int,int|string> $entries The discriminator entries that could not be found.
* @param string $owningClass The class that declares the discriminator map.
* @param string $enumType The enum that entries were checked against.
*/
public static function invalidEntriesInDiscriminatorMap(array $entries, string $owningClass, string $enumType): self
{
return new self(sprintf(
"The entries %s in the discriminator map of class '%s' do not correspond to enum cases of '%s'.",
implode(', ', array_map(static fn ($entry): string => sprintf("'%s'", $entry), $entries)),
$owningClass,
$enumType,
));
}
/**
* Returns an exception that indicates that a class used in a discriminator map does not exist.
* An example would be an outdated (maybe renamed) classname.

View File

@@ -10,6 +10,8 @@ use ReflectionProperty;
use function sprintf;
use const PHP_VERSION_ID;
/** @internal */
class ReadonlyAccessor implements PropertyAccessor
{
@@ -26,7 +28,12 @@ class ReadonlyAccessor implements PropertyAccessor
public function setValue(object $object, mixed $value): void
{
if (! $this->reflectionProperty->isInitialized($object)) {
/* For lazy properties, skip the isInitialized() check
because it would trigger the initialization of the whole object. */
if (
PHP_VERSION_ID >= 80400 && $this->reflectionProperty->isLazy($object)
|| ! $this->reflectionProperty->isInitialized($object)
) {
$this->parent->setValue($object, $value);
return;

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

@@ -162,6 +162,7 @@ EOPHP;
);
}
// @phpstan-ignore function.impossibleType (This method has been removed in Symfony 8)
if (! method_exists(ProxyHelper::class, 'generateLazyGhost')) {
throw ORMInvalidArgumentException::lazyGhostUnavailable();
}
@@ -469,7 +470,7 @@ EOPHP;
private function generateUseLazyGhostTrait(ClassMetadata $class): string
{
// @phpstan-ignore staticMethod.deprecated (Because we support Symfony < 7.3)
// @phpstan-ignore staticMethod.notFound (This method has been removed in Symfony 8)
$code = ProxyHelper::generateLazyGhost($class->getReflectionClass());
$code = substr($code, 7 + (int) strpos($code, "\n{"));
$code = substr($code, 0, (int) strpos($code, "\n}"));

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

@@ -117,6 +117,13 @@ class QueryBuilder implements Stringable
*/
private int $boundCounter = 0;
/**
* The hints to set on the query.
*
* @var array<string, string|int|bool|iterable<mixed>|object>
*/
private array $hints = [];
/**
* Initializes a new <tt>QueryBuilder</tt> that uses the given <tt>EntityManager</tt>.
*
@@ -208,6 +215,39 @@ class QueryBuilder implements Stringable
return $this;
}
/** @return array<string, string|int|bool|iterable<mixed>|object> */
public function getHints(): array
{
return $this->hints;
}
/**
* Gets the value of a query hint. If the hint name is not recognized, FALSE is returned.
*
* @return mixed The value of the hint or FALSE, if the hint name is not recognized.
*/
public function getHint(string $name): mixed
{
return $this->hints[$name] ?? false;
}
public function hasHint(string $name): bool
{
return isset($this->hints[$name]);
}
/**
* Adds hints for the query.
*
* @return $this
*/
public function setHint(string $name, mixed $value): static
{
$this->hints[$name] = $value;
return $this;
}
/** @phpstan-return Cache::MODE_*|null */
public function getCacheMode(): int|null
{
@@ -288,6 +328,10 @@ class QueryBuilder implements Stringable
$query->setCacheRegion($this->cacheRegion);
}
foreach ($this->hints as $name => $value) {
$query->setHint($name, $value);
}
return $query;
}

View File

@@ -18,11 +18,12 @@ trait ApplicationCompatibility
{
private static function addCommandToApplication(Application $application, Command $command): Command|null
{
// @phpstan-ignore function.alreadyNarrowedType (This method did not exist before Symfony 7.4)
if (method_exists(Application::class, 'addCommand')) {
// @phpstan-ignore method.notFound (This method will be added in Symfony 7.4)
return $application->addCommand($command);
}
// @phpstan-ignore method.notFound
return $application->add($command);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\Console\Command\Debug;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Command\Command;
use function assert;
/** @internal */
abstract class AbstractCommand extends Command
{
public function __construct(private readonly ManagerRegistry $managerRegistry)
{
parent::__construct();
}
final protected function getEntityManager(string $name): EntityManagerInterface
{
$manager = $this->getManagerRegistry()->getManager($name);
assert($manager instanceof EntityManagerInterface);
return $manager;
}
final protected function getManagerRegistry(): ManagerRegistry
{
return $this->managerRegistry;
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\Console\Command\Debug;
use Doctrine\ORM\Mapping\ClassMetadata;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_keys;
use function array_merge;
use function array_unique;
use function array_values;
use function assert;
use function class_exists;
use function ksort;
use function ltrim;
use function sort;
use function sprintf;
final class DebugEntityListenersDoctrineCommand extends AbstractCommand
{
protected function configure(): void
{
$this
->setName('orm:debug:entity-listeners')
->setDescription('Lists entity listeners for a given entity')
->addArgument('entity', InputArgument::OPTIONAL, 'The fully-qualified entity class name')
->addArgument('event', InputArgument::OPTIONAL, 'The event name to filter by (e.g. postPersist)')
->setHelp(<<<'EOT'
The <info>%command.name%</info> command lists all entity listeners for a given entity:
<info>php %command.full_name% 'App\Entity\User'</info>
To show only listeners for a specific event, pass the event name:
<info>php %command.full_name% 'App\Entity\User' postPersist</info>
EOT);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var class-string|null $entityName */
$entityName = $input->getArgument('entity');
if ($entityName === null) {
$choices = $this->listAllEntities();
if ($choices === []) {
$io->error('No entities are configured.');
return self::FAILURE;
}
/** @var class-string $entityName */
$entityName = $io->choice('Which entity do you want to list listeners for?', $choices);
}
$entityName = ltrim($entityName, '\\');
$entityManager = $this->getManagerRegistry()->getManagerForClass($entityName);
if ($entityManager === null) {
$io->error(sprintf('No entity manager found for class "%s".', $entityName));
return self::FAILURE;
}
$classMetadata = $entityManager->getClassMetadata($entityName);
assert($classMetadata instanceof ClassMetadata);
$eventName = $input->getArgument('event');
if ($eventName === null) {
$allListeners = $classMetadata->entityListeners;
if (! $allListeners) {
$io->info(sprintf('No listeners are configured for the "%s" entity.', $entityName));
return self::SUCCESS;
}
ksort($allListeners);
} else {
if (! isset($classMetadata->entityListeners[$eventName])) {
$io->info(sprintf('No listeners are configured for the "%s" event.', $eventName));
return self::SUCCESS;
}
$allListeners = [$eventName => $classMetadata->entityListeners[$eventName]];
}
$io->title(sprintf('Entity listeners for <info>%s</info>', $entityName));
$rows = [];
foreach ($allListeners as $event => $listeners) {
if ($rows) {
$rows[] = new TableSeparator();
}
foreach ($listeners as $order => $listener) {
$rows[] = [$order === 0 ? $event : '', sprintf('#%d', ++$order), sprintf('%s::%s()', $listener['class'], $listener['method'])];
}
}
$io->table(['Event', 'Order', 'Listener'], $rows);
return self::SUCCESS;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('entity')) {
$suggestions->suggestValues($this->listAllEntities());
return;
}
if ($input->mustSuggestArgumentValuesFor('event')) {
$entityName = ltrim($input->getArgument('entity'), '\\');
if (! class_exists($entityName)) {
return;
}
$entityManager = $this->getManagerRegistry()->getManagerForClass($entityName);
if ($entityManager === null) {
return;
}
$classMetadata = $entityManager->getClassMetadata($entityName);
assert($classMetadata instanceof ClassMetadata);
$suggestions->suggestValues(array_keys($classMetadata->entityListeners));
return;
}
}
/** @return list<class-string> */
private function listAllEntities(): array
{
$entities = [];
foreach (array_keys($this->getManagerRegistry()->getManagerNames()) as $managerName) {
$entities[] = $this->getEntityManager($managerName)->getConfiguration()->getMetadataDriverImpl()->getAllClassNames();
}
$entities = array_values(array_unique(array_merge(...$entities)));
sort($entities);
return $entities;
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Tools\Console\Command\Debug;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_keys;
use function array_values;
use function ksort;
use function method_exists;
use function sprintf;
final class DebugEventManagerDoctrineCommand extends AbstractCommand
{
protected function configure(): void
{
$this
->setName('orm:debug:event-manager')
->setDescription('Lists event listeners for an entity manager')
->addArgument('event', InputArgument::OPTIONAL, 'The event name to filter by (e.g. postPersist)')
->addOption('em', null, InputOption::VALUE_REQUIRED, 'The entity manager to use for this command')
->setHelp(<<<'EOT'
The <info>%command.name%</info> command lists all event listeners for the default entity manager:
<info>php %command.full_name%</info>
You can also specify an entity manager:
<info>php %command.full_name% --em=default</info>
To show only listeners for a specific event, pass the event name as an argument:
<info>php %command.full_name% postPersist</info>
EOT);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$entityManagerName = $input->getOption('em') ?: $this->getManagerRegistry()->getDefaultManagerName();
$eventManager = $this->getEntityManager($entityManagerName)->getEventManager();
$eventName = $input->getArgument('event');
if ($eventName === null) {
$allListeners = $eventManager->getAllListeners();
if (! $allListeners) {
$io->info(sprintf('No listeners are configured for the "%s" entity manager.', $entityManagerName));
return self::SUCCESS;
}
ksort($allListeners);
} else {
$listeners = $eventManager->getListeners($eventName);
if (! $listeners) {
$io->info(sprintf('No listeners are configured for the "%s" event.', $eventName));
return self::SUCCESS;
}
$allListeners = [$eventName => $listeners];
}
$io->title(sprintf('Event listeners for <info>%s</info> entity manager', $entityManagerName));
$rows = [];
foreach ($allListeners as $event => $listeners) {
if ($rows) {
$rows[] = new TableSeparator();
}
foreach (array_values($listeners) as $order => $listener) {
$method = method_exists($listener, '__invoke') ? '__invoke' : $event;
$rows[] = [$order === 0 ? $event : '', sprintf('#%d', ++$order), sprintf('%s::%s()', $listener::class, $method)];
}
}
$io->table(['Event', 'Order', 'Listener'], $rows);
return self::SUCCESS;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('event')) {
$entityManagerName = $input->getOption('em') ?: $this->getManagerRegistry()->getDefaultManagerName();
$eventManager = $this->getEntityManager($entityManagerName)->getEventManager();
$suggestions->suggestValues(array_keys($eventManager->getAllListeners()));
return;
}
if ($input->mustSuggestOptionValuesFor('em')) {
$suggestions->suggestValues(array_keys($this->getManagerRegistry()->getManagerNames()));
return;
}
}
}

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

@@ -23,6 +23,7 @@ use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Types;
use Doctrine\Deprecations\Deprecation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\AssociationMapping;
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -388,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;
}
@@ -484,7 +481,9 @@ class SchemaTool
$options['scale'] = $mapping->scale;
}
/** @phpstan-ignore property.deprecated */
if (isset($mapping->default)) {
/** @phpstan-ignore property.deprecated */
$options['default'] = $mapping->default;
}
@@ -505,7 +504,16 @@ class SchemaTool
], true)
&& $options['default'] === $this->platform->getCurrentTimestampSQL()
) {
/** @phpstan-ignore class.notFound (if DefaultExpression exists, CurrentTimestamp exists as well) */
Deprecation::trigger(
'doctrine/orm',
'https://github.com/doctrine/orm/issues/12252',
<<<'DEPRECATION'
Using "%s" as a default value for datetime fields is deprecated and
will not be supported in Doctrine ORM 4.0.
Pass a `Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp` instance instead.
DEPRECATION,
$this->platform->getCurrentTimestampSQL(),
);
$options['default'] = new CurrentTimestamp();
}
@@ -513,7 +521,16 @@ class SchemaTool
in_array($mapping->type, [Types::TIME_MUTABLE, Types::TIME_IMMUTABLE], true)
&& $options['default'] === $this->platform->getCurrentTimeSQL()
) {
/** @phpstan-ignore class.notFound (if DefaultExpression exists, CurrentTime exists as well) */
Deprecation::trigger(
'doctrine/orm',
'https://github.com/doctrine/orm/issues/12252',
<<<'DEPRECATION'
Using "%s" as a default value for time fields is deprecated and
will not be supported in Doctrine ORM 4.0.
Pass a `Doctrine\DBAL\Schema\DefaultExpression\CurrentTime` instance instead.
DEPRECATION,
$this->platform->getCurrentTimeSQL(),
);
$options['default'] = new CurrentTime();
}
@@ -521,7 +538,16 @@ class SchemaTool
in_array($mapping->type, [Types::DATE_MUTABLE, Types::DATE_IMMUTABLE], true)
&& $options['default'] === $this->platform->getCurrentDateSQL()
) {
/** @phpstan-ignore class.notFound (if DefaultExpression exists, CurrentDate exists as well) */
Deprecation::trigger(
'doctrine/orm',
'https://github.com/doctrine/orm/issues/12252',
<<<'DEPRECATION'
Using "%s" as a default value for date fields is deprecated and
will not be supported in Doctrine ORM 4.0.
Pass a `Doctrine\DBAL\Schema\DefaultExpression\CurrentDate` instance instead.
DEPRECATION,
$this->platform->getCurrentDateSQL(),
);
$options['default'] = new CurrentDate();
}
}
@@ -1046,7 +1072,7 @@ class SchemaTool
{
return $asset instanceof NamedObject
? $asset->getObjectName()->toString()
// DBAL < 4.4
// @phpstan-ignore method.deprecated (DBAL < 4.4)
: $asset->getName();
}
}

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;
@@ -62,6 +62,7 @@ use function array_map;
use function array_sum;
use function array_values;
use function assert;
use function count;
use function current;
use function get_debug_type;
use function implode;
@@ -260,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.
@@ -313,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());
@@ -343,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();
@@ -377,8 +375,7 @@ class UnitOfWork implements PropertyChangedListener
$this->dispatchOnFlushEvent();
$conn = $this->em->getConnection();
$conn->beginTransaction();
$connection->beginTransaction();
$successful = false;
@@ -429,7 +426,7 @@ class UnitOfWork implements PropertyChangedListener
$commitFailed = false;
try {
if ($conn->commit() === false) {
if ($connection->commit() === false) {
$commitFailed = true;
}
} catch (DBAL\Exception $e) {
@@ -445,8 +442,8 @@ class UnitOfWork implements PropertyChangedListener
if (! $successful) {
$this->em->close();
if ($conn->isTransactionActive()) {
$conn->rollBack();
if ($connection->isTransactionActive()) {
$connection->rollBack();
}
$this->afterTransactionRolledBack();
@@ -2298,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));
}
/**
@@ -2594,8 +2589,14 @@ class UnitOfWork implements PropertyChangedListener
$reflField->setValue($entity, $pColl);
if ($hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER) {
$isIteration = isset($hints[Query::HINT_INTERNAL_ITERATION]) && $hints[Query::HINT_INTERNAL_ITERATION];
if (! $isIteration && $assoc->isOneToMany() && ! $targetClass->isIdentifierComposite && ! $assoc->isIndexed()) {
if (
$assoc->isOneToMany()
// is iteration
&& ! (isset($hints[Query::HINT_INTERNAL_ITERATION]) && $hints[Query::HINT_INTERNAL_ITERATION])
// is foreign key composite
&& ! ($targetClass->hasAssociation($assoc->mappedBy) && count($targetClass->getAssociationMapping($assoc->mappedBy)->joinColumns) > 1)
&& ! $assoc->isIndexed()
) {
$this->scheduleCollectionForBatchLoading($pColl, $class);
} else {
$this->loadCollection($pColl);
@@ -3140,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

@@ -24,11 +24,16 @@ class RootEntity
#[ORM\OneToMany(mappedBy: 'root', targetEntity: SecondLevel::class, fetch: 'EAGER')]
private Collection $secondLevel;
/** @var Collection<int, SecondLevelWithoutCompositePrimaryKey> */
#[ORM\OneToMany(mappedBy: 'root', targetEntity: SecondLevelWithoutCompositePrimaryKey::class, fetch: 'EAGER')]
private Collection $anotherSecondLevel;
public function __construct(int $id, string $other)
{
$this->otherKey = $other;
$this->secondLevel = new ArrayCollection();
$this->id = $id;
$this->otherKey = $other;
$this->secondLevel = new ArrayCollection();
$this->anotherSecondLevel = new ArrayCollection();
$this->id = $id;
}
public function getId(): int|null

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\Models\EagerFetchedCompositeOneToMany;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class SecondLevelWithoutCompositePrimaryKey
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer', nullable: false)]
private int|null $id;
#[ORM\ManyToOne(targetEntity: RootEntity::class, inversedBy: 'anotherSecondLevel')]
#[ORM\JoinColumn(name: 'root_id', referencedColumnName: 'id')]
#[ORM\JoinColumn(name: 'root_other_key', referencedColumnName: 'other_key')]
private RootEntity $root;
public function __construct(RootEntity $upper)
{
$this->root = $upper;
}
public function getId(): int|null
{
return $this->id;
}
}

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

@@ -6,38 +6,187 @@ namespace Doctrine\Tests\ORM\Functional;
use DateTime;
use DateTimeImmutable;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Schema\DefaultExpression;
use Doctrine\DBAL\Schema\DefaultExpression\CurrentDate;
use Doctrine\DBAL\Schema\DefaultExpression\CurrentTime;
use Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp;
use Doctrine\DBAL\Types\Types;
use Doctrine\Deprecations\PHPUnit\VerifyDeprecations;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
use PHPUnit\Framework\Attributes\RequiresMethod;
use function interface_exists;
class DefaultTimeExpressionTest extends OrmFunctionalTestCase
{
use VerifyDeprecations;
public function testUsingTimeRelatedDefaultExpressionCausesNoDbalDeprecation(): void
#[IgnoreDeprecations]
#[RequiresMethod(DefaultExpression::class, 'toSQL')]
public function testUsingTimeRelatedDefaultExpressionCausesAnOrmDeprecationAndNoDbalDeprecation(): void
{
$platform = $this->_em->getConnection()->getDatabasePlatform();
if (
$platform->getCurrentTimestampSQL() !== 'CURRENT_TIMESTAMP'
|| $platform->getCurrentTimeSQL() !== 'CURRENT_TIME'
|| $platform->getCurrentDateSQL() !== 'CURRENT_DATE'
) {
$this->markTestSkipped(
'This test requires platforms to support exactly CURRENT_TIMESTAMP, CURRENT_TIME and CURRENT_DATE.',
);
}
if ($platform instanceof AbstractMySQLPlatform) {
$this->markTestSkipped(
'MySQL platform does not support CURRENT_TIME or CURRENT_DATE as default expression.',
);
}
$this->expectDeprecationWithIdentifier('https://github.com/doctrine/orm/issues/12252');
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/dbal/pull/7195');
$this->createSchemaForModels(LegacyTimeEntity::class);
$this->_em->persist($entity = new LegacyTimeEntity());
$this->_em->flush();
$this->_em->find(LegacyTimeEntity::class, $entity->id);
}
public function testNoDeprecationsAreTrownWhenTheyCannotBeAddressed(): void
{
if (interface_exists(DefaultExpression::class)) {
$this->markTestSkipped(
'This test requires Doctrine DBAL 4.3 or lower.',
);
}
$platform = $this->_em->getConnection()->getDatabasePlatform();
if (
$platform->getCurrentTimestampSQL() !== 'CURRENT_TIMESTAMP'
|| $platform->getCurrentTimeSQL() !== 'CURRENT_TIME'
|| $platform->getCurrentDateSQL() !== 'CURRENT_DATE'
) {
$this->markTestSkipped(
'This test requires platforms to support exactly CURRENT_TIMESTAMP, CURRENT_TIME and CURRENT_DATE.',
);
}
if ($platform instanceof AbstractMySQLPlatform) {
$this->markTestSkipped(
'MySQL platform does not support CURRENT_TIME or CURRENT_DATE as default expression.',
);
}
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/orm/issues/12252');
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/dbal/pull/7195');
$this->createSchemaForModels(LegacyTimeEntity::class);
$this->_em->persist($entity = new LegacyTimeEntity());
$this->_em->flush();
$this->_em->find(LegacyTimeEntity::class, $entity->id);
}
#[RequiresMethod(DefaultExpression::class, 'toSQL')]
public function testUsingDefaultExpressionInstancesCausesNoDeprecation(): void
{
$platform = $this->_em->getConnection()->getDatabasePlatform();
if ($platform instanceof AbstractMySQLPlatform) {
$this->markTestSkipped('MySQL platform does not support CURRENT_TIME or CURRENT_DATE as default expression.');
}
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/orm/issues/12252');
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/dbal/pull/7195');
$this->createSchemaForModels(TimeEntity::class);
$this->_em->persist($entity = new TimeEntity());
$this->_em->flush();
$this->_em->find(TimeEntity::class, $entity->id);
}
}
#[ORM\Entity]
class LegacyTimeEntity
{
#[ORM\Id]
#[ORM\Column]
#[ORM\GeneratedValue]
public int $id;
#[ORM\Column(
type: Types::DATETIME_MUTABLE,
options: ['default' => 'CURRENT_TIMESTAMP'],
insertable: false,
updatable: false,
)]
public DateTime $createdAt;
#[ORM\Column(
type: Types::DATETIME_IMMUTABLE,
options: ['default' => 'CURRENT_TIMESTAMP'],
insertable: false,
updatable: false,
)]
public DateTimeImmutable $createdAtImmutable;
#[ORM\Column(
type: Types::TIME_MUTABLE,
options: ['default' => 'CURRENT_TIME'],
insertable: false,
updatable: false,
)]
public DateTime $createdTime;
#[ORM\Column(
type: Types::DATE_MUTABLE,
options: ['default' => 'CURRENT_DATE'],
insertable: false,
updatable: false,
)]
public DateTime $createdDate;
}
#[ORM\Entity]
class TimeEntity
{
#[ORM\Id]
#[ORM\Column]
#[ORM\GeneratedValue]
public int $id;
#[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])]
#[ORM\Column(
type: Types::DATETIME_MUTABLE,
options: ['default' => new CurrentTimestamp()],
insertable: false,
updatable: false,
)]
public DateTime $createdAt;
#[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])]
#[ORM\Column(
type: Types::DATETIME_IMMUTABLE,
options: ['default' => new CurrentTimestamp()],
insertable: false,
updatable: false,
)]
public DateTimeImmutable $createdAtImmutable;
#[ORM\Column(options: ['default' => 'CURRENT_TIME'])]
#[ORM\Column(
type: Types::TIME_MUTABLE,
options: ['default' => new CurrentTime()],
insertable: false,
updatable: false,
)]
public DateTime $createdTime;
#[ORM\Column(options: ['default' => 'CURRENT_DATE'])]
#[ORM\Column(
type: Types::DATE_MUTABLE,
options: ['default' => new CurrentDate()],
insertable: false,
updatable: false,
)]
public DateTime $createdDate;
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional;
use DateTime;
use DateTimeImmutable;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Schema\DefaultExpression;
use Doctrine\Deprecations\PHPUnit\VerifyDeprecations;
use Doctrine\ORM\Mapping\Driver\XmlDriver;
use Doctrine\ORM\Tools\SchemaTool;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
use PHPUnit\Framework\Attributes\RequiresMethod;
class DefaultTimeExpressionXmlTest extends OrmFunctionalTestCase
{
use VerifyDeprecations;
#[IgnoreDeprecations]
#[RequiresMethod(DefaultExpression::class, 'toSQL')]
public function testUsingTimeRelatedDefaultExpressionCausesAnOrmDeprecationAndNoDbalDeprecation(): void
{
$platform = $this->_em->getConnection()->getDatabasePlatform();
if (
$platform->getCurrentTimestampSQL() !== 'CURRENT_TIMESTAMP'
|| $platform->getCurrentTimeSQL() !== 'CURRENT_TIME'
|| $platform->getCurrentDateSQL() !== 'CURRENT_DATE'
) {
$this->markTestSkipped('Platform does not use standard SQL for current time expressions.');
}
if ($platform instanceof AbstractMySQLPlatform) {
$this->markTestSkipped(
'MySQL platform does not support CURRENT_TIME or CURRENT_DATE as default expression.',
);
}
$this->_em = $this->getEntityManager(
mappingDriver: new XmlDriver(__DIR__ . '/../Mapping/xml/'),
);
$this->_schemaTool = new SchemaTool($this->_em);
$this->expectDeprecationWithIdentifier('https://github.com/doctrine/orm/issues/12252');
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/dbal/pull/7195');
$this->createSchemaForModels(XmlLegacyTimeEntity::class);
$this->_em->persist($entity = new XmlLegacyTimeEntity());
$this->_em->flush();
$this->_em->find(XmlLegacyTimeEntity::class, $entity->id);
}
#[RequiresMethod(DefaultExpression::class, 'toSQL')]
public function testUsingDefaultExpressionInstancesCausesNoDeprecationXmlDriver(): void
{
$platform = $this->_em->getConnection()->getDatabasePlatform();
if ($platform instanceof AbstractMySQLPlatform) {
$this->markTestSkipped(
'MySQL platform does not support CURRENT_TIME or CURRENT_DATE as default expression.',
);
}
$this->_em = $this->getEntityManager(
mappingDriver: new XmlDriver(__DIR__ . '/../Mapping/xml/'),
);
$this->_schemaTool = new SchemaTool($this->_em);
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/orm/issues/12252');
$this->expectNoDeprecationWithIdentifier('https://github.com/doctrine/dbal/pull/7195');
$this->createSchemaForModels(XmlTimeEntity::class);
$this->_em->persist($entity = new XmlTimeEntity());
$this->_em->flush();
$this->_em->clear();
$this->_em->find(XmlTimeEntity::class, $entity->id);
}
}
class XmlLegacyTimeEntity
{
public int $id;
public DateTime $createdAt;
public DateTimeImmutable $createdAtImmutable;
public DateTime $createdTime;
public DateTime $createdDate;
}
class XmlTimeEntity
{
public int $id;
public DateTime $createdAt;
public DateTimeImmutable $createdAtImmutable;
public DateTime $createdTime;
public DateTime $createdDate;
}

View File

@@ -6,6 +6,7 @@ namespace Doctrine\Tests\ORM\Functional;
use Doctrine\Tests\Models\EagerFetchedCompositeOneToMany\RootEntity;
use Doctrine\Tests\Models\EagerFetchedCompositeOneToMany\SecondLevel;
use Doctrine\Tests\Models\EagerFetchedCompositeOneToMany\SecondLevelWithoutCompositePrimaryKey;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
@@ -14,7 +15,7 @@ final class EagerFetchOneToManyWithCompositeKeyTest extends OrmFunctionalTestCas
#[Group('GH11154')]
public function testItDoesNotThrowAnExceptionWhenTriggeringALoad(): void
{
$this->setUpEntitySchema([RootEntity::class, SecondLevel::class]);
$this->setUpEntitySchema([RootEntity::class, SecondLevel::class, SecondLevelWithoutCompositePrimaryKey::class]);
$a1 = new RootEntity(1, 'A');

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

@@ -17,7 +17,6 @@ use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
use ReflectionMethod;
use Symfony\Component\VarExporter\Instantiator;
use Symfony\Component\VarExporter\VarExporter;
use function file_get_contents;
@@ -81,11 +80,6 @@ class ParserResultSerializationTest extends OrmFunctionalTestCase
},
];
$instantiatorMethod = new ReflectionMethod(Instantiator::class, 'instantiate');
if ($instantiatorMethod->getReturnType() === null) {
self::markTestSkipped('symfony/var-exporter 5.4+ is required.');
}
yield 'symfony/var-exporter' => [
static function (ParserResult $parserResult): ParserResult {
return eval('return ' . VarExporter::export($parserResult) . ';');

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

@@ -474,7 +474,9 @@ class QueryTest extends OrmFunctionalTestCase
$this->_em->flush();
$this->_em->clear();
$query = $this->_em->createQuery('select a, u, a.topic, a.text from ' . CmsArticle::class . ' a, ' . CmsUser::class . ' u WHERE a.user = u ');
$query = $this->_em->createQuery(
'select a, u, a.topic, a.text from ' . CmsArticle::class . ' a, ' . CmsUser::class . ' u WHERE a.user = u order by a.id asc',
);
$result = $query->toIterable();
$it = iterator_to_array($result);
@@ -517,7 +519,9 @@ class QueryTest extends OrmFunctionalTestCase
$this->_em->flush();
$this->_em->clear();
$query = $this->_em->createQuery('select a.topic, a.text from ' . CmsArticle::class . ' a ');
$query = $this->_em->createQuery(
'select a.topic, a.text from ' . CmsArticle::class . ' a order by a.id asc',
);
$result = $query->toIterable();
$it = iterator_to_array($result);
@@ -545,7 +549,9 @@ class QueryTest extends OrmFunctionalTestCase
$this->_em->flush();
$this->_em->clear();
$query = $this->_em->createQuery('select a from Doctrine\Tests\Models\CMS\CmsArticle a');
$query = $this->_em->createQuery(
'select a from Doctrine\Tests\Models\CMS\CmsArticle a order by a.id asc',
);
$articles = $query->toIterable();
$iteratedCount = 0;

View File

@@ -142,6 +142,8 @@ class MySqlSchemaToolTest extends OrmFunctionalTestCase
self::equalTo('CREATE TABLE boolean_model (id INT AUTO_INCREMENT NOT NULL, booleanField TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'),
// DBAL 4.3 (see https://github.com/doctrine/dbal/pull/6864)
self::equalTo('CREATE TABLE boolean_model (id INT AUTO_INCREMENT NOT NULL, booleanField TINYINT(1) NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'),
// DBAL 4.4 (see https://github.com/doctrine/dbal/pull/7221)
self::equalTo('CREATE TABLE boolean_model (id INT AUTO_INCREMENT NOT NULL, booleanField TINYINT NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'),
));
}

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

@@ -49,7 +49,7 @@ class DDC2359Test extends TestCase
$entityManager->expects(self::any())->method('getConnection')->willReturn($connection);
$entityManager
->method('getEventManager')
->willReturn($this->createMock(EventManager::class));
->willReturn(new EventManager());
$metadataFactory->method('newClassMetadataInstance')->willReturn($mockMetadata);
$metadataFactory->expects(self::once())->method('wakeupReflection');

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

@@ -34,7 +34,7 @@ class GH10387Test extends OrmTestCase
{
yield 'hierarchy with Entity classes only' => [[GH10387EntitiesOnlyRoot::class, GH10387EntitiesOnlyMiddle::class, GH10387EntitiesOnlyLeaf::class]];
yield 'MappedSuperclass in the middle of the hierarchy' => [[GH10387MappedSuperclassRoot::class, GH10387MappedSuperclassMiddle::class, GH10387MappedSuperclassLeaf::class]];
yield 'abstract entity the the root and in the middle of the hierarchy' => [[GH10387AbstractEntitiesRoot::class, GH10387AbstractEntitiesMiddle::class, GH10387AbstractEntitiesLeaf::class]];
yield 'abstract entity at the root and in the middle of the hierarchy' => [[GH10387AbstractEntitiesRoot::class, GH10387AbstractEntitiesMiddle::class, GH10387AbstractEntitiesLeaf::class]];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket\GH12166;
use Doctrine\Tests\OrmFunctionalTestCase;
class GH12166Test extends OrmFunctionalTestCase
{
public function testProxyWithReadonlyIdIsNotInitializedImmediately(): void
{
$this->createSchemaForModels(LazyEntityWithReadonlyId::class);
$this->_em->persist(new LazyEntityWithReadonlyId(123, 'Test Name'));
$this->_em->flush();
$this->_em->clear();
$proxy = $this->_em->getReference(LazyEntityWithReadonlyId::class, 123);
$reflClass = $this->_em->getClassMetadata(LazyEntityWithReadonlyId::class)->reflClass;
self::assertTrue(
$this->isUninitializedObject($proxy),
'Proxy should remain uninitialized after creation',
);
$id = $proxy->getId();
self::assertSame(123, $id);
self::assertTrue(
$this->isUninitializedObject($proxy),
'Proxy should remain uninitialized after accessing ID',
);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket\GH12166;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
#[Entity]
#[Table(name: 'gh12166_lazy_entity')]
class LazyEntityWithReadonlyId
{
#[Column(type: 'integer')]
#[Id]
private readonly int $id;
#[Column(type: 'string')]
private string $name;
public function __construct(int $id, string $name)
{
$this->id = $id;
$this->name = $name;
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}

View File

@@ -23,7 +23,7 @@ class GH6394Test extends OrmFunctionalTestCase
}
/**
* Test the the version of an entity can be fetched, when the id field and
* Test the version of an entity can be fetched, when the id field and
* the id column are different.
*/
#[Group('6393')]

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'),
)));
}

Some files were not shown because too many files have changed in this diff Show More