Skip to content

Commit

Permalink
Rebase and squash on branch 3.1
Browse files Browse the repository at this point in the history
  • Loading branch information
Zuruuh committed Feb 7, 2024
1 parent 1051817 commit 11021dc
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 7 deletions.
12 changes: 11 additions & 1 deletion docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,16 @@ And then use the ``NEW`` DQL keyword :
Note that you can only pass scalar expressions to the constructor.

The ``NEW`` operator also supports named arguments:

.. code-block:: php
<?php
$query = $em->createQuery('SELECT NEW CustomerDTO(email: e.email, name: c.name, address: a.city) FROM Customer c JOIN c.email e JOIN c.address a');
$users = $query->getResult(); // array of CustomerDTO
Note that you must not pass ordered arguments after named ones.

Using INDEX BY
~~~~~~~~~~~~~~

Expand Down Expand Up @@ -1650,7 +1660,7 @@ Select Expressions
SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable]
SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable]
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
NewObjectArg ::= ScalarExpression | "(" Subselect ")"
NewObjectArg ::= ScalarExpression | NamedScalarExpression | "(" Subselect ")"
Conditional Expressions
~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
3 changes: 2 additions & 1 deletion src/Internal/Hydration/ObjectHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,8 @@ protected function hydrateRowData(array $row, array &$result): void
foreach ($rowData['newObjects'] as $objIndex => $newObject) {
$class = $newObject['class'];
$args = $newObject['args'];
$obj = $class->newInstanceArgs($args);

$obj = $class->newInstanceArgs($args);

if ($scalarCount === 0 && count($rowData['newObjects']) === 1) {
$result[$resultKey] = $obj;
Expand Down
21 changes: 21 additions & 0 deletions src/Query/AST/NamedScalarExpression.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Query\AST;

use Doctrine\ORM\Query\SqlWalker;

class NamedScalarExpression extends Node
{
public function __construct(
public readonly Node $innerExpression,
public readonly string|null $name = null,
) {
}

public function dispatch(SqlWalker $walker): string
{
return $this->innerExpression->dispatch($walker);
}
}
27 changes: 23 additions & 4 deletions src/Query/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -1634,12 +1634,20 @@ public function NewObjectExpression(): AST\NewObjectExpression

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

$args[] = $this->NewObjectArg();
$arg = $this->NewObjectArg();
$namedArgAlreadyParsed = $arg instanceof AST\NamedScalarExpression;
$args = [$arg];

while ($this->lexer->isNextToken(TokenType::T_COMMA)) {
$this->match(TokenType::T_COMMA);
if ($this->lexer->isNextToken(TokenType::T_CLOSE_PARENTHESIS)) {
// Comma above is a trailing comma, ignore it
break;
}

$args[] = $this->NewObjectArg();
$arg = $this->NewObjectArg($namedArgAlreadyParsed);
$namedArgAlreadyParsed = $namedArgAlreadyParsed || $arg instanceof AST\NamedScalarExpression;
$args[] = $arg;
}

$this->match(TokenType::T_CLOSE_PARENTHESIS);
Expand All @@ -1657,15 +1665,26 @@ public function NewObjectExpression(): AST\NewObjectExpression
}

/**
* NewObjectArg ::= ScalarExpression | "(" Subselect ")"
* NewObjectArg ::= ScalarExpression | NamedScalarExpression | "(" Subselect ")"
*/
public function NewObjectArg(): mixed
public function NewObjectArg(bool $namedArgAlreadyParsed = false): mixed
{
assert($this->lexer->lookahead !== null);
$token = $this->lexer->lookahead;
$peek = $this->lexer->glimpse();

assert($peek !== null);
if ($token->type === TokenType::T_IDENTIFIER && $peek->type === TokenType::T_INPUT_PARAMETER) {
$this->match(TokenType::T_IDENTIFIER);
$this->match(TokenType::T_INPUT_PARAMETER);

return new AST\NamedScalarExpression($this->ScalarExpression(), $token->value);
}

if ($namedArgAlreadyParsed) {
throw QueryException::syntaxError('Cannot specify ordered arguments after named ones.');
}

if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) {
$this->match(TokenType::T_OPEN_PARENTHESIS);
$expression = $this->Subselect();
Expand Down
2 changes: 1 addition & 1 deletion src/Query/SqlWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -1503,7 +1503,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
$this->rsm->newObjectMappings[$columnAlias] = [
'className' => $newObjectExpression->className,
'objIndex' => $objIndex,
'argIndex' => $argIndex,
'argIndex' => $e instanceof AST\NamedScalarExpression ? $e->name : $argIndex,
];
}

Expand Down
183 changes: 183 additions & 0 deletions tests/Tests/ORM/Functional/NewOperatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,189 @@ public function testClassCantBeInstantiatedException(): void
$dql = 'SELECT new Doctrine\Tests\ORM\Functional\ClassWithPrivateConstructor(u.name) FROM Doctrine\Tests\Models\CMS\CmsUser u';
$this->_em->createQuery($dql)->getResult();
}

/** @return array<string, array{string}> */
public static function provideQueriesWithNamedArguments(): array
{
return [
'Only named arguments in order' => [
'SELECT
new Doctrine\Tests\Models\CMS\CmsUserDTO(
name: u.name,
email: e.email,
address: a.city,
)
FROM
Doctrine\Tests\Models\CMS\CmsUser u
JOIN
u.email e
JOIN
u.address a
ORDER BY
u.name',
],
'Only named arguments not in order' => [
'SELECT
new Doctrine\Tests\Models\CMS\CmsUserDTO(
email: e.email,
name: u.name,
address: a.city,
)
FROM
Doctrine\Tests\Models\CMS\CmsUser u
JOIN
u.email e
JOIN
u.address a
ORDER BY
u.name',
],
'Both named and ordered arguments' => [
'SELECT
new Doctrine\Tests\Models\CMS\CmsUserDTO(
u.name,
address: a.city,
email: e.email,
)
FROM
Doctrine\Tests\Models\CMS\CmsUser u
JOIN
u.email e
JOIN
u.address a
ORDER BY
u.name',
],
'Both named and ordered arguments without trailing comma' => [
'SELECT
new Doctrine\Tests\Models\CMS\CmsUserDTO(
u.name,
address: a.city,
email: e.email
)
FROM
Doctrine\Tests\Models\CMS\CmsUser u
JOIN
u.email e
JOIN
u.address a
ORDER BY
u.name',
],
];
}

#[DataProvider('provideQueriesWithNamedArguments')]
public function testQueryWithNamedArguments(string $query): void
{
$query = $this->_em->createQuery($query);
$result = $query->getResult();

self::assertCount(3, $result);

self::assertInstanceOf(CmsUserDTO::class, $result[0]);
self::assertInstanceOf(CmsUserDTO::class, $result[1]);
self::assertInstanceOf(CmsUserDTO::class, $result[2]);

self::assertEquals($this->fixtures[0]->name, $result[0]->name);
self::assertEquals($this->fixtures[1]->name, $result[1]->name);
self::assertEquals($this->fixtures[2]->name, $result[2]->name);

self::assertEquals($this->fixtures[0]->email->email, $result[0]->email);
self::assertEquals($this->fixtures[1]->email->email, $result[1]->email);
self::assertEquals($this->fixtures[2]->email->email, $result[2]->email);

self::assertEquals($this->fixtures[0]->address->city, $result[0]->address);
self::assertEquals($this->fixtures[1]->address->city, $result[1]->address);
self::assertEquals($this->fixtures[2]->address->city, $result[2]->address);

self::assertNull($result[0]->phonenumbers);
self::assertNull($result[1]->phonenumbers);
self::assertNull($result[2]->phonenumbers);
}

public function testQueryWithOrderedArgumentAfterNamedArgument(): void
{
$dql = '
SELECT
new Doctrine\Tests\Models\CMS\CmsUserDTO(
address: a.city,
email: e.email,
u.name,
)
FROM
Doctrine\Tests\Models\CMS\CmsUser u
JOIN
u.email e
JOIN
u.address a
ORDER BY
u.name';

$query = $this->_em->createQuery($dql);
$this->expectException(QueryException::class);
$this->expectExceptionMessage('[Syntax Error] Cannot specify ordered arguments after named ones.');

$query->getResult();
}

public function testQueryWithNamedArgumentsWithoutOptionalParameters(): void
{
$dql = '
SELECT
new Doctrine\Tests\Models\CMS\CmsUserDTO(
address: a.city,
email: e.email,
)
FROM
Doctrine\Tests\Models\CMS\CmsUser u
JOIN
u.email e
JOIN
u.address a
ORDER BY
u.name';

$query = $this->_em->createQuery($dql);
$result = $query->getResult();

self::assertInstanceOf(CmsUserDTO::class, $result[0]);
self::assertInstanceOf(CmsUserDTO::class, $result[1]);
self::assertInstanceOf(CmsUserDTO::class, $result[2]);

self::assertNull($result[0]->name);
self::assertNull($result[1]->name);
self::assertNull($result[2]->name);

self::assertEquals($this->fixtures[0]->email->email, $result[0]->email);
self::assertEquals($this->fixtures[1]->email->email, $result[1]->email);
self::assertEquals($this->fixtures[2]->email->email, $result[2]->email);

self::assertEquals($this->fixtures[0]->address->city, $result[0]->address);
self::assertEquals($this->fixtures[1]->address->city, $result[1]->address);
self::assertEquals($this->fixtures[2]->address->city, $result[2]->address);

self::assertNull($result[0]->phonenumbers);
self::assertNull($result[1]->phonenumbers);
self::assertNull($result[2]->phonenumbers);
}

public function testQueryWithNamedArgumentsMissingRequiredArguments(): void
{
$dql = '
SELECT
new ' . ClassWithTooMuchArgs::class . '(
bar: u.name,
)
FROM
Doctrine\Tests\Models\CMS\CmsUser u
';

$query = $this->_em->createQuery($dql);
$this->expectException(QueryException::class);
$this->expectExceptionMessage('Number of arguments does not match with "Doctrine\Tests\ORM\Functional\ClassWithTooMuchArgs" constructor declaration.');
$result = $query->getResult();
}
}

class ClassWithTooMuchArgs
Expand Down

0 comments on commit 11021dc

Please sign in to comment.