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