1<?php
2
3/*
4 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
5 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
6 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
7 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
8 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
9 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
10 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
11 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
12 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
13 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
14 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
15 *
16 * This software consists of voluntary contributions made by many individuals
17 * and is licensed under the MIT license. For more information, see
18 * <http://www.doctrine-project.org>.
19 */
20
21namespace Doctrine\ORM\Cache;
22
23use Doctrine\Common\Collections\ArrayCollection;
24use Doctrine\ORM\Cache\Persister\CachedPersister;
25use Doctrine\ORM\Cache\Persister\Entity\CachedEntityPersister;
26use Doctrine\ORM\EntityManagerInterface;
27use Doctrine\ORM\Query\ResultSetMapping;
28use Doctrine\ORM\Mapping\ClassMetadata;
29use Doctrine\ORM\PersistentCollection;
30use Doctrine\Common\Proxy\Proxy;
31use Doctrine\ORM\Cache;
32use Doctrine\ORM\Query;
33use function assert;
34
35/**
36 * Default query cache implementation.
37 *
38 * @since   2.5
39 * @author  Fabio B. Silva <fabio.bat.silva@gmail.com>
40 */
41class DefaultQueryCache implements QueryCache
42{
43     /**
44     * @var \Doctrine\ORM\EntityManagerInterface
45     */
46    private $em;
47
48    /**
49     * @var \Doctrine\ORM\UnitOfWork
50     */
51    private $uow;
52
53    /**
54     * @var \Doctrine\ORM\Cache\Region
55     */
56    private $region;
57
58    /**
59     * @var \Doctrine\ORM\Cache\QueryCacheValidator
60     */
61    private $validator;
62
63    /**
64     * @var \Doctrine\ORM\Cache\Logging\CacheLogger
65     */
66    protected $cacheLogger;
67
68    /**
69     * @var array
70     */
71    private static $hints = [Query::HINT_CACHE_ENABLED => true];
72
73    /**
74     * @param \Doctrine\ORM\EntityManagerInterface $em     The entity manager.
75     * @param \Doctrine\ORM\Cache\Region           $region The query region.
76     */
77    public function __construct(EntityManagerInterface $em, Region $region)
78    {
79        $cacheConfig = $em->getConfiguration()->getSecondLevelCacheConfiguration();
80
81        $this->em           = $em;
82        $this->region       = $region;
83        $this->uow          = $em->getUnitOfWork();
84        $this->cacheLogger  = $cacheConfig->getCacheLogger();
85        $this->validator    = $cacheConfig->getQueryValidator();
86    }
87
88    /**
89     * {@inheritdoc}
90     */
91    public function get(QueryCacheKey $key, ResultSetMapping $rsm, array $hints = [])
92    {
93        if ( ! ($key->cacheMode & Cache::MODE_GET)) {
94            return null;
95        }
96
97        $cacheEntry = $this->region->get($key);
98
99        if ( ! $cacheEntry instanceof QueryCacheEntry) {
100            return null;
101        }
102
103        if ( ! $this->validator->isValid($key, $cacheEntry)) {
104            $this->region->evict($key);
105
106            return null;
107        }
108
109        $result      = [];
110        $entityName  = reset($rsm->aliasMap);
111        $hasRelation = ! empty($rsm->relationMap);
112        $persister   = $this->uow->getEntityPersister($entityName);
113        assert($persister instanceof CachedEntityPersister);
114
115        $region     = $persister->getCacheRegion();
116        $regionName = $region->getName();
117
118        $cm = $this->em->getClassMetadata($entityName);
119
120        $generateKeys = static function (array $entry) use ($cm) : EntityCacheKey {
121            return new EntityCacheKey($cm->rootEntityName, $entry['identifier']);
122        };
123
124        $cacheKeys = new CollectionCacheEntry(array_map($generateKeys, $cacheEntry->result));
125        $entries   = $region->getMultiple($cacheKeys) ?? [];
126
127        // @TODO - move to cache hydration component
128        foreach ($cacheEntry->result as $index => $entry) {
129            $entityEntry = $entries[$index] ?? null;
130
131            if (! $entityEntry instanceof EntityCacheEntry) {
132                if ($this->cacheLogger !== null) {
133                    $this->cacheLogger->entityCacheMiss($regionName, $cacheKeys->identifiers[$index]);
134                }
135
136                return null;
137            }
138
139            if ($this->cacheLogger !== null) {
140                $this->cacheLogger->entityCacheHit($regionName, $cacheKeys->identifiers[$index]);
141            }
142
143            if ( ! $hasRelation) {
144                $result[$index]  = $this->uow->createEntity($entityEntry->class, $entityEntry->resolveAssociationEntries($this->em), self::$hints);
145
146                continue;
147            }
148
149            $data = $entityEntry->data;
150
151            foreach ($entry['associations'] as $name => $assoc) {
152                $assocPersister = $this->uow->getEntityPersister($assoc['targetEntity']);
153                assert($assocPersister instanceof CachedEntityPersister);
154
155                $assocRegion   = $assocPersister->getCacheRegion();
156                $assocMetadata = $this->em->getClassMetadata($assoc['targetEntity']);
157
158                if ($assoc['type'] & ClassMetadata::TO_ONE) {
159
160                    if (($assocEntry = $assocRegion->get($assocKey = new EntityCacheKey($assocMetadata->rootEntityName, $assoc['identifier']))) === null) {
161
162                        if ($this->cacheLogger !== null) {
163                            $this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKey);
164                        }
165
166                        $this->uow->hydrationComplete();
167
168                        return null;
169                    }
170
171                    $data[$name] = $this->uow->createEntity($assocEntry->class, $assocEntry->resolveAssociationEntries($this->em), self::$hints);
172
173                    if ($this->cacheLogger !== null) {
174                        $this->cacheLogger->entityCacheHit($assocRegion->getName(), $assocKey);
175                    }
176
177                    continue;
178                }
179
180                if ( ! isset($assoc['list']) || empty($assoc['list'])) {
181                    continue;
182                }
183
184                $generateKeys = function ($id) use ($assocMetadata): EntityCacheKey {
185                    return new EntityCacheKey($assocMetadata->rootEntityName, $id);
186                };
187
188                $collection   = new PersistentCollection($this->em, $assocMetadata, new ArrayCollection());
189                $assocKeys    = new CollectionCacheEntry(array_map($generateKeys, $assoc['list']));
190                $assocEntries = $assocRegion->getMultiple($assocKeys);
191
192                foreach ($assoc['list'] as $assocIndex => $assocId) {
193                    $assocEntry = is_array($assocEntries) && array_key_exists($assocIndex, $assocEntries) ? $assocEntries[$assocIndex] : null;
194
195                    if ($assocEntry === null) {
196                        if ($this->cacheLogger !== null) {
197                            $this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKeys->identifiers[$assocIndex]);
198                        }
199
200                        $this->uow->hydrationComplete();
201
202                        return null;
203                    }
204
205                    $element = $this->uow->createEntity($assocEntry->class, $assocEntry->resolveAssociationEntries($this->em), self::$hints);
206
207                    $collection->hydrateSet($assocIndex, $element);
208
209                    if ($this->cacheLogger !== null) {
210                        $this->cacheLogger->entityCacheHit($assocRegion->getName(), $assocKeys->identifiers[$assocIndex]);
211                    }
212                }
213
214                $data[$name] = $collection;
215
216                $collection->setInitialized(true);
217            }
218
219            foreach ($data as $fieldName => $unCachedAssociationData) {
220                // In some scenarios, such as EAGER+ASSOCIATION+ID+CACHE, the
221                // cache key information in `$cacheEntry` will not contain details
222                // for fields that are associations.
223                //
224                // This means that `$data` keys for some associations that may
225                // actually not be cached will not be converted to actual association
226                // data, yet they contain L2 cache AssociationCacheEntry objects.
227                //
228                // We need to unwrap those associations into proxy references,
229                // since we don't have actual data for them except for identifiers.
230                if ($unCachedAssociationData instanceof AssociationCacheEntry) {
231                    $data[$fieldName] = $this->em->getReference(
232                        $unCachedAssociationData->class,
233                        $unCachedAssociationData->identifier
234                    );
235                }
236            }
237
238            $result[$index] = $this->uow->createEntity($entityEntry->class, $data, self::$hints);
239        }
240
241        $this->uow->hydrationComplete();
242
243        return $result;
244    }
245
246    /**
247     * {@inheritdoc}
248     */
249    public function put(QueryCacheKey $key, ResultSetMapping $rsm, $result, array $hints = [])
250    {
251        if ($rsm->scalarMappings) {
252            throw new CacheException("Second level cache does not support scalar results.");
253        }
254
255        if (count($rsm->entityMappings) > 1) {
256            throw new CacheException("Second level cache does not support multiple root entities.");
257        }
258
259        if ( ! $rsm->isSelect) {
260            throw new CacheException("Second-level cache query supports only select statements.");
261        }
262
263        if (($hints[Query\SqlWalker::HINT_PARTIAL] ?? false) === true || ($hints[Query::HINT_FORCE_PARTIAL_LOAD] ?? false) === true) {
264            throw new CacheException("Second level cache does not support partial entities.");
265        }
266
267        if ( ! ($key->cacheMode & Cache::MODE_PUT)) {
268            return false;
269        }
270
271        $data        = [];
272        $entityName  = reset($rsm->aliasMap);
273        $rootAlias   = key($rsm->aliasMap);
274        $persister   = $this->uow->getEntityPersister($entityName);
275
276        if (! $persister instanceof CachedEntityPersister) {
277            throw CacheException::nonCacheableEntity($entityName);
278        }
279
280        $region = $persister->getCacheRegion();
281
282        $cm = $this->em->getClassMetadata($entityName);
283        assert($cm instanceof ClassMetadata);
284
285        foreach ($result as $index => $entity) {
286            $identifier = $this->uow->getEntityIdentifier($entity);
287            $entityKey  = new EntityCacheKey($cm->rootEntityName, $identifier);
288
289            if (($key->cacheMode & Cache::MODE_REFRESH) || ! $region->contains($entityKey)) {
290                // Cancel put result if entity put fail
291                if ( ! $persister->storeEntityCache($entity, $entityKey)) {
292                    return false;
293                }
294            }
295
296            $data[$index]['identifier']   = $identifier;
297            $data[$index]['associations'] = [];
298
299            // @TODO - move to cache hydration components
300            foreach ($rsm->relationMap as $alias => $name) {
301                $parentAlias  = $rsm->parentAliasMap[$alias];
302                $parentClass  = $rsm->aliasMap[$parentAlias];
303                $metadata     = $this->em->getClassMetadata($parentClass);
304                $assoc        = $metadata->associationMappings[$name];
305                $assocValue   = $this->getAssociationValue($rsm, $alias, $entity);
306
307                if ($assocValue === null) {
308                    continue;
309                }
310
311                // root entity association
312                if ($rootAlias === $parentAlias) {
313                    // Cancel put result if association put fail
314                    if ( ($assocInfo = $this->storeAssociationCache($key, $assoc, $assocValue)) === null) {
315                        return false;
316                    }
317
318                    $data[$index]['associations'][$name] = $assocInfo;
319
320                    continue;
321                }
322
323                // store single nested association
324                if ( ! is_array($assocValue)) {
325                    // Cancel put result if association put fail
326                    if ($this->storeAssociationCache($key, $assoc, $assocValue) === null) {
327                        return false;
328                    }
329
330                    continue;
331                }
332
333                // store array of nested association
334                foreach ($assocValue as $aVal) {
335                    // Cancel put result if association put fail
336                    if ($this->storeAssociationCache($key, $assoc, $aVal) === null) {
337                        return false;
338                    }
339                }
340            }
341        }
342
343        return $this->region->put($key, new QueryCacheEntry($data));
344    }
345
346    /**
347     * @param \Doctrine\ORM\Cache\QueryCacheKey $key
348     * @param array                             $assoc
349     * @param mixed                             $assocValue
350     *
351     * @return mixed[]|null
352     *
353     * @psalm-return array{targetEntity: string, type: mixed, list?: array[], identifier?: array}|null
354     */
355    private function storeAssociationCache(QueryCacheKey $key, array $assoc, $assocValue)
356    {
357        $assocPersister = $this->uow->getEntityPersister($assoc['targetEntity']);
358        $assocMetadata  = $assocPersister->getClassMetadata();
359        $assocRegion    = $assocPersister->getCacheRegion();
360
361        // Handle *-to-one associations
362        if ($assoc['type'] & ClassMetadata::TO_ONE) {
363            $assocIdentifier = $this->uow->getEntityIdentifier($assocValue);
364            $entityKey       = new EntityCacheKey($assocMetadata->rootEntityName, $assocIdentifier);
365
366            if ( ! $assocValue instanceof Proxy && ($key->cacheMode & Cache::MODE_REFRESH) || ! $assocRegion->contains($entityKey)) {
367                // Entity put fail
368                if ( ! $assocPersister->storeEntityCache($assocValue, $entityKey)) {
369                    return null;
370                }
371            }
372
373            return [
374                'targetEntity'  => $assocMetadata->rootEntityName,
375                'identifier'    => $assocIdentifier,
376                'type'          => $assoc['type']
377            ];
378        }
379
380        // Handle *-to-many associations
381        $list = [];
382
383        foreach ($assocValue as $assocItemIndex => $assocItem) {
384            $assocIdentifier = $this->uow->getEntityIdentifier($assocItem);
385            $entityKey       = new EntityCacheKey($assocMetadata->rootEntityName, $assocIdentifier);
386
387            if (($key->cacheMode & Cache::MODE_REFRESH) || ! $assocRegion->contains($entityKey)) {
388                // Entity put fail
389                if ( ! $assocPersister->storeEntityCache($assocItem, $entityKey)) {
390                    return null;
391                }
392            }
393
394            $list[$assocItemIndex] = $assocIdentifier;
395        }
396
397        return [
398            'targetEntity'  => $assocMetadata->rootEntityName,
399            'type'          => $assoc['type'],
400            'list'          => $list,
401        ];
402    }
403
404    /**
405     * @param \Doctrine\ORM\Query\ResultSetMapping $rsm
406     * @param string                               $assocAlias
407     * @param object                               $entity
408     *
409     * @return array|object
410     */
411    private function getAssociationValue(ResultSetMapping $rsm, $assocAlias, $entity)
412    {
413        $path  = [];
414        $alias = $assocAlias;
415
416        while (isset($rsm->parentAliasMap[$alias])) {
417            $parent = $rsm->parentAliasMap[$alias];
418            $field  = $rsm->relationMap[$alias];
419            $class  = $rsm->aliasMap[$parent];
420
421            array_unshift($path, [
422                'field'  => $field,
423                'class'  => $class
424            ]
425            );
426
427            $alias = $parent;
428        }
429
430        return $this->getAssociationPathValue($entity, $path);
431    }
432
433    /**
434     * @param mixed $value
435     * @param array $path
436     *
437     * @return array|object|null
438     */
439    private function getAssociationPathValue($value, array $path)
440    {
441        $mapping  = array_shift($path);
442        $metadata = $this->em->getClassMetadata($mapping['class']);
443        $assoc    = $metadata->associationMappings[$mapping['field']];
444        $value    = $metadata->getFieldValue($value, $mapping['field']);
445
446        if ($value === null) {
447            return null;
448        }
449
450        if (empty($path)) {
451            return $value;
452        }
453
454        // Handle *-to-one associations
455        if ($assoc['type'] & ClassMetadata::TO_ONE) {
456            return $this->getAssociationPathValue($value, $path);
457        }
458
459        $values = [];
460
461        foreach ($value as $item) {
462            $values[] = $this->getAssociationPathValue($item, $path);
463        }
464
465        return $values;
466    }
467
468    /**
469     * {@inheritdoc}
470     */
471    public function clear()
472    {
473        return $this->region->evictAll();
474    }
475
476    /**
477     * {@inheritdoc}
478     */
479    public function getRegion()
480    {
481        return $this->region;
482    }
483}
484