From e1f9064b402e25103512fcf87f8e13cd688eaba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Wed, 24 Sep 2025 08:52:55 +0200 Subject: [PATCH 1/3] add configuration for using returning clause --- src/Configuration.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Configuration.php b/src/Configuration.php index 9280cdc189..0087623920 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -723,4 +723,14 @@ public function getEagerFetchBatchSize(): int { return $this->attributes['fetchModeSubselectBatchSize'] ?? 100; } + + public function setUseReturningClauseForGeneratingId(bool $flag): void + { + $this->attributes['useReturningClauseForGeneratingId'] = $flag; + } + + public function getUseReturningClauseForGeneratingId(): bool + { + return $this->attributes['useReturningClauseForGeneratingId'] ?? false; + } } From d503fade4b5c73d5576462e2ae82507bc943b7f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Wed, 24 Sep 2025 08:53:38 +0200 Subject: [PATCH 2/3] update persisters to use returning clause when possible and enabled --- .../Entity/AbstractEntityPersister.php | 5 ++ .../Entity/BasicEntityPersister.php | 68 ++++++++++++++++--- src/Persisters/Entity/EntityPersister.php | 2 + .../Entity/JoinedSubclassPersister.php | 11 ++- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/Cache/Persister/Entity/AbstractEntityPersister.php b/src/Cache/Persister/Entity/AbstractEntityPersister.php index 945ad5b348..e04ee1c6a9 100644 --- a/src/Cache/Persister/Entity/AbstractEntityPersister.php +++ b/src/Cache/Persister/Entity/AbstractEntityPersister.php @@ -549,6 +549,11 @@ public function refresh(array $id, object $entity, LockMode|int|null $lockMode = $this->persister->refresh($id, $entity, $lockMode); } + public function usesReturningClause(): bool + { + return $this->persister->usesReturningClause(); + } + /** @param array $ownerId */ protected function buildCollectionCacheKey(AssociationMapping $association, array $ownerId, /* string $filterHash */): CollectionCacheKey { diff --git a/src/Persisters/Entity/BasicEntityPersister.php b/src/Persisters/Entity/BasicEntityPersister.php index 4670a62a9e..c43cfc236a 100644 --- a/src/Persisters/Entity/BasicEntityPersister.php +++ b/src/Persisters/Entity/BasicEntityPersister.php @@ -12,6 +12,9 @@ use Doctrine\DBAL\LockMode; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MariaDb1052Platform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Result; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; @@ -164,6 +167,8 @@ class BasicEntityPersister implements EntityPersister private string|null $filterHash = null; + private bool $shouldUseReturningClause; + /** * Initializes a new BasicEntityPersister that uses the given EntityManager * and persists instances of the class described by the given ClassMetadata descriptor. @@ -174,20 +179,23 @@ public function __construct( protected EntityManagerInterface $em, protected ClassMetadata $class, ) { - $this->conn = $em->getConnection(); - $this->platform = $this->conn->getDatabasePlatform(); - $this->quoteStrategy = $em->getConfiguration()->getQuoteStrategy(); - $this->identifierFlattener = new IdentifierFlattener($em->getUnitOfWork(), $em->getMetadataFactory()); - $this->noLimitsContext = $this->currentPersisterContext = new CachedPersisterContext( + $this->conn = $em->getConnection(); + $this->platform = $this->conn->getDatabasePlatform(); + $this->quoteStrategy = $em->getConfiguration()->getQuoteStrategy(); + $this->identifierFlattener = new IdentifierFlattener($em->getUnitOfWork(), $em->getMetadataFactory()); + $this->noLimitsContext = $this->currentPersisterContext = new CachedPersisterContext( $class, new Query\ResultSetMapping(), false, ); - $this->limitsHandlingContext = new CachedPersisterContext( + $this->limitsHandlingContext = new CachedPersisterContext( $class, new Query\ResultSetMapping(), true, ); + $this->shouldUseReturningClause = $class->isIdGeneratorIdentity() + && $em->getConfiguration()->getUseReturningClauseForGeneratingId() + && $this->supportsReturningClause($this->platform); } final protected function isFilterHashUpToDate(): bool @@ -247,11 +255,16 @@ public function executeInserts(): void } } - $stmt->executeStatement(); + $result = $stmt->executeQuery(); if ($isPostInsertId) { - $generatedId = $idGenerator->generateId($this->em, $entity); - $id = [$this->class->identifier[0] => $generatedId]; + if ($this->shouldUseReturningClause) { + $generatedId = $result->fetchOne(); + } else { + $generatedId = $idGenerator->generateId($this->em, $entity); + } + + $id = [$this->class->identifier[0] => $generatedId]; $uow->assignPostInsertId($entity, $generatedId); } else { @@ -1413,7 +1426,16 @@ public function getInsertSQL(): string if ($columns === []) { $identityColumn = $this->quoteStrategy->getColumnName($this->class->identifier[0], $this->class, $this->platform); - return $this->platform->getEmptyIdentityInsertSQL($tableName, $identityColumn); + $insertSql = $this->platform->getEmptyIdentityInsertSQL($tableName, $identityColumn); + + if ($this->shouldUseReturningClause) { + $insertSql .= sprintf( + ' RETURNING %s', + $this->quoteStrategy->getColumnName($this->class->getSingleIdentifierFieldName(), $this->class, $this->platform), + ); + } + + return $insertSql; } $placeholders = []; @@ -1437,7 +1459,26 @@ public function getInsertSQL(): string $columns = implode(', ', $columns); $placeholders = implode(', ', $placeholders); - return sprintf('INSERT INTO %s (%s) VALUES (%s)', $tableName, $columns, $placeholders); + $insertSql = sprintf('INSERT INTO %s (%s) VALUES (%s)', $tableName, $columns, $placeholders); + + if ($this->shouldUseReturningClause) { + $insertSql .= sprintf( + ' RETURNING %s', + $this->quoteStrategy->getColumnName($this->class->getSingleIdentifierFieldName(), $this->class, $this->platform), + ); + } + + return $insertSql; + } + + /** + * This should be replaced with a method call from AbstractPlatform + */ + private function supportsReturningClause(AbstractPlatform $platform): bool + { + return $platform instanceof PostgreSQLPlatform + || $platform instanceof MariaDb1052Platform // lowest version 10.5.0 (https://mariadb.com/kb/en/insertreturning/) + || $platform instanceof SqlitePlatform; // lowest version 3.35.0 (https://www.sqlite.org/lang_returning.html) } /** @@ -2003,4 +2044,9 @@ static function ($fieldName) use ($class, $entityManager): string { $class->identifier, ); } + + public function usesReturningClause(): bool + { + return $this->shouldUseReturningClause; + } } diff --git a/src/Persisters/Entity/EntityPersister.php b/src/Persisters/Entity/EntityPersister.php index 1c4da2f4c0..e389349923 100644 --- a/src/Persisters/Entity/EntityPersister.php +++ b/src/Persisters/Entity/EntityPersister.php @@ -297,4 +297,6 @@ public function getOneToManyCollection( * Checks whether the given managed entity exists in the database. */ public function exists(object $entity, Criteria|null $extraConditions = null): bool; + + public function usesReturningClause(): bool; } diff --git a/src/Persisters/Entity/JoinedSubclassPersister.php b/src/Persisters/Entity/JoinedSubclassPersister.php index 08ab72c0f4..8c9f993279 100644 --- a/src/Persisters/Entity/JoinedSubclassPersister.php +++ b/src/Persisters/Entity/JoinedSubclassPersister.php @@ -144,11 +144,16 @@ public function executeInserts(): void $rootTableStmt->bindValue($paramIndex++, $value, $this->columnTypes[$columnName]); } - $rootTableStmt->executeStatement(); + $result = $rootTableStmt->executeQuery(); if ($isPostInsertId) { - $generatedId = $idGenerator->generateId($this->em, $entity); - $id = [$this->class->identifier[0] => $generatedId]; + if ($rootPersister->usesReturningClause()) { + $generatedId = $result->fetchOne(); + } else { + $generatedId = $idGenerator->generateId($this->em, $entity); + } + + $id = [$this->class->identifier[0] => $generatedId]; $uow->assignPostInsertId($entity, $generatedId); } else { From 5c07b968f71c8856eba5b84ea194b20c27bc5071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Wed, 24 Sep 2025 08:53:49 +0200 Subject: [PATCH 3/3] add tests --- tests/Tests/Models/Identity/Cat.php | 22 +++++++ tests/Tests/Models/Identity/Dog.php | 19 ++++++ .../StandardEntityPersisterTest.php | 27 ++++++++ ...asicEntityPersisterReturningClauseTest.php | 66 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 tests/Tests/Models/Identity/Cat.php create mode 100644 tests/Tests/Models/Identity/Dog.php create mode 100644 tests/Tests/ORM/Persisters/BasicEntityPersisterReturningClauseTest.php diff --git a/tests/Tests/Models/Identity/Cat.php b/tests/Tests/Models/Identity/Cat.php new file mode 100644 index 0000000000..56119f96e1 --- /dev/null +++ b/tests/Tests/Models/Identity/Cat.php @@ -0,0 +1,22 @@ +getFeatures()); } + + public function testInsertWithReturningClause(): void + { + $this->_em->getConfiguration()->setUseReturningClauseForGeneratingId(true); + + $c = new ECommerceCategory(); + $c->setName('Electronics'); + + $this->_em->persist($c); + $this->_em->flush(); + + $platform = $this->_em->getConnection()->getDatabasePlatform(); + if ( + // this should be replaced with a method call from AbstractPlatform + $platform instanceof PostgreSQLPlatform + || $platform instanceof MariaDb1052Platform + || $platform instanceof SqlitePlatform + ) { + self::assertStringEndsWith('RETURNING id', $this->getLastLoggedQuery()['sql']); + } else { + self::assertStringEndsNotWith('RETURNING id', $this->getLastLoggedQuery()['sql']); + } + } } diff --git a/tests/Tests/ORM/Persisters/BasicEntityPersisterReturningClauseTest.php b/tests/Tests/ORM/Persisters/BasicEntityPersisterReturningClauseTest.php new file mode 100644 index 0000000000..70f3799ec3 --- /dev/null +++ b/tests/Tests/ORM/Persisters/BasicEntityPersisterReturningClauseTest.php @@ -0,0 +1,66 @@ +createMock(EventManager::class); + $evm->method('hasListeners') + ->willReturn(false); + + $platform = $this->createMock(PostgreSQLPlatform::class); + $platform->method('getEmptyIdentityInsertSQL') + ->willReturnCallback(static function (string $table, string $column): string { + return 'INSERT INTO ' . $table . ' (' . $column . ') VALUES (DEFAULT)'; + }); + + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform') + ->willReturn($platform); + $connection->method('getEventManager') + ->willReturn($evm); + + $this->entityManager = $this->createTestEntityManagerWithConnection($connection); + $this->entityManager->getConfiguration()->setUseReturningClauseForGeneratingId(true); + } + + public function testEmptyIdentityInsertSQL(): void + { + $persister = new BasicEntityPersister($this->entityManager, $this->entityManager->getClassMetadata(Dog::class)); + $method = new ReflectionMethod($persister, 'getInsertSQL'); + $method->setAccessible(true); + + $sql = $method->invoke($persister); + + self::assertSame('INSERT INTO Dog (id) VALUES (DEFAULT) RETURNING id', $sql); + } + + public function testInsertSqlWithColumn(): void + { + $persister = new BasicEntityPersister($this->entityManager, $this->entityManager->getClassMetadata(Cat::class)); + $method = new ReflectionMethod($persister, 'getInsertSQL'); + $method->setAccessible(true); + + $sql = $method->invoke($persister); + + self::assertSame('INSERT INTO Cat (name) VALUES (?) RETURNING id', $sql); + } +}