diff --git a/.doctrine-project.json b/.doctrine-project.json
index f3a38fb4bdd..8dfa2ed2450 100644
--- a/.doctrine-project.json
+++ b/.doctrine-project.json
@@ -11,17 +11,23 @@
"slug": "latest",
"upcoming": true
},
+ {
+ "name": "3.4",
+ "branchName": "3.4.x",
+ "slug": "3.4",
+ "upcoming": true
+ },
{
"name": "3.3",
"branchName": "3.3.x",
"slug": "3.3",
- "upcoming": true
+ "current": true
},
{
"name": "3.2",
"branchName": "3.2.x",
"slug": "3.2",
- "current": true
+ "maintained": false
},
{
"name": "3.1",
@@ -35,17 +41,23 @@
"slug": "3.0",
"maintained": false
},
+ {
+ "name": "2.21",
+ "branchName": "2.21.x",
+ "slug": "2.21",
+ "upcoming": true
+ },
{
"name": "2.20",
"branchName": "2.20.x",
"slug": "2.20",
- "upcoming": true
+ "maintained": true
},
{
"name": "2.19",
"branchName": "2.19.x",
"slug": "2.19",
- "maintained": true
+ "maintained": false
},
{
"name": "2.18",
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c704d7d05b4..54e72673914 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -6,4 +6,4 @@ updates:
interval: "weekly"
labels:
- "CI"
- target-branch: "2.19.x"
+ target-branch: "2.20.x"
diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml
index 390bdb5afc2..e0e880ef39f 100644
--- a/.github/workflows/coding-standards.yml
+++ b/.github/workflows/coding-standards.yml
@@ -24,4 +24,4 @@ on:
jobs:
coding-standards:
- uses: "doctrine/.github/.github/workflows/coding-standards.yml@5.1.0"
+ uses: "doctrine/.github/.github/workflows/coding-standards.yml@5.2.0"
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index e9b2c71857d..bf2ba69d770 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -17,4 +17,4 @@ on:
jobs:
documentation:
name: "Documentation"
- uses: "doctrine/.github/.github/workflows/documentation.yml@5.1.0"
+ uses: "doctrine/.github/.github/workflows/documentation.yml@5.2.0"
diff --git a/.github/workflows/release-on-milestone-closed.yml b/.github/workflows/release-on-milestone-closed.yml
index d54a784ebe5..f8bd8bce82e 100644
--- a/.github/workflows/release-on-milestone-closed.yml
+++ b/.github/workflows/release-on-milestone-closed.yml
@@ -7,7 +7,7 @@ on:
jobs:
release:
- uses: "doctrine/.github/.github/workflows/release-on-milestone-closed.yml@5.1.0"
+ uses: "doctrine/.github/.github/workflows/release-on-milestone-closed.yml@5.2.0"
secrets:
GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 00000000000..08a88527759
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,24 @@
+name: 'Close stale pull requests'
+on:
+ schedule:
+ - cron: '0 3 * * *'
+
+jobs:
+ stale:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/stale@v9
+ with:
+ stale-pr-message: >
+ There hasn't been any activity on this pull request in the past 90 days, so
+ it has been marked as stale and it will be closed automatically if no
+ further activity occurs in the next 7 days.
+
+ If you want to continue working on it, please leave a comment.
+
+ close-pr-message: >
+ This pull request was closed due to inactivity.
+
+ days-before-stale: -1
+ days-before-pr-stale: 90
+ days-before-pr-close: 7
diff --git a/README.md b/README.md
index 1df322cf7e8..daef3b0c0e7 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-| [4.0.x][4.0] | [3.3.x][3.3] | [3.2.x][3.2] | [2.20.x][2.20] | [2.19.x][2.19] |
+| [4.0.x][4.0] | [3.4.x][3.4] | [3.3.x][3.3] | [2.21.x][2.21] | [2.20.x][2.20] |
|:------------------------------------------------------:|:------------------------------------------------------:|:------------------------------------------------------:|:--------------------------------------------------------:|:--------------------------------------------------------:|
-| [![Build status][4.0 image]][4.0] | [![Build status][3.3 image]][3.3] | [![Build status][3.2 image]][3.2] | [![Build status][2.20 image]][2.20] | [![Build status][2.19 image]][2.19] |
-| [![Coverage Status][4.0 coverage image]][4.0 coverage] | [![Coverage Status][3.3 coverage image]][3.3 coverage] | [![Coverage Status][3.2 coverage image]][3.2 coverage] | [![Coverage Status][2.20 coverage image]][2.20 coverage] | [![Coverage Status][2.19 coverage image]][2.19 coverage] |
+| [![Build status][4.0 image]][4.0] | [![Build status][3.4 image]][3.4] | [![Build status][3.3 image]][3.3] | [![Build status][2.21 image]][2.21] | [![Build status][2.20 image]][2.20] |
+| [![Coverage Status][4.0 coverage image]][4.0 coverage] | [![Coverage Status][3.4 coverage image]][3.4 coverage] | [![Coverage Status][3.3 coverage image]][3.3 coverage] | [![Coverage Status][2.21 coverage image]][2.21 coverage] | [![Coverage Status][2.20 coverage image]][2.20 coverage] |
[
πΊπ¦ UKRAINE NEEDS YOUR HELP NOW!
](https://www.doctrine-project.org/stop-war.html)
@@ -22,19 +22,19 @@ without requiring unnecessary code duplication.
[4.0]: https://github.com/doctrine/orm/tree/4.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.4 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.4.x
+ [3.4]: https://github.com/doctrine/orm/tree/3.4.x
+ [3.4 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.4.x/graph/badge.svg
+ [3.4 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.4.x
[3.3 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.3.x
[3.3]: https://github.com/doctrine/orm/tree/3.3.x
[3.3 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.3.x/graph/badge.svg
[3.3 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.3.x
- [3.2 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.2.x
- [3.2]: https://github.com/doctrine/orm/tree/3.2.x
- [3.2 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.2.x/graph/badge.svg
- [3.2 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.2.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 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.21.x/graph/badge.svg
+ [2.21 coverage]: https://codecov.io/gh/doctrine/orm/branch/2.21.x
[2.20 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.20.x
[2.20]: https://github.com/doctrine/orm/tree/2.20.x
[2.20 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.20.x/graph/badge.svg
[2.20 coverage]: https://codecov.io/gh/doctrine/orm/branch/2.20.x
- [2.19 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.19.x
- [2.19]: https://github.com/doctrine/orm/tree/2.19.x
- [2.19 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.19.x/graph/badge.svg
- [2.19 coverage]: https://codecov.io/gh/doctrine/orm/branch/2.19.x
diff --git a/UPGRADE.md b/UPGRADE.md
index 18a4bd192ea..6da5e0be57c 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -733,6 +733,15 @@ Use `toIterable()` instead.
# Upgrade to 2.20
+## Add `Doctrine\ORM\Query\OutputWalker` interface, deprecate `Doctrine\ORM\Query\SqlWalker::getExecutor()`
+
+Output walkers should implement the new `\Doctrine\ORM\Query\OutputWalker` interface and create
+`Doctrine\ORM\Query\Exec\SqlFinalizer` instances instead of `Doctrine\ORM\Query\Exec\AbstractSqlExecutor`s.
+The output walker must not base its workings on the query `firstResult`/`maxResult` values, so that the
+`SqlFinalizer` can be kept in the query cache and used regardless of the actual `firstResult`/`maxResult` values.
+Any operation dependent on `firstResult`/`maxResult` should take place within the `SqlFinalizer::createExecutor()`
+method. Details can be found at https://github.com/doctrine/orm/pull/11188.
+
## Explictly forbid property hooks
Property hooks are not supported yet by Doctrine ORM. Until support is added,
@@ -741,10 +750,16 @@ change in behavior.
Progress on this is tracked at https://github.com/doctrine/orm/issues/11624 .
-## PARTIAL DQL syntax is undeprecated for non-object hydration
+## PARTIAL DQL syntax is undeprecated
+
+Use of the PARTIAL keyword is not deprecated anymore in DQL, because we will be
+able to support PARTIAL objects with PHP 8.4 Lazy Objects and
+Symfony/VarExporter in a better way. When we decided to remove this feature
+these two abstractions did not exist yet.
-Use of the PARTIAL keyword is not deprecated anymore in DQL when used with a hydrator
-that is not creating entities, such as the ArrayHydrator.
+WARNING: If you want to upgrade to 3.x and still use PARTIAL keyword in DQL
+with array or object hydrators, then you have to directly migrate to ORM 3.3.x or higher.
+PARTIAL keyword in DQL is not available in 3.0, 3.1 and 3.2 of ORM.
## Deprecate `\Doctrine\ORM\Query\Parser::setCustomOutputTreeWalker()`
diff --git a/composer.json b/composer.json
index 1d7a00b818e..ec2ff1fd5a7 100644
--- a/composer.json
+++ b/composer.json
@@ -47,7 +47,7 @@
"psr/log": "^1 || ^2 || ^3",
"squizlabs/php_codesniffer": "3.7.2",
"symfony/cache": "^5.4 || ^6.2 || ^7.0",
- "vimeo/psalm": "5.24.0"
+ "vimeo/psalm": "5.26.1"
},
"suggest": {
"ext-dom": "Provides support for XSD validation for XML mapping files",
diff --git a/docs/en/_exts/configurationblock.py b/docs/en/_exts/configurationblock.py
deleted file mode 100644
index c1d413643b1..00000000000
--- a/docs/en/_exts/configurationblock.py
+++ /dev/null
@@ -1,91 +0,0 @@
-#Copyright (c) 2010 Fabien Potencier
-#
-#Permission is hereby granted, free of charge, to any person obtaining a copy
-#of this software and associated documentation files (the "Software"), to deal
-#in the Software without restriction, including without limitation the rights
-#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-#copies of the Software, and to permit persons to whom the Software is furnished
-#to do so, subject to the following conditions:
-#
-#The above copyright notice and this permission notice shall be included in all
-#copies or substantial portions of the Software.
-#
-#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-#THE SOFTWARE.
-
-from docutils.parsers.rst import Directive, directives
-from docutils import nodes
-from string import upper
-
-class configurationblock(nodes.General, nodes.Element):
- pass
-
-class ConfigurationBlock(Directive):
- has_content = True
- required_arguments = 0
- optional_arguments = 0
- final_argument_whitespace = True
- option_spec = {}
- formats = {
- 'html': 'HTML',
- 'xml': 'XML',
- 'php': 'PHP',
- 'jinja': 'Twig',
- 'html+jinja': 'Twig',
- 'jinja+html': 'Twig',
- 'php+html': 'PHP',
- 'html+php': 'PHP',
- 'ini': 'INI',
- }
-
- def run(self):
- env = self.state.document.settings.env
-
- node = nodes.Element()
- node.document = self.state.document
- self.state.nested_parse(self.content, self.content_offset, node)
-
- entries = []
- for i, child in enumerate(node):
- if isinstance(child, nodes.literal_block):
- # add a title (the language name) before each block
- #targetid = "configuration-block-%d" % env.new_serialno('configuration-block')
- #targetnode = nodes.target('', '', ids=[targetid])
- #targetnode.append(child)
-
- innernode = nodes.emphasis(self.formats[child['language']], self.formats[child['language']])
-
- para = nodes.paragraph()
- para += [innernode, child]
-
- entry = nodes.list_item('')
- entry.append(para)
- entries.append(entry)
-
- resultnode = configurationblock()
- resultnode.append(nodes.bullet_list('', *entries))
-
- return [resultnode]
-
-def visit_configurationblock_html(self, node):
- self.body.append(self.starttag(node, 'div', CLASS='configuration-block'))
-
-def depart_configurationblock_html(self, node):
- self.body.append('\n')
-
-def visit_configurationblock_latex(self, node):
- pass
-
-def depart_configurationblock_latex(self, node):
- pass
-
-def setup(app):
- app.add_node(configurationblock,
- html=(visit_configurationblock_html, depart_configurationblock_html),
- latex=(visit_configurationblock_latex, depart_configurationblock_latex))
- app.add_directive('configuration-block', ConfigurationBlock)
diff --git a/docs/en/_theme b/docs/en/_theme
deleted file mode 160000
index 6f1bc8bead1..00000000000
--- a/docs/en/_theme
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 6f1bc8bead17b8032389659c0b071d00f2c58328
diff --git a/docs/en/index.rst b/docs/en/index.rst
index 4d23062cd0d..32f8c070175 100644
--- a/docs/en/index.rst
+++ b/docs/en/index.rst
@@ -74,6 +74,7 @@ Advanced Topics
* :doc:`Improving Performance `
* :doc:`Caching `
* :doc:`Partial Hydration `
+* :doc:`Partial Objects `
* :doc:`Change Tracking Policies `
* :doc:`Best Practices `
* :doc:`Metadata Drivers `
diff --git a/docs/en/make.bat b/docs/en/make.bat
deleted file mode 100644
index 38fcba6007a..00000000000
--- a/docs/en/make.bat
+++ /dev/null
@@ -1,113 +0,0 @@
-@ECHO OFF
-
-REM Command file for Sphinx documentation
-
-set SPHINXBUILD=sphinx-build
-set BUILDDIR=_build
-set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
-if NOT "%PAPER%" == "" (
- set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
-)
-
-if "%1" == "" goto help
-
-if "%1" == "help" (
- :help
- echo.Please use `make ^` where ^ is one of
- echo. html to make standalone HTML files
- echo. dirhtml to make HTML files named index.html in directories
- echo. pickle to make pickle files
- echo. json to make JSON files
- echo. htmlhelp to make HTML files and a HTML help project
- echo. qthelp to make HTML files and a qthelp project
- echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
- echo. changes to make an overview over all changed/added/deprecated items
- echo. linkcheck to check all external links for integrity
- echo. doctest to run all doctests embedded in the documentation if enabled
- goto end
-)
-
-if "%1" == "clean" (
- for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
- del /q /s %BUILDDIR%\*
- goto end
-)
-
-if "%1" == "html" (
- %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/html.
- goto end
-)
-
-if "%1" == "dirhtml" (
- %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
- goto end
-)
-
-if "%1" == "pickle" (
- %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
- echo.
- echo.Build finished; now you can process the pickle files.
- goto end
-)
-
-if "%1" == "json" (
- %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
- echo.
- echo.Build finished; now you can process the JSON files.
- goto end
-)
-
-if "%1" == "htmlhelp" (
- %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
- echo.
- echo.Build finished; now you can run HTML Help Workshop with the ^
-.hhp project file in %BUILDDIR%/htmlhelp.
- goto end
-)
-
-if "%1" == "qthelp" (
- %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
- echo.
- echo.Build finished; now you can run "qcollectiongenerator" with the ^
-.qhcp project file in %BUILDDIR%/qthelp, like this:
- echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Doctrine2ORM.qhcp
- echo.To view the help file:
- echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Doctrine2ORM.ghc
- goto end
-)
-
-if "%1" == "latex" (
- %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
- echo.
- echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
- goto end
-)
-
-if "%1" == "changes" (
- %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
- echo.
- echo.The overview file is in %BUILDDIR%/changes.
- goto end
-)
-
-if "%1" == "linkcheck" (
- %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
- echo.
- echo.Link check complete; look for any errors in the above output ^
-or in %BUILDDIR%/linkcheck/output.txt.
- goto end
-)
-
-if "%1" == "doctest" (
- %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
- echo.
- echo.Testing of doctests in the sources finished, look at the ^
-results in %BUILDDIR%/doctest/output.txt.
- goto end
-)
-
-:end
diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst
index ab3cb138889..e668c08fd82 100644
--- a/docs/en/reference/dql-doctrine-query-language.rst
+++ b/docs/en/reference/dql-doctrine-query-language.rst
@@ -533,14 +533,23 @@ back. Instead, you receive only arrays as a flat rectangular result
set, similar to how you would if you were just using SQL directly
and joining some data.
-If you want to select a partial number of fields for hydration entity in
-the context of array hydration and joins you can use the ``partial`` DQL keyword:
+If you want to select partial objects or fields in array hydration you can use the ``partial``
+DQL keyword:
+
+.. code-block:: php
+
+ createQuery('SELECT partial u.{id, username} FROM CmsUser u');
+ $users = $query->getResult(); // array of partially loaded CmsUser objects
+
+You can use the partial syntax when joining as well:
.. code-block:: php
createQuery('SELECT partial u.{id, username}, partial a.{id, name} FROM CmsUser u JOIN u.articles a');
- $users = $query->getArrayResult(); // array of partially loaded CmsUser and CmsArticle fields
+ $usersArray = $query->getArrayResult(); // array of partially loaded CmsUser and CmsArticle fields
+ $users = $query->getResult(); // array of partially loaded CmsUser objects
"NEW" Operator Syntax
^^^^^^^^^^^^^^^^^^^^^
@@ -591,7 +600,7 @@ You can also nest several DTO :
// Bind values to the object properties.
}
}
-
+
class AddressDTO
{
public function __construct(string $street, string $city, string $zip)
@@ -599,15 +608,72 @@ You can also nest several DTO :
// Bind values to the object properties.
}
}
-
+
.. code-block:: php
createQuery('SELECT NEW CustomerDTO(c.name, e.email, NEW AddressDTO(a.street, a.city, a.zip)) FROM Customer c JOIN c.email e JOIN c.address a');
$users = $query->getResult(); // array of CustomerDTO
-
+
Note that you can only pass scalar expressions or other Data Transfer Objects to the constructor.
+If you use your data transfer objects for multiple queries, and you would rather not have to
+specify arguments that precede the ones you are really interested in, you can use named arguments.
+
+Consider the following DTO, which uses optional arguments:
+
+.. code-block:: php
+
+ createQuery('SELECT NEW NAMED CustomerDTO(a.city, c.name) FROM Customer c JOIN c.address a');
+ $users = $query->getResult(); // array of CustomerDTO
+
+ // CustomerDTO => {name : 'SMITH', email: null, city: 'London', value: null}
+
+ORM will also give precedence to column aliases over column names :
+
+.. code-block:: php
+
+ createQuery('SELECT NEW NAMED CustomerDTO(c.name, CONCAT(a.city, ' ' , a.zip) AS value) FROM Customer c JOIN c.address a');
+ $users = $query->getResult(); // array of CustomerDTO
+
+ // CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
+
+To define a custom name for a DTO constructor argument, you can either alias the column with the ``AS`` keyword.
+
+The ``NAMED`` keyword must precede all DTO you want to instantiate :
+
+.. code-block:: php
+
+ createQuery('SELECT NEW NAMED CustomerDTO(c.name, NEW NAMED AddressDTO(a.street, a.city, a.zip) AS address) FROM Customer c JOIN c.address a');
+ $users = $query->getResult(); // array of CustomerDTO
+
+ // CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
+
+If two arguments have the same name, a ``DuplicateFieldException`` is thrown.
+If a field cannot be matched with a property name, a ``NoMatchingPropertyException`` is thrown. This typically happens when using functions without aliasing them.
+
Using INDEX BY
~~~~~~~~~~~~~~
@@ -1370,6 +1436,15 @@ exist mostly internal query hints that are not be consumed in
userland. However the following few hints are to be used in
userland:
+
+- ``Query::HINT_FORCE_PARTIAL_LOAD`` - Allows to hydrate objects
+ although not all their columns are fetched. This query hint can be
+ used to handle memory consumption problems with large result-sets
+ that contain char or binary data. Doctrine has no way of implicitly
+ reloading this data. Partially loaded objects have to be passed to
+ ``EntityManager::refresh()`` if they are to be reloaded fully from
+ the database. This query hint is deprecated and will be removed
+ in the future (\ `Details `_)
- ``Query::HINT_REFRESH`` - This query is used internally by
``EntityManager::refresh()`` and can be used in userland as well.
If you specify this hint and a query returns the data for an entity
@@ -1627,7 +1702,7 @@ Select Expressions
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
- NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression
+ NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable]
Conditional Expressions
~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst
index 16879c45c52..fda14b98efa 100644
--- a/docs/en/reference/partial-hydration.rst
+++ b/docs/en/reference/partial-hydration.rst
@@ -1,11 +1,6 @@
Partial Hydration
=================
-.. note::
-
- Creating Partial Objects through DQL was possible in ORM 2,
- but is only supported for array hydration as of ORM 3.
-
Partial hydration of entities is allowed in the array hydrator, when
only a subset of the fields of an entity are loaded from the database
and the nested results are still created based on the entity relationship structure.
diff --git a/docs/en/reference/partial-objects.rst b/docs/en/reference/partial-objects.rst
new file mode 100644
index 00000000000..3123c083f3f
--- /dev/null
+++ b/docs/en/reference/partial-objects.rst
@@ -0,0 +1,88 @@
+Partial Objects
+===============
+
+A partial object is an object whose state is not fully initialized
+after being reconstituted from the database and that is
+disconnected from the rest of its data. The following section will
+describe why partial objects are problematic and what the approach
+of Doctrine to this problem is.
+
+.. note::
+
+ The partial object problem in general does not apply to
+ methods or queries where you do not retrieve the query result as
+ objects. Examples are: ``Query#getArrayResult()``,
+ ``Query#getScalarResult()``, ``Query#getSingleScalarResult()``,
+ etc.
+
+.. warning::
+
+ Use of partial objects is tricky. Fields that are not retrieved
+ from the database will not be updated by the UnitOfWork even if they
+ get changed in your objects. You can only promote a partial object
+ to a fully-loaded object by calling ``EntityManager#refresh()``
+ or a DQL query with the refresh flag.
+
+
+What is the problem?
+--------------------
+
+In short, partial objects are problematic because they are usually
+objects with broken invariants. As such, code that uses these
+partial objects tends to be very fragile and either needs to "know"
+which fields or methods can be safely accessed or add checks around
+every field access or method invocation. The same holds true for
+the internals, i.e. the method implementations, of such objects.
+You usually simply assume the state you need in the method is
+available, after all you properly constructed this object before
+you pushed it into the database, right? These blind assumptions can
+quickly lead to null reference errors when working with such
+partial objects.
+
+It gets worse with the scenario of an optional association (0..1 to
+1). When the associated field is NULL, you don't know whether this
+object does not have an associated object or whether it was simply
+not loaded when the owning object was loaded from the database.
+
+These are reasons why many ORMs do not allow partial objects at all
+and instead you always have to load an object with all its fields
+(associations being proxied). One secure way to allow partial
+objects is if the programming language/platform allows the ORM tool
+to hook deeply into the object and instrument it in such a way that
+individual fields (not only associations) can be loaded lazily on
+first access. This is possible in Java, for example, through
+bytecode instrumentation. In PHP though this is not possible, so
+there is no way to have "secure" partial objects in an ORM with
+transparent persistence.
+
+Doctrine, by default, does not allow partial objects. That means,
+any query that only selects partial object data and wants to
+retrieve the result as objects (i.e. ``Query#getResult()``) will
+raise an exception telling you that partial objects are dangerous.
+If you want to force a query to return you partial objects,
+possibly as a performance tweak, you can use the ``partial``
+keyword as follows:
+
+.. code-block:: php
+
+ createQuery("select partial u.{id,name} from MyApp\Domain\User u");
+
+You can also get a partial reference instead of a proxy reference by
+calling:
+
+.. code-block:: php
+
+ getPartialReference('MyApp\Domain\User', 1);
+
+Partial references are objects with only the identifiers set as they
+are passed to the second argument of the ``getPartialReference()`` method.
+All other fields are null.
+
+When should I force partial objects?
+------------------------------------
+
+Mainly for optimization purposes, but be careful of premature
+optimization as partial objects lead to potentially more fragile
+code.
diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst
index 7ccdfb3a054..f52801c6b37 100644
--- a/docs/en/sidebar.rst
+++ b/docs/en/sidebar.rst
@@ -38,6 +38,7 @@
reference/native-sql
reference/change-tracking-policies
reference/partial-hydration
+ reference/partial-objects
reference/attributes-reference
reference/xml-mapping
reference/php-mapping
diff --git a/docs/en/tutorials/extra-lazy-associations.rst b/docs/en/tutorials/extra-lazy-associations.rst
index 4c31357eecc..1dae001a36c 100644
--- a/docs/en/tutorials/extra-lazy-associations.rst
+++ b/docs/en/tutorials/extra-lazy-associations.rst
@@ -17,6 +17,7 @@ can be called without triggering a full load of the collection:
- ``Collection#contains($entity)``
- ``Collection#containsKey($key)``
- ``Collection#count()``
+- ``Collection#first()``
- ``Collection#get($key)``
- ``Collection#slice($offset, $length = null)``
diff --git a/docs/en/tutorials/working-with-indexed-associations.rst b/docs/en/tutorials/working-with-indexed-associations.rst
index e31b9be8f99..e15cae87aa4 100644
--- a/docs/en/tutorials/working-with-indexed-associations.rst
+++ b/docs/en/tutorials/working-with-indexed-associations.rst
@@ -29,83 +29,11 @@ You can map indexed associations by adding:
The code and mappings for the Market entity looks like this:
.. configuration-block::
- .. code-block:: attribute
-
- */
- #[OneToMany(targetEntity: Stock::class, mappedBy: 'market', indexBy: 'symbol')]
- private Collection $stocks;
-
- public function __construct(string $name)
- {
- $this->name = $name;
- $this->stocks = new ArrayCollection();
- }
-
- public function getId(): int|null
- {
- return $this->id;
- }
-
- public function getName(): string
- {
- return $this->name;
- }
-
- public function addStock(Stock $stock): void
- {
- $this->stocks[$stock->getSymbol()] = $stock;
- }
+ .. literalinclude:: working-with-indexed-associations/Market.php
+ :language: attribute
- public function getStock(string $symbol): Stock
- {
- if (!isset($this->stocks[$symbol])) {
- throw new \InvalidArgumentException("Symbol is not traded on this market.");
- }
-
- return $this->stocks[$symbol];
- }
-
- /** @return array */
- public function getStocks(): array
- {
- return $this->stocks->toArray();
- }
- }
-
- .. code-block:: xml
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ .. literalinclude:: working-with-indexed-associations/market.xml
+ :language: xml
Inside the ``addStock()`` method you can see how we directly set the key of the association to the symbol,
so that we can work with the indexed association directly after invoking ``addStock()``. Inside ``getStock($symbol)``
diff --git a/docs/en/tutorials/working-with-indexed-associations/Market.php b/docs/en/tutorials/working-with-indexed-associations/Market.php
new file mode 100644
index 00000000000..bb16d3902b3
--- /dev/null
+++ b/docs/en/tutorials/working-with-indexed-associations/Market.php
@@ -0,0 +1,68 @@
+ */
+ #[OneToMany(targetEntity: Stock::class, mappedBy: 'market', indexBy: 'symbol')]
+ private Collection $stocks;
+
+ public function __construct(string $name)
+ {
+ $this->name = $name;
+ $this->stocks = new ArrayCollection();
+ }
+
+ public function getId(): int|null
+ {
+ return $this->id;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function addStock(Stock $stock): void
+ {
+ $this->stocks[$stock->getSymbol()] = $stock;
+ }
+
+ public function getStock(string $symbol): Stock
+ {
+ if (! isset($this->stocks[$symbol])) {
+ throw new InvalidArgumentException('Symbol is not traded on this market.');
+ }
+
+ return $this->stocks[$symbol];
+ }
+
+ /** @return array */
+ public function getStocks(): array
+ {
+ return $this->stocks->toArray();
+ }
+}
diff --git a/docs/en/tutorials/working-with-indexed-associations/market.xml b/docs/en/tutorials/working-with-indexed-associations/market.xml
new file mode 100644
index 00000000000..3fc9fa2a857
--- /dev/null
+++ b/docs/en/tutorials/working-with-indexed-associations/market.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doctrine-mapping.xsd b/doctrine-mapping.xsd
index 58175a2e8dd..6131026f5f6 100644
--- a/doctrine-mapping.xsd
+++ b/doctrine-mapping.xsd
@@ -321,7 +321,7 @@
-
+
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index b83ae43a889..c7897f4d614 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1,5 +1,5 @@
-
+
isEmpty() ? $filteredParameters->first() : null]]>
@@ -212,14 +212,14 @@
-
-
-
-
+
+
+
+
@@ -402,6 +402,7 @@
+
@@ -923,6 +924,9 @@
+
+
+
@@ -1113,6 +1117,12 @@
+
+ getSqlStatements()]]>
+
+
+
+
diff --git a/src/Cache/DefaultQueryCache.php b/src/Cache/DefaultQueryCache.php
index f3bb8ac95c8..08e703cd4b0 100644
--- a/src/Cache/DefaultQueryCache.php
+++ b/src/Cache/DefaultQueryCache.php
@@ -16,6 +16,7 @@
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\ResultSetMapping;
+use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\UnitOfWork;
use function array_map;
@@ -210,6 +211,10 @@ public function put(QueryCacheKey $key, ResultSetMapping $rsm, mixed $result, ar
throw FeatureNotImplemented::nonSelectStatements();
}
+ if (($hints[SqlWalker::HINT_PARTIAL] ?? false) === true || ($hints[Query::HINT_FORCE_PARTIAL_LOAD] ?? false) === true) {
+ throw FeatureNotImplemented::partialEntities();
+ }
+
if (! ($key->cacheMode & Cache::MODE_PUT)) {
return false;
}
diff --git a/src/Cache/Exception/FeatureNotImplemented.php b/src/Cache/Exception/FeatureNotImplemented.php
index 8767d574190..7bae90b775d 100644
--- a/src/Cache/Exception/FeatureNotImplemented.php
+++ b/src/Cache/Exception/FeatureNotImplemented.php
@@ -20,4 +20,9 @@ public static function nonSelectStatements(): self
{
return new self('Second-level cache query supports only select statements.');
}
+
+ public static function partialEntities(): self
+ {
+ return new self('Second level cache does not support partial entities.');
+ }
}
diff --git a/src/EntityManager.php b/src/EntityManager.php
index 4e1dfaf5816..eb5a123d0b6 100644
--- a/src/EntityManager.php
+++ b/src/EntityManager.php
@@ -24,7 +24,6 @@
use Doctrine\ORM\Query\FilterCollection;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Repository\RepositoryFactory;
-use Throwable;
use function array_keys;
use function is_array;
@@ -178,18 +177,24 @@ public function wrapInTransaction(callable $func): mixed
{
$this->conn->beginTransaction();
+ $successful = false;
+
try {
$return = $func($this);
$this->flush();
$this->conn->commit();
- return $return;
- } catch (Throwable $e) {
- $this->close();
- $this->conn->rollBack();
+ $successful = true;
- throw $e;
+ return $return;
+ } finally {
+ if (! $successful) {
+ $this->close();
+ if ($this->conn->isTransactionActive()) {
+ $this->conn->rollBack();
+ }
+ }
}
}
diff --git a/src/Exception/DuplicateFieldException.php b/src/Exception/DuplicateFieldException.php
new file mode 100644
index 00000000000..ec7cb00593e
--- /dev/null
+++ b/src/Exception/DuplicateFieldException.php
@@ -0,0 +1,17 @@
+ []];
+ $rowData = ['data' => [], 'newObjects' => []];
foreach ($data as $key => $value) {
$cacheKeyInfo = $this->hydrateColumnInfo($key);
@@ -282,10 +282,6 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
$value = $this->buildEnum($value, $cacheKeyInfo['enumType']);
}
- if (! isset($rowData['newObjects'])) {
- $rowData['newObjects'] = [];
- }
-
$rowData['newObjects'][$objIndex]['class'] = $cacheKeyInfo['class'];
$rowData['newObjects'][$objIndex]['args'][$argIndex] = $value;
break;
@@ -341,28 +337,22 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
}
foreach ($this->resultSetMapping()->nestedNewObjectArguments as $objIndex => ['ownerIndex' => $ownerIndex, 'argIndex' => $argIndex]) {
- if (! isset($rowData['newObjects'][$objIndex])) {
+ if (! isset($rowData['newObjects'][$ownerIndex . ':' . $argIndex])) {
continue;
}
- $newObject = $rowData['newObjects'][$objIndex];
- unset($rowData['newObjects'][$objIndex]);
+ $newObject = $rowData['newObjects'][$ownerIndex . ':' . $argIndex];
+ unset($rowData['newObjects'][$ownerIndex . ':' . $argIndex]);
- $class = $newObject['class'];
- $args = $newObject['args'];
- $obj = $class->newInstanceArgs($args);
+ $obj = $newObject['class']->newInstanceArgs($newObject['args']);
$rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $obj;
}
- if (isset($rowData['newObjects'])) {
- foreach ($rowData['newObjects'] as $objIndex => $newObject) {
- $class = $newObject['class'];
- $args = $newObject['args'];
- $obj = $class->newInstanceArgs($args);
+ foreach ($rowData['newObjects'] as $objIndex => $newObject) {
+ $obj = $newObject['class']->newInstanceArgs($newObject['args']);
- $rowData['newObjects'][$objIndex]['obj'] = $obj;
- }
+ $rowData['newObjects'][$objIndex]['obj'] = $obj;
}
return $rowData;
diff --git a/src/Mapping/ClassMetadata.php b/src/Mapping/ClassMetadata.php
index 9f9c130f7a8..e39d4b6437c 100644
--- a/src/Mapping/ClassMetadata.php
+++ b/src/Mapping/ClassMetadata.php
@@ -7,6 +7,7 @@
use BackedEnum;
use BadMethodCallException;
use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Types\Types;
use Doctrine\Deprecations\Deprecation;
use Doctrine\Instantiator\Instantiator;
use Doctrine\Instantiator\InstantiatorInterface;
@@ -28,6 +29,7 @@
use ReflectionProperty;
use Stringable;
+use function array_column;
use function array_diff;
use function array_intersect;
use function array_key_exists;
@@ -39,6 +41,7 @@
use function assert;
use function class_exists;
use function count;
+use function defined;
use function enum_exists;
use function explode;
use function in_array;
@@ -1150,9 +1153,7 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array
{
$field = $this->reflClass->getProperty($mapping['fieldName']);
- $mapping = $this->typedFieldMapper->validateAndComplete($mapping, $field);
-
- return $mapping;
+ return $this->typedFieldMapper->validateAndComplete($mapping, $field);
}
/**
@@ -1263,6 +1264,14 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping
if (! empty($mapping->id)) {
$this->containsEnumIdentifier = true;
}
+
+ if (
+ defined('Doctrine\DBAL\Types\Types::ENUM')
+ && $mapping->type === Types::ENUM
+ && ! isset($mapping->options['values'])
+ ) {
+ $mapping->options['values'] = array_column($mapping->enumType::cases(), 'value');
+ }
}
return $mapping;
diff --git a/src/Mapping/DefaultTypedFieldMapper.php b/src/Mapping/DefaultTypedFieldMapper.php
index 49144b8b7c8..40b37b8c426 100644
--- a/src/Mapping/DefaultTypedFieldMapper.php
+++ b/src/Mapping/DefaultTypedFieldMapper.php
@@ -16,6 +16,7 @@
use function array_merge;
use function assert;
+use function defined;
use function enum_exists;
use function is_a;
@@ -49,30 +50,40 @@ public function validateAndComplete(array $mapping, ReflectionProperty $field):
{
$type = $field->getType();
+ if (! $type instanceof ReflectionNamedType) {
+ return $mapping;
+ }
+
if (
- ! isset($mapping['type'])
- && ($type instanceof ReflectionNamedType)
+ ! $type->isBuiltin()
+ && enum_exists($type->getName())
+ && (! isset($mapping['type']) || (
+ defined('Doctrine\DBAL\Types\Types::ENUM')
+ && $mapping['type'] === Types::ENUM
+ ))
) {
- if (! $type->isBuiltin() && enum_exists($type->getName())) {
- $reflection = new ReflectionEnum($type->getName());
- if (! $reflection->isBacked()) {
- throw MappingException::backedEnumTypeRequired(
- $field->class,
- $mapping['fieldName'],
- $type->getName(),
- );
- }
+ $reflection = new ReflectionEnum($type->getName());
+ if (! $reflection->isBacked()) {
+ throw MappingException::backedEnumTypeRequired(
+ $field->class,
+ $mapping['fieldName'],
+ $type->getName(),
+ );
+ }
- assert(is_a($type->getName(), BackedEnum::class, true));
- $mapping['enumType'] = $type->getName();
- $type = $reflection->getBackingType();
+ assert(is_a($type->getName(), BackedEnum::class, true));
+ $mapping['enumType'] = $type->getName();
+ $type = $reflection->getBackingType();
- assert($type instanceof ReflectionNamedType);
- }
+ assert($type instanceof ReflectionNamedType);
+ }
- if (isset($this->typedFieldMappings[$type->getName()])) {
- $mapping['type'] = $this->typedFieldMappings[$type->getName()];
- }
+ if (isset($mapping['type'])) {
+ return $mapping;
+ }
+
+ if (isset($this->typedFieldMappings[$type->getName()])) {
+ $mapping['type'] = $this->typedFieldMappings[$type->getName()];
}
return $mapping;
diff --git a/src/PersistentCollection.php b/src/PersistentCollection.php
index 291e57a1939..ebc29cca8f3 100644
--- a/src/PersistentCollection.php
+++ b/src/PersistentCollection.php
@@ -504,6 +504,20 @@ public function __wakeup(): void
$this->em = null;
}
+ /**
+ * {@inheritDoc}
+ */
+ public function first()
+ {
+ if (! $this->initialized && ! $this->isDirty && $this->getMapping()['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY) {
+ $persister = $this->getUnitOfWork()->getCollectionPersister($this->getMapping());
+
+ return array_values($persister->slice($this, 0, 1))[0] ?? false;
+ }
+
+ return parent::first();
+ }
+
/**
* Extracts a slice of $length elements starting at position $offset from the Collection.
*
diff --git a/src/Query.php b/src/Query.php
index a869316d3e7..b97d4d93667 100644
--- a/src/Query.php
+++ b/src/Query.php
@@ -7,11 +7,14 @@
use Doctrine\DBAL\LockMode;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Types\Type;
+use Doctrine\Deprecations\Deprecation;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\AST\DeleteStatement;
use Doctrine\ORM\Query\AST\SelectStatement;
use Doctrine\ORM\Query\AST\UpdateStatement;
use Doctrine\ORM\Query\Exec\AbstractSqlExecutor;
+use Doctrine\ORM\Query\Exec\SqlFinalizer;
+use Doctrine\ORM\Query\OutputWalker;
use Doctrine\ORM\Query\Parameter;
use Doctrine\ORM\Query\ParameterTypeInferer;
use Doctrine\ORM\Query\Parser;
@@ -27,6 +30,7 @@
use function count;
use function get_debug_type;
use function in_array;
+use function is_a;
use function ksort;
use function md5;
use function reset;
@@ -70,6 +74,14 @@ class Query extends AbstractQuery
*/
public const HINT_REFRESH_ENTITY = 'doctrine.refresh.entity';
+ /**
+ * The forcePartialLoad query hint forces a particular query to return
+ * partial objects.
+ *
+ * @todo Rename: HINT_OPTIMIZE
+ */
+ public const HINT_FORCE_PARTIAL_LOAD = 'doctrine.forcePartialLoad';
+
/**
* The includeMetaColumns query hint causes meta columns like foreign keys and
* discriminator columns to be selected and returned as part of the query result.
@@ -163,7 +175,7 @@ class Query extends AbstractQuery
*/
public function getSQL(): string|array
{
- return $this->parse()->getSqlExecutor()->getSqlStatements();
+ return $this->getSqlExecutor()->getSqlStatements();
}
/**
@@ -242,7 +254,7 @@ private function parse(): ParserResult
protected function _doExecute(): Result|int
{
- $executor = $this->parse()->getSqlExecutor();
+ $executor = $this->getSqlExecutor();
if ($this->queryCacheProfile) {
$executor->setQueryCacheProfile($this->queryCacheProfile);
@@ -656,11 +668,31 @@ protected function getQueryCacheId(): string
{
ksort($this->hints);
+ if (! $this->hasHint(self::HINT_CUSTOM_OUTPUT_WALKER)) {
+ // Assume Parser will create the SqlOutputWalker; save is_a call, which might trigger a class load
+ $firstAndMaxResult = '';
+ } else {
+ $outputWalkerClass = $this->getHint(self::HINT_CUSTOM_OUTPUT_WALKER);
+ if (is_a($outputWalkerClass, OutputWalker::class, true)) {
+ $firstAndMaxResult = '';
+ } else {
+ Deprecation::trigger(
+ 'doctrine/orm',
+ 'https://github.com/doctrine/orm/pull/11188/',
+ 'Your output walker class %s should implement %s in order to provide a %s. This also means the output walker should not use the query firstResult/maxResult values, which should be read from the query by the SqlFinalizer only.',
+ $outputWalkerClass,
+ OutputWalker::class,
+ SqlFinalizer::class,
+ );
+ $firstAndMaxResult = '&firstResult=' . $this->firstResult . '&maxResult=' . $this->maxResults;
+ }
+ }
+
return md5(
$this->getDQL() . serialize($this->hints) .
'&platform=' . get_debug_type($this->getEntityManager()->getConnection()->getDatabasePlatform()) .
($this->em->hasFilters() ? $this->em->getFilters()->getHash() : '') .
- '&firstResult=' . $this->firstResult . '&maxResult=' . $this->maxResults .
+ $firstAndMaxResult .
'&hydrationMode=' . $this->hydrationMode . '&types=' . serialize($this->parsedTypes) . 'DOCTRINE_QUERY_CACHE_SALT',
);
}
@@ -679,4 +711,9 @@ public function __clone()
$this->state = self::STATE_DIRTY;
}
+
+ private function getSqlExecutor(): AbstractSqlExecutor
+ {
+ return $this->parse()->prepareSqlExecutor($this);
+ }
}
diff --git a/src/Query/Exec/FinalizedSelectExecutor.php b/src/Query/Exec/FinalizedSelectExecutor.php
new file mode 100644
index 00000000000..872d42cb6c4
--- /dev/null
+++ b/src/Query/Exec/FinalizedSelectExecutor.php
@@ -0,0 +1,29 @@
+sqlStatements = $sql;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function execute(Connection $conn, array $params, array $types): Result
+ {
+ return $conn->executeQuery($this->getSqlStatements(), $params, $types, $this->queryCacheProfile);
+ }
+}
diff --git a/src/Query/Exec/PreparedExecutorFinalizer.php b/src/Query/Exec/PreparedExecutorFinalizer.php
new file mode 100644
index 00000000000..26161dba782
--- /dev/null
+++ b/src/Query/Exec/PreparedExecutorFinalizer.php
@@ -0,0 +1,27 @@
+executor = $exeutor;
+ }
+
+ public function createExecutor(Query $query): AbstractSqlExecutor
+ {
+ return $this->executor;
+ }
+}
diff --git a/src/Query/Exec/SingleSelectSqlFinalizer.php b/src/Query/Exec/SingleSelectSqlFinalizer.php
new file mode 100644
index 00000000000..ac31c0cde36
--- /dev/null
+++ b/src/Query/Exec/SingleSelectSqlFinalizer.php
@@ -0,0 +1,60 @@
+getEntityManager()->getConnection()->getDatabasePlatform();
+
+ $sql = $platform->modifyLimitQuery($this->sql, $query->getMaxResults(), $query->getFirstResult());
+
+ $lockMode = $query->getHint(Query::HINT_LOCK_MODE) ?: LockMode::NONE;
+
+ if ($lockMode !== LockMode::NONE && $lockMode !== LockMode::OPTIMISTIC && $lockMode !== LockMode::PESSIMISTIC_READ && $lockMode !== LockMode::PESSIMISTIC_WRITE) {
+ throw QueryException::invalidLockMode();
+ }
+
+ if ($lockMode === LockMode::PESSIMISTIC_READ) {
+ $sql .= ' ' . $this->getReadLockSQL($platform);
+ } elseif ($lockMode === LockMode::PESSIMISTIC_WRITE) {
+ $sql .= ' ' . $this->getWriteLockSQL($platform);
+ }
+
+ return $sql;
+ }
+
+ /** @return FinalizedSelectExecutor */
+ public function createExecutor(Query $query): AbstractSqlExecutor
+ {
+ return new FinalizedSelectExecutor($this->finalizeSql($query));
+ }
+}
diff --git a/src/Query/Exec/SingleTableDeleteUpdateExecutor.php b/src/Query/Exec/SingleTableDeleteUpdateExecutor.php
index 66696dbde52..721bb40ad1f 100644
--- a/src/Query/Exec/SingleTableDeleteUpdateExecutor.php
+++ b/src/Query/Exec/SingleTableDeleteUpdateExecutor.php
@@ -14,8 +14,6 @@
* that are mapped to a single table.
*
* @link www.doctrine-project.org
- *
- * @todo This is exactly the same as SingleSelectExecutor. Unify in SingleStatementExecutor.
*/
class SingleTableDeleteUpdateExecutor extends AbstractSqlExecutor
{
diff --git a/src/Query/Exec/SqlFinalizer.php b/src/Query/Exec/SqlFinalizer.php
new file mode 100644
index 00000000000..cddad84e8a3
--- /dev/null
+++ b/src/Query/Exec/SqlFinalizer.php
@@ -0,0 +1,26 @@
+queryComponents = $treeWalkerChain->getQueryComponents();
}
- $outputWalkerClass = $this->customOutputWalker ?: SqlWalker::class;
+ $outputWalkerClass = $this->customOutputWalker ?: SqlOutputWalker::class;
$outputWalker = new $outputWalkerClass($this->query, $this->parserResult, $this->queryComponents);
- // Assign an SQL executor to the parser result
- $this->parserResult->setSqlExecutor($outputWalker->getExecutor($AST));
+ if ($outputWalker instanceof OutputWalker) {
+ $finalizer = $outputWalker->getFinalizer($AST);
+ $this->parserResult->setSqlFinalizer($finalizer);
+ } else {
+ Deprecation::trigger(
+ 'doctrine/orm',
+ 'https://github.com/doctrine/orm/pull/11188/',
+ 'Your output walker class %s should implement %s in order to provide a %s. This also means the output walker should not use the query firstResult/maxResult values, which should be read from the query by the SqlFinalizer only.',
+ $outputWalkerClass,
+ OutputWalker::class,
+ SqlFinalizer::class,
+ );
+ // @phpstan-ignore method.deprecated
+ $executor = $outputWalker->getExecutor($AST);
+ // @phpstan-ignore method.deprecated
+ $this->parserResult->setSqlExecutor($executor);
+ }
return $this->parserResult;
}
@@ -1682,10 +1701,6 @@ public function JoinAssociationDeclaration(): AST\JoinAssociationDeclaration
*/
public function PartialObjectExpression(): AST\PartialObjectExpression
{
- if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) {
- throw HydrationException::partialObjectHydrationDisallowed();
- }
-
$this->match(TokenType::T_PARTIAL);
$partialFieldSet = [];
@@ -1742,20 +1757,26 @@ public function PartialObjectExpression(): AST\PartialObjectExpression
*/
public function NewObjectExpression(): AST\NewObjectExpression
{
- $args = [];
+ $useNamedArguments = false;
+ $args = [];
+ $argFieldAlias = [];
$this->match(TokenType::T_NEW);
+ if ($this->lexer->isNextToken(TokenType::T_NAMED)) {
+ $this->match(TokenType::T_NAMED);
+ $useNamedArguments = true;
+ }
+
$className = $this->AbstractSchemaName(); // note that this is not yet validated
$token = $this->lexer->token;
$this->match(TokenType::T_OPEN_PARENTHESIS);
- $args[] = $this->NewObjectArg();
+ $this->addArgument($args, $useNamedArguments);
while ($this->lexer->isNextToken(TokenType::T_COMMA)) {
$this->match(TokenType::T_COMMA);
-
- $args[] = $this->NewObjectArg();
+ $this->addArgument($args, $useNamedArguments);
}
$this->match(TokenType::T_CLOSE_PARENTHESIS);
@@ -1772,29 +1793,71 @@ public function NewObjectExpression(): AST\NewObjectExpression
return $expression;
}
+ /** @param array $args */
+ public function addArgument(array &$args, bool $useNamedArguments): void
+ {
+ $fieldAlias = null;
+
+ if ($useNamedArguments) {
+ $startToken = $this->lexer->lookahead?->position ?? 0;
+
+ $newArg = $this->NewObjectArg($fieldAlias);
+
+ $key = $fieldAlias ?? $newArg->field ?? null;
+
+ if ($key === null) {
+ throw NoMatchingPropertyException::create(trim(substr(
+ ($this->query->getDQL() ?? ''),
+ $startToken,
+ ($this->lexer->lookahead->position ?? 0) - $startToken,
+ )));
+ }
+
+ if (array_key_exists($key, $args)) {
+ throw DuplicateFieldException::create($key, trim(substr(
+ ($this->query->getDQL() ?? ''),
+ $startToken,
+ ($this->lexer->lookahead->position ?? 0) - $startToken,
+ )));
+ }
+
+ $args[$key] = $newArg;
+ } else {
+ $args[] = $this->NewObjectArg($fieldAlias);
+ }
+ }
+
/**
- * NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression
+ * NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable]
*/
- public function NewObjectArg(): mixed
+ public function NewObjectArg(string|null &$fieldAlias = null): mixed
{
+ $fieldAlias = null;
+
assert($this->lexer->lookahead !== null);
$token = $this->lexer->lookahead;
$peek = $this->lexer->glimpse();
assert($peek !== null);
+
+ $expression = null;
+
if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) {
$this->match(TokenType::T_OPEN_PARENTHESIS);
$expression = $this->Subselect();
$this->match(TokenType::T_CLOSE_PARENTHESIS);
-
- return $expression;
+ } elseif ($token->type === TokenType::T_NEW) {
+ $expression = $this->NewObjectExpression();
+ } else {
+ $expression = $this->ScalarExpression();
}
- if ($token->type === TokenType::T_NEW) {
- return $this->NewObjectExpression();
+ if ($this->lexer->isNextToken(TokenType::T_AS)) {
+ $this->match(TokenType::T_AS);
+ $fieldAlias = $this->AliasIdentificationVariable();
}
- return $this->ScalarExpression();
+ return $expression;
}
/**
diff --git a/src/Query/ParserResult.php b/src/Query/ParserResult.php
index 8b5ee1f7ee5..7539e999ac3 100644
--- a/src/Query/ParserResult.php
+++ b/src/Query/ParserResult.php
@@ -4,7 +4,9 @@
namespace Doctrine\ORM\Query;
+use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Exec\AbstractSqlExecutor;
+use Doctrine\ORM\Query\Exec\SqlFinalizer;
use LogicException;
use function sprintf;
@@ -22,6 +24,11 @@ class ParserResult
*/
private AbstractSqlExecutor|null $sqlExecutor = null;
+ /**
+ * The SQL executor used for executing the SQL.
+ */
+ private SqlFinalizer|null $sqlFinalizer = null;
+
/**
* The ResultSetMapping that describes how to map the SQL result set.
*/
@@ -63,6 +70,8 @@ public function setResultSetMapping(ResultSetMapping $rsm): void
/**
* Sets the SQL executor that should be used for this ParserResult.
+ *
+ * @deprecated
*/
public function setSqlExecutor(AbstractSqlExecutor $executor): void
{
@@ -71,6 +80,8 @@ public function setSqlExecutor(AbstractSqlExecutor $executor): void
/**
* Gets the SQL executor used by this ParserResult.
+ *
+ * @deprecated
*/
public function getSqlExecutor(): AbstractSqlExecutor
{
@@ -84,6 +95,24 @@ public function getSqlExecutor(): AbstractSqlExecutor
return $this->sqlExecutor;
}
+ public function setSqlFinalizer(SqlFinalizer $finalizer): void
+ {
+ $this->sqlFinalizer = $finalizer;
+ }
+
+ public function prepareSqlExecutor(Query $query): AbstractSqlExecutor
+ {
+ if ($this->sqlFinalizer !== null) {
+ return $this->sqlFinalizer->createExecutor($query);
+ }
+
+ if ($this->sqlExecutor !== null) {
+ return $this->sqlExecutor;
+ }
+
+ throw new LogicException('This ParserResult lacks both the SqlFinalizer as well as the (legacy) SqlExecutor');
+ }
+
/**
* Adds a DQL to SQL parameter mapping. One DQL parameter name/position can map to
* several SQL parameter positions.
diff --git a/src/Query/QueryException.php b/src/Query/QueryException.php
index ae945b167fe..5c82b20a7a4 100644
--- a/src/Query/QueryException.php
+++ b/src/Query/QueryException.php
@@ -88,6 +88,15 @@ public static function iterateWithFetchJoinCollectionNotAllowed(AssociationMappi
);
}
+ public static function partialObjectsAreDangerous(): self
+ {
+ return new self(
+ 'Loading partial objects is dangerous. Fetch full objects or consider ' .
+ 'using a different fetch mode. If you really want partial objects, ' .
+ 'set the doctrine.forcePartialLoad query hint to TRUE.',
+ );
+ }
+
/**
* @param string[] $assoc
* @psalm-param array $assoc
diff --git a/src/Query/ResultSetMapping.php b/src/Query/ResultSetMapping.php
index 15b095d8482..920461b53d4 100644
--- a/src/Query/ResultSetMapping.php
+++ b/src/Query/ResultSetMapping.php
@@ -4,7 +4,6 @@
namespace Doctrine\ORM\Query;
-use function array_merge;
use function count;
/**
@@ -549,25 +548,4 @@ public function addMetaResult(
return $this;
}
-
- public function addNewObjectAsArgument(string|int $alias, string|int $objOwner, int $objOwnerIdx): static
- {
- $owner = [
- 'ownerIndex' => $objOwner,
- 'argIndex' => $objOwnerIdx,
- ];
-
- if (! isset($this->nestedNewObjectArguments[$owner['ownerIndex']])) {
- $this->nestedNewObjectArguments[$alias] = $owner;
-
- return $this;
- }
-
- $this->nestedNewObjectArguments = array_merge(
- [$alias => $owner],
- $this->nestedNewObjectArguments,
- );
-
- return $this;
- }
}
diff --git a/src/Query/SqlOutputWalker.php b/src/Query/SqlOutputWalker.php
new file mode 100644
index 00000000000..96cf347fc6a
--- /dev/null
+++ b/src/Query/SqlOutputWalker.php
@@ -0,0 +1,29 @@
+createSqlForFinalizer($AST));
+
+ case $AST instanceof AST\UpdateStatement:
+ return new PreparedExecutorFinalizer($this->createUpdateStatementExecutor($AST));
+
+ case $AST instanceof AST\DeleteStatement:
+ return new PreparedExecutorFinalizer($this->createDeleteStatementExecutor($AST));
+ }
+
+ throw new LogicException('Unexpected AST node type');
+ }
+}
diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php
index 46296e719e7..8164ae13bb8 100644
--- a/src/Query/SqlWalker.php
+++ b/src/Query/SqlWalker.php
@@ -15,7 +15,6 @@
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Utility\HierarchyDiscriminatorResolver;
-use Doctrine\ORM\Utility\LockSqlHelper;
use Doctrine\ORM\Utility\PersisterHelper;
use InvalidArgumentException;
use LogicException;
@@ -51,8 +50,6 @@
*/
class SqlWalker
{
- use LockSqlHelper;
-
public const HINT_DISTINCT = 'doctrine.distinct';
/**
@@ -235,23 +232,40 @@ public function setQueryComponent(string $dqlAlias, array $queryComponent): void
/**
* Gets an executor that can be used to execute the result of this walker.
+ *
+ * @deprecated Output walkers should no longer create the executor directly, but instead provide
+ * a SqlFinalizer by implementing the `OutputWalker` interface. Thus, this method is
+ * no longer needed and will be removed in 4.0.
*/
public function getExecutor(AST\SelectStatement|AST\UpdateStatement|AST\DeleteStatement $statement): Exec\AbstractSqlExecutor
{
return match (true) {
- $statement instanceof AST\SelectStatement
- => new Exec\SingleSelectExecutor($statement, $this),
- $statement instanceof AST\UpdateStatement
- => $this->em->getClassMetadata($statement->updateClause->abstractSchemaName)->isInheritanceTypeJoined()
- ? new Exec\MultiTableUpdateExecutor($statement, $this)
- : new Exec\SingleTableDeleteUpdateExecutor($statement, $this),
- $statement instanceof AST\DeleteStatement
- => $this->em->getClassMetadata($statement->deleteClause->abstractSchemaName)->isInheritanceTypeJoined()
- ? new Exec\MultiTableDeleteExecutor($statement, $this)
- : new Exec\SingleTableDeleteUpdateExecutor($statement, $this),
+ $statement instanceof AST\UpdateStatement => $this->createUpdateStatementExecutor($statement),
+ $statement instanceof AST\DeleteStatement => $this->createDeleteStatementExecutor($statement),
+ default => new Exec\SingleSelectExecutor($statement, $this),
};
}
+ /** @psalm-internal Doctrine\ORM */
+ protected function createUpdateStatementExecutor(AST\UpdateStatement $AST): Exec\AbstractSqlExecutor
+ {
+ $primaryClass = $this->em->getClassMetadata($AST->updateClause->abstractSchemaName);
+
+ return $primaryClass->isInheritanceTypeJoined()
+ ? new Exec\MultiTableUpdateExecutor($AST, $this)
+ : new Exec\SingleTableDeleteUpdateExecutor($AST, $this);
+ }
+
+ /** @psalm-internal Doctrine\ORM */
+ protected function createDeleteStatementExecutor(AST\DeleteStatement $AST): Exec\AbstractSqlExecutor
+ {
+ $primaryClass = $this->em->getClassMetadata($AST->deleteClause->abstractSchemaName);
+
+ return $primaryClass->isInheritanceTypeJoined()
+ ? new Exec\MultiTableDeleteExecutor($AST, $this)
+ : new Exec\SingleTableDeleteUpdateExecutor($AST, $this);
+ }
+
/**
* Generates a unique, short SQL table alias.
*/
@@ -321,6 +335,11 @@ private function generateClassTableInheritanceJoins(
$sql .= implode(' AND ', array_filter($sqlParts));
}
+ // Ignore subclassing inclusion if partial objects is disallowed
+ if ($this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) {
+ return $sql;
+ }
+
// LEFT JOIN child class tables
foreach ($class->subClasses as $subClassName) {
$subClass = $this->em->getClassMetadata($subClassName);
@@ -479,10 +498,15 @@ private function generateFilterConditionSQL(
*/
public function walkSelectStatement(AST\SelectStatement $selectStatement): string
{
- $limit = $this->query->getMaxResults();
- $offset = $this->query->getFirstResult();
- $lockMode = $this->query->getHint(Query::HINT_LOCK_MODE) ?: LockMode::NONE;
- $sql = $this->walkSelectClause($selectStatement->selectClause)
+ $sql = $this->createSqlForFinalizer($selectStatement);
+ $finalizer = new Exec\SingleSelectSqlFinalizer($sql);
+
+ return $finalizer->finalizeSql($this->query);
+ }
+
+ protected function createSqlForFinalizer(AST\SelectStatement $selectStatement): string
+ {
+ $sql = $this->walkSelectClause($selectStatement->selectClause)
. $this->walkFromClause($selectStatement->fromClause)
. $this->walkWhereClause($selectStatement->whereClause);
@@ -503,31 +527,22 @@ public function walkSelectStatement(AST\SelectStatement $selectStatement): strin
$sql .= ' ORDER BY ' . $orderBySql;
}
- $sql = $this->platform->modifyLimitQuery($sql, $limit, $offset);
+ $this->assertOptimisticLockingHasAllClassesVersioned();
- if ($lockMode === LockMode::NONE) {
- return $sql;
- }
-
- if ($lockMode === LockMode::PESSIMISTIC_READ) {
- return $sql . ' ' . $this->getReadLockSQL($this->platform);
- }
-
- if ($lockMode === LockMode::PESSIMISTIC_WRITE) {
- return $sql . ' ' . $this->getWriteLockSQL($this->platform);
- }
+ return $sql;
+ }
- if ($lockMode !== LockMode::OPTIMISTIC) {
- throw QueryException::invalidLockMode();
- }
+ private function assertOptimisticLockingHasAllClassesVersioned(): void
+ {
+ $lockMode = $this->query->getHint(Query::HINT_LOCK_MODE) ?: LockMode::NONE;
- foreach ($this->selectedClasses as $selectedClass) {
- if (! $selectedClass['class']->isVersioned) {
- throw OptimisticLockException::lockFailed($selectedClass['class']->name);
+ if ($lockMode === LockMode::OPTIMISTIC) {
+ foreach ($this->selectedClasses as $selectedClass) {
+ if (! $selectedClass['class']->isVersioned) {
+ throw OptimisticLockException::lockFailed($selectedClass['class']->name);
+ }
}
}
-
- return $sql;
}
/**
@@ -659,7 +674,8 @@ public function walkSelectClause(AST\SelectClause $selectClause): string
$this->query->setHint(self::HINT_DISTINCT, true);
}
- $addMetaColumns = $this->query->getHydrationMode() === Query::HYDRATE_OBJECT
+ $addMetaColumns = ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD) &&
+ $this->query->getHydrationMode() === Query::HYDRATE_OBJECT
|| $this->query->getHint(Query::HINT_INCLUDE_META_COLUMNS);
foreach ($this->selectedClasses as $selectedClass) {
@@ -1398,28 +1414,30 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st
// 1) on Single Table Inheritance: always, since its marginal overhead
// 2) on Class Table Inheritance only if partial objects are disallowed,
// since it requires outer joining subtables.
- foreach ($class->subClasses as $subClassName) {
- $subClass = $this->em->getClassMetadata($subClassName);
- $sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias);
+ if ($class->isInheritanceTypeSingleTable() || ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) {
+ foreach ($class->subClasses as $subClassName) {
+ $subClass = $this->em->getClassMetadata($subClassName);
+ $sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias);
- foreach ($subClass->fieldMappings as $fieldName => $mapping) {
- if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) {
- continue;
- }
+ foreach ($subClass->fieldMappings as $fieldName => $mapping) {
+ if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) {
+ continue;
+ }
- $columnAlias = $this->getSQLColumnAlias($mapping->columnName);
- $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform);
+ $columnAlias = $this->getSQLColumnAlias($mapping->columnName);
+ $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform);
- $col = $sqlTableAlias . '.' . $quotedColumnName;
+ $col = $sqlTableAlias . '.' . $quotedColumnName;
- $type = Type::getType($mapping->type);
- $col = $type->convertToPHPValueSQL($col, $this->platform);
+ $type = Type::getType($mapping->type);
+ $col = $type->convertToPHPValueSQL($col, $this->platform);
- $sqlParts[] = $col . ' AS ' . $columnAlias;
+ $sqlParts[] = $col . ' AS ' . $columnAlias;
- $this->scalarResultAliasMap[$resultAlias][] = $columnAlias;
+ $this->scalarResultAliasMap[$resultAlias][] = $columnAlias;
- $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName);
+ $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName);
+ }
}
}
@@ -1510,6 +1528,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
$this->newObjectStack[] = [$objIndex, $argIndex];
$sqlSelectExpressions[] = $e->dispatch($this);
array_pop($this->newObjectStack);
+ $this->rsm->nestedNewObjectArguments[$columnAlias] = ['ownerIndex' => $objIndex, 'argIndex' => $argIndex];
break;
case $e instanceof AST\Subselect:
@@ -1563,10 +1582,6 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
'objIndex' => $objIndex,
'argIndex' => $argIndex,
];
-
- if ($objOwner !== null && $objOwnerIdx !== null) {
- $this->rsm->addNewObjectAsArgument($objIndex, $objOwner, $objOwnerIdx);
- }
}
return implode(', ', $sqlSelectExpressions);
diff --git a/src/Query/TokenType.php b/src/Query/TokenType.php
index bf1c351c2a6..47cc7912711 100644
--- a/src/Query/TokenType.php
+++ b/src/Query/TokenType.php
@@ -89,4 +89,5 @@ enum TokenType: int
case T_WHEN = 254;
case T_WHERE = 255;
case T_WITH = 256;
+ case T_NAMED = 257;
}
diff --git a/src/Tools/Pagination/CountOutputWalker.php b/src/Tools/Pagination/CountOutputWalker.php
index c7f31dbf628..35f7d051ecf 100644
--- a/src/Tools/Pagination/CountOutputWalker.php
+++ b/src/Tools/Pagination/CountOutputWalker.php
@@ -11,7 +11,7 @@
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\ParserResult;
use Doctrine\ORM\Query\ResultSetMapping;
-use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\SqlOutputWalker;
use RuntimeException;
use function array_diff;
@@ -37,7 +37,7 @@
*
* @psalm-import-type QueryComponent from Parser
*/
-class CountOutputWalker extends SqlWalker
+class CountOutputWalker extends SqlOutputWalker
{
private readonly AbstractPlatform $platform;
private readonly ResultSetMapping $rsm;
@@ -53,13 +53,13 @@ public function __construct(Query $query, ParserResult $parserResult, array $que
parent::__construct($query, $parserResult, $queryComponents);
}
- public function walkSelectStatement(SelectStatement $selectStatement): string
+ protected function createSqlForFinalizer(SelectStatement $selectStatement): string
{
if ($this->platform instanceof SQLServerPlatform) {
$selectStatement->orderByClause = null;
}
- $sql = parent::walkSelectStatement($selectStatement);
+ $sql = parent::createSqlForFinalizer($selectStatement);
if ($selectStatement->groupByClause) {
return sprintf(
diff --git a/src/Tools/Pagination/LimitSubqueryOutputWalker.php b/src/Tools/Pagination/LimitSubqueryOutputWalker.php
index 8bbc44c21a1..5cb65e7a993 100644
--- a/src/Tools/Pagination/LimitSubqueryOutputWalker.php
+++ b/src/Tools/Pagination/LimitSubqueryOutputWalker.php
@@ -13,16 +13,20 @@
use Doctrine\ORM\Mapping\QuoteStrategy;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\Query;
+use Doctrine\ORM\Query\AST;
use Doctrine\ORM\Query\AST\OrderByClause;
use Doctrine\ORM\Query\AST\PathExpression;
use Doctrine\ORM\Query\AST\SelectExpression;
use Doctrine\ORM\Query\AST\SelectStatement;
use Doctrine\ORM\Query\AST\Subselect;
+use Doctrine\ORM\Query\Exec\SingleSelectSqlFinalizer;
+use Doctrine\ORM\Query\Exec\SqlFinalizer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\ParserResult;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\Query\ResultSetMapping;
-use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\SqlOutputWalker;
+use LogicException;
use RuntimeException;
use function array_diff;
@@ -50,7 +54,7 @@
*
* @psalm-import-type QueryComponent from Parser
*/
-class LimitSubqueryOutputWalker extends SqlWalker
+class LimitSubqueryOutputWalker extends SqlOutputWalker
{
private const ORDER_BY_PATH_EXPRESSION = '/(?platform = $query->getEntityManager()->getConnection()->getDatabasePlatform();
$this->rsm = $parserResult->getResultSetMapping();
+ $query = clone $query;
+
// Reset limit and offset
$this->firstResult = $query->getFirstResult();
$this->maxResults = $query->getMaxResults();
@@ -139,11 +145,28 @@ private function rebuildOrderByForRowNumber(SelectStatement $AST): void
public function walkSelectStatement(SelectStatement $selectStatement): string
{
+ $sqlFinalizer = $this->getFinalizer($selectStatement);
+
+ $query = $this->getQuery();
+
+ $abstractSqlExecutor = $sqlFinalizer->createExecutor($query);
+
+ return $abstractSqlExecutor->getSqlStatements();
+ }
+
+ public function getFinalizer(AST\DeleteStatement|AST\UpdateStatement|AST\SelectStatement $AST): SqlFinalizer
+ {
+ if (! $AST instanceof SelectStatement) {
+ throw new LogicException(self::class . ' is to be used on SelectStatements only');
+ }
+
if ($this->platformSupportsRowNumber()) {
- return $this->walkSelectStatementWithRowNumber($selectStatement);
+ $sql = $this->createSqlWithRowNumber($AST);
+ } else {
+ $sql = $this->createSqlWithoutRowNumber($AST);
}
- return $this->walkSelectStatementWithoutRowNumber($selectStatement);
+ return new SingleSelectSqlFinalizer($sql);
}
/**
@@ -153,6 +176,16 @@ public function walkSelectStatement(SelectStatement $selectStatement): string
* @throws RuntimeException
*/
public function walkSelectStatementWithRowNumber(SelectStatement $AST): string
+ {
+ // Apply the limit and offset.
+ return $this->platform->modifyLimitQuery(
+ $this->createSqlWithRowNumber($AST),
+ $this->maxResults,
+ $this->firstResult,
+ );
+ }
+
+ private function createSqlWithRowNumber(SelectStatement $AST): string
{
$hasOrderBy = false;
$outerOrderBy = ' ORDER BY dctrn_minrownum ASC';
@@ -182,13 +215,6 @@ public function walkSelectStatementWithRowNumber(SelectStatement $AST): string
$sql .= $orderGroupBy . $outerOrderBy;
}
- // Apply the limit and offset.
- $sql = $this->platform->modifyLimitQuery(
- $sql,
- $this->maxResults,
- $this->firstResult,
- );
-
// Add the columns to the ResultSetMapping. It's not really nice but
// it works. Preferably I'd clear the RSM or simply create a new one
// but that is not possible from inside the output walker, so we dirty
@@ -207,6 +233,16 @@ public function walkSelectStatementWithRowNumber(SelectStatement $AST): string
* @throws RuntimeException
*/
public function walkSelectStatementWithoutRowNumber(SelectStatement $AST, bool $addMissingItemsFromOrderByToSelect = true): string
+ {
+ // Apply the limit and offset.
+ return $this->platform->modifyLimitQuery(
+ $this->createSqlWithoutRowNumber($AST, $addMissingItemsFromOrderByToSelect),
+ $this->maxResults,
+ $this->firstResult,
+ );
+ }
+
+ private function createSqlWithoutRowNumber(SelectStatement $AST, bool $addMissingItemsFromOrderByToSelect = true): string
{
// We don't want to call this recursively!
if ($AST->orderByClause instanceof OrderByClause && $addMissingItemsFromOrderByToSelect) {
@@ -235,13 +271,6 @@ public function walkSelectStatementWithoutRowNumber(SelectStatement $AST, bool $
// https://github.com/doctrine/orm/issues/2630
$sql = $this->preserveSqlOrdering($sqlIdentifier, $innerSql, $sql, $orderByClause);
- // Apply the limit and offset.
- $sql = $this->platform->modifyLimitQuery(
- $sql,
- $this->maxResults,
- $this->firstResult,
- );
-
// Add the columns to the ResultSetMapping. It's not really nice but
// it works. Preferably I'd clear the RSM or simply create a new one
// but that is not possible from inside the output walker, so we dirty
diff --git a/src/Tools/Pagination/RootTypeWalker.php b/src/Tools/Pagination/RootTypeWalker.php
index f630ee14dea..82d52c2f4c4 100644
--- a/src/Tools/Pagination/RootTypeWalker.php
+++ b/src/Tools/Pagination/RootTypeWalker.php
@@ -5,7 +5,10 @@
namespace Doctrine\ORM\Tools\Pagination;
use Doctrine\ORM\Query\AST;
-use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\Exec\FinalizedSelectExecutor;
+use Doctrine\ORM\Query\Exec\PreparedExecutorFinalizer;
+use Doctrine\ORM\Query\Exec\SqlFinalizer;
+use Doctrine\ORM\Query\SqlOutputWalker;
use Doctrine\ORM\Utility\PersisterHelper;
use RuntimeException;
@@ -22,7 +25,7 @@
* Returning the type instead of a "real" SQL statement is a slight hack. However, it has the
* benefit that the DQL -> root entity id type resolution can be cached in the query cache.
*/
-final class RootTypeWalker extends SqlWalker
+final class RootTypeWalker extends SqlOutputWalker
{
public function walkSelectStatement(AST\SelectStatement $selectStatement): string
{
@@ -45,4 +48,13 @@ public function walkSelectStatement(AST\SelectStatement $selectStatement): strin
->getEntityManager(),
)[0];
}
+
+ public function getFinalizer(AST\DeleteStatement|AST\UpdateStatement|AST\SelectStatement $AST): SqlFinalizer
+ {
+ if (! $AST instanceof AST\SelectStatement) {
+ throw new RuntimeException(self::class . ' is to be used on SelectStatements only');
+ }
+
+ return new PreparedExecutorFinalizer(new FinalizedSelectExecutor($this->walkSelectStatement($AST)));
+ }
}
diff --git a/src/Tools/SchemaTool.php b/src/Tools/SchemaTool.php
index cff59aecdd8..e0a24d9459d 100644
--- a/src/Tools/SchemaTool.php
+++ b/src/Tools/SchemaTool.php
@@ -47,7 +47,7 @@
*/
class SchemaTool
{
- private const KNOWN_COLUMN_OPTIONS = ['comment', 'unsigned', 'fixed', 'default'];
+ private const KNOWN_COLUMN_OPTIONS = ['comment', 'unsigned', 'fixed', 'default', 'values'];
private readonly AbstractPlatform $platform;
private readonly QuoteStrategy $quoteStrategy;
diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php
index e8d92669b33..49749af3652 100644
--- a/src/UnitOfWork.php
+++ b/src/UnitOfWork.php
@@ -28,7 +28,6 @@
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Exception\UnexpectedAssociationValue;
use Doctrine\ORM\Id\AssignedGenerator;
-use Doctrine\ORM\Internal\Hydration\HydrationException;
use Doctrine\ORM\Internal\HydrationCompleteHandler;
use Doctrine\ORM\Internal\StronglyConnectedComponents;
use Doctrine\ORM\Internal\TopologicalSort;
@@ -44,14 +43,12 @@
use Doctrine\ORM\Persisters\Entity\JoinedSubclassPersister;
use Doctrine\ORM\Persisters\Entity\SingleTablePersister;
use Doctrine\ORM\Proxy\InternalProxy;
-use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Utility\IdentifierFlattener;
use Doctrine\Persistence\PropertyChangedListener;
use Exception;
use InvalidArgumentException;
use RuntimeException;
use Stringable;
-use Throwable;
use UnexpectedValueException;
use function array_chunk;
@@ -381,6 +378,8 @@ public function commit(): void
$conn = $this->em->getConnection();
$conn->beginTransaction();
+ $successful = false;
+
try {
// Collection deletions (deletions of complete collections)
foreach ($this->collectionDeletions as $collectionToDelete) {
@@ -438,16 +437,18 @@ public function commit(): void
if ($commitFailed) {
throw new OptimisticLockException('Commit failed', null, $e ?? null);
}
- } catch (Throwable $e) {
- $this->em->close();
- if ($conn->isTransactionActive()) {
- $conn->rollBack();
- }
+ $successful = true;
+ } finally {
+ if (! $successful) {
+ $this->em->close();
- $this->afterTransactionRolledBack();
+ if ($conn->isTransactionActive()) {
+ $conn->rollBack();
+ }
- throw $e;
+ $this->afterTransactionRolledBack();
+ }
}
$this->afterTransactionComplete();
@@ -2353,10 +2354,6 @@ public function isCollectionScheduledForDeletion(PersistentCollection $coll): bo
*/
public function createEntity(string $className, array $data, array &$hints = []): object
{
- if (isset($hints[SqlWalker::HINT_PARTIAL])) {
- throw HydrationException::partialObjectHydrationDisallowed();
- }
-
$class = $this->em->getClassMetadata($className);
$id = $this->identifierFlattener->flattenIdentifier($class, $data);
diff --git a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php
new file mode 100644
index 00000000000..0694e8fff6a
--- /dev/null
+++ b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php
@@ -0,0 +1,91 @@
+ '1',
+ 'u__status' => 'developer',
+ 'u__username' => 'romanb',
+ 'u__name' => 'Roman',
+ 'sclr0' => 'ROMANB',
+ 'p__phonenumber' => '42',
+ 'a__id' => '1',
+ ],
+ [
+ 'u__id' => '1',
+ 'u__status' => 'developer',
+ 'u__username' => 'romanb',
+ 'u__name' => 'Roman',
+ 'sclr0' => 'ROMANB',
+ 'p__phonenumber' => '43',
+ 'a__id' => '1',
+ ],
+ [
+ 'u__id' => '2',
+ 'u__status' => 'developer',
+ 'u__username' => 'romanb',
+ 'u__name' => 'Roman',
+ 'sclr0' => 'JWAGE',
+ 'p__phonenumber' => '91',
+ 'a__id' => '1',
+ ],
+ ];
+
+ for ($i = 4; $i < 2000; ++$i) {
+ $resultSet[] = [
+ 'u__id' => $i,
+ 'u__status' => 'developer',
+ 'u__username' => 'jwage',
+ 'u__name' => 'Jonathan',
+ 'sclr0' => 'JWAGE' . $i,
+ 'p__phonenumber' => '91',
+ 'a__id' => '1',
+ ];
+ }
+
+ $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet);
+ $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([]));
+ $this->rsm = new ResultSetMapping();
+
+ $this->rsm->addEntityResult(CmsUser::class, 'u');
+ $this->rsm->addJoinedEntityResult(CmsPhonenumber::class, 'p', 'u', 'phonenumbers');
+ $this->rsm->addFieldResult('u', 'u__id', 'id');
+ $this->rsm->addFieldResult('u', 'u__status', 'status');
+ $this->rsm->addFieldResult('u', 'u__username', 'username');
+ $this->rsm->addFieldResult('u', 'u__name', 'name');
+ $this->rsm->addScalarResult('sclr0', 'nameUpper');
+ $this->rsm->addFieldResult('p', 'p__phonenumber', 'phonenumber');
+ $this->rsm->addJoinedEntityResult(CmsAddress::class, 'a', 'u', 'address');
+ $this->rsm->addFieldResult('a', 'a__id', 'id');
+ }
+
+ public function benchHydration(): void
+ {
+ $this->hydrator->hydrateAll($this->result, $this->rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]);
+ }
+}
diff --git a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php
new file mode 100644
index 00000000000..14ed606508c
--- /dev/null
+++ b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php
@@ -0,0 +1,79 @@
+ '1',
+ 'u__status' => 'developer',
+ 'u__username' => 'romanb',
+ 'u__name' => 'Roman',
+ 'a__id' => '1',
+ ],
+ [
+ 'u__id' => '1',
+ 'u__status' => 'developer',
+ 'u__username' => 'romanb',
+ 'u__name' => 'Roman',
+ 'a__id' => '1',
+ ],
+ [
+ 'u__id' => '2',
+ 'u__status' => 'developer',
+ 'u__username' => 'romanb',
+ 'u__name' => 'Roman',
+ 'a__id' => '1',
+ ],
+ ];
+
+ for ($i = 4; $i < 10000; ++$i) {
+ $resultSet[] = [
+ 'u__id' => $i,
+ 'u__status' => 'developer',
+ 'u__username' => 'jwage',
+ 'u__name' => 'Jonathan',
+ 'a__id' => '1',
+ ];
+ }
+
+ $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet);
+ $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([]));
+ $this->rsm = new ResultSetMapping();
+
+ $this->rsm->addEntityResult(CmsUser::class, 'u');
+ $this->rsm->addFieldResult('u', 'u__id', 'id');
+ $this->rsm->addFieldResult('u', 'u__status', 'status');
+ $this->rsm->addFieldResult('u', 'u__username', 'username');
+ $this->rsm->addFieldResult('u', 'u__name', 'name');
+ $this->rsm->addJoinedEntityResult(CmsAddress::class, 'a', 'u', 'address');
+ $this->rsm->addFieldResult('a', 'a__id', 'id');
+ }
+
+ public function benchHydration(): void
+ {
+ $this->hydrator->hydrateAll($this->result, $this->rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]);
+ }
+}
diff --git a/tests/Tests/Mocks/NullSqlWalker.php b/tests/Tests/Mocks/NullSqlWalker.php
index 3e940e08e59..f94d1705a60 100644
--- a/tests/Tests/Mocks/NullSqlWalker.php
+++ b/tests/Tests/Mocks/NullSqlWalker.php
@@ -7,12 +7,14 @@
use Doctrine\DBAL\Connection;
use Doctrine\ORM\Query\AST;
use Doctrine\ORM\Query\Exec\AbstractSqlExecutor;
-use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\Exec\PreparedExecutorFinalizer;
+use Doctrine\ORM\Query\Exec\SqlFinalizer;
+use Doctrine\ORM\Query\SqlOutputWalker;
/**
* SqlWalker implementation that does not produce SQL.
*/
-final class NullSqlWalker extends SqlWalker
+final class NullSqlWalker extends SqlOutputWalker
{
public function walkSelectStatement(AST\SelectStatement $selectStatement): string
{
@@ -29,13 +31,15 @@ public function walkDeleteStatement(AST\DeleteStatement $deleteStatement): strin
return '';
}
- public function getExecutor(AST\SelectStatement|AST\UpdateStatement|AST\DeleteStatement $statement): AbstractSqlExecutor
+ public function getFinalizer(AST\SelectStatement|AST\UpdateStatement|AST\DeleteStatement $statement): SqlFinalizer
{
- return new class extends AbstractSqlExecutor {
- public function execute(Connection $conn, array $params, array $types): int
- {
- return 0;
- }
- };
+ return new PreparedExecutorFinalizer(
+ new class extends AbstractSqlExecutor {
+ public function execute(Connection $conn, array $params, array $types): int
+ {
+ return 0;
+ }
+ },
+ );
}
}
diff --git a/tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php b/tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php
new file mode 100644
index 00000000000..547c7fb0c31
--- /dev/null
+++ b/tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php
@@ -0,0 +1,16 @@
+name = $args['name'] ?? null;
+ $this->email = $args['email'] ?? null;
+ $this->phonenumbers = $args['phonenumbers'] ?? null;
+ $this->address = $args['address'] ?? null;
+ }
+}
diff --git a/tests/Tests/Models/Customer/CustomerType.php b/tests/Tests/Models/Customer/CustomerType.php
new file mode 100644
index 00000000000..bd68c07ecf2
--- /dev/null
+++ b/tests/Tests/Models/Customer/CustomerType.php
@@ -0,0 +1,16 @@
+name = $name;
+ }
+}
diff --git a/tests/Tests/Models/Customer/ExternalCustomer.php b/tests/Tests/Models/Customer/ExternalCustomer.php
new file mode 100644
index 00000000000..cf50d3e85e0
--- /dev/null
+++ b/tests/Tests/Models/Customer/ExternalCustomer.php
@@ -0,0 +1,13 @@
+ ['H', 'D', 'C', 'S', 'Z']])]
+ public $suit;
+}
diff --git a/tests/Tests/Models/Enums/TypedCardNativeEnum.php b/tests/Tests/Models/Enums/TypedCardNativeEnum.php
new file mode 100644
index 00000000000..59e4eb00e55
--- /dev/null
+++ b/tests/Tests/Models/Enums/TypedCardNativeEnum.php
@@ -0,0 +1,23 @@
+factory->expects(self::once())
->method('getRegion')
->with(self::equalTo($metadata->cache))
- ->will(self::returnValue($region));
+ ->willReturn($region);
$cachedPersister = $this->factory->buildCachedEntityPersister($em, $persister, $metadata);
@@ -91,7 +91,7 @@ public function testBuildCachedEntityPersisterReadWrite(): void
$this->factory->expects(self::once())
->method('getRegion')
->with(self::equalTo($metadata->cache))
- ->will(self::returnValue($region));
+ ->willReturn($region);
$cachedPersister = $this->factory->buildCachedEntityPersister($em, $persister, $metadata);
@@ -111,7 +111,7 @@ public function testBuildCachedEntityPersisterNonStrictReadWrite(): void
$this->factory->expects(self::once())
->method('getRegion')
->with(self::equalTo($metadata->cache))
- ->will(self::returnValue($region));
+ ->willReturn($region);
$cachedPersister = $this->factory->buildCachedEntityPersister($em, $persister, $metadata);
@@ -132,7 +132,7 @@ public function testBuildCachedCollectionPersisterReadOnly(): void
$this->factory->expects(self::once())
->method('getRegion')
->with(self::equalTo($mapping->cache))
- ->will(self::returnValue($region));
+ ->willReturn($region);
$cachedPersister = $this->factory->buildCachedCollectionPersister($em, $persister, $mapping);
@@ -153,7 +153,7 @@ public function testBuildCachedCollectionPersisterReadWrite(): void
$this->factory->expects(self::once())
->method('getRegion')
->with(self::equalTo($mapping->cache))
- ->will(self::returnValue($region));
+ ->willReturn($region);
$cachedPersister = $this->factory->buildCachedCollectionPersister($em, $persister, $mapping);
@@ -174,7 +174,7 @@ public function testBuildCachedCollectionPersisterNonStrictReadWrite(): void
$this->factory->expects(self::once())
->method('getRegion')
->with(self::equalTo($mapping->cache))
- ->will(self::returnValue($region));
+ ->willReturn($region);
$cachedPersister = $this->factory->buildCachedCollectionPersister($em, $persister, $mapping);
diff --git a/tests/Tests/ORM/Cache/Persister/Entity/NonStrictReadWriteCachedEntityPersisterTest.php b/tests/Tests/ORM/Cache/Persister/Entity/NonStrictReadWriteCachedEntityPersisterTest.php
index 0c8f7c4387d..fe6c050bc71 100644
--- a/tests/Tests/ORM/Cache/Persister/Entity/NonStrictReadWriteCachedEntityPersisterTest.php
+++ b/tests/Tests/ORM/Cache/Persister/Entity/NonStrictReadWriteCachedEntityPersisterTest.php
@@ -60,7 +60,7 @@ public function testInsertTransactionCommitShouldPutCache(): void
$this->entityPersister->expects(self::once())
->method('getInserts')
- ->will(self::returnValue([$entity]));
+ ->willReturn([$entity]);
$this->entityPersister->expects(self::once())
->method('executeInserts');
diff --git a/tests/Tests/ORM/EntityManagerTest.php b/tests/Tests/ORM/EntityManagerTest.php
index 501f86550ce..9e9f67d1a83 100644
--- a/tests/Tests/ORM/EntityManagerTest.php
+++ b/tests/Tests/ORM/EntityManagerTest.php
@@ -6,6 +6,7 @@
use Doctrine\Common\EventManager;
use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Driver;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
@@ -19,7 +20,9 @@
use Doctrine\Tests\Mocks\EntityManagerMock;
use Doctrine\Tests\Models\CMS\CmsUser;
use Doctrine\Tests\OrmTestCase;
+use Exception;
use Generator;
+use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use ReflectionProperty;
@@ -207,4 +210,59 @@ public function clear(): void
$em->resetLazyObject();
$this->assertTrue($em->isOpen());
}
+
+ public function testItPreservesTheOriginalExceptionOnRollbackFailure(): void
+ {
+ $driver = $this->createMock(Driver::class);
+ $driver->method('connect')
+ ->willReturn($this->createMock(Driver\Connection::class));
+
+ $entityManager = new EntityManagerMock(new class ([], $driver) extends Connection {
+ public function rollBack(): void
+ {
+ throw new Exception('Rollback exception');
+ }
+ });
+
+ try {
+ $entityManager->wrapInTransaction(static function (): void {
+ throw new Exception('Original exception');
+ });
+ self::fail('Exception expected');
+ } catch (Exception $e) {
+ self::assertSame('Rollback exception', $e->getMessage());
+ self::assertNotNull($e->getPrevious());
+ self::assertSame('Original exception', $e->getPrevious()->getMessage());
+ }
+ }
+
+ public function testItDoesNotAttemptToRollbackIfNoTransactionIsActive(): void
+ {
+ $driver = $this->createMock(Driver::class);
+ $driver->method('connect')
+ ->willReturn($this->createMock(Driver\Connection::class));
+
+ $entityManager = new EntityManagerMock(
+ new class ([], $driver) extends Connection {
+ public function commit(): void
+ {
+ throw new Exception('Commit exception that happens after doing the actual commit');
+ }
+
+ public function rollBack(): void
+ {
+ Assert::fail('Should not attempt to rollback if no transaction is active');
+ }
+
+ public function isTransactionActive(): bool
+ {
+ return false;
+ }
+ },
+ );
+
+ $this->expectExceptionMessage('Commit exception');
+ $entityManager->wrapInTransaction(static function (): void {
+ });
+ }
}
diff --git a/tests/Tests/ORM/Functional/EnumTest.php b/tests/Tests/ORM/Functional/EnumTest.php
index b5de27ab80d..c4439be9672 100644
--- a/tests/Tests/ORM/Functional/EnumTest.php
+++ b/tests/Tests/ORM/Functional/EnumTest.php
@@ -4,6 +4,7 @@
namespace Doctrine\Tests\ORM\Functional;
+use Doctrine\DBAL\Types\EnumType;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
@@ -13,6 +14,7 @@
use Doctrine\Tests\Models\DataTransferObjects\DtoWithArrayOfEnums;
use Doctrine\Tests\Models\DataTransferObjects\DtoWithEnum;
use Doctrine\Tests\Models\Enums\Card;
+use Doctrine\Tests\Models\Enums\CardNativeEnum;
use Doctrine\Tests\Models\Enums\CardWithDefault;
use Doctrine\Tests\Models\Enums\CardWithNullable;
use Doctrine\Tests\Models\Enums\Product;
@@ -20,10 +22,12 @@
use Doctrine\Tests\Models\Enums\Scale;
use Doctrine\Tests\Models\Enums\Suit;
use Doctrine\Tests\Models\Enums\TypedCard;
+use Doctrine\Tests\Models\Enums\TypedCardNativeEnum;
use Doctrine\Tests\Models\Enums\Unit;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
+use function class_exists;
use function dirname;
use function sprintf;
use function uniqid;
@@ -55,7 +59,7 @@ public function testEnumMapping(string $cardClass): void
$this->_em->flush();
$this->_em->clear();
- $fetchedCard = $this->_em->find(Card::class, $card->id);
+ $fetchedCard = $this->_em->find($cardClass, $card->id);
$this->assertInstanceOf(Suit::class, $fetchedCard->suit);
$this->assertEquals(Suit::Clubs, $fetchedCard->suit);
@@ -417,6 +421,10 @@ public function testFindByEnum(): void
#[DataProvider('provideCardClasses')]
public function testEnumWithNonMatchingDatabaseValueThrowsException(string $cardClass): void
{
+ if ($cardClass === TypedCardNativeEnum::class) {
+ self::markTestSkipped('MySQL won\'t allow us to insert invalid values in this case.');
+ }
+
$this->setUpEntitySchema([$cardClass]);
$card = new $cardClass();
@@ -429,7 +437,7 @@ public function testEnumWithNonMatchingDatabaseValueThrowsException(string $card
$metadata = $this->_em->getClassMetadata($cardClass);
$this->_em->getConnection()->update(
$metadata->table['name'],
- [$metadata->fieldMappings['suit']->columnName => 'invalid'],
+ [$metadata->fieldMappings['suit']->columnName => 'Z'],
[$metadata->fieldMappings['id']->columnName => $card->id],
);
@@ -437,7 +445,7 @@ public function testEnumWithNonMatchingDatabaseValueThrowsException(string $card
$this->expectExceptionMessage(sprintf(
<<<'EXCEPTION'
Context: Trying to hydrate enum property "%s::$suit"
-Problem: Case "invalid" is not listed in enum "Doctrine\Tests\Models\Enums\Suit"
+Problem: Case "Z" is not listed in enum "Doctrine\Tests\Models\Enums\Suit"
Solution: Either add the case to the enum type or migrate the database column to use another case of the enum
EXCEPTION
,
@@ -447,13 +455,16 @@ public function testEnumWithNonMatchingDatabaseValueThrowsException(string $card
$this->_em->find($cardClass, $card->id);
}
- /** @return array */
- public static function provideCardClasses(): array
+ /** @return iterable */
+ public static function provideCardClasses(): iterable
{
- return [
- Card::class => [Card::class],
- TypedCard::class => [TypedCard::class],
- ];
+ yield Card::class => [Card::class];
+ yield TypedCard::class => [TypedCard::class];
+
+ if (class_exists(EnumType::class)) {
+ yield CardNativeEnum::class => [CardNativeEnum::class];
+ yield TypedCardNativeEnum::class => [TypedCardNativeEnum::class];
+ }
}
public function testItAllowsReadingAttributes(): void
diff --git a/tests/Tests/ORM/Functional/ExtraLazyCollectionTest.php b/tests/Tests/ORM/Functional/ExtraLazyCollectionTest.php
index a092f6555cd..569ea135009 100644
--- a/tests/Tests/ORM/Functional/ExtraLazyCollectionTest.php
+++ b/tests/Tests/ORM/Functional/ExtraLazyCollectionTest.php
@@ -179,6 +179,63 @@ public function testCountOneToManyJoinedInheritance(): void
self::assertCount(2, $otherClass->childClasses);
}
+ #[Group('non-cacheable')]
+ public function testFirstWhenInitialized(): void
+ {
+ $user = $this->_em->find(CmsUser::class, $this->userId);
+ $this->getQueryLog()->reset()->enable();
+ $user->groups->toArray();
+
+ self::assertTrue($user->groups->isInitialized());
+ self::assertInstanceOf(CmsGroup::class, $user->groups->first());
+ $this->assertQueryCount(1, 'Should only execute one query to initialize collection, no extra query for first().');
+ }
+
+ public function testFirstOnEmptyCollectionWhenInitialized(): void
+ {
+ foreach ($this->_em->getRepository(CmsGroup::class)->findAll() as $group) {
+ $this->_em->remove($group);
+ }
+
+ $this->_em->flush();
+
+ $user = $this->_em->find(CmsUser::class, $this->userId);
+ $this->getQueryLog()->reset()->enable();
+ $user->groups->toArray();
+
+ self::assertTrue($user->groups->isInitialized());
+ self::assertFalse($user->groups->first());
+ $this->assertQueryCount(1, 'Should only execute one query to initialize collection, no extra query for first().');
+ }
+
+ public function testFirstWhenNotInitialized(): void
+ {
+ $user = $this->_em->find(CmsUser::class, $this->userId);
+ $this->getQueryLog()->reset()->enable();
+
+ self::assertFalse($user->groups->isInitialized());
+ self::assertInstanceOf(CmsGroup::class, $user->groups->first());
+ self::assertFalse($user->groups->isInitialized());
+ $this->assertQueryCount(1, 'Should only execute one query for first().');
+ }
+
+ public function testFirstOnEmptyCollectionWhenNotInitialized(): void
+ {
+ foreach ($this->_em->getRepository(CmsGroup::class)->findAll() as $group) {
+ $this->_em->remove($group);
+ }
+
+ $this->_em->flush();
+
+ $user = $this->_em->find(CmsUser::class, $this->userId);
+ $this->getQueryLog()->reset()->enable();
+
+ self::assertFalse($user->groups->isInitialized());
+ self::assertFalse($user->groups->first());
+ self::assertFalse($user->groups->isInitialized());
+ $this->assertQueryCount(1, 'Should only execute one query for first().');
+ }
+
#[Group('DDC-546')]
public function testFullSlice(): void
{
diff --git a/tests/Tests/ORM/Functional/NewOperatorTest.php b/tests/Tests/ORM/Functional/NewOperatorTest.php
index 4497af517bf..5a742c1b3a9 100644
--- a/tests/Tests/ORM/Functional/NewOperatorTest.php
+++ b/tests/Tests/ORM/Functional/NewOperatorTest.php
@@ -4,19 +4,25 @@
namespace Doctrine\Tests\ORM\Functional;
+use Doctrine\ORM\Exception\DuplicateFieldException;
+use Doctrine\ORM\Exception\NoMatchingPropertyException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\QueryException;
use Doctrine\Tests\Models\CMS\CmsAddress;
use Doctrine\Tests\Models\CMS\CmsAddressDTO;
+use Doctrine\Tests\Models\CMS\CmsAddressDTONamedArgs;
use Doctrine\Tests\Models\CMS\CmsEmail;
use Doctrine\Tests\Models\CMS\CmsPhonenumber;
use Doctrine\Tests\Models\CMS\CmsUser;
use Doctrine\Tests\Models\CMS\CmsUserDTO;
+use Doctrine\Tests\Models\CMS\CmsUserDTONamedArgs;
+use Doctrine\Tests\Models\CMS\CmsUserDTOVariadicArg;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use function count;
+use function sprintf;
#[Group('DDC-1574')]
class NewOperatorTest extends OrmFunctionalTestCase
@@ -1080,6 +1086,327 @@ public function testShouldSupportNestedNewOperators(): void
self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']);
self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']);
}
+
+ public function testNamedArguments(): void
+ {
+ $dql = <<<'SQL'
+ SELECT
+ new named CmsUserDTONamedArgs(
+ e.email,
+ u.name,
+ CONCAT(a.country, ' ', a.city, ' ', a.zip) AS address
+ ) as user,
+ u.status,
+ u.username as cmsUserUsername
+ FROM
+ Doctrine\Tests\Models\CMS\CmsUser u
+ JOIN
+ u.email e
+ JOIN
+ u.address a
+ ORDER BY
+ u.name
+ SQL;
+
+ $query = $this->getEntityManager()->createQuery($dql);
+ $result = $query->getResult();
+
+ self::assertCount(3, $result);
+
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[0]['user']);
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[1]['user']);
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[2]['user']);
+
+ self::assertSame($this->fixtures[0]->name, $result[0]['user']->name);
+ self::assertSame($this->fixtures[1]->name, $result[1]['user']->name);
+ self::assertSame($this->fixtures[2]->name, $result[2]['user']->name);
+
+ self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email);
+ self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email);
+ self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email);
+
+ self::assertSame(sprintf(
+ '%s %s %s',
+ $this->fixtures[0]->address->country,
+ $this->fixtures[0]->address->city,
+ $this->fixtures[0]->address->zip,
+ ), $result[0]['user']->address);
+ self::assertSame(
+ sprintf(
+ '%s %s %s',
+ $this->fixtures[1]->address->country,
+ $this->fixtures[1]->address->city,
+ $this->fixtures[1]->address->zip,
+ ),
+ $result[1]['user']->address,
+ );
+ self::assertSame(
+ sprintf(
+ '%s %s %s',
+ $this->fixtures[2]->address->country,
+ $this->fixtures[2]->address->city,
+ $this->fixtures[2]->address->zip,
+ ),
+ $result[2]['user']->address,
+ );
+
+ self::assertSame($this->fixtures[0]->status, $result[0]['status']);
+ self::assertSame($this->fixtures[1]->status, $result[1]['status']);
+ self::assertSame($this->fixtures[2]->status, $result[2]['status']);
+
+ self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']);
+ self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']);
+ self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']);
+ }
+
+ public function testVariadicArgument(): void
+ {
+ $dql = <<<'SQL'
+ SELECT
+ new named CmsUserDTOVariadicArg(
+ CONCAT(a.country, ' ', a.city, ' ', a.zip) AS address,
+ e.email,
+ u.name
+ ) as user,
+ u.status,
+ u.username as cmsUserUsername
+ FROM
+ Doctrine\Tests\Models\CMS\CmsUser u
+ JOIN
+ u.email e
+ JOIN
+ u.address a
+ ORDER BY
+ u.name
+ SQL;
+
+ $query = $this->getEntityManager()->createQuery($dql);
+ $result = $query->getResult();
+
+ self::assertCount(3, $result);
+
+ self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[0]['user']);
+ self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[1]['user']);
+ self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[2]['user']);
+
+ self::assertSame($this->fixtures[0]->name, $result[0]['user']->name);
+ self::assertSame($this->fixtures[1]->name, $result[1]['user']->name);
+ self::assertSame($this->fixtures[2]->name, $result[2]['user']->name);
+
+ self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email);
+ self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email);
+ self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email);
+
+ self::assertSame(
+ sprintf(
+ '%s %s %s',
+ $this->fixtures[0]->address->country,
+ $this->fixtures[0]->address->city,
+ $this->fixtures[0]->address->zip,
+ ),
+ $result[0]['user']->address,
+ );
+ self::assertSame(
+ sprintf(
+ '%s %s %s',
+ $this->fixtures[1]->address->country,
+ $this->fixtures[1]->address->city,
+ $this->fixtures[1]->address->zip,
+ ),
+ $result[1]['user']->address,
+ );
+ self::assertSame(
+ sprintf(
+ '%s %s %s',
+ $this->fixtures[2]->address->country,
+ $this->fixtures[2]->address->city,
+ $this->fixtures[2]->address->zip,
+ ),
+ $result[2]['user']->address,
+ );
+
+ self::assertSame($this->fixtures[0]->status, $result[0]['status']);
+ self::assertSame($this->fixtures[1]->status, $result[1]['status']);
+ self::assertSame($this->fixtures[2]->status, $result[2]['status']);
+
+ self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']);
+ self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']);
+ self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']);
+ }
+
+ public function testShouldSupportNestedNewOperatorsAndNamedArguments(): void
+ {
+ $dql = '
+ SELECT
+ new named CmsUserDTONamedArgs(
+ e.email,
+ u.name as name,
+ new CmsAddressDTO(
+ a.country,
+ a.city,
+ a.zip
+ ) as addressDto
+ ) as user,
+ u.status,
+ u.username as cmsUserUsername
+ FROM
+ Doctrine\Tests\Models\CMS\CmsUser u
+ JOIN
+ u.email e
+ JOIN
+ u.address a
+ ORDER BY
+ u.name';
+
+ $query = $this->getEntityManager()->createQuery($dql);
+ $result = $query->getResult();
+
+ self::assertCount(3, $result);
+
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[0]['user']);
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[1]['user']);
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[2]['user']);
+
+ self::assertNull($result[0]['user']->address);
+ self::assertNull($result[1]['user']->address);
+ self::assertNull($result[2]['user']->address);
+
+ self::assertInstanceOf(CmsAddressDTO::class, $result[0]['user']->addressDto);
+ self::assertInstanceOf(CmsAddressDTO::class, $result[1]['user']->addressDto);
+ self::assertInstanceOf(CmsAddressDTO::class, $result[2]['user']->addressDto);
+
+ self::assertSame($this->fixtures[0]->name, $result[0]['user']->name);
+ self::assertSame($this->fixtures[1]->name, $result[1]['user']->name);
+ self::assertSame($this->fixtures[2]->name, $result[2]['user']->name);
+
+ self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email);
+ self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email);
+ self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email);
+
+ self::assertSame($this->fixtures[0]->address->city, $result[0]['user']->addressDto->city);
+ self::assertSame($this->fixtures[1]->address->city, $result[1]['user']->addressDto->city);
+ self::assertSame($this->fixtures[2]->address->city, $result[2]['user']->addressDto->city);
+
+ self::assertSame($this->fixtures[0]->address->country, $result[0]['user']->addressDto->country);
+ self::assertSame($this->fixtures[1]->address->country, $result[1]['user']->addressDto->country);
+ self::assertSame($this->fixtures[2]->address->country, $result[2]['user']->addressDto->country);
+
+ self::assertSame($this->fixtures[0]->status, $result[0]['status']);
+ self::assertSame($this->fixtures[1]->status, $result[1]['status']);
+ self::assertSame($this->fixtures[2]->status, $result[2]['status']);
+
+ self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']);
+ self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']);
+ self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']);
+ }
+
+ public function testShouldSupportNestedNamedArguments(): void
+ {
+ $dql = '
+ SELECT
+ new named CmsUserDTONamedArgs(
+ e.email,
+ u.name as name,
+ new named CmsAddressDTONamedArgs(
+ a.zip,
+ a.city,
+ a.country
+ ) as addressDtoNamedArgs
+ ) as user,
+ u.status,
+ u.username as cmsUserUsername
+ FROM
+ Doctrine\Tests\Models\CMS\CmsUser u
+ JOIN
+ u.email e
+ JOIN
+ u.address a
+ ORDER BY
+ u.name';
+
+ $query = $this->getEntityManager()->createQuery($dql);
+ $result = $query->getResult();
+
+ self::assertCount(3, $result);
+
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[0]['user']);
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[1]['user']);
+ self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[2]['user']);
+
+ self::assertNull($result[0]['user']->address);
+ self::assertNull($result[1]['user']->address);
+ self::assertNull($result[2]['user']->address);
+
+ self::assertNull($result[0]['user']->addressDto);
+ self::assertNull($result[1]['user']->addressDto);
+ self::assertNull($result[2]['user']->addressDto);
+
+ self::assertInstanceOf(CmsAddressDTONamedArgs::class, $result[0]['user']->addressDtoNamedArgs);
+ self::assertInstanceOf(CmsAddressDTONamedArgs::class, $result[1]['user']->addressDtoNamedArgs);
+ self::assertInstanceOf(CmsAddressDTONamedArgs::class, $result[2]['user']->addressDtoNamedArgs);
+
+ self::assertSame($this->fixtures[0]->name, $result[0]['user']->name);
+ self::assertSame($this->fixtures[1]->name, $result[1]['user']->name);
+ self::assertSame($this->fixtures[2]->name, $result[2]['user']->name);
+
+ self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email);
+ self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email);
+ self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email);
+
+ self::assertSame($this->fixtures[0]->address->city, $result[0]['user']->addressDtoNamedArgs->city);
+ self::assertSame($this->fixtures[1]->address->city, $result[1]['user']->addressDtoNamedArgs->city);
+ self::assertSame($this->fixtures[2]->address->city, $result[2]['user']->addressDtoNamedArgs->city);
+
+ self::assertSame($this->fixtures[0]->address->country, $result[0]['user']->addressDtoNamedArgs->country);
+ self::assertSame($this->fixtures[1]->address->country, $result[1]['user']->addressDtoNamedArgs->country);
+ self::assertSame($this->fixtures[2]->address->country, $result[2]['user']->addressDtoNamedArgs->country);
+
+ self::assertSame($this->fixtures[0]->status, $result[0]['status']);
+ self::assertSame($this->fixtures[1]->status, $result[1]['status']);
+ self::assertSame($this->fixtures[2]->status, $result[2]['status']);
+
+ self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']);
+ self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']);
+ self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']);
+ }
+
+ public function testExceptionIfTwoAliases(): void
+ {
+ $dql = '
+ SELECT
+ new named Doctrine\Tests\Models\CMS\CmsUserDTO(
+ u.name,
+ u.username AS name
+ )
+ FROM
+ Doctrine\Tests\Models\CMS\CmsUser u';
+
+ $this->expectException(DuplicateFieldException::class);
+ $this->expectExceptionMessage('Name "name" for "u.username AS name" already in use.');
+
+ $query = $this->_em->createQuery($dql);
+ $result = $query->getResult();
+ }
+
+ public function testExceptionIfFunctionHasNoAlias(): void
+ {
+ $dql = "
+ SELECT
+ new named Doctrine\Tests\Models\CMS\CmsUserDTO(
+ u.name,
+ CASE WHEN (e.email = 'email@test1.com') THEN 'TEST1' ELSE 'OTHER_TEST' END
+ )
+ FROM
+ Doctrine\Tests\Models\CMS\CmsUser u
+ JOIN
+ u.email e";
+
+ $this->expectException(NoMatchingPropertyException::class);
+ $this->expectExceptionMessage('Column name "CASE WHEN (e.email = \'email@test1.com\') THEN \'TEST1\' ELSE \'OTHER_TEST\' END" does not match any property name. Consider aliasing it to the name of an existing property.');
+
+ $query = $this->_em->createQuery($dql);
+ $result = $query->getResult();
+ }
}
class ClassWithTooMuchArgs
diff --git a/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php b/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php
index cdb34bfcdbf..b2baaaa254e 100644
--- a/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php
+++ b/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php
@@ -5,6 +5,7 @@
namespace Doctrine\Tests\ORM\Functional;
use Doctrine\ORM\Mapping\ClassMetadata;
+use Doctrine\ORM\Query;
use Doctrine\Tests\Models\ECommerce\ECommerceProduct;
use Doctrine\Tests\Models\ECommerce\ECommerceShipping;
use Doctrine\Tests\OrmFunctionalTestCase;
@@ -78,6 +79,19 @@ public function testLazyLoadsObjects(): void
self::assertEquals(1, $product->getShipping()->getDays());
}
+ public function testDoesNotLazyLoadObjectsIfConfigurationDoesNotAllowIt(): void
+ {
+ $this->createFixture();
+
+ $query = $this->_em->createQuery('select p from Doctrine\Tests\Models\ECommerce\ECommerceProduct p');
+ $query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true);
+
+ $result = $query->getResult();
+ $product = $result[0];
+
+ self::assertNull($product->getShipping());
+ }
+
protected function createFixture(): void
{
$product = new ECommerceProduct();
diff --git a/tests/Tests/ORM/Functional/ParserResultSerializationTest.php b/tests/Tests/ORM/Functional/ParserResultSerializationTest.php
index e927ba5af5f..6918bd8e50b 100644
--- a/tests/Tests/ORM/Functional/ParserResultSerializationTest.php
+++ b/tests/Tests/ORM/Functional/ParserResultSerializationTest.php
@@ -6,6 +6,8 @@
use Closure;
use Doctrine\ORM\Query;
+use Doctrine\ORM\Query\Exec\FinalizedSelectExecutor;
+use Doctrine\ORM\Query\Exec\PreparedExecutorFinalizer;
use Doctrine\ORM\Query\Exec\SingleSelectExecutor;
use Doctrine\ORM\Query\ParserResult;
use Doctrine\ORM\Query\ResultSetMapping;
@@ -32,18 +34,37 @@ protected function setUp(): void
/** @param Closure(ParserResult): ParserResult $toSerializedAndBack */
#[DataProvider('provideToSerializedAndBack')]
- public function testSerializeParserResult(Closure $toSerializedAndBack): void
+ public function testSerializeParserResultForQueryWithSqlWalker(Closure $toSerializedAndBack): void
{
$query = $this->_em
->createQuery('SELECT u FROM Doctrine\Tests\Models\Company\CompanyEmployee u WHERE u.name = :name');
+ // Use the (legacy) SqlWalker which directly puts an SqlExecutor instance into the parser result
+ $query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, Query\SqlWalker::class);
+
$parserResult = self::parseQuery($query);
$unserialized = $toSerializedAndBack($parserResult);
$this->assertInstanceOf(ParserResult::class, $unserialized);
$this->assertInstanceOf(ResultSetMapping::class, $unserialized->getResultSetMapping());
$this->assertEquals(['name' => [0]], $unserialized->getParameterMappings());
- $this->assertInstanceOf(SingleSelectExecutor::class, $unserialized->getSqlExecutor());
+ $this->assertNotNull($unserialized->prepareSqlExecutor($query));
+ }
+
+ /** @param Closure(ParserResult): ParserResult $toSerializedAndBack */
+ #[DataProvider('provideToSerializedAndBack')]
+ public function testSerializeParserResultForQueryWithSqlOutputWalker(Closure $toSerializedAndBack): void
+ {
+ $query = $this->_em
+ ->createQuery('SELECT u FROM Doctrine\Tests\Models\Company\CompanyEmployee u WHERE u.name = :name');
+
+ $parserResult = self::parseQuery($query);
+ $unserialized = $toSerializedAndBack($parserResult);
+
+ $this->assertInstanceOf(ParserResult::class, $unserialized);
+ $this->assertInstanceOf(ResultSetMapping::class, $unserialized->getResultSetMapping());
+ $this->assertEquals(['name' => [0]], $unserialized->getParameterMappings());
+ $this->assertNotNull($unserialized->prepareSqlExecutor($query));
}
/** @return Generator */
@@ -87,11 +108,12 @@ public static function provideSerializedSingleSelectResults(): Generator
public function testSymfony44ProvidedData(): void
{
- $sqlExecutor = $this->createMock(SingleSelectExecutor::class);
+ $sqlExecutor = new FinalizedSelectExecutor('test');
+ $sqlFinalizer = new PreparedExecutorFinalizer($sqlExecutor);
$resultSetMapping = $this->createMock(ResultSetMapping::class);
$parserResult = new ParserResult();
- $parserResult->setSqlExecutor($sqlExecutor);
+ $parserResult->setSqlFinalizer($sqlFinalizer);
$parserResult->setResultSetMapping($resultSetMapping);
$parserResult->addParameterMapping('name', 0);
@@ -101,7 +123,7 @@ public function testSymfony44ProvidedData(): void
$this->assertInstanceOf(ParserResult::class, $unserialized);
$this->assertInstanceOf(ResultSetMapping::class, $unserialized->getResultSetMapping());
$this->assertEquals(['name' => [0]], $unserialized->getParameterMappings());
- $this->assertInstanceOf(SingleSelectExecutor::class, $unserialized->getSqlExecutor());
+ $this->assertEquals($sqlExecutor, $unserialized->prepareSqlExecutor($this->createMock(Query::class)));
}
private static function parseQuery(Query $query): ParserResult
diff --git a/tests/Tests/ORM/Functional/PostLoadEventTest.php b/tests/Tests/ORM/Functional/PostLoadEventTest.php
index cb7a98ab1a4..01d634b0380 100644
--- a/tests/Tests/ORM/Functional/PostLoadEventTest.php
+++ b/tests/Tests/ORM/Functional/PostLoadEventTest.php
@@ -35,8 +35,7 @@ public function testLoadedEntityUsingFindShouldTriggerEvent(): void
// CmsUser and CmsAddres, because it's a ToOne inverse side on CmsUser
$mockListener
->expects(self::exactly(2))
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager = $this->_em->getEventManager();
@@ -52,8 +51,7 @@ public function testLoadedEntityUsingQueryShouldTriggerEvent(): void
// CmsUser and CmsAddres, because it's a ToOne inverse side on CmsUser
$mockListener
->expects(self::exactly(2))
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager = $this->_em->getEventManager();
@@ -72,8 +70,7 @@ public function testLoadedAssociationToOneShouldTriggerEvent(): void
// CmsUser (root), CmsAddress (ToOne inverse side), CmsEmail (joined association)
$mockListener
->expects(self::exactly(3))
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager = $this->_em->getEventManager();
@@ -92,8 +89,7 @@ public function testLoadedAssociationToManyShouldTriggerEvent(): void
// CmsUser (root), CmsAddress (ToOne inverse side), 2 CmsPhonenumber (joined association)
$mockListener
->expects(self::exactly(4))
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager = $this->_em->getEventManager();
@@ -114,8 +110,7 @@ public function testLoadedProxyEntityShouldTriggerEvent(): void
$mockListener
->expects(self::never())
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager->addEventListener([Events::postLoad], $mockListener);
@@ -128,14 +123,33 @@ public function testLoadedProxyEntityShouldTriggerEvent(): void
$mockListener2
->expects(self::exactly(2))
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager->addEventListener([Events::postLoad], $mockListener2);
$userProxy->getName();
}
+ public function testLoadedProxyPartialShouldTriggerEvent(): void
+ {
+ $eventManager = $this->_em->getEventManager();
+
+ // Should not be invoked during getReference call
+ $mockListener = $this->createMock(PostLoadListener::class);
+
+ // CmsUser (partially loaded), CmsAddress (inverse ToOne), 2 CmsPhonenumber
+ $mockListener
+ ->expects(self::exactly(4))
+ ->method('postLoad');
+
+ $eventManager->addEventListener([Events::postLoad], $mockListener);
+
+ $query = $this->_em->createQuery('SELECT PARTIAL u.{id, name}, p FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN u.phonenumbers p WHERE u.id = :id');
+
+ $query->setParameter('id', $this->userId);
+ $query->getResult();
+ }
+
public function testLoadedProxyAssociationToOneShouldTriggerEvent(): void
{
$user = $this->_em->find(CmsUser::class, $this->userId);
@@ -145,8 +159,7 @@ public function testLoadedProxyAssociationToOneShouldTriggerEvent(): void
// CmsEmail (proxy)
$mockListener
->expects(self::exactly(1))
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager = $this->_em->getEventManager();
@@ -166,8 +179,7 @@ public function testLoadedProxyAssociationToManyShouldTriggerEvent(): void
// 2 CmsPhonenumber (proxy)
$mockListener
->expects(self::exactly(2))
- ->method('postLoad')
- ->will(self::returnValue(true));
+ ->method('postLoad');
$eventManager = $this->_em->getEventManager();
diff --git a/tests/Tests/ORM/Functional/QueryCacheTest.php b/tests/Tests/ORM/Functional/QueryCacheTest.php
index 1c1bc13764e..891a3ba18c7 100644
--- a/tests/Tests/ORM/Functional/QueryCacheTest.php
+++ b/tests/Tests/ORM/Functional/QueryCacheTest.php
@@ -44,7 +44,7 @@ public function testQueryCacheDependsOnHints(): array
}
#[Depends('testQueryCacheDependsOnHints')]
- public function testQueryCacheDependsOnFirstResult(array $previous): void
+ public function testQueryCacheDoesNotDependOnFirstResultForDefaultOutputWalker(array $previous): void
{
[$query, $cache] = $previous;
assert($query instanceof Query);
@@ -56,11 +56,11 @@ public function testQueryCacheDependsOnFirstResult(array $previous): void
$query->setMaxResults(9999);
$query->getResult();
- self::assertCount($cacheCount + 1, $cache->getValues());
+ self::assertCount($cacheCount, $cache->getValues());
}
#[Depends('testQueryCacheDependsOnHints')]
- public function testQueryCacheDependsOnMaxResults(array $previous): void
+ public function testQueryCacheDoesNotDependOnMaxResultsForDefaultOutputWalker(array $previous): void
{
[$query, $cache] = $previous;
assert($query instanceof Query);
@@ -71,7 +71,7 @@ public function testQueryCacheDependsOnMaxResults(array $previous): void
$query->setMaxResults(10);
$query->getResult();
- self::assertCount($cacheCount + 1, $cache->getValues());
+ self::assertCount($cacheCount, $cache->getValues());
}
#[Depends('testQueryCacheDependsOnHints')]
diff --git a/tests/Tests/ORM/Functional/SQLFilterTest.php b/tests/Tests/ORM/Functional/SQLFilterTest.php
index 3f12f4db069..6183b9c56e2 100644
--- a/tests/Tests/ORM/Functional/SQLFilterTest.php
+++ b/tests/Tests/ORM/Functional/SQLFilterTest.php
@@ -226,7 +226,7 @@ private function addMockFilterCollection(EntityManagerInterface&MockObject $em):
$em->expects(self::any())
->method('getFilters')
- ->will(self::returnValue($filterCollection));
+ ->willReturn($filterCollection);
return $filterCollection;
}
@@ -238,12 +238,12 @@ public function testSQLFilterGetSetParameter(): void
$conn->expects(self::once())
->method('quote')
->with(self::equalTo('en'))
- ->will(self::returnValue("'en'"));
+ ->willReturn("'en'");
$em = $this->getMockEntityManager();
$em->expects(self::once())
->method('getConnection')
- ->will(self::returnValue($conn));
+ ->willReturn($conn);
$filterCollection = $this->addMockFilterCollection($em);
$filterCollection
@@ -267,7 +267,7 @@ public function testSQLFilterGetConnection(): void
$em = $this->getMockEntityManager();
$em->expects(self::once())
->method('getConnection')
- ->will(self::returnValue($conn));
+ ->willReturn($conn);
$filter = new MyLocaleFilter($em);
@@ -283,12 +283,12 @@ public function testSQLFilterSetParameterInfersType(): void
$conn->expects(self::once())
->method('quote')
->with(self::equalTo('en'))
- ->will(self::returnValue("'en'"));
+ ->willReturn("'en'");
$em = $this->getMockEntityManager();
$em->expects(self::once())
->method('getConnection')
- ->will(self::returnValue($conn));
+ ->willReturn($conn);
$filterCollection = $this->addMockFilterCollection($em);
$filterCollection
@@ -307,11 +307,11 @@ public function testSQLFilterSetArrayParameterInfersType(): void
// Setup mock connection
$conn = $this->getMockConnection();
$conn->method('quote')
- ->will(self::returnCallback(static fn ($value) => "'" . $value . "'"));
+ ->willReturnCallback(static fn ($value) => "'" . $value . "'");
$em = $this->getMockEntityManager();
$em->method('getConnection')
- ->will(self::returnValue($conn));
+ ->willReturn($conn);
$filterCollection = $this->addMockFilterCollection($em);
$filterCollection
diff --git a/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php
index a3350aa5e6c..e2dec649342 100644
--- a/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php
+++ b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php
@@ -1086,6 +1086,31 @@ public function testHintClearEntityRegionDeleteStatement(): void
self::assertFalse($this->cache->containsEntity(Country::class, $this->countries[1]->getId()));
}
+ public function testCacheablePartialQueryException(): void
+ {
+ $this->expectException(CacheException::class);
+ $this->expectExceptionMessage('Second level cache does not support partial entities.');
+ $this->evictRegions();
+ $this->loadFixturesCountries();
+
+ $this->_em->createQuery('SELECT PARTIAL c.{id} FROM Doctrine\Tests\Models\Cache\Country c')
+ ->setCacheable(true)
+ ->getResult();
+ }
+
+ public function testCacheableForcePartialLoadHintQueryException(): void
+ {
+ $this->expectException(CacheException::class);
+ $this->expectExceptionMessage('Second level cache does not support partial entities.');
+ $this->evictRegions();
+ $this->loadFixturesCountries();
+
+ $this->_em->createQuery('SELECT c FROM Doctrine\Tests\Models\Cache\Country c')
+ ->setCacheable(true)
+ ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true)
+ ->getResult();
+ }
+
public function testNonCacheableQueryDeleteStatementException(): void
{
$this->expectException(CacheException::class);
diff --git a/tests/Tests/ORM/Functional/Ticket/DDC163Test.php b/tests/Tests/ORM/Functional/Ticket/DDC163Test.php
index 83aebb4dcae..6c0c7c94fe3 100644
--- a/tests/Tests/ORM/Functional/Ticket/DDC163Test.php
+++ b/tests/Tests/ORM/Functional/Ticket/DDC163Test.php
@@ -46,7 +46,7 @@ public function testQueryWithOrConditionUsingTwoRelationOnSameEntity(): void
$this->_em->flush();
$this->_em->clear();
- $dql = 'SELECT person.name as person_name, spouse.name as spouse_name,friend.name as friend_name
+ $dql = 'SELECT PARTIAL person.{id,name}, PARTIAL spouse.{id,name}, PARTIAL friend.{id,name}
FROM Doctrine\Tests\Models\Company\CompanyPerson person
LEFT JOIN person.spouse spouse
LEFT JOIN person.friends friend
diff --git a/tests/Tests/ORM/Functional/Ticket/DDC2359Test.php b/tests/Tests/ORM/Functional/Ticket/DDC2359Test.php
index b02a8dfe62f..7e8a753d53d 100644
--- a/tests/Tests/ORM/Functional/Ticket/DDC2359Test.php
+++ b/tests/Tests/ORM/Functional/Ticket/DDC2359Test.php
@@ -43,15 +43,15 @@ public function testIssue(): void
$configuration
->method('getMetadataDriverImpl')
- ->will(self::returnValue($mockDriver));
+ ->willReturn($mockDriver);
- $entityManager->expects(self::any())->method('getConfiguration')->will(self::returnValue($configuration));
- $entityManager->expects(self::any())->method('getConnection')->will(self::returnValue($connection));
+ $entityManager->expects(self::any())->method('getConfiguration')->willReturn($configuration);
+ $entityManager->expects(self::any())->method('getConnection')->willReturn($connection);
$entityManager
->method('getEventManager')
- ->will(self::returnValue($this->createMock(EventManager::class)));
+ ->willReturn($this->createMock(EventManager::class));
- $metadataFactory->method('newClassMetadataInstance')->will(self::returnValue($mockMetadata));
+ $metadataFactory->method('newClassMetadataInstance')->willReturn($mockMetadata);
$metadataFactory->expects(self::once())->method('wakeupReflection');
$metadataFactory->setEntityManager($entityManager);
diff --git a/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php b/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php
new file mode 100644
index 00000000000..b8dfba13b9c
--- /dev/null
+++ b/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php
@@ -0,0 +1,76 @@
+useModelSet('legacy');
+
+ parent::setUp();
+
+ $this->loadFixture();
+ }
+
+ #[Group('DDC-2519')]
+ public function testIssue(): void
+ {
+ $dql = 'SELECT PARTIAL l.{_source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l';
+ $result = $this->_em->createQuery($dql)->getResult();
+
+ self::assertCount(2, $result);
+ self::assertInstanceOf(LegacyUserReference::class, $result[0]);
+ self::assertInstanceOf(LegacyUserReference::class, $result[1]);
+
+ self::assertInstanceOf(LegacyUser::class, $result[0]->source());
+ self::assertInstanceOf(LegacyUser::class, $result[0]->target());
+ self::assertInstanceOf(LegacyUser::class, $result[1]->source());
+ self::assertInstanceOf(LegacyUser::class, $result[1]->target());
+
+ self::assertTrue($this->isUninitializedObject($result[0]->target()));
+ self::assertTrue($this->isUninitializedObject($result[0]->source()));
+ self::assertTrue($this->isUninitializedObject($result[1]->target()));
+ self::assertTrue($this->isUninitializedObject($result[1]->source()));
+
+ self::assertNotNull($result[0]->source()->getId());
+ self::assertNotNull($result[0]->target()->getId());
+ self::assertNotNull($result[1]->source()->getId());
+ self::assertNotNull($result[1]->target()->getId());
+ }
+
+ public function loadFixture(): void
+ {
+ $user1 = new LegacyUser();
+ $user1->username = 'FabioBatSilva';
+ $user1->name = 'Fabio B. Silva';
+
+ $user2 = new LegacyUser();
+ $user2->username = 'doctrinebot';
+ $user2->name = 'Doctrine Bot';
+
+ $user3 = new LegacyUser();
+ $user3->username = 'test';
+ $user3->name = 'Tester';
+
+ $this->_em->persist($user1);
+ $this->_em->persist($user2);
+ $this->_em->persist($user3);
+
+ $this->_em->flush();
+
+ $this->_em->persist(new LegacyUserReference($user1, $user2, 'foo'));
+ $this->_em->persist(new LegacyUserReference($user1, $user3, 'bar'));
+
+ $this->_em->flush();
+ $this->_em->clear();
+ }
+}
diff --git a/tests/Tests/ORM/Functional/Ticket/GH10747Test.php b/tests/Tests/ORM/Functional/Ticket/GH10747Test.php
index bbe15501e94..90caec353bd 100644
--- a/tests/Tests/ORM/Functional/Ticket/GH10747Test.php
+++ b/tests/Tests/ORM/Functional/Ticket/GH10747Test.php
@@ -17,7 +17,6 @@
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
-use function method_exists;
use function str_replace;
/**
@@ -135,23 +134,19 @@ public function __construct(GH10747Article $article, public string $name)
class GH10747CustomIdObjectHashType extends DBALType
{
- public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed
+ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): string
{
return $value->id . '_test';
}
- public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed
+ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): CustomIdObject
{
return new CustomIdObject(str_replace('_test', '', $value));
}
- public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
+ public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
- if (method_exists($platform, 'getStringTypeDeclarationSQL')) {
- return $platform->getStringTypeDeclarationSQL($fieldDeclaration);
- }
-
- return $platform->getVarcharTypeDeclarationSQL($fieldDeclaration);
+ return $platform->getStringTypeDeclarationSQL($column);
}
public function getName(): string
diff --git a/tests/Tests/ORM/Functional/Ticket/GH11112Test.php b/tests/Tests/ORM/Functional/Ticket/GH11112Test.php
new file mode 100644
index 00000000000..d5a11cda6bf
--- /dev/null
+++ b/tests/Tests/ORM/Functional/Ticket/GH11112Test.php
@@ -0,0 +1,79 @@
+useModelSet('cms');
+ self::$queryCache = new ArrayAdapter();
+
+ parent::setUp();
+ }
+
+ public function testSimpleQueryHasLimitAndOffsetApplied(): void
+ {
+ $platform = $this->_em->getConnection()->getDatabasePlatform();
+ $query = $this->_em->createQuery('SELECT u FROM ' . CmsUser::class . ' u');
+ $originalSql = $query->getSQL();
+
+ $query->setMaxResults(10);
+ $query->setFirstResult(20);
+ $sqlMax10First20 = $query->getSQL();
+
+ $query->setMaxResults(30);
+ $query->setFirstResult(40);
+ $sqlMax30First40 = $query->getSQL();
+
+ // The SQL is platform specific and may even be something with outer SELECTS being added. So,
+ // derive the expected value at runtime through the platform.
+ self::assertSame($platform->modifyLimitQuery($originalSql, 10, 20), $sqlMax10First20);
+ self::assertSame($platform->modifyLimitQuery($originalSql, 30, 40), $sqlMax30First40);
+
+ $cacheEntries = self::$queryCache->getValues();
+ self::assertCount(1, $cacheEntries);
+ }
+
+ public function testSubqueryLimitAndOffsetAreIgnored(): void
+ {
+ // Not sure what to do about this test. Basically, I want to make sure that
+ // firstResult/maxResult for subqueries are not relevant, they do not make it
+ // into the final query at all. That would give us the guarantee that the
+ // "sql finalizer" step is sufficient for the final, "outer" query and we
+ // do not need to run finalizers for the subqueries.
+
+ // This DQL/query makes no sense, it's just about creating a subquery in the first place
+ $queryBuilder = $this->_em->createQueryBuilder();
+ $queryBuilder
+ ->select('o')
+ ->from(CmsUser::class, 'o')
+ ->where($queryBuilder->expr()->exists(
+ $this->_em->createQueryBuilder()
+ ->select('u')
+ ->from(CmsUser::class, 'u')
+ ->setFirstResult(10)
+ ->setMaxResults(20),
+ ));
+
+ $query = $queryBuilder->getQuery();
+ $originalSql = $query->getSQL();
+
+ $clone = clone $query;
+ $clone->setFirstResult(24);
+ $clone->setMaxResults(42);
+ $limitedSql = $clone->getSQL();
+
+ $platform = $this->_em->getConnection()->getDatabasePlatform();
+
+ // The SQL is platform specific and may even be something with outer SELECTS being added. So,
+ // derive the expected value at runtime through the platform.
+ self::assertSame($platform->modifyLimitQuery($originalSql, 42, 24), $limitedSql);
+ }
+}
diff --git a/tests/Tests/ORM/Functional/Ticket/GH8443Test.php b/tests/Tests/ORM/Functional/Ticket/GH8443Test.php
index 0b5352a6ea8..44695f1856e 100644
--- a/tests/Tests/ORM/Functional/Ticket/GH8443Test.php
+++ b/tests/Tests/ORM/Functional/Ticket/GH8443Test.php
@@ -14,6 +14,9 @@
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\OneToOne;
use Doctrine\ORM\Mapping\Table;
+use Doctrine\ORM\Query;
+use Doctrine\Tests\Models\Company\CompanyManager;
+use Doctrine\Tests\Models\Company\CompanyPerson;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Attributes\Group;
@@ -30,6 +33,35 @@ protected function setUp(): void
$this->createSchemaForModels(GH8443Foo::class);
}
+ #[Group('GH-8443')]
+ public function testJoinRootEntityWithForcePartialLoad(): void
+ {
+ $person = new CompanyPerson();
+ $person->setName('John');
+
+ $manager = new CompanyManager();
+ $manager->setName('Adam');
+ $manager->setSalary(1000);
+ $manager->setDepartment('IT');
+ $manager->setTitle('manager');
+
+ $manager->setSpouse($person);
+
+ $this->_em->persist($person);
+ $this->_em->persist($manager);
+ $this->_em->flush();
+ $this->_em->clear();
+
+ $manager = $this->_em->createQuery(
+ "SELECT m from Doctrine\Tests\Models\Company\CompanyManager m
+ JOIN m.spouse s
+ WITH s.name = 'John'",
+ )->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true)->getSingleResult();
+ $this->_em->refresh($manager);
+
+ $this->assertEquals('John', $manager->getSpouse()->getName());
+ }
+
#[Group('GH-8443')]
public function testJoinRootEntityWithOnlyOneEntityInHierarchy(): void
{
diff --git a/tests/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Tests/ORM/Functional/ValueObjectsTest.php
index 82cd1eff2e4..21f948aff4f 100644
--- a/tests/Tests/ORM/Functional/ValueObjectsTest.php
+++ b/tests/Tests/ORM/Functional/ValueObjectsTest.php
@@ -180,6 +180,58 @@ public function testDqlOnEmbeddedObjectsField(): void
self::assertNull($this->_em->find(DDC93Person::class, $person->id));
}
+ public function testPartialDqlOnEmbeddedObjectsField(): void
+ {
+ $person = new DDC93Person('Karl', new DDC93Address('Foo', '12345', 'Gosport', new DDC93Country('England')));
+ $this->_em->persist($person);
+ $this->_em->flush();
+ $this->_em->clear();
+
+ // Prove that the entity was persisted correctly.
+ $dql = 'SELECT p FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name';
+
+ $person = $this->_em->createQuery($dql)
+ ->setParameter('name', 'Karl')
+ ->getSingleResult();
+
+ self::assertEquals('Gosport', $person->address->city);
+ self::assertEquals('Foo', $person->address->street);
+ self::assertEquals('12345', $person->address->zip);
+ self::assertEquals('England', $person->address->country->name);
+
+ // Clear the EM and prove that the embeddable can be the subject of a partial query.
+ $this->_em->clear();
+
+ $dql = 'SELECT PARTIAL p.{id,address.city} FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name';
+
+ $person = $this->_em->createQuery($dql)
+ ->setParameter('name', 'Karl')
+ ->getSingleResult();
+
+ // Selected field must be equal, all other fields must be null.
+ self::assertEquals('Gosport', $person->address->city);
+ self::assertNull($person->address->street);
+ self::assertNull($person->address->zip);
+ self::assertNull($person->address->country);
+ self::assertNull($person->name);
+
+ // Clear the EM and prove that the embeddable can be the subject of a partial query regardless of attributes positions.
+ $this->_em->clear();
+
+ $dql = 'SELECT PARTIAL p.{address.city, id} FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name';
+
+ $person = $this->_em->createQuery($dql)
+ ->setParameter('name', 'Karl')
+ ->getSingleResult();
+
+ // Selected field must be equal, all other fields must be null.
+ self::assertEquals('Gosport', $person->address->city);
+ self::assertNull($person->address->street);
+ self::assertNull($person->address->zip);
+ self::assertNull($person->address->country);
+ self::assertNull($person->name);
+ }
+
public function testDqlWithNonExistentEmbeddableField(): void
{
$this->expectException(QueryException::class);
@@ -195,7 +247,7 @@ public function testPartialDqlWithNonExistentEmbeddableField(): void
$this->expectExceptionMessage("no mapped field named 'address.asdfasdf'");
$this->_em->createQuery('SELECT PARTIAL p.{id,address.asdfasdf} FROM ' . __NAMESPACE__ . '\\DDC93Person p')
- ->getArrayResult();
+ ->execute();
}
public function testEmbeddableWithInheritance(): void
diff --git a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php
index 14b9205abfe..0c20eab0866 100644
--- a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php
+++ b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php
@@ -102,21 +102,4 @@ public function testIndexByMetadataColumn(): void
self::assertTrue($this->_rsm->hasIndexBy('lu'));
}
-
- public function testNewObjectNestedArgumentsDeepestLeavesShouldComeFirst(): void
- {
- $this->_rsm->addNewObjectAsArgument('objALevel2', 'objALevel1', 0);
- $this->_rsm->addNewObjectAsArgument('objALevel3', 'objALevel2', 1);
- $this->_rsm->addNewObjectAsArgument('objBLevel3', 'objBLevel2', 0);
- $this->_rsm->addNewObjectAsArgument('objBLevel2', 'objBLevel1', 1);
-
- $expectedArgumentMapping = [
- 'objALevel3' => ['ownerIndex' => 'objALevel2', 'argIndex' => 1],
- 'objALevel2' => ['ownerIndex' => 'objALevel1', 'argIndex' => 0],
- 'objBLevel3' => ['ownerIndex' => 'objBLevel2', 'argIndex' => 0],
- 'objBLevel2' => ['ownerIndex' => 'objBLevel1', 'argIndex' => 1],
- ];
-
- self::assertSame($expectedArgumentMapping, $this->_rsm->nestedNewObjectArguments);
- }
}
diff --git a/tests/Tests/ORM/Internal/HydrationCompleteHandlerTest.php b/tests/Tests/ORM/Internal/HydrationCompleteHandlerTest.php
index 5275d980a54..51cf37e0494 100644
--- a/tests/Tests/ORM/Internal/HydrationCompleteHandlerTest.php
+++ b/tests/Tests/ORM/Internal/HydrationCompleteHandlerTest.php
@@ -49,7 +49,7 @@ public function testDefersPostLoadOfEntity(int $listenersFlag): void
->expects(self::any())
->method('getSubscribedSystems')
->with($metadata)
- ->will(self::returnValue($listenersFlag));
+ ->willReturn($listenersFlag);
$this->handler->deferPostLoadInvoking($metadata, $entity);
@@ -80,7 +80,7 @@ public function testDefersPostLoadOfEntityOnlyOnce(int $listenersFlag): void
->expects(self::any())
->method('getSubscribedSystems')
->with($metadata)
- ->will(self::returnValue($listenersFlag));
+ ->willReturn($listenersFlag);
$this->handler->deferPostLoadInvoking($metadata, $entity);
@@ -104,7 +104,7 @@ public function testDefersMultiplePostLoadOfEntity(int $listenersFlag): void
->expects(self::any())
->method('getSubscribedSystems')
->with(self::logicalOr($metadata1, $metadata2))
- ->will(self::returnValue($listenersFlag));
+ ->willReturn($listenersFlag);
$this->handler->deferPostLoadInvoking($metadata1, $entity1);
$this->handler->deferPostLoadInvoking($metadata2, $entity2);
@@ -136,7 +136,7 @@ public function testSkipsDeferredPostLoadOfMetadataWithNoInvokedListeners(): voi
->expects(self::any())
->method('getSubscribedSystems')
->with($metadata)
- ->will(self::returnValue(ListenersInvoker::INVOKE_NONE));
+ ->willReturn(ListenersInvoker::INVOKE_NONE);
$this->handler->deferPostLoadInvoking($metadata, $entity);
diff --git a/tests/Tests/ORM/LazyCriteriaCollectionTest.php b/tests/Tests/ORM/LazyCriteriaCollectionTest.php
index 4a974972328..82733dd643c 100644
--- a/tests/Tests/ORM/LazyCriteriaCollectionTest.php
+++ b/tests/Tests/ORM/LazyCriteriaCollectionTest.php
@@ -29,7 +29,7 @@ protected function setUp(): void
public function testCountIsCached(): void
{
- $this->persister->expects(self::once())->method('count')->with($this->criteria)->will(self::returnValue(10));
+ $this->persister->expects(self::once())->method('count')->with($this->criteria)->willReturn(10);
self::assertSame(10, $this->lazyCriteriaCollection->count());
self::assertSame(10, $this->lazyCriteriaCollection->count());
@@ -38,7 +38,7 @@ public function testCountIsCached(): void
public function testCountIsCachedEvenWithZeroResult(): void
{
- $this->persister->expects(self::once())->method('count')->with($this->criteria)->will(self::returnValue(0));
+ $this->persister->expects(self::once())->method('count')->with($this->criteria)->willReturn(0);
self::assertSame(0, $this->lazyCriteriaCollection->count());
self::assertSame(0, $this->lazyCriteriaCollection->count());
@@ -52,7 +52,7 @@ public function testCountUsesWrappedCollectionWhenInitialized(): void
->expects(self::once())
->method('loadCriteria')
->with($this->criteria)
- ->will(self::returnValue(['foo', 'bar', 'baz']));
+ ->willReturn(['foo', 'bar', 'baz']);
// should never call the persister's count
$this->persister->expects(self::never())->method('count');
@@ -77,7 +77,7 @@ public function testMatchingUsesThePersisterOnlyOnce(): void
->expects(self::once())
->method('loadCriteria')
->with($this->criteria)
- ->will(self::returnValue([$foo, $bar, $baz]));
+ ->willReturn([$foo, $bar, $baz]);
$criteria = new Criteria();
@@ -93,14 +93,14 @@ public function testMatchingUsesThePersisterOnlyOnce(): void
public function testIsEmptyUsesCountWhenNotInitialized(): void
{
- $this->persister->expects(self::once())->method('count')->with($this->criteria)->will(self::returnValue(0));
+ $this->persister->expects(self::once())->method('count')->with($this->criteria)->willReturn(0);
self::assertTrue($this->lazyCriteriaCollection->isEmpty());
}
public function testIsEmptyIsFalseIfCountIsNotZero(): void
{
- $this->persister->expects(self::once())->method('count')->with($this->criteria)->will(self::returnValue(1));
+ $this->persister->expects(self::once())->method('count')->with($this->criteria)->willReturn(1);
self::assertFalse($this->lazyCriteriaCollection->isEmpty());
}
@@ -112,7 +112,7 @@ public function testIsEmptyUsesWrappedCollectionWhenInitialized(): void
->expects(self::once())
->method('loadCriteria')
->with($this->criteria)
- ->will(self::returnValue(['foo', 'bar', 'baz']));
+ ->willReturn(['foo', 'bar', 'baz']);
// should never call the persister's count
$this->persister->expects(self::never())->method('count');
diff --git a/tests/Tests/ORM/Mapping/ClassMetadataFactoryTest.php b/tests/Tests/ORM/Mapping/ClassMetadataFactoryTest.php
index 1bef18a908b..540ab47dde8 100644
--- a/tests/Tests/ORM/Mapping/ClassMetadataFactoryTest.php
+++ b/tests/Tests/ORM/Mapping/ClassMetadataFactoryTest.php
@@ -245,7 +245,7 @@ public function testGetAllMetadataWorksWithBadConnection(): void
$conn->expects(self::any())
->method('getDatabasePlatform')
- ->will(self::throwException(new Exception('Exception thrown in test when calling getDatabasePlatform')));
+ ->willThrowException(new Exception('Exception thrown in test when calling getDatabasePlatform'));
$cmf = new ClassMetadataFactory();
$cmf->setEntityManager($em);
diff --git a/tests/Tests/ORM/Mapping/ClassMetadataTest.php b/tests/Tests/ORM/Mapping/ClassMetadataTest.php
index bb93811f16c..540dfdf3447 100644
--- a/tests/Tests/ORM/Mapping/ClassMetadataTest.php
+++ b/tests/Tests/ORM/Mapping/ClassMetadataTest.php
@@ -14,6 +14,7 @@
use Doctrine\ORM\Mapping\DefaultNamingStrategy;
use Doctrine\ORM\Mapping\DefaultTypedFieldMapper;
use Doctrine\ORM\Mapping\DiscriminatorColumnMapping;
+use Doctrine\ORM\Mapping\Driver\XmlDriver;
use Doctrine\ORM\Mapping\JoinTableMapping;
use Doctrine\ORM\Mapping\MappedSuperclass;
use Doctrine\ORM\Mapping\MappingException;
@@ -33,6 +34,7 @@
use Doctrine\Tests\Models\CMS\UserRepository;
use Doctrine\Tests\Models\Company\CompanyContract;
use Doctrine\Tests\Models\Company\CompanyContractListener;
+use Doctrine\Tests\Models\Customer\CustomerType;
use Doctrine\Tests\Models\CustomType\CustomTypeParent;
use Doctrine\Tests\Models\DDC117\DDC117Article;
use Doctrine\Tests\Models\DDC117\DDC117ArticleDetails;
@@ -1081,14 +1083,27 @@ public function testItThrowsOnInvalidCallToGetAssociationMappedByTargetField():
$metadata->getAssociationMappedByTargetField('foo');
}
+
+ public function testClassNameMappingDiscriminatorValue(): void
+ {
+ $driver = new XmlDriver(
+ __DIR__ . '/xml',
+ XmlDriver::DEFAULT_FILE_EXTENSION,
+ true,
+ );
+ $xmlElement = $driver->getElement(CustomerType::class);
+ self::assertEquals(
+ 'Doctrine\Tests\Models\Customer\InternalCustomer',
+ $xmlElement->children()->{'discriminator-map'}->{'discriminator-mapping'}[0]->attributes()['value'],
+ );
+ }
}
#[MappedSuperclass]
class DDC2700MappedSuperClass
{
- /** @var mixed */
#[Column]
- private $foo;
+ private mixed $foo;
}
class MyNamespacedNamingStrategy extends DefaultNamingStrategy
diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Customer.CustomerType.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Customer.CustomerType.dcm.xml
new file mode 100644
index 00000000000..8767e3220a8
--- /dev/null
+++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Customer.CustomerType.dcm.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Tests/ORM/Proxy/ProxyFactoryTest.php b/tests/Tests/ORM/Proxy/ProxyFactoryTest.php
index 31cb9cc001f..05b19a4abcf 100644
--- a/tests/Tests/ORM/Proxy/ProxyFactoryTest.php
+++ b/tests/Tests/ORM/Proxy/ProxyFactoryTest.php
@@ -75,7 +75,7 @@ public function testReferenceProxyDelegatesLoadingToThePersister(): void
->expects(self::atLeastOnce())
->method('loadById')
->with(self::equalTo($identifier))
- ->will(self::returnValue($proxy));
+ ->willReturn($proxy);
$proxy->getDescription();
}
@@ -132,7 +132,7 @@ public function testFailedProxyLoadingDoesNotMarkTheProxyAsInitialized(): void
$persister
->expects(self::atLeastOnce())
->method('load')
- ->will(self::returnValue(null));
+ ->willReturn(null);
try {
$proxy->getDescription();
@@ -158,7 +158,7 @@ public function testFailedProxyCloningDoesNotMarkTheProxyAsInitialized(): void
$persister
->expects(self::atLeastOnce())
->method('load')
- ->will(self::returnValue(null));
+ ->willReturn(null);
try {
$cloned = clone $proxy;
diff --git a/tests/Tests/ORM/Query/CustomTreeWalkersTest.php b/tests/Tests/ORM/Query/CustomTreeWalkersTest.php
index acd9d22ae32..83e001fbdcd 100644
--- a/tests/Tests/ORM/Query/CustomTreeWalkersTest.php
+++ b/tests/Tests/ORM/Query/CustomTreeWalkersTest.php
@@ -15,6 +15,7 @@
use Doctrine\ORM\Query\AST\SelectStatement;
use Doctrine\ORM\Query\AST\WhereClause;
use Doctrine\ORM\Query\QueryException;
+use Doctrine\ORM\Query\SqlOutputWalker;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TreeWalker;
use Doctrine\ORM\Query\TreeWalkerAdapter;
@@ -118,15 +119,13 @@ public function testSupportsSeveralHintsQueries(): void
}
}
-class AddUnknownQueryComponentWalker extends SqlWalker
+class AddUnknownQueryComponentWalker extends SqlOutputWalker
{
- public function walkSelectStatement(SelectStatement $selectStatement): string
+ protected function createSqlForFinalizer(SelectStatement $selectStatement): string
{
- $sql = parent::walkSelectStatement($selectStatement);
-
$this->setQueryComponent('x', []);
- return $sql;
+ return parent::createSqlForFinalizer($selectStatement);
}
}
diff --git a/tests/Tests/ORM/Query/LanguageRecognitionTest.php b/tests/Tests/ORM/Query/LanguageRecognitionTest.php
index 448b425b64e..31181de1a44 100644
--- a/tests/Tests/ORM/Query/LanguageRecognitionTest.php
+++ b/tests/Tests/ORM/Query/LanguageRecognitionTest.php
@@ -533,13 +533,11 @@ public function testUnknownAbstractSchemaName(): void
public function testCorrectPartialObjectLoad(): void
{
- $this->hydrationMode = AbstractQuery::HYDRATE_ARRAY;
$this->assertValidDQL('SELECT PARTIAL u.{id,name} FROM Doctrine\Tests\Models\CMS\CmsUser u');
}
public function testIncorrectPartialObjectLoadBecauseOfMissingIdentifier(): void
{
- $this->hydrationMode = AbstractQuery::HYDRATE_ARRAY;
$this->assertInvalidDQL('SELECT PARTIAL u.{name} FROM Doctrine\Tests\Models\CMS\CmsUser u');
}
diff --git a/tests/Tests/ORM/Query/ParserTest.php b/tests/Tests/ORM/Query/ParserTest.php
index 6290bbc4dab..430177b1fcc 100644
--- a/tests/Tests/ORM/Query/ParserTest.php
+++ b/tests/Tests/ORM/Query/ParserTest.php
@@ -4,7 +4,6 @@
namespace Doctrine\Tests\ORM\Query;
-use Doctrine\ORM\Internal\Hydration\HydrationException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\QueryException;
@@ -116,15 +115,6 @@ public function testNullLookahead(): void
$parser->match(TokenType::T_SELECT);
}
- public function testPartialExpressionWithObjectHydratorThrows(): void
- {
- $this->expectException(HydrationException::class);
- $this->expectExceptionMessage('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.');
-
- $parser = $this->createParser(CmsUser::class);
- $parser->PartialObjectExpression();
- }
-
private function createParser(string $dql): Parser
{
$query = new Query($this->getTestEntityManager());
diff --git a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php
index 3969fe51636..6b7d9585462 100644
--- a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php
+++ b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php
@@ -1335,8 +1335,6 @@ public function testIdentityFunctionWithCompositePrimaryKey(): void
#[Group('DDC-2519')]
public function testPartialWithAssociationIdentifier(): void
{
- $this->hydrationMode = ORMQuery::HYDRATE_ARRAY;
-
$this->assertSqlGeneration(
'SELECT PARTIAL l.{_source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l',
'SELECT l0_.iUserIdSource AS iUserIdSource_0, l0_.iUserIdTarget AS iUserIdTarget_1 FROM legacy_users_reference l0_',
@@ -1382,58 +1380,122 @@ public function testIdentityFunctionDoesNotAcceptStateField(): void
}
#[Group('DDC-1389')]
- public function testInheritanceTypeJoinInRootClass(): void
+ public function testInheritanceTypeJoinInRootClassWithDisabledForcePartialLoad(): void
{
$this->assertSqlGeneration(
'SELECT p FROM Doctrine\Tests\Models\Company\CompanyPerson p',
'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.title AS title_2, c2_.salary AS salary_3, c2_.department AS department_4, c2_.startDate AS startDate_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c1_.car_id AS car_id_8 FROM company_persons c0_ LEFT JOIN company_managers c1_ ON c0_.id = c1_.id LEFT JOIN company_employees c2_ ON c0_.id = c2_.id',
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false],
);
}
#[Group('DDC-1389')]
- public function testInheritanceTypeJoinInChildClass(): void
+ public function testInheritanceTypeJoinInRootClassWithEnabledForcePartialLoad(): void
+ {
+ $this->assertSqlGeneration(
+ 'SELECT p FROM Doctrine\Tests\Models\Company\CompanyPerson p',
+ 'SELECT c0_.id AS id_0, c0_.name AS name_1, c0_.discr AS discr_2 FROM company_persons c0_',
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true],
+ );
+ }
+
+ #[Group('DDC-1389')]
+ public function testInheritanceTypeJoinInChildClassWithDisabledForcePartialLoad(): void
{
$this->assertSqlGeneration(
'SELECT e FROM Doctrine\Tests\Models\Company\CompanyEmployee e',
'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c2_.car_id AS car_id_8 FROM company_employees c1_ INNER JOIN company_persons c0_ ON c1_.id = c0_.id LEFT JOIN company_managers c2_ ON c1_.id = c2_.id',
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false],
+ );
+ }
+
+ #[Group('DDC-1389')]
+ public function testInheritanceTypeJoinInChildClassWithEnabledForcePartialLoad(): void
+ {
+ $this->assertSqlGeneration(
+ 'SELECT e FROM Doctrine\Tests\Models\Company\CompanyEmployee e',
+ 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c0_.discr AS discr_5 FROM company_employees c1_ INNER JOIN company_persons c0_ ON c1_.id = c0_.id',
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true],
);
}
#[Group('DDC-1389')]
- public function testInheritanceTypeJoinInLeafClass(): void
+ public function testInheritanceTypeJoinInLeafClassWithDisabledForcePartialLoad(): void
{
$this->assertSqlGeneration(
'SELECT m FROM Doctrine\Tests\Models\Company\CompanyManager m',
'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c2_.car_id AS car_id_8 FROM company_managers c2_ INNER JOIN company_employees c1_ ON c2_.id = c1_.id INNER JOIN company_persons c0_ ON c2_.id = c0_.id',
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false],
+ );
+ }
+
+ #[Group('DDC-1389')]
+ public function testInheritanceTypeJoinInLeafClassWithEnabledForcePartialLoad(): void
+ {
+ $this->assertSqlGeneration(
+ 'SELECT m FROM Doctrine\Tests\Models\Company\CompanyManager m',
+ 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6 FROM company_managers c2_ INNER JOIN company_employees c1_ ON c2_.id = c1_.id INNER JOIN company_persons c0_ ON c2_.id = c0_.id',
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true],
);
}
#[Group('DDC-1389')]
- public function testInheritanceTypeSingleTableInRootClass(): void
+ public function testInheritanceTypeSingleTableInRootClassWithDisabledForcePartialLoad(): void
{
$this->assertSqlGeneration(
'SELECT c FROM Doctrine\Tests\Models\Company\CompanyContract c',
"SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.fixPrice AS fixPrice_2, c0_.hoursWorked AS hoursWorked_3, c0_.pricePerHour AS pricePerHour_4, c0_.maxPrice AS maxPrice_5, c0_.discr AS discr_6, c0_.salesPerson_id AS salesPerson_id_7 FROM company_contracts c0_ WHERE c0_.discr IN ('fix', 'flexible', 'flexultra')",
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false],
+ );
+ }
+
+ #[Group('DDC-1389')]
+ public function testInheritanceTypeSingleTableInRootClassWithEnabledForcePartialLoad(): void
+ {
+ $this->assertSqlGeneration(
+ 'SELECT c FROM Doctrine\Tests\Models\Company\CompanyContract c',
+ "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.fixPrice AS fixPrice_2, c0_.hoursWorked AS hoursWorked_3, c0_.pricePerHour AS pricePerHour_4, c0_.maxPrice AS maxPrice_5, c0_.discr AS discr_6 FROM company_contracts c0_ WHERE c0_.discr IN ('fix', 'flexible', 'flexultra')",
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true],
);
}
#[Group('DDC-1389')]
public function testInheritanceTypeSingleTableInChildClassWithDisabledForcePartialLoad(): void
{
- $this->hydrationMode = ORMQuery::HYDRATE_ARRAY;
+ $this->assertSqlGeneration(
+ 'SELECT fc FROM Doctrine\Tests\Models\Company\CompanyFlexContract fc',
+ "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5, c0_.salesPerson_id AS salesPerson_id_6 FROM company_contracts c0_ WHERE c0_.discr IN ('flexible', 'flexultra')",
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false],
+ );
+ }
+ #[Group('DDC-1389')]
+ public function testInheritanceTypeSingleTableInChildClassWithEnabledForcePartialLoad(): void
+ {
$this->assertSqlGeneration(
'SELECT fc FROM Doctrine\Tests\Models\Company\CompanyFlexContract fc',
"SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5 FROM company_contracts c0_ WHERE c0_.discr IN ('flexible', 'flexultra')",
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true],
);
}
#[Group('DDC-1389')]
- public function testInheritanceTypeSingleTableInLeafClass(): void
+ public function testInheritanceTypeSingleTableInLeafClassWithDisabledForcePartialLoad(): void
{
$this->assertSqlGeneration(
'SELECT fuc FROM Doctrine\Tests\Models\Company\CompanyFlexUltraContract fuc',
"SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5, c0_.salesPerson_id AS salesPerson_id_6 FROM company_contracts c0_ WHERE c0_.discr IN ('flexultra')",
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false],
+ );
+ }
+
+ #[Group('DDC-1389')]
+ public function testInheritanceTypeSingleTableInLeafClassWithEnabledForcePartialLoad(): void
+ {
+ $this->assertSqlGeneration(
+ 'SELECT fuc FROM Doctrine\Tests\Models\Company\CompanyFlexUltraContract fuc',
+ "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5 FROM company_contracts c0_ WHERE c0_.discr IN ('flexultra')",
+ [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true],
);
}
@@ -1712,8 +1774,6 @@ public function testCustomTypeValueSqlForAllFields(): void
public function testCustomTypeValueSqlForPartialObject(): void
{
- $this->hydrationMode = ORMQuery::HYDRATE_ARRAY;
-
if (DBALType::hasType('negative_to_positive')) {
DBALType::overrideType('negative_to_positive', NegativeToPositiveType::class);
} else {
@@ -1722,7 +1782,7 @@ public function testCustomTypeValueSqlForPartialObject(): void
$this->assertSqlGeneration(
'SELECT partial p.{id, customInteger} FROM Doctrine\Tests\Models\CustomType\CustomTypeParent p',
- 'SELECT c0_.id AS id_0, -(c0_.customInteger) AS customInteger_1 FROM customtype_parents c0_',
+ 'SELECT c0_.id AS id_0, -(c0_.customInteger) AS customInteger_1, c0_.child_id AS child_id_2 FROM customtype_parents c0_',
);
}
diff --git a/tests/Tests/ORM/Query/SqlExpressionVisitorTest.php b/tests/Tests/ORM/Query/SqlExpressionVisitorTest.php
index 3e2a3e30eda..79bb44f1910 100644
--- a/tests/Tests/ORM/Query/SqlExpressionVisitorTest.php
+++ b/tests/Tests/ORM/Query/SqlExpressionVisitorTest.php
@@ -31,7 +31,7 @@ public function testWalkNotCompositeExpression(): void
$this->persister
->expects(self::once())
->method('getSelectConditionStatementSQL')
- ->will(self::returnValue('dummy expression'));
+ ->willReturn('dummy expression');
$expr = $this->visitor->walkCompositeExpression(
$cb->not(
diff --git a/tests/Tests/ORM/Repository/DefaultRepositoryFactoryTest.php b/tests/Tests/ORM/Repository/DefaultRepositoryFactoryTest.php
index a9b71ef9571..83d39881f58 100644
--- a/tests/Tests/ORM/Repository/DefaultRepositoryFactoryTest.php
+++ b/tests/Tests/ORM/Repository/DefaultRepositoryFactoryTest.php
@@ -34,7 +34,7 @@ protected function setUp(): void
$this->configuration
->expects(self::any())
->method('getDefaultRepositoryClassName')
- ->will(self::returnValue(DDC869PaymentRepository::class));
+ ->willReturn(DDC869PaymentRepository::class);
}
public function testCreatesRepositoryFromDefaultRepositoryClass(): void
@@ -42,7 +42,7 @@ public function testCreatesRepositoryFromDefaultRepositoryClass(): void
$this->entityManager
->expects(self::any())
->method('getClassMetadata')
- ->will(self::returnCallback($this->buildClassMetadata(...)));
+ ->willReturnCallback($this->buildClassMetadata(...));
self::assertInstanceOf(
DDC869PaymentRepository::class,
@@ -55,7 +55,7 @@ public function testCreatedRepositoriesAreCached(): void
$this->entityManager
->expects(self::any())
->method('getClassMetadata')
- ->will(self::returnCallback($this->buildClassMetadata(...)));
+ ->willReturnCallback($this->buildClassMetadata(...));
self::assertSame(
$this->repositoryFactory->getRepository($this->entityManager, self::class),
@@ -71,7 +71,7 @@ public function testCreatesRepositoryFromCustomClassMetadata(): void
$this->entityManager
->expects(self::any())
->method('getClassMetadata')
- ->will(self::returnValue($customMetadata));
+ ->willReturn($customMetadata);
self::assertInstanceOf(
DDC753DefaultRepository::class,
@@ -86,11 +86,11 @@ public function testCachesDistinctRepositoriesPerDistinctEntityManager(): void
$em1->expects(self::any())
->method('getClassMetadata')
- ->will(self::returnCallback($this->buildClassMetadata(...)));
+ ->willReturnCallback($this->buildClassMetadata(...));
$em2->expects(self::any())
->method('getClassMetadata')
- ->will(self::returnCallback($this->buildClassMetadata(...)));
+ ->willReturnCallback($this->buildClassMetadata(...));
$repo1 = $this->repositoryFactory->getRepository($em1, self::class);
$repo2 = $this->repositoryFactory->getRepository($em2, self::class);
diff --git a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php
index 0f5bac25b72..2aeca48dd5d 100644
--- a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php
+++ b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php
@@ -10,6 +10,7 @@
use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\Pagination\LimitSubqueryOutputWalker;
use PHPUnit\Framework\Attributes\Group;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class LimitSubqueryOutputWalkerTest extends PaginationTestCase
{
@@ -137,7 +138,7 @@ public function testCountQueryWithComplexScalarOrderByItemWithoutJoin(): void
);
}
- public function testCountQueryWithComplexScalarOrderByItemJoined(): void
+ public function testCountQueryWithComplexScalarOrderByItemJoinedWithoutPartial(): void
{
$this->entityManager = $this->createTestEntityManagerWithPlatform(new MySQLPlatform());
@@ -273,6 +274,28 @@ public function testLimitSubqueryOrderBySubSelectOrderByExpressionOracle(): void
);
}
+ public function testParsingQueryWithDifferentLimitOffsetValuesTakesOnlyOneCacheEntry(): void
+ {
+ $queryCache = new ArrayAdapter();
+ $this->entityManager->getConfiguration()->setQueryCache($queryCache);
+
+ $query = $this->createQuery('SELECT p, c, a FROM Doctrine\Tests\ORM\Tools\Pagination\MyBlogPost p JOIN p.category c JOIN p.author a');
+
+ self::assertSame(
+ 'SELECT DISTINCT id_0 FROM (SELECT m0_.id AS id_0, m0_.title AS title_1, c1_.id AS id_2, a2_.id AS id_3, a2_.name AS name_4, m0_.author_id AS author_id_5, m0_.category_id AS category_id_6 FROM MyBlogPost m0_ INNER JOIN Category c1_ ON m0_.category_id = c1_.id INNER JOIN Author a2_ ON m0_.author_id = a2_.id) dctrn_result LIMIT 20 OFFSET 10',
+ $query->getSQL(),
+ );
+
+ $query->setFirstResult(30)->setMaxResults(40);
+
+ self::assertSame(
+ 'SELECT DISTINCT id_0 FROM (SELECT m0_.id AS id_0, m0_.title AS title_1, c1_.id AS id_2, a2_.id AS id_3, a2_.name AS name_4, m0_.author_id AS author_id_5, m0_.category_id AS category_id_6 FROM MyBlogPost m0_ INNER JOIN Category c1_ ON m0_.category_id = c1_.id INNER JOIN Author a2_ ON m0_.author_id = a2_.id) dctrn_result LIMIT 40 OFFSET 30',
+ $query->getSQL(),
+ );
+
+ self::assertCount(1, $queryCache->getValues());
+ }
+
private function createQuery(string $dql): Query
{
$query = $this->entityManager->createQuery($dql);
diff --git a/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php b/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php
index 41e3981e3b2..fcd8a9b2aa7 100644
--- a/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php
+++ b/tests/Tests/ORM/Tools/Pagination/PaginatorTest.php
@@ -94,7 +94,8 @@ public function testExtraParametersAreStrippedWhenWalkerRemovingOriginalSelectEl
public function testPaginatorNotCaringAboutExtraParametersWithoutOutputWalkers(): void
{
- $this->connection->expects(self::exactly(3))->method('executeQuery');
+ $result = $this->getMockBuilder(Result::class)->disableOriginalConstructor()->getMock();
+ $this->connection->expects(self::exactly(3))->method('executeQuery')->willReturn($result);
$this->createPaginatorWithExtraParametersWithoutOutputWalkers([])->count();
$this->createPaginatorWithExtraParametersWithoutOutputWalkers([[10]])->count();
@@ -103,7 +104,8 @@ public function testPaginatorNotCaringAboutExtraParametersWithoutOutputWalkers()
public function testgetIteratorDoesCareAboutExtraParametersWithoutOutputWalkersWhenResultIsNotEmpty(): void
{
- $this->connection->expects(self::exactly(1))->method('executeQuery');
+ $result = $this->getMockBuilder(Result::class)->disableOriginalConstructor()->getMock();
+ $this->connection->expects(self::exactly(1))->method('executeQuery')->willReturn($result);
$this->expectException(QueryException::class);
$this->expectExceptionMessage('Too many parameters: the query defines 1 parameters and you bound 2');
diff --git a/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php
index b017ba8de6e..4889b983528 100644
--- a/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php
+++ b/tests/Tests/ORM/Tools/Pagination/WhereInWalkerTest.php
@@ -21,9 +21,10 @@ public function testDqlQueryTransformation(string $dql, string $expectedSql): vo
$query->setHint(Query::HINT_CUSTOM_TREE_WALKERS, [WhereInWalker::class]);
$query->setHint(WhereInWalker::HINT_PAGINATOR_HAS_IDS, true);
- $result = (new Parser($query))->parse();
+ $result = (new Parser($query))->parse();
+ $executor = $result->prepareSqlExecutor($query);
- self::assertEquals($expectedSql, $result->getSqlExecutor()->getSqlStatements());
+ self::assertEquals($expectedSql, $executor->getSqlStatements());
self::assertEquals([0], $result->getSqlParameterPositions(WhereInWalker::PAGINATOR_ID_ALIAS));
}
diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php
index 8de0eb03e25..2a421c6687d 100644
--- a/tests/Tests/ORM/UnitOfWorkTest.php
+++ b/tests/Tests/ORM/UnitOfWorkTest.php
@@ -13,7 +13,6 @@
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Exception\EntityIdentityCollisionException;
-use Doctrine\ORM\Internal\Hydration\HydrationException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
@@ -23,7 +22,6 @@
use Doctrine\ORM\Mapping\Version;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMInvalidArgumentException;
-use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\UnitOfWork;
use Doctrine\Tests\Mocks\EntityManagerMock;
use Doctrine\Tests\Mocks\EntityPersisterMock;
@@ -34,6 +32,7 @@
use Doctrine\Tests\Models\Forum\ForumAvatar;
use Doctrine\Tests\Models\Forum\ForumUser;
use Doctrine\Tests\OrmTestCase;
+use Exception as BaseException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\MockObject;
@@ -652,13 +651,45 @@ public function testItThrowsWhenApplicationProvidedIdsCollide(): void
$this->_unitOfWork->persist($phone2);
}
- public function testItThrowsWhenCreateEntityWithSqlWalkerPartialQueryHint(): void
+ public function testItPreservesTheOriginalExceptionOnRollbackFailure(): void
{
- $this->expectException(HydrationException::class);
- $this->expectExceptionMessage('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.');
+ $driver = $this->createStub(Driver::class);
+ $driver->method('connect')
+ ->willReturn($this->createMock(Driver\Connection::class));
+
+ $connection = new class (['platform' => $this->createStub(AbstractPlatform::class)], $driver) extends Connection {
+ public function commit(): void
+ {
+ throw new BaseException('Commit failed');
+ }
+
+ public function rollBack(): void
+ {
+ throw new BaseException('Rollback exception');
+ }
+ };
+ $this->_emMock = new EntityManagerMock($connection);
+ $this->_unitOfWork = new UnitOfWorkMock($this->_emMock);
+ $this->_emMock->setUnitOfWork($this->_unitOfWork);
+
+ // Setup fake persister and id generator
+ $userPersister = new EntityPersisterMock($this->_emMock, $this->_emMock->getClassMetadata(ForumUser::class));
+ $userPersister->setMockIdGeneratorType(ClassMetadata::GENERATOR_TYPE_IDENTITY);
+ $this->_unitOfWork->setEntityPersister(ForumUser::class, $userPersister);
- $hints = [SqlWalker::HINT_PARTIAL => true];
- $this->_unitOfWork->createEntity(VersionedAssignedIdentifierEntity::class, ['id' => 1], $hints);
+ // Create a test user
+ $user = new ForumUser();
+ $user->username = 'Jasper';
+ $this->_unitOfWork->persist($user);
+
+ try {
+ $this->_unitOfWork->commit();
+ self::fail('Exception expected');
+ } catch (BaseException $e) {
+ self::assertSame('Rollback exception', $e->getMessage());
+ self::assertNotNull($e->getPrevious());
+ self::assertSame('Commit failed', $e->getPrevious()->getMessage());
+ }
}
}
@@ -666,60 +697,52 @@ public function testItThrowsWhenCreateEntityWithSqlWalkerPartialQueryHint(): voi
#[Entity]
class VersionedAssignedIdentifierEntity
{
- /** @var int */
#[Id]
#[Column(type: 'integer')]
- public $id;
+ public int $id;
- /** @var int */
#[Version]
#[Column(type: 'integer')]
- public $version;
+ public int $version;
}
#[Entity]
class EntityWithStringIdentifier
{
- /** @var string|null */
#[Id]
#[Column(type: 'string', length: 255)]
- public $id;
+ public string|null $id = null;
}
#[Entity]
class EntityWithBooleanIdentifier
{
- /** @var bool|null */
#[Id]
#[Column(type: 'boolean')]
- public $id;
+ public bool|null $id = null;
}
#[Entity]
class EntityWithCompositeStringIdentifier
{
- /** @var string|null */
#[Id]
#[Column(type: 'string', length: 255)]
- public $id1;
+ public string|null $id1 = null;
- /** @var string|null */
#[Id]
#[Column(type: 'string', length: 255)]
- public $id2;
+ public string|null $id2 = null;
}
#[Entity]
class EntityWithRandomlyGeneratedField
{
- /** @var string */
#[Id]
#[Column(type: 'string', length: 255)]
- public $id;
+ public string $id;
- /** @var int */
#[Column(type: 'integer')]
- public $generatedField;
+ public int $generatedField;
public function __construct()
{
@@ -750,9 +773,8 @@ class EntityWithCascadingAssociation
#[GeneratedValue(strategy: 'NONE')]
private string $id;
- /** @var CascadePersistedEntity|null */
#[ManyToOne(targetEntity: CascadePersistedEntity::class, cascade: ['persist'])]
- public $cascaded;
+ public CascadePersistedEntity|null $cascaded = null;
public function __construct()
{
@@ -768,9 +790,8 @@ class EntityWithNonCascadingAssociation
#[GeneratedValue(strategy: 'NONE')]
private string $id;
- /** @var CascadePersistedEntity|null */
#[ManyToOne(targetEntity: CascadePersistedEntity::class)]
- public $nonCascaded;
+ public CascadePersistedEntity|null $nonCascaded = null;
public function __construct()
{