Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reintroduce PARTIAL, but only for non-object hydration. #11365

Merged
merged 5 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,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 @@ -1647,8 +1666,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 @@ -43,6 +43,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 @@
/** @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 @@
// 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 @@
}
}

/**
* 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 @@
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;

Check warning on line 1698 in src/Query/Parser.php

View check run for this annotation

Codecov / codecov/patch

src/Query/Parser.php#L1696-L1698

Added lines #L1696 - L1698 were not covered by tests
}

$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 @@
/**
* 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 @@

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 @@

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_numeric;
Expand All @@ -51,6 +52,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 @@ -1318,7 +1324,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 @@ -1334,6 +1350,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 @@ -1360,13 +1380,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
Loading