Repository: doctrine/orm (php)

Issue:
QueryBuilder function "createNamedParameter" like in doctrine/dbal
### Feature Request

Hello,
i noticed that the function "createNamedParameter" does not exist in the [QueryBuilder](https://github.com/doctrine/orm/blob/2.12.x/lib/Doctrine/ORM/QueryBuilder.php), unlike the [QueryBuilder](https://github.com/doctrine/dbal/blob/3.3.x/src/Query/QueryBuilder.php#L1463) from "doctrine/dbal".
Is there a reason why it is not available in "doctrine/orm"?
Otherwise I can create a pull request.

|    Q        |   A
|------------ | ------
| New Feature | yes
| RFC         | no
| BC Break    | no

#### Summary

Documentation of the function "createNamedParameter": https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/query-builder.html#binding-parameters-to-placeholders


Pull request:
Add `createNamedParameter` to `QueryBuilder`
Closes #9878

Replaces #9883

Cherry picked tests from #9883

Fixed review suggestions

Patch:
diff --git a/docs/en/reference/query-builder.rst b/docs/en/reference/query-builder.rst
index 3070cc234..41ef31420 100644
--- a/docs/en/reference/query-builder.rst
+++ b/docs/en/reference/query-builder.rst
@@ -611,3 +611,21 @@ same query of example 6 written using
       ->add('from', new Expr\From('User', 'u'))
       ->add('where', new Expr\Comparison('u.id', '=', '?1'))
       ->add('orderBy', new Expr\OrderBy('u.name', 'ASC'));
+
+Binding Parameters to Placeholders
+----------------------------------
+
+It is often not necessary to know about the exact placeholder names when
+building a query. You can use a helper method to bind a value to a placeholder
+and directly use that placeholder in your query as a return value:
+
+.. code-block:: php
+
+    <?php
+    // $qb instanceof QueryBuilder
+
+    $qb->select('u')
+        ->from('User', 'u')
+        ->where('u.email = ' .  $qb->createNamedParameter($userInputEmail))
+    ;
+    // SELECT u FROM User u WHERE email = :dcValue1
diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php
index a6a39a964..fe2d750e3 100644
--- a/src/QueryBuilder.php
+++ b/src/QueryBuilder.php
@@ -110,6 +110,13 @@ class QueryBuilder implements Stringable
 
     protected int $lifetime = 0;
 
+    /**
+     * The counter of bound parameters.
+     *
+     * @var int<0, max>
+     */
+    private int $boundCounter = 0;
+
     /**
      * Initializes a new <tt>QueryBuilder</tt> that uses the given <tt>EntityManager</tt>.
      *
@@ -1336,6 +1343,41 @@ class QueryBuilder implements Stringable
         return $this;
     }
 
+    /**
+     * Creates a new named parameter and bind the value $value to it.
+     *
+     * The parameter $value specifies the value that you want to bind. If
+     * $placeholder is not provided createNamedParameter() will automatically
+     * create a placeholder for you. An automatic placeholder will be of the
+     * name ':dcValue1', ':dcValue2' etc.
+     *
+     * Example:
+     *  <code>
+     *   $qb = $em->createQueryBuilder();
+     *   $qb
+     *      ->select('u')
+     *      ->from('User', 'u')
+     *      ->where('u.username = ' . $qb->createNamedParameter('Foo', Types::STRING))
+     *      ->orWhere('u.username = ' . $qb->createNamedParameter('Bar', Types::STRING))
+     *  </code>
+     *
+     * @param ParameterType|ArrayParameterType|string|int|null $type        ParameterType::*, ArrayParameterType::* or \Doctrine\DBAL\Types\Type::* constant
+     * @param non-empty-string|null                            $placeholder The name to bind with. The string must start with a colon ':'.
+     *
+     * @return non-empty-string the placeholder name used.
+     */
+    public function createNamedParameter(mixed $value, ParameterType|ArrayParameterType|string|int|null $type = null, string|null $placeholder = null): string
+    {
+        if ($placeholder === null) {
+            $this->boundCounter++;
+            $placeholder = ':dcValue' . $this->boundCounter;
+        }
+
+        $this->setParameter(substr($placeholder, 1), $value, $type);
+
+        return $placeholder;
+    }
+
     /**
      * Gets a string representation of this QueryBuilder which corresponds to
      * the final DQL query being constructed.
diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php
index 26f17d27b..4ec090684 100644
--- a/src/UnitOfWork.php
+++ b/src/UnitOfWork.php
@@ -2467,7 +2467,10 @@ class UnitOfWork implements PropertyChangedListener
                             } else {
                                 $associatedId[$targetClass->fieldNames[$targetColumn]] = $joinColumnValue;
                             }
-                        } elseif (in_array($targetClass->getFieldForColumn($targetColumn), $targetClass->identifier, true)) {
+                        } elseif (
+                            $targetClass->containsForeignIdentifier
+                            && in_array($targetClass->getFieldForColumn($targetColumn), $targetClass->identifier, true)
+                        ) {
                             // the missing key is part of target's entity primary key
                             $associatedId = [];
                             break;


Test patch:
diff --git a/tests/Tests/Models/CompositeKeyRelations/CustomerClass.php b/tests/Tests/Models/CompositeKeyRelations/CustomerClass.php
deleted file mode 100644
index fb557f990..000000000
--- a/tests/Tests/Models/CompositeKeyRelations/CustomerClass.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Doctrine\Tests\Models\CompositeKeyRelations;
-
-use Doctrine\ORM\Mapping\Column;
-use Doctrine\ORM\Mapping\Entity;
-use Doctrine\ORM\Mapping\Id;
-
-#[Entity]
-class CustomerClass
-{
-    #[Id]
-    #[Column(type: 'string')]
-    public string $companyCode;
-
-    #[Id]
-    #[Column(type: 'string')]
-    public string $code;
-
-    #[Column(type: 'string')]
-    public string $name;
-}
diff --git a/tests/Tests/Models/CompositeKeyRelations/InvoiceClass.php b/tests/Tests/Models/CompositeKeyRelations/InvoiceClass.php
deleted file mode 100644
index 25295ade3..000000000
--- a/tests/Tests/Models/CompositeKeyRelations/InvoiceClass.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Doctrine\Tests\Models\CompositeKeyRelations;
-
-use Doctrine\ORM\Mapping\Column;
-use Doctrine\ORM\Mapping\Entity;
-use Doctrine\ORM\Mapping\Id;
-use Doctrine\ORM\Mapping\JoinColumn;
-use Doctrine\ORM\Mapping\ManyToOne;
-
-#[Entity]
-class InvoiceClass
-{
-    #[Id]
-    #[Column(type: 'string')]
-    public string $companyCode;
-
-    #[Id]
-    #[Column(type: 'string')]
-    public string $invoiceNumber;
-
-    #[ManyToOne(targetEntity: CustomerClass::class)]
-    #[JoinColumn(name: 'companyCode', referencedColumnName: 'companyCode')]
-    #[JoinColumn(name: 'customerCode', referencedColumnName: 'code')]
-    public CustomerClass|null $customer;
-
-    #[Column(type: 'string', nullable: true)]
-    public string|null $customerCode = null;
-}
diff --git a/tests/Tests/ORM/Functional/CompositeKeyRelationsTest.php b/tests/Tests/ORM/Functional/CompositeKeyRelationsTest.php
deleted file mode 100644
index 955d6acaa..000000000
--- a/tests/Tests/ORM/Functional/CompositeKeyRelationsTest.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Doctrine\Tests\ORM\Functional;
-
-use Doctrine\Tests\Models\CompositeKeyRelations\CustomerClass;
-use Doctrine\Tests\Models\CompositeKeyRelations\InvoiceClass;
-use Doctrine\Tests\OrmFunctionalTestCase;
-
-class CompositeKeyRelationsTest extends OrmFunctionalTestCase
-{
-    protected function setUp(): void
-    {
-        $this->useModelSet('compositekeyrelations');
-
-        parent::setUp();
-    }
-
-    public function testFindEntityWithNotNullRelation(): void
-    {
-        $this->_em->getConnection()->insert('CustomerClass', [
-            'companyCode' => 'AA',
-            'code' => 'CUST1',
-            'name' => 'Customer 1',
-        ]);
-
-        $this->_em->getConnection()->insert('InvoiceClass', [
-            'companyCode' => 'AA',
-            'invoiceNumber' => 'INV1',
-            'customerCode' => 'CUST1',
-        ]);
-
-        $entity = $this->findEntity('AA', 'INV1');
-        self::assertSame('AA', $entity->companyCode);
-        self::assertSame('INV1', $entity->invoiceNumber);
-        self::assertInstanceOf(CustomerClass::class, $entity->customer);
-        self::assertSame('Customer 1', $entity->customer->name);
-    }
-
-    public function testFindEntityWithNullRelation(): void
-    {
-        $this->_em->getConnection()->insert('InvoiceClass', [
-            'companyCode' => 'BB',
-            'invoiceNumber' => 'INV1',
-        ]);
-
-        $entity = $this->findEntity('BB', 'INV1');
-        self::assertSame('BB', $entity->companyCode);
-        self::assertSame('INV1', $entity->invoiceNumber);
-        self::assertNull($entity->customer);
-    }
-
-    private function findEntity(string $companyCode, string $invoiceNumber): InvoiceClass
-    {
-        return $this->_em->find(
-            InvoiceClass::class,
-            ['companyCode' => $companyCode, 'invoiceNumber' => $invoiceNumber],
-        );
-    }
-}
diff --git a/tests/Tests/ORM/QueryBuilderTest.php b/tests/Tests/ORM/QueryBuilderTest.php
index 577c86581..fd610bced 100644
--- a/tests/Tests/ORM/QueryBuilderTest.php
+++ b/tests/Tests/ORM/QueryBuilderTest.php
@@ -8,6 +8,7 @@ use BadMethodCallException;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Criteria;
 use Doctrine\Common\Collections\Order;
+use Doctrine\DBAL\Types\Types;
 use Doctrine\ORM\Cache;
 use Doctrine\ORM\Query;
 use Doctrine\ORM\Query\Expr\Join;
@@ -1285,4 +1286,44 @@ class QueryBuilderTest extends OrmTestCase
         $this->expectExceptionMessage('Doctrine\ORM\QueryBuilder::delete(): The alias for entity Doctrine\Tests\Models\CMS\CmsUser u must not be omitted.');
         $qb->delete(CmsUser::class . ' u');
     }
+
+    public function testCreateNamedParameter(): void
+    {
+        $qb = $this->entityManager->createQueryBuilder();
+
+        $qb->select('u')
+            ->from(CmsUser::class, 'u')
+            ->where(
+                $qb->expr()->eq('u.name', $qb->createNamedParameter('john doe', Types::STRING)),
+            )
+            ->orWhere(
+                $qb->expr()->eq('u.rank', $qb->createNamedParameter(100, Types::INTEGER)),
+            );
+
+        self::assertEquals('SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u WHERE u.name = :dcValue1 OR u.rank = :dcValue2', $qb->getDQL());
+        self::assertEquals('john doe', $qb->getParameter('dcValue1')->getValue());
+        self::assertEquals(Types::STRING, $qb->getParameter('dcValue1')->getType());
+        self::assertEquals(100, $qb->getParameter('dcValue2')->getValue());
+        self::assertEquals(Types::INTEGER, $qb->getParameter('dcValue2')->getType());
+    }
+
+    public function testCreateNamedParameterCustomPlaceholder(): void
+    {
+        $qb = $this->entityManager->createQueryBuilder();
+
+        $qb->select('u')
+            ->from(CmsUser::class, 'u')
+            ->where(
+                $qb->expr()->eq('u.name', $qb->createNamedParameter('john doe', Types::STRING, ':test')),
+            )
+            ->andWhere(
+                $qb->expr()->eq('u.rank', $qb->createNamedParameter(100, Types::INTEGER)),
+            );
+
+        self::assertEquals('SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u WHERE u.name = :test AND u.rank = :dcValue1', $qb->getDQL());
+        self::assertEquals('john doe', $qb->getParameter('test')->getValue());
+        self::assertEquals(Types::STRING, $qb->getParameter('test')->getType());
+        self::assertEquals(100, $qb->getParameter('dcValue1')->getValue());
+        self::assertEquals(Types::INTEGER, $qb->getParameter('dcValue1')->getType());
+    }
 }
diff --git a/tests/Tests/OrmFunctionalTestCase.php b/tests/Tests/OrmFunctionalTestCase.php
index edec9ca26..cfb4f73b4 100644
--- a/tests/Tests/OrmFunctionalTestCase.php
+++ b/tests/Tests/OrmFunctionalTestCase.php
@@ -349,10 +349,6 @@ abstract class OrmFunctionalTestCase extends OrmTestCase
             SingleRootClass::class,
             SingleChildClass::class,
         ],
-        'compositekeyrelations' => [
-            Models\CompositeKeyRelations\InvoiceClass::class,
-            Models\CompositeKeyRelations\CustomerClass::class,
-        ],
         'taxi' => [
             PaidRide::class,
             Ride::class,