diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index b0b60532f7a..b90bf61646a 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -39,6 +39,7 @@ jobs: php-version: - "8.4" - "8.5" + - "8.6" dbal-version: - "default" extension: @@ -190,6 +191,7 @@ jobs: php-version: - "8.4" - "8.5" + - "8.6" dbal-version: - "default" postgres-version: @@ -270,6 +272,7 @@ jobs: php-version: - "8.4" - "8.5" + - "8.6" dbal-version: - "default" - "4@dev" @@ -343,6 +346,7 @@ jobs: php-version: - "8.4" - "8.5" + - "8.6" dbal-version: - "default" mysql-version: diff --git a/docs/en/tutorials/extra-lazy-associations.rst b/docs/en/tutorials/extra-lazy-associations.rst index e6e6f14b527..3bf225cf65b 100644 --- a/docs/en/tutorials/extra-lazy-associations.rst +++ b/docs/en/tutorials/extra-lazy-associations.rst @@ -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)`` diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 00017cff82a..2aa01696f94 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -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 { diff --git a/src/Utility/IdentifierFlattener.php b/src/Utility/IdentifierFlattener.php index 2f16e56e4ac..9ba08303c35 100644 --- a/src/Utility/IdentifierFlattener.php +++ b/src/Utility/IdentifierFlattener.php @@ -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 @@ -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, ); diff --git a/tests/Tests/ORM/Functional/Ticket/EagerSecondLevelCacheCollectionTest.php b/tests/Tests/ORM/Functional/Ticket/EagerSecondLevelCacheCollectionTest.php new file mode 100644 index 00000000000..8f1f5ba9e00 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/EagerSecondLevelCacheCollectionTest.php @@ -0,0 +1,140 @@ +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 */ + #[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, + ) { + } +}