Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
116cdf8
#11977 implemented simplistic (and ugly) batch handing of `INSERT` op…
Ocramius Jun 11, 2025
4a24860
#11977 isolated `INSERT` batch generation to own `@internal` performa…
Ocramius Jun 11, 2025
259f83b
#11977 added test coverage verifying that persisters are being used t…
Ocramius Jun 11, 2025
ad48737
#11977 hardened `InsertBatchTest` to check entity types of sequential…
Ocramius Jun 11, 2025
658940d
#11977 only perform batching if/when the `AssignedGenerator` is in use
Ocramius Jun 11, 2025
21b144f
#11977 removed unused type-hint, which can be completely inferred by …
Ocramius Jun 11, 2025
4e6b5a1
#11977 provided method documentation / example, as per @greg0ire's fe…
Ocramius Jun 11, 2025
79cc70a
#11977 expanded test coverage to check interleaved assigned-id vs gen…
Ocramius Jun 11, 2025
5afadf1
Add console completion for entityName param of orm:mapping:describe c…
stlgaits Jun 27, 2025
79e103c
Merge pull request #11978 from Ocramius/feature/#11977-batch-handling…
greg0ire Jun 28, 2025
8a5dfc8
Merge pull request #12037 from stlgaits/mapping-describe-completion
greg0ire Jun 29, 2025
c04bfb7
Only throw PHP 8.4 requirement exception when enabling native lazy ob…
beberlei Jun 30, 2025
b41d9da
do not register the legacy proxy class name resolver with enabled nat…
xabbuh Jun 30, 2025
dddcc50
Merge pull request #12039 from xabbuh/pr-12036
greg0ire Jun 30, 2025
8005333
Merge pull request #12044 from doctrine/3.4.x
greg0ire Jun 30, 2025
e67fa53
Merge pull request #12043 from beberlei/Bugfix-DisableNativeLazyLogic…
greg0ire Jun 30, 2025
7f40422
Merge remote-tracking branch 'origin/3.4.x' into 3.5.x
greg0ire Jul 1, 2025
6deec36
Merge pull request #12046 from greg0ire/3.5.x
greg0ire Jul 1, 2025
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
4 changes: 2 additions & 2 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -667,15 +667,15 @@ public function isNativeLazyObjectsEnabled(): bool

public function enableNativeLazyObjects(bool $nativeLazyObjects): void
{
if (! $nativeLazyObjects) {
if (PHP_VERSION_ID >= 80400 && ! $nativeLazyObjects) {
Deprecation::trigger(
'doctrine/orm',
'https://github.com/doctrine/orm/pull/12005',
'Disabling native lazy objects is deprecated and will be impossible in Doctrine ORM 4.0.',
);
}

if (PHP_VERSION_ID < 80400) {
if (PHP_VERSION_ID < 80400 && $nativeLazyObjects) {
throw new LogicException('Lazy loading proxies require PHP 8.4 or higher.');
}

Expand Down
79 changes: 79 additions & 0 deletions src/Internal/UnitOfWork/InsertBatch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Internal\UnitOfWork;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Id\AssignedGenerator;
use Doctrine\ORM\Mapping\ClassMetadata;

/**
* An {@see InsertBatch} represents a set of entities that are safe to be batched
* together in a single query.
*
* These entities are only those that have all fields already assigned, including the
* identifier field(s).
*
* This data structure only exists for internal {@see UnitOfWork} optimisations, and
* should not be relied upon outside the ORM.
*
* @internal
*
* @template TEntity of object
*/
final class InsertBatch
{
/**
* @param ClassMetadata<TEntity> $class
* @param non-empty-list<TEntity> $entities
*/
public function __construct(
public readonly ClassMetadata $class,
public array $entities,
) {
}

/**
* Note: Code in here is procedural/ugly due to it being in a hot path of the {@see UnitOfWork}
*
* This method will batch the given entity set by type, preserving their order. For example,
* given an input [A1, A2, A3, B1, B2, A4, A5], it will create an [[A1, A2, A3], [B1, B2], [A4, A5]] batch.
*
* Entities for which the identifier needs to be generated or fetched by a sequence are put as single
* items in a batch of their own, since it is unsafe to batch-insert them.
*
* @param list<TEntities> $entities
*
* @return list<self<TEntities>>
*
* @template TEntities of object
*/
public static function batchByEntityType(
EntityManagerInterface $entityManager,
array $entities,
): array {
$currentClass = null;
$batches = [];
$batchIndex = -1;

foreach ($entities as $entity) {
$entityClass = $entityManager->getClassMetadata($entity::class);

if (
$currentClass?->name !== $entityClass->name
|| ! $entityClass->idGenerator instanceof AssignedGenerator
) {
$currentClass = $entityClass;
$batches[] = new InsertBatch($entityClass, [$entity]);
$batchIndex += 1;

continue;
}

$batches[$batchIndex]->entities[] = $entity;
}

return $batches;
}
}
4 changes: 3 additions & 1 deletion src/Mapping/ClassMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory

public function setEntityManager(EntityManagerInterface $em): void
{
parent::setProxyClassNameResolver(new DefaultProxyClassNameResolver());
if (! $em->getConfiguration()->isNativeLazyObjectsEnabled()) {
parent::setProxyClassNameResolver(new DefaultProxyClassNameResolver());
}

$this->em = $em;
}
Expand Down
18 changes: 18 additions & 0 deletions src/Tools/Console/Command/MappingDescribeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Doctrine\ORM\Mapping\FieldMapping;
use Doctrine\Persistence\Mapping\MappingException;
use InvalidArgumentException;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
Expand All @@ -19,6 +21,7 @@
use function array_filter;
use function array_map;
use function array_merge;
use function array_values;
use function count;
use function current;
use function get_debug_type;
Expand All @@ -32,6 +35,7 @@
use function preg_quote;
use function print_r;
use function sprintf;
use function str_replace;

use const JSON_PRETTY_PRINT;
use const JSON_THROW_ON_ERROR;
Expand Down Expand Up @@ -73,6 +77,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 0;
}

public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('entityName')) {
$entityManager = $this->getEntityManager($input);

$entities = array_map(
static fn (string $fqcn) => str_replace('\\', '\\\\', $fqcn),
$this->getMappedEntities($entityManager),
);

$suggestions->suggestValues(array_values($entities));
}
}

/**
* Display all the mapping information for a single Entity.
*
Expand Down
35 changes: 21 additions & 14 deletions src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Doctrine\ORM\Internal\HydrationCompleteHandler;
use Doctrine\ORM\Internal\StronglyConnectedComponents;
use Doctrine\ORM\Internal\TopologicalSort;
use Doctrine\ORM\Internal\UnitOfWork\InsertBatch;
use Doctrine\ORM\Mapping\AssociationMapping;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\MappingException;
Expand Down Expand Up @@ -1037,30 +1038,36 @@ public function recomputeSingleEntityChangeSet(ClassMetadata $class, object $ent
*/
private function executeInserts(): void
{
$entities = $this->computeInsertExecutionOrder();
$batchedByType = InsertBatch::batchByEntityType($this->em, $this->computeInsertExecutionOrder());
$eventsToDispatch = [];

foreach ($entities as $entity) {
$oid = spl_object_id($entity);
$class = $this->em->getClassMetadata($entity::class);
foreach ($batchedByType as $batch) {
$class = $batch->class;
$invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postPersist);
$persister = $this->getEntityPersister($class->name);

$persister->addInsert($entity);
foreach ($batch->entities as $entity) {
$oid = spl_object_id($entity);

unset($this->entityInsertions[$oid]);
$persister->addInsert($entity);

unset($this->entityInsertions[$oid]);
}

$persister->executeInserts();

if (! isset($this->entityIdentifiers[$oid])) {
//entity was not added to identity map because some identifiers are foreign keys to new entities.
//add it now
$this->addToEntityIdentifiersAndEntityMap($class, $oid, $entity);
}
foreach ($batch->entities as $entity) {
$oid = spl_object_id($entity);

$invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postPersist);
if (! isset($this->entityIdentifiers[$oid])) {
//entity was not added to identity map because some identifiers are foreign keys to new entities.
//add it now
$this->addToEntityIdentifiersAndEntityMap($class, $oid, $entity);
}

if ($invoke !== ListenersInvoker::INVOKE_NONE) {
$eventsToDispatch[] = ['class' => $class, 'entity' => $entity, 'invoke' => $invoke];
if ($invoke !== ListenersInvoker::INVOKE_NONE) {
$eventsToDispatch[] = ['class' => $class, 'entity' => $entity, 'invoke' => $invoke];
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions tests/Tests/Mocks/EntityPersisterMock.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
*/
class EntityPersisterMock extends BasicEntityPersister
{
/** @var int<0, max> */
private int $countOfExecuteInsertCalls = 0;
private array $inserts = [];
private array $updates = [];
private array $deletes = [];
Expand Down Expand Up @@ -40,6 +42,8 @@ public function addInsert(object $entity): void

public function executeInserts(): void
{
$this->countOfExecuteInsertCalls += 1;

foreach ($this->postInsertIds as $item) {
$this->em->getUnitOfWork()->assignPostInsertId($item['entity'], $item['generatedId']);
}
Expand Down Expand Up @@ -86,6 +90,7 @@ public function getDeletes(): array

public function reset(): void
{
$this->countOfExecuteInsertCalls = 0;
$this->existsCalled = false;
$this->identityColumnValueCounter = 0;
$this->inserts = [];
Expand All @@ -97,4 +102,10 @@ public function isExistsCalled(): bool
{
return $this->existsCalled;
}

/** @return int<0, max> */
public function countOfExecuteInsertCalls(): int
{
return $this->countOfExecuteInsertCalls;
}
}
143 changes: 143 additions & 0 deletions tests/Tests/ORM/Internal/UnitOfWork/InsertBatchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\ORM\Internal\UnitOfWork;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Id\AssignedGenerator;
use Doctrine\ORM\Id\IdentityGenerator;
use Doctrine\ORM\Internal\UnitOfWork\InsertBatch;
use Doctrine\ORM\Mapping\ClassMetadata;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;

#[CoversClass(InsertBatch::class)]
#[Group('#11977')]
final class InsertBatchTest extends TestCase
{
private EntityManagerInterface&Stub $entityManager;

protected function setUp(): void
{
$this->entityManager = $this->createStub(EntityManagerInterface::class);

$entityAMetadata = new ClassMetadata(EntityA::class);
$entityBMetadata = new ClassMetadata(EntityB::class);
$entityCMetadata = new ClassMetadata(EntityC::class);

$entityAMetadata->idGenerator = new AssignedGenerator();
$entityBMetadata->idGenerator = new AssignedGenerator();
$entityCMetadata->idGenerator = new IdentityGenerator();

$this->entityManager->method('getClassMetadata')
->willReturnMap([
[EntityA::class, $entityAMetadata],
[EntityB::class, $entityBMetadata],
[EntityC::class, $entityCMetadata],
]);
}

public function testWillProduceEmptyBatchOnNoGivenEntities(): void
{
self::assertEmpty(InsertBatch::batchByEntityType($this->entityManager, []));
}

public function testWillBatchSameEntityOperationsInSingleBatch(): void
{
$batches = InsertBatch::batchByEntityType(
$this->entityManager,
[
new EntityA(),
new EntityA(),
new EntityA(),
],
);

self::assertCount(1, $batches);
self::assertSame(EntityA::class, $batches[0]->class->name);
self::assertCount(3, $batches[0]->entities);
}

public function testWillBatchInterleavedEntityOperationsInGroups(): void
{
$batches = InsertBatch::batchByEntityType(
$this->entityManager,
[
new EntityA(),
new EntityA(),
new EntityB(),
new EntityB(),
new EntityA(),
new EntityA(),
],
);

self::assertCount(3, $batches);
self::assertSame(EntityA::class, $batches[0]->class->name);
self::assertCount(2, $batches[0]->entities);
self::assertSame(EntityB::class, $batches[1]->class->name);
self::assertCount(2, $batches[1]->entities);
self::assertSame(EntityA::class, $batches[2]->class->name);
self::assertCount(2, $batches[2]->entities);
}

public function testWillNotBatchOperationsForAGeneratedIdentifierEntity(): void
{
$batches = InsertBatch::batchByEntityType(
$this->entityManager,
[
new EntityC(),
new EntityC(),
new EntityC(),
],
);

self::assertCount(3, $batches);
self::assertSame(EntityC::class, $batches[0]->class->name);
self::assertCount(1, $batches[0]->entities);
self::assertSame(EntityC::class, $batches[1]->class->name);
self::assertCount(1, $batches[1]->entities);
self::assertSame(EntityC::class, $batches[2]->class->name);
self::assertCount(1, $batches[2]->entities);
}

public function testWillIsolateBatchesForEntitiesWithGeneratedIdentifiers(): void
{
$batches = InsertBatch::batchByEntityType(
$this->entityManager,
[
new EntityA(),
new EntityA(),
new EntityC(),
new EntityC(),
new EntityA(),
new EntityA(),
],
);

self::assertCount(4, $batches);
self::assertSame(EntityA::class, $batches[0]->class->name);
self::assertCount(2, $batches[0]->entities);
self::assertSame(EntityC::class, $batches[1]->class->name);
self::assertCount(1, $batches[1]->entities);
self::assertSame(EntityC::class, $batches[2]->class->name);
self::assertCount(1, $batches[2]->entities);
self::assertSame(EntityA::class, $batches[3]->class->name);
self::assertCount(2, $batches[3]->entities);
}
}

class EntityA
{
}

class EntityB
{
}

class EntityC
{
}
Loading