Skip to content

Commit

Permalink
Revert undprecate PARTIAL for objects in DQL.
Browse files Browse the repository at this point in the history
  • Loading branch information
beberlei committed Oct 9, 2024
1 parent 4a3c7f0 commit f717255
Show file tree
Hide file tree
Showing 26 changed files with 629 additions and 61 deletions.
1 change: 1 addition & 0 deletions docs/en/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Advanced Topics
* :doc:`Improving Performance <reference/improving-performance>`
* :doc:`Caching <reference/caching>`
* :doc:`Partial Hydration <reference/partial-hydration>`
* :doc:`Partial Objects <reference/partial-objects>`
* :doc:`Change Tracking Policies <reference/change-tracking-policies>`
* :doc:`Best Practices <reference/best-practices>`
* :doc:`Metadata Drivers <reference/metadata-drivers>`
Expand Down
24 changes: 21 additions & 3 deletions docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php
$query = $em->createQuery('SELECT partial u.{id, username} FROM CmsUser u');
$users = $query->getResult(); // array of partially loaded CmsUser objects
You use the partial syntax when joining as well:

.. code-block:: php
<?php
$query = $em->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
^^^^^^^^^^^^^^^^^^^^^
Expand Down Expand Up @@ -1370,6 +1379,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 <https://github.com/doctrine/orm/issues/8471>`_)
- ``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
Expand Down
98 changes: 98 additions & 0 deletions docs/en/reference/partial-objects.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
Partial Objects
===============


.. note::

Creating Partial Objects through DQL is deprecated and
will be removed in the future, use data transfer object
support in DQL instead. (\ `Details
<https://github.com/doctrine/orm/issues/8471>`_)

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 Doctrine2 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
<?php
$q = $em->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
<?php
$reference = $em->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.


1 change: 1 addition & 0 deletions docs/en/sidebar.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/Cache/DefaultQueryCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions src/Cache/Exception/FeatureNotImplemented.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
}
1 change: 1 addition & 0 deletions src/Decorator/EntityManagerDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Doctrine\Common\EventManager;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\LockMode;
use Doctrine\Deprecations\Deprecation;

Check failure on line 11 in src/Decorator/EntityManagerDecorator.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Type Doctrine\Deprecations\Deprecation is not used in this file.
use Doctrine\ORM\Cache;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManagerInterface;
Expand Down
1 change: 1 addition & 0 deletions src/EntityManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Doctrine\Common\EventManager;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\LockMode;
use Doctrine\Deprecations\Deprecation;

Check failure on line 12 in src/EntityManager.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Type Doctrine\Deprecations\Deprecation is not used in this file.
use Doctrine\ORM\Exception\EntityManagerClosed;
use Doctrine\ORM\Exception\InvalidHydrationMode;
use Doctrine\ORM\Exception\MissingIdentifierField;
Expand Down
8 changes: 8 additions & 0 deletions src/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,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.
Expand Down
5 changes: 1 addition & 4 deletions src/Query/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Doctrine\ORM\Query;

use Doctrine\Common\Lexer\Token;
use Doctrine\Deprecations\Deprecation;

Check failure on line 8 in src/Query/Parser.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Type Doctrine\Deprecations\Deprecation is not used in this file.
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Internal\Hydration\HydrationException;

Check failure on line 10 in src/Query/Parser.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Type Doctrine\ORM\Internal\Hydration\HydrationException is not used in this file.
use Doctrine\ORM\Mapping\AssociationMapping;
Expand Down Expand Up @@ -1674,10 +1675,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 = [];
Expand Down
40 changes: 24 additions & 16 deletions src/Query/SqlWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,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);
Expand Down Expand Up @@ -659,7 +664,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) {
Expand Down Expand Up @@ -1398,28 +1404,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);

Check failure on line 1409 in src/Query/SqlWalker.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space
$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))) {

Check failure on line 1413 in src/Query/SqlWalker.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Expected 1 space(s) after NOT operator; 0 found
continue;
}

$columnAlias = $this->getSQLColumnAlias($mapping->columnName);
$quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform);
$columnAlias = $this->getSQLColumnAlias($mapping->columnName);

Check failure on line 1417 in src/Query/SqlWalker.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space
$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);

Check failure on line 1423 in src/Query/SqlWalker.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (8.3)

Equals sign not aligned with surrounding assignments; expected 2 spaces but found 1 space

$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);
}
}
}

Expand Down
17 changes: 13 additions & 4 deletions src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Doctrine\DBAL;
use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
use Doctrine\DBAL\LockMode;
use Doctrine\Deprecations\Deprecation;
use Doctrine\ORM\Cache\Persister\CachedPersister;
use Doctrine\ORM\Event\ListenersInvoker;
use Doctrine\ORM\Event\OnClearEventArgs;
Expand Down Expand Up @@ -2355,10 +2356,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);
Expand Down Expand Up @@ -2417,6 +2414,18 @@ public function createEntity(string $className, array $data, array &$hints = [])
unset($this->eagerLoadingEntities[$class->rootEntityName]);
}

// Properly initialize any unfetched associations, if partial objects are not allowed.
if (isset($hints[Query::HINT_FORCE_PARTIAL_LOAD])) {
Deprecation::trigger(
'doctrine/orm',
'https://github.com/doctrine/orm/issues/8471',
'Partial Objects are deprecated (here entity %s)',
$className,
);

return $entity;
}

foreach ($class->associationMappings as $field => $assoc) {
// Check if the association is not among the fetch-joined associations already.
if (isset($hints['fetchAlias'], $hints['fetched'][$hints['fetchAlias']][$field])) {
Expand Down
Loading

0 comments on commit f717255

Please sign in to comment.