Skip to content

Commit

Permalink
Merge pull request #11365 from beberlei/ReintroducePartialForArrayHyd…
Browse files Browse the repository at this point in the history
…ration

Reintroduce PARTIAL, but only for non-object hydration.
  • Loading branch information
greg0ire authored Jul 24, 2024
2 parents 1281707 + 80278c5 commit 96546ca
Show file tree
Hide file tree
Showing 18 changed files with 362 additions and 11 deletions.
8 changes: 5 additions & 3 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ now they throw an exception.

## BC BREAK: Partial objects are removed

- The `PARTIAL` keyword in DQL no longer exists.
- `Doctrine\ORM\Query\AST\PartialObjectExpression`is removed.
- `Doctrine\ORM\Query\SqlWalker::HINT_PARTIAL` and
WARNING: This was relaxed in ORM 3.2 when partial was re-allowed for array-hydration.

- The `PARTIAL` keyword in DQL no longer exists (reintroduced in ORM 3.2)
- `Doctrine\ORM\Query\AST\PartialObjectExpression` is removed. (reintroduced in ORM 3.2)
- `Doctrine\ORM\Query\SqlWalker::HINT_PARTIAL` (reintroduced in ORM 3.2) and
`Doctrine\ORM\Query::HINT_FORCE_PARTIAL_LOAD` are removed.
- `Doctrine\ORM\EntityManager*::getPartialReference()` is removed.

Expand Down
1 change: 1 addition & 0 deletions docs/en/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Advanced Topics
* :doc:`TypedFieldMapper <reference/typedfieldmapper>`
* :doc:`Improving Performance <reference/improving-performance>`
* :doc:`Caching <reference/caching>`
* :doc:`Partial Hydration <reference/partial-hydration>`
* :doc:`Change Tracking Policies <reference/change-tracking-policies>`
* :doc:`Best Practices <reference/best-practices>`
* :doc:`Metadata Drivers <reference/metadata-drivers>`
Expand Down
23 changes: 22 additions & 1 deletion docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,25 @@ when the DQL is switched to an arbitrary join.
- HAVING is applied to the results of a query after
aggregation (GROUP BY)


Partial Hydration Syntax
^^^^^^^^^^^^^^^^^^^^^^^^

By default when you run a DQL query in Doctrine and select only a
subset of the fields for a given entity, you do not receive objects
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:

.. 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
"NEW" Operator Syntax
^^^^^^^^^^^^^^^^^^^^^

Expand Down Expand Up @@ -1576,8 +1595,10 @@ Select Expressions

.. code-block:: php
SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable]
SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable]
SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable]
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
NewObjectArg ::= ScalarExpression | "(" Subselect ")"
Expand Down
20 changes: 20 additions & 0 deletions docs/en/reference/partial-hydration.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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.

.. code-block:: php
<?php
$users = $em->createQuery("SELECT PARTIAL u.{id,name}, partial a.{id,street} FROM MyApp\Domain\User u JOIN u.addresses a")
->getArrayResult();
This is a useful optimization when you are not interested in all fields of an entity
for performance reasons, for example in use-cases for exporting or rendering lots of data.
1 change: 1 addition & 0 deletions docs/en/sidebar.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
reference/query-builder
reference/native-sql
reference/change-tracking-policies
reference/partial-hydration
reference/attributes-reference
reference/xml-mapping
reference/php-mapping
Expand Down
5 changes: 5 additions & 0 deletions src/Internal/Hydration/HydrationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,9 @@ public static function invalidDiscriminatorValue(string $discrValue, array $disc
implode('", "', $discrValues),
));
}

public static function partialObjectHydrationDisallowed(): self
{
return new self('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.');
}
}
15 changes: 15 additions & 0 deletions src/Query/AST/PartialObjectExpression.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Query\AST;

class PartialObjectExpression extends Node
{
/** @param mixed[] $partialFieldSet */
public function __construct(
public string $identificationVariable,
public array $partialFieldSet,
) {
}
}
118 changes: 116 additions & 2 deletions src/Query/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@

use Doctrine\Common\Lexer\Token;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Internal\Hydration\HydrationException;
use Doctrine\ORM\Mapping\AssociationMapping;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\AST\Functions;
use LogicException;
use ReflectionClass;

use function array_intersect;
use function array_search;
use function assert;
use function class_exists;
Expand Down Expand Up @@ -102,6 +104,9 @@ final class Parser
/** @psalm-var list<array{token: DqlToken|null, expression: mixed, nestingLevel: int}> */
private array $deferredIdentificationVariables = [];

/** @psalm-var list<array{token: DqlToken|null, expression: AST\PartialObjectExpression, nestingLevel: int}> */
private array $deferredPartialObjectExpressions = [];

/** @psalm-var list<array{token: DqlToken|null, expression: AST\PathExpression, nestingLevel: int}> */
private array $deferredPathExpressions = [];

Expand Down Expand Up @@ -224,6 +229,10 @@ public function getAST(): AST\SelectStatement|AST\UpdateStatement|AST\DeleteStat
// This also allows post-processing of the AST for modification purposes.
$this->processDeferredIdentificationVariables();

if ($this->deferredPartialObjectExpressions) {
$this->processDeferredPartialObjectExpressions();
}

if ($this->deferredPathExpressions) {
$this->processDeferredPathExpressions();
}
Expand Down Expand Up @@ -599,6 +608,44 @@ private function processDeferredNewObjectExpressions(AST\SelectStatement $AST):
}
}

/**
* Validates that the given <tt>PartialObjectExpression</tt> is semantically correct.
* It must exist in query components list.
*/
private function processDeferredPartialObjectExpressions(): void
{
foreach ($this->deferredPartialObjectExpressions as $deferredItem) {
$expr = $deferredItem['expression'];
$class = $this->getMetadataForDqlAlias($expr->identificationVariable);

foreach ($expr->partialFieldSet as $field) {
if (isset($class->fieldMappings[$field])) {
continue;
}

if (
isset($class->associationMappings[$field]) &&
$class->associationMappings[$field]->isToOneOwningSide()
) {
continue;
}

$this->semanticalError(sprintf(
"There is no mapped field named '%s' on class %s.",
$field,
$class->name,
), $deferredItem['token']);
}

if (array_intersect($class->identifier, $expr->partialFieldSet) !== $class->identifier) {
$this->semanticalError(
'The partial field selection of class ' . $class->name . ' must contain the identifier.',
$deferredItem['token'],
);
}
}
}

/**
* Validates that the given <tt>ResultVariable</tt> is semantically correct.
* It must exist in query components list.
Expand Down Expand Up @@ -1621,6 +1668,67 @@ public function JoinAssociationDeclaration(): AST\JoinAssociationDeclaration
return new AST\JoinAssociationDeclaration($joinAssociationPathExpression, $aliasIdentificationVariable, $indexBy);
}

/**
* PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
* PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
*/
public function PartialObjectExpression(): AST\PartialObjectExpression
{
if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) {
throw HydrationException::partialObjectHydrationDisallowed();
}

$this->match(TokenType::T_PARTIAL);

$partialFieldSet = [];

$identificationVariable = $this->IdentificationVariable();

$this->match(TokenType::T_DOT);
$this->match(TokenType::T_OPEN_CURLY_BRACE);
$this->match(TokenType::T_IDENTIFIER);

assert($this->lexer->token !== null);
$field = $this->lexer->token->value;

// First field in partial expression might be embeddable property
while ($this->lexer->isNextToken(TokenType::T_DOT)) {
$this->match(TokenType::T_DOT);
$this->match(TokenType::T_IDENTIFIER);
$field .= '.' . $this->lexer->token->value;
}

$partialFieldSet[] = $field;

while ($this->lexer->isNextToken(TokenType::T_COMMA)) {
$this->match(TokenType::T_COMMA);
$this->match(TokenType::T_IDENTIFIER);

$field = $this->lexer->token->value;

while ($this->lexer->isNextToken(TokenType::T_DOT)) {
$this->match(TokenType::T_DOT);
$this->match(TokenType::T_IDENTIFIER);
$field .= '.' . $this->lexer->token->value;
}

$partialFieldSet[] = $field;
}

$this->match(TokenType::T_CLOSE_CURLY_BRACE);

$partialObjectExpression = new AST\PartialObjectExpression($identificationVariable, $partialFieldSet);

// Defer PartialObjectExpression validation
$this->deferredPartialObjectExpressions[] = [
'expression' => $partialObjectExpression,
'nestingLevel' => $this->nestingLevel,
'token' => $this->lexer->token,
];

return $partialObjectExpression;
}

/**
* NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
*/
Expand Down Expand Up @@ -1920,7 +2028,7 @@ public function SimpleWhenClause(): AST\SimpleWhenClause
/**
* SelectExpression ::= (
* IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration |
* "(" Subselect ")" | CaseExpression | NewObjectExpression
* PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression
* ) [["AS"] ["HIDDEN"] AliasResultVariable]
*/
public function SelectExpression(): AST\SelectExpression
Expand Down Expand Up @@ -1961,6 +2069,12 @@ public function SelectExpression(): AST\SelectExpression

break;

// PartialObjectExpression (PARTIAL u.{id, name})
case $lookaheadType === TokenType::T_PARTIAL:
$expression = $this->PartialObjectExpression();
$identVariable = $expression->identificationVariable;
break;

// Subselect
case $lookaheadType === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT:
$this->match(TokenType::T_OPEN_PARENTHESIS);
Expand All @@ -1986,7 +2100,7 @@ public function SelectExpression(): AST\SelectExpression

default:
$this->syntaxError(
'IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression',
'IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression',
$this->lexer->lookahead,
);
}
Expand Down
27 changes: 24 additions & 3 deletions src/Query/SqlWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use function assert;
use function count;
use function implode;
use function in_array;
use function is_array;
use function is_float;
use function is_int;
Expand All @@ -52,6 +53,11 @@ class SqlWalker

public const HINT_DISTINCT = 'doctrine.distinct';

/**
* Used to mark a query as containing a PARTIAL expression, which needs to be known by SLC.
*/
public const HINT_PARTIAL = 'doctrine.partial';

private readonly ResultSetMapping $rsm;

/**
Expand Down Expand Up @@ -1325,7 +1331,17 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st
break;

default:
$dqlAlias = $expr;
// IdentificationVariable or PartialObjectExpression
if ($expr instanceof AST\PartialObjectExpression) {
$this->query->setHint(self::HINT_PARTIAL, true);

$dqlAlias = $expr->identificationVariable;
$partialFieldSet = $expr->partialFieldSet;
} else {
$dqlAlias = $expr;
$partialFieldSet = [];
}

$class = $this->getMetadataForDqlAlias($dqlAlias);
$resultAlias = $selectExpression->fieldIdentificationVariable ?: null;

Expand All @@ -1341,6 +1357,10 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st

// Select all fields from the queried class
foreach ($class->fieldMappings as $fieldName => $mapping) {
if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) {
continue;
}

$tableName = isset($mapping->inherited)
? $this->em->getClassMetadata($mapping->inherited)->getTableName()
: $class->getTableName();
Expand All @@ -1367,13 +1387,14 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st

// Add any additional fields of subclasses (excluding inherited fields)
// 1) on Single Table Inheritance: always, since its marginal overhead
// 2) on Class Table Inheritance
// 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);

foreach ($subClass->fieldMappings as $fieldName => $mapping) {
if (isset($mapping->inherited)) {
if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) {
continue;
}

Expand Down
1 change: 1 addition & 0 deletions src/Query/TokenType.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ enum TokenType: int
case T_OR = 242;
case T_ORDER = 243;
case T_OUTER = 244;
case T_PARTIAL = 245;
case T_SELECT = 246;
case T_SET = 247;
case T_SOME = 248;
Expand Down
6 changes: 6 additions & 0 deletions src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
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;
Expand All @@ -43,6 +44,7 @@
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;
Expand Down Expand Up @@ -2353,6 +2355,10 @@ 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
Loading

0 comments on commit 96546ca

Please sign in to comment.