Skip to content
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
4 changes: 4 additions & 0 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
php-version:
- "8.4"
- "8.5"
- "8.6"
dbal-version:
- "default"
extension:
Expand Down Expand Up @@ -190,6 +191,7 @@ jobs:
php-version:
- "8.4"
- "8.5"
- "8.6"
dbal-version:
- "default"
postgres-version:
Expand Down Expand Up @@ -270,6 +272,7 @@ jobs:
php-version:
- "8.4"
- "8.5"
- "8.6"
dbal-version:
- "default"
- "4@dev"
Expand Down Expand Up @@ -343,6 +346,7 @@ jobs:
php-version:
- "8.4"
- "8.5"
- "8.6"
dbal-version:
- "default"
mysql-version:
Expand Down
4 changes: 2 additions & 2 deletions docs/en/tutorials/extra-lazy-associations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ the first time its accessed. If you mark an association as extra lazy the follow
can be called without triggering a full load of the collection:

- ``Collection#contains($entity)``
- ``Collection#containsKey($key)``
- ``Collection#containsKey($key)`` only if the association includes an ``indexBy`` field.
- ``Collection#count()``
- ``Collection#first()``
- ``Collection#get($key)``
- ``Collection#get($key)`` only if the association includes an ``indexBy`` field.
- ``Collection#isEmpty()``
- ``Collection#slice($offset, $length = null)``

Expand Down
4 changes: 4 additions & 0 deletions src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -2719,6 +2719,10 @@ public function createEntity(string $className, array $data, array &$hints = [])
// is foreign key composite
&& ! ($targetClass->hasAssociation($assoc->mappedBy) && count($targetClass->getAssociationMapping($assoc->mappedBy)->joinColumns) > 1)
&& ! $assoc->isIndexed()
// Loading from the second-level cache does not go through ObjectHydrator,
// so deferred batch eager loads are never flushed there. Load immediately
// instead (also allows hitting the collection cache region).
&& ! isset($hints[Query::HINT_CACHE_ENABLED])
) {
$this->scheduleCollectionForBatchLoading($pColl, $class);
} else {
Expand Down
3 changes: 2 additions & 1 deletion src/Utility/IdentifierFlattener.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use function assert;
use function implode;
use function is_a;
use function is_object;

/**
* The IdentifierFlattener utility now houses some of the identifier manipulation logic from unit of work, so that it
Expand Down Expand Up @@ -47,7 +48,7 @@ public function flattenIdentifier(ClassMetadata $class, array $id): array
$flatId = [];

foreach ($class->identifier as $field) {
if (isset($class->associationMappings[$field]) && isset($id[$field]) && is_a($id[$field], $class->associationMappings[$field]->targetEntity)) {
if (isset($class->associationMappings[$field]) && isset($id[$field]) && is_object($id[$field]) && is_a($id[$field], $class->associationMappings[$field]->targetEntity)) {
$targetClassMetadata = $this->metadataFactory->getMetadataFor(
$class->associationMappings[$field]->targetEntity,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\ORM\Functional\Ticket;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\Cache;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\Tests\OrmFunctionalTestCase;

/**
* Reproduces duplicate hydration of EAGER OneToMany collections when the owner is loaded from the
* second-level cache, the collection is initialized (e.g. via the collection cache), and a later
* hydration triggers deferred eager collection loading.
*/
final class EagerSecondLevelCacheCollectionTest extends OrmFunctionalTestCase
{
protected function setUp(): void
{
$this->enableSecondLevelCache();

parent::setUp();

$this->createSchemaForModels(
EagerSlcUser::class,
EagerSlcRole::class,
EagerSlcOther::class,
);
}

public function testEagerCollectionIsNotDuplicatedAfterSecondLevelCacheHit(): void
{
$user = new EagerSlcUser('alice');
$user->addRole(new EagerSlcRole($user, 'ROLE_USER'));
$user->addRole(new EagerSlcRole($user, 'ROLE_ADMIN'));
$other = new EagerSlcOther('marker');

$this->_em->persist($user);
$this->_em->persist($other);
$this->_em->flush();

$userId = $user->id;
$otherId = $other->id;

$this->_em->clear();

// Warm entity + collection cache (first load misses, then puts).
$warmed = $this->_em->find(EagerSlcUser::class, $userId);
self::assertNotNull($warmed);
self::assertCount(2, $warmed->roles);
$this->_em->clear();

// Load owner from the entity second-level cache. EAGER schedules deferred collection
// loading, but DefaultEntityHydrator does not call triggerEagerLoads().
$cachedUser = $this->_em->find(EagerSlcUser::class, $userId);
self::assertNotNull($cachedUser);

// Initialize the collection (collection-cache hit or DB) before deferred eager runs.
self::assertCount(2, $cachedUser->roles);

// A subsequent hydration flushes pending deferred eager collection loads.
$this->_em->find(EagerSlcOther::class, $otherId);

self::assertCount(
2,
$cachedUser->roles,
'EAGER OneToMany must not be hydrated twice after a second-level cache hit',
);
}
}

#[Entity]
#[Cache(usage: 'NONSTRICT_READ_WRITE')]
class EagerSlcUser
{
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public int|null $id = null;

/** @var Collection<int, EagerSlcRole> */
#[Cache(usage: 'NONSTRICT_READ_WRITE')]
#[OneToMany(targetEntity: EagerSlcRole::class, mappedBy: 'user', cascade: ['persist'], fetch: 'EAGER')]
public Collection $roles;

public function __construct(
#[Column(length: 255)]
public string $name,
) {
$this->roles = new ArrayCollection();
}

public function addRole(EagerSlcRole $role): void
{
$this->roles->add($role);
}
}

#[Entity]
#[Cache(usage: 'NONSTRICT_READ_WRITE')]
class EagerSlcRole
{
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public int|null $id = null;

public function __construct(
#[Cache(usage: 'NONSTRICT_READ_WRITE')]
#[ManyToOne(targetEntity: EagerSlcUser::class, inversedBy: 'roles')]
#[JoinColumn(nullable: false)]
public EagerSlcUser $user,
#[Column(length: 255)]
public string $name,
) {
}
}

#[Entity]
class EagerSlcOther
{
#[Id]
#[Column(type: 'integer')]
#[GeneratedValue]
public int|null $id = null;

public function __construct(
#[Column(length: 255)]
public string $name,
) {
}
}
Loading