66use Doctrine \ORM \Mapping \ClassMetadata ;
77use Doctrine \ORM \PersistentCollection ;
88use Doctrine \ORM \QueryBuilder ;
9- use Doctrine \Persistence \Proxy ;
109use LogicException ;
10+ use ReflectionProperty ;
1111use function array_chunk ;
12- use function array_keys ;
1312use function array_values ;
1413use function count ;
1514use function get_parent_class ;
2120class EntityPreloader
2221{
2322
24- private const BATCH_SIZE = 1_000 ;
23+ private const PRELOAD_ENTITY_DEFAULT_BATCH_SIZE = 1_000 ;
2524 private const PRELOAD_COLLECTION_DEFAULT_BATCH_SIZE = 100 ;
2625
2726 public function __construct (
@@ -65,14 +64,15 @@ public function preload(
6564 }
6665
6766 $ maxFetchJoinSameFieldCount ??= 1 ;
68- $ sourceEntities = $ this ->loadProxies ($ sourceClassMetadata , $ sourceEntities , $ batchSize ?? self ::BATCH_SIZE , $ maxFetchJoinSameFieldCount );
67+ $ sourceEntities = $ this ->loadProxies ($ sourceClassMetadata , $ sourceEntities , $ batchSize ?? self ::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE , $ maxFetchJoinSameFieldCount );
6968
70- return match ($ associationMapping ->type ()) {
71- ClassMetadata::ONE_TO_MANY => $ this ->preloadOneToMany ($ sourceEntities , $ sourceClassMetadata , $ sourcePropertyName , $ targetClassMetadata , $ batchSize , $ maxFetchJoinSameFieldCount ),
72- ClassMetadata::ONE_TO_ONE ,
73- ClassMetadata::MANY_TO_ONE => $ this ->preloadToOne ($ sourceEntities , $ sourceClassMetadata , $ sourcePropertyName , $ targetClassMetadata , $ batchSize , $ maxFetchJoinSameFieldCount ),
69+ $ preloader = match (true ) {
70+ $ associationMapping ->isToOne () => $ this ->preloadToOne (...),
71+ $ associationMapping ->isToMany () => $ this ->preloadToMany (...),
7472 default => throw new LogicException ("Unsupported association mapping type {$ associationMapping ->type ()}" ),
7573 };
74+
75+ return $ preloader ($ sourceEntities , $ sourceClassMetadata , $ sourcePropertyName , $ targetClassMetadata , $ batchSize , $ maxFetchJoinSameFieldCount );
7676 }
7777
7878 /**
@@ -135,7 +135,7 @@ private function loadProxies(
135135 $ entityKey = (string ) $ entityId ;
136136 $ uniqueEntities [$ entityKey ] = $ entity ;
137137
138- if ($ entity instanceof Proxy && ! $ entity -> __isInitialized ( )) {
138+ if ($ this -> entityManager -> isUninitializedObject ( $ entity )) {
139139 $ uninitializedIds [$ entityKey ] = $ entityId ;
140140 }
141141 }
@@ -157,7 +157,7 @@ private function loadProxies(
157157 * @template S of E
158158 * @template T of E
159159 */
160- private function preloadOneToMany (
160+ private function preloadToMany (
161161 array $ sourceEntities ,
162162 ClassMetadata $ sourceClassMetadata ,
163163 string $ sourcePropertyName ,
@@ -168,50 +168,170 @@ private function preloadOneToMany(
168168 {
169169 $ sourceIdentifierReflection = $ sourceClassMetadata ->getSingleIdReflectionProperty (); // e.g. Order::$id reflection
170170 $ sourcePropertyReflection = $ sourceClassMetadata ->getReflectionProperty ($ sourcePropertyName ); // e.g. Order::$items reflection
171- $ targetPropertyName = $ sourceClassMetadata ->getAssociationMappedByTargetField ($ sourcePropertyName ); // e.g. 'order'
172- $ targetPropertyReflection = $ targetClassMetadata ->getReflectionProperty ($ targetPropertyName ); // e.g. Item::$order reflection
171+ $ targetIdentifierReflection = $ targetClassMetadata ->getSingleIdReflectionProperty ();
173172
174- if ($ sourceIdentifierReflection === null || $ sourcePropertyReflection === null || $ targetPropertyReflection === null ) {
173+ if ($ sourceIdentifierReflection === null || $ sourcePropertyReflection === null || $ targetIdentifierReflection === null ) {
175174 throw new LogicException ('Doctrine should use RuntimeReflectionService which never returns null. ' );
176175 }
177176
178177 $ batchSize ??= self ::PRELOAD_COLLECTION_DEFAULT_BATCH_SIZE ;
179-
180178 $ targetEntities = [];
179+ $ uninitializedSourceEntityIds = [];
181180 $ uninitializedCollections = [];
182181
183182 foreach ($ sourceEntities as $ sourceEntity ) {
184- $ sourceEntityId = (string ) $ sourceIdentifierReflection ->getValue ($ sourceEntity );
183+ $ sourceEntityId = $ sourceIdentifierReflection ->getValue ($ sourceEntity );
184+ $ sourceEntityKey = (string ) $ sourceEntityId ;
185185 $ sourceEntityCollection = $ sourcePropertyReflection ->getValue ($ sourceEntity );
186186
187187 if (
188188 $ sourceEntityCollection instanceof PersistentCollection
189189 && !$ sourceEntityCollection ->isInitialized ()
190190 && !$ sourceEntityCollection ->isDirty () // preloading dirty collection is too hard to handle
191191 ) {
192- $ uninitializedCollections [$ sourceEntityId ] = $ sourceEntityCollection ;
192+ $ uninitializedSourceEntityIds [$ sourceEntityKey ] = $ sourceEntityId ;
193+ $ uninitializedCollections [$ sourceEntityKey ] = $ sourceEntityCollection ;
193194 continue ;
194195 }
195196
196197 foreach ($ sourceEntityCollection as $ targetEntity ) {
197- $ targetEntities [] = $ targetEntity ;
198+ $ targetEntityKey = (string ) $ targetIdentifierReflection ->getValue ($ targetEntity );
199+ $ targetEntities [$ targetEntityKey ] = $ targetEntity ;
198200 }
199201 }
200202
201- foreach (array_chunk ($ uninitializedCollections , $ batchSize , true ) as $ chunk ) {
202- $ targetEntitiesChunk = $ this ->loadEntitiesBy ($ targetClassMetadata , $ targetPropertyName , array_keys ($ chunk ), $ maxFetchJoinSameFieldCount );
203+ $ innerLoader = match ($ sourceClassMetadata ->getAssociationMapping ($ sourcePropertyName )->type ()) {
204+ ClassMetadata::ONE_TO_MANY => $ this ->preloadOneToManyInner (...),
205+ ClassMetadata::MANY_TO_MANY => $ this ->preloadManyToManyInner (...),
206+ default => throw new LogicException ('Unsupported association mapping type ' ),
207+ };
203208
204- foreach ($ targetEntitiesChunk as $ targetEntity ) {
205- $ sourceEntity = $ targetPropertyReflection ->getValue ($ targetEntity );
206- $ sourceEntityId = (string ) $ sourceIdentifierReflection ->getValue ($ sourceEntity );
207- $ uninitializedCollections [$ sourceEntityId ]->add ($ targetEntity );
208- $ targetEntities [] = $ targetEntity ;
209+ foreach (array_chunk ($ uninitializedSourceEntityIds , $ batchSize , preserve_keys: true ) as $ uninitializedSourceEntityIdsChunk ) {
210+ $ targetEntitiesChunk = $ innerLoader (
211+ sourceClassMetadata: $ sourceClassMetadata ,
212+ sourceIdentifierReflection: $ sourceIdentifierReflection ,
213+ sourcePropertyName: $ sourcePropertyName ,
214+ targetClassMetadata: $ targetClassMetadata ,
215+ targetIdentifierReflection: $ targetIdentifierReflection ,
216+ uninitializedSourceEntityIdsChunk: array_values ($ uninitializedSourceEntityIdsChunk ),
217+ uninitializedCollections: $ uninitializedCollections ,
218+ maxFetchJoinSameFieldCount: $ maxFetchJoinSameFieldCount ,
219+ );
220+
221+ foreach ($ targetEntitiesChunk as $ targetEntityKey => $ targetEntity ) {
222+ $ targetEntities [$ targetEntityKey ] = $ targetEntity ;
209223 }
224+ }
225+
226+ foreach ($ uninitializedCollections as $ sourceEntityCollection ) {
227+ $ sourceEntityCollection ->setInitialized (true );
228+ $ sourceEntityCollection ->takeSnapshot ();
229+ }
230+
231+ return array_values ($ targetEntities );
232+ }
233+
234+ /**
235+ * @param ClassMetadata<S> $sourceClassMetadata
236+ * @param ClassMetadata<T> $targetClassMetadata
237+ * @param list<mixed> $uninitializedSourceEntityIdsChunk
238+ * @param array<string, PersistentCollection<int, T>> $uninitializedCollections
239+ * @param non-negative-int $maxFetchJoinSameFieldCount
240+ * @return array<string, T>
241+ * @template S of E
242+ * @template T of E
243+ */
244+ private function preloadOneToManyInner (
245+ ClassMetadata $ sourceClassMetadata ,
246+ ReflectionProperty $ sourceIdentifierReflection ,
247+ string $ sourcePropertyName ,
248+ ClassMetadata $ targetClassMetadata ,
249+ ReflectionProperty $ targetIdentifierReflection ,
250+ array $ uninitializedSourceEntityIdsChunk ,
251+ array $ uninitializedCollections ,
252+ int $ maxFetchJoinSameFieldCount ,
253+ ): array
254+ {
255+ $ targetPropertyName = $ sourceClassMetadata ->getAssociationMappedByTargetField ($ sourcePropertyName ); // e.g. 'order'
256+ $ targetPropertyReflection = $ targetClassMetadata ->getReflectionProperty ($ targetPropertyName ); // e.g. Item::$order reflection
257+ $ targetEntities = [];
258+
259+ if ($ targetPropertyReflection === null ) {
260+ throw new LogicException ('Doctrine should use RuntimeReflectionService which never returns null. ' );
261+ }
262+
263+ foreach ($ this ->loadEntitiesBy ($ targetClassMetadata , $ targetPropertyName , $ uninitializedSourceEntityIdsChunk , $ maxFetchJoinSameFieldCount ) as $ targetEntity ) {
264+ $ sourceEntity = $ targetPropertyReflection ->getValue ($ targetEntity );
265+ $ sourceEntityKey = (string ) $ sourceIdentifierReflection ->getValue ($ sourceEntity );
266+ $ uninitializedCollections [$ sourceEntityKey ]->add ($ targetEntity );
267+
268+ $ targetEntityKey = (string ) $ targetIdentifierReflection ->getValue ($ targetEntity );
269+ $ targetEntities [$ targetEntityKey ] = $ targetEntity ;
270+ }
210271
211- foreach ($ chunk as $ sourceEntityCollection ) {
212- $ sourceEntityCollection ->setInitialized (true );
213- $ sourceEntityCollection ->takeSnapshot ();
272+ return $ targetEntities ;
273+ }
274+
275+ /**
276+ * @param ClassMetadata<S> $sourceClassMetadata
277+ * @param ClassMetadata<T> $targetClassMetadata
278+ * @param list<mixed> $uninitializedSourceEntityIdsChunk
279+ * @param array<string, PersistentCollection<int, T>> $uninitializedCollections
280+ * @param non-negative-int $maxFetchJoinSameFieldCount
281+ * @return array<string, T>
282+ * @template S of E
283+ * @template T of E
284+ */
285+ private function preloadManyToManyInner (
286+ ClassMetadata $ sourceClassMetadata ,
287+ ReflectionProperty $ sourceIdentifierReflection ,
288+ string $ sourcePropertyName ,
289+ ClassMetadata $ targetClassMetadata ,
290+ ReflectionProperty $ targetIdentifierReflection ,
291+ array $ uninitializedSourceEntityIdsChunk ,
292+ array $ uninitializedCollections ,
293+ int $ maxFetchJoinSameFieldCount ,
294+ ): array
295+ {
296+ $ sourceIdentifierName = $ sourceClassMetadata ->getSingleIdentifierFieldName ();
297+ $ targetIdentifierName = $ targetClassMetadata ->getSingleIdentifierFieldName ();
298+
299+ $ manyToManyRows = $ this ->entityManager ->createQueryBuilder ()
300+ ->select ("source. {$ sourceIdentifierName } AS sourceId " , "target. {$ targetIdentifierName } AS targetId " )
301+ ->from ($ sourceClassMetadata ->getName (), 'source ' )
302+ ->join ("source. {$ sourcePropertyName }" , 'target ' )
303+ ->andWhere ('source IN (:sourceEntityIds) ' )
304+ ->setParameter ('sourceEntityIds ' , $ uninitializedSourceEntityIdsChunk )
305+ ->getQuery ()
306+ ->getResult ();
307+
308+ $ targetEntities = [];
309+ $ uninitializedTargetEntityIds = [];
310+
311+ foreach ($ manyToManyRows as $ manyToManyRow ) {
312+ $ targetEntityId = $ manyToManyRow ['targetId ' ];
313+ $ targetEntityKey = (string ) $ targetEntityId ;
314+
315+ /** @var T|false $targetEntity */
316+ $ targetEntity = $ this ->entityManager ->getUnitOfWork ()->tryGetById ($ targetEntityId , $ targetClassMetadata ->getName ());
317+
318+ if ($ targetEntity !== false && !$ this ->entityManager ->isUninitializedObject ($ targetEntity )) {
319+ $ targetEntities [$ targetEntityKey ] = $ targetEntity ;
320+ continue ;
214321 }
322+
323+ $ uninitializedTargetEntityIds [$ targetEntityKey ] = $ targetEntityId ;
324+ }
325+
326+ foreach ($ this ->loadEntitiesBy ($ targetClassMetadata , $ targetIdentifierName , array_values ($ uninitializedTargetEntityIds ), $ maxFetchJoinSameFieldCount ) as $ targetEntity ) {
327+ $ targetEntityKey = (string ) $ targetIdentifierReflection ->getValue ($ targetEntity );
328+ $ targetEntities [$ targetEntityKey ] = $ targetEntity ;
329+ }
330+
331+ foreach ($ manyToManyRows as $ manyToManyRow ) {
332+ $ sourceEntityKey = (string ) $ manyToManyRow ['sourceId ' ];
333+ $ targetEntityKey = (string ) $ manyToManyRow ['targetId ' ];
334+ $ uninitializedCollections [$ sourceEntityKey ]->add ($ targetEntities [$ targetEntityKey ]);
215335 }
216336
217337 return $ targetEntities ;
@@ -237,12 +357,14 @@ private function preloadToOne(
237357 ): array
238358 {
239359 $ sourcePropertyReflection = $ sourceClassMetadata ->getReflectionProperty ($ sourcePropertyName ); // e.g. Item::$order reflection
240- $ targetEntities = [];
241360
242361 if ($ sourcePropertyReflection === null ) {
243362 throw new LogicException ('Doctrine should use RuntimeReflectionService which never returns null. ' );
244363 }
245364
365+ $ batchSize ??= self ::PRELOAD_ENTITY_DEFAULT_BATCH_SIZE ;
366+ $ targetEntities = [];
367+
246368 foreach ($ sourceEntities as $ sourceEntity ) {
247369 $ targetEntity = $ sourcePropertyReflection ->getValue ($ sourceEntity );
248370
@@ -253,7 +375,7 @@ private function preloadToOne(
253375 $ targetEntities [] = $ targetEntity ;
254376 }
255377
256- return $ this ->loadProxies ($ targetClassMetadata , $ targetEntities , $ batchSize ?? self :: BATCH_SIZE , $ maxFetchJoinSameFieldCount );
378+ return $ this ->loadProxies ($ targetClassMetadata , $ targetEntities , $ batchSize , $ maxFetchJoinSameFieldCount );
257379 }
258380
259381 /**
@@ -270,6 +392,10 @@ private function loadEntitiesBy(
270392 int $ maxFetchJoinSameFieldCount ,
271393 ): array
272394 {
395+ if (count ($ fieldValues ) === 0 ) {
396+ return [];
397+ }
398+
273399 $ rootLevelAlias = 'e ' ;
274400
275401 $ queryBuilder = $ this ->entityManager ->createQueryBuilder ()
0 commit comments