*/ protected ArrayCollection $parameters; /** * The user-specified ResultSetMapping to use. */ protected ResultSetMapping|null $resultSetMapping = null; /** * The map of query hints. * * @psalm-var array */ protected array $hints = []; /** * The hydration mode. * * @psalm-var string|AbstractQuery::HYDRATE_* */ protected string|int $hydrationMode = self::HYDRATE_OBJECT; protected QueryCacheProfile|null $queryCacheProfile = null; /** * Whether or not expire the result cache. */ protected bool $expireResultCache = false; protected QueryCacheProfile|null $hydrationCacheProfile = null; /** * Whether to use second level cache, if available. */ protected bool $cacheable = false; protected bool $hasCache = false; /** * Second level cache region name. */ protected string|null $cacheRegion = null; /** * Second level query cache mode. * * @psalm-var Cache::MODE_*|null */ protected int|null $cacheMode = null; protected CacheLogger|null $cacheLogger = null; protected int $lifetime = 0; /** * Initializes a new instance of a class derived from AbstractQuery. */ public function __construct( /** * The entity manager used by this query object. */ protected EntityManagerInterface $em, ) { $this->parameters = new ArrayCollection(); $this->hints = $em->getConfiguration()->getDefaultQueryHints(); $this->hasCache = $this->em->getConfiguration()->isSecondLevelCacheEnabled(); if ($this->hasCache) { $this->cacheLogger = $em->getConfiguration() ->getSecondLevelCacheConfiguration() ->getCacheLogger(); } } /** * Enable/disable second level query (result) caching for this query. * * @return $this */ public function setCacheable(bool $cacheable): static { $this->cacheable = $cacheable; return $this; } /** @return bool TRUE if the query results are enabled for second level cache, FALSE otherwise. */ public function isCacheable(): bool { return $this->cacheable; } /** @return $this */ public function setCacheRegion(string $cacheRegion): static { $this->cacheRegion = $cacheRegion; return $this; } /** * Obtain the name of the second level query cache region in which query results will be stored * * @return string|null The cache region name; NULL indicates the default region. */ public function getCacheRegion(): string|null { return $this->cacheRegion; } /** @return bool TRUE if the query cache and second level cache are enabled, FALSE otherwise. */ protected function isCacheEnabled(): bool { return $this->cacheable && $this->hasCache; } public function getLifetime(): int { return $this->lifetime; } /** * Sets the life-time for this query into second level cache. * * @return $this */ public function setLifetime(int $lifetime): static { $this->lifetime = $lifetime; return $this; } /** @psalm-return Cache::MODE_*|null */ public function getCacheMode(): int|null { return $this->cacheMode; } /** * @psalm-param Cache::MODE_* $cacheMode * * @return $this */ public function setCacheMode(int $cacheMode): static { $this->cacheMode = $cacheMode; return $this; } /** * Gets the SQL query that corresponds to this query object. * The returned SQL syntax depends on the connection driver that is used * by this query object at the time of this method call. * * @return list|string SQL query */ abstract public function getSQL(): string|array; /** * Retrieves the associated EntityManager of this Query instance. */ public function getEntityManager(): EntityManagerInterface { return $this->em; } /** * Frees the resources used by the query object. * * Resets Parameters, Parameter Types and Query Hints. */ public function free(): void { $this->parameters = new ArrayCollection(); $this->hints = $this->em->getConfiguration()->getDefaultQueryHints(); } /** * Get all defined parameters. * * @psalm-return ArrayCollection */ public function getParameters(): ArrayCollection { return $this->parameters; } /** * Gets a query parameter. * * @param int|string $key The key (index or name) of the bound parameter. * * @return Parameter|null The value of the bound parameter, or NULL if not available. */ public function getParameter(int|string $key): Parameter|null { $key = Parameter::normalizeName($key); $filteredParameters = $this->parameters->filter( static fn (Parameter $parameter): bool => $parameter->getName() === $key ); return ! $filteredParameters->isEmpty() ? $filteredParameters->first() : null; } /** * Sets a collection of query parameters. * * @param ArrayCollection|mixed[] $parameters * @psalm-param ArrayCollection|mixed[] $parameters * * @return $this */ public function setParameters(ArrayCollection|array $parameters): static { if (is_array($parameters)) { /** @psalm-var ArrayCollection $parameterCollection */ $parameterCollection = new ArrayCollection(); foreach ($parameters as $key => $value) { $parameterCollection->add(new Parameter($key, $value)); } $parameters = $parameterCollection; } $this->parameters = $parameters; return $this; } /** * Sets a query parameter. * * @param string|int $key The parameter position or name. * @param mixed $value The parameter value. * @param ParameterType|ArrayParameterType|string|int|null $type The parameter type. If specified, the given value * will be run through the type conversion of this * type. This is usually not needed for strings and * numeric types. * * @return $this */ public function setParameter(string|int $key, mixed $value, ParameterType|ArrayParameterType|string|int|null $type = null): static { $existingParameter = $this->getParameter($key); if ($existingParameter !== null) { $existingParameter->setValue($value, $type); return $this; } $this->parameters->add(new Parameter($key, $value, $type)); return $this; } /** * Processes an individual parameter value. * * @throws ORMInvalidArgumentException */ public function processParameterValue(mixed $value): mixed { if (is_scalar($value)) { return $value; } if ($value instanceof Collection) { $value = iterator_to_array($value); } if (is_array($value)) { $value = $this->processArrayParameterValue($value); return $value; } if ($value instanceof ClassMetadata) { return $value->name; } if ($value instanceof BackedEnum) { return $value->value; } if (! is_object($value)) { return $value; } try { $class = DefaultProxyClassNameResolver::getClass($value); $value = $this->em->getUnitOfWork()->getSingleIdentifierValue($value); if ($value === null) { throw ORMInvalidArgumentException::invalidIdentifierBindingEntity($class); } } catch (MappingException | ORMMappingException) { /* Silence any mapping exceptions. These can occur if the object in question is not a mapped entity, in which case we just don't do any preparation on the value. Depending on MappingDriver, either MappingException or ORMMappingException is thrown. */ $value = $this->potentiallyProcessIterable($value); } return $value; } /** * If no mapping is detected, trying to resolve the value as a Traversable */ private function potentiallyProcessIterable(mixed $value): mixed { if ($value instanceof Traversable) { $value = iterator_to_array($value); $value = $this->processArrayParameterValue($value); } return $value; } /** * Process a parameter value which was previously identified as an array * * @param mixed[] $value * * @return mixed[] */ private function processArrayParameterValue(array $value): array { foreach ($value as $key => $paramValue) { $paramValue = $this->processParameterValue($paramValue); $value[$key] = is_array($paramValue) ? reset($paramValue) : $paramValue; } return $value; } /** * Sets the ResultSetMapping that should be used for hydration. * * @return $this */ public function setResultSetMapping(ResultSetMapping $rsm): static { $this->translateNamespaces($rsm); $this->resultSetMapping = $rsm; return $this; } /** * Gets the ResultSetMapping used for hydration. */ protected function getResultSetMapping(): ResultSetMapping|null { return $this->resultSetMapping; } /** * Allows to translate entity namespaces to full qualified names. */ private function translateNamespaces(ResultSetMapping $rsm): void { $translate = fn ($alias): string => $this->em->getClassMetadata($alias)->getName(); $rsm->aliasMap = array_map($translate, $rsm->aliasMap); $rsm->declaringClasses = array_map($translate, $rsm->declaringClasses); } /** * Set a cache profile for hydration caching. * * If no result cache driver is set in the QueryCacheProfile, the default * result cache driver is used from the configuration. * * Important: Hydration caching does NOT register entities in the * UnitOfWork when retrieved from the cache. Never use result cached * entities for requests that also flush the EntityManager. If you want * some form of caching with UnitOfWork registration you should use * {@see AbstractQuery::setResultCacheProfile()}. * * @return $this * * @example * $lifetime = 100; * $resultKey = "abc"; * $query->setHydrationCacheProfile(new QueryCacheProfile()); * $query->setHydrationCacheProfile(new QueryCacheProfile($lifetime, $resultKey)); */ public function setHydrationCacheProfile(QueryCacheProfile|null $profile): static { if ($profile === null) { $this->hydrationCacheProfile = null; return $this; } if (! $profile->getResultCache()) { $defaultHydrationCacheImpl = $this->em->getConfiguration()->getHydrationCache(); if ($defaultHydrationCacheImpl) { $profile = $profile->setResultCache($defaultHydrationCacheImpl); } } $this->hydrationCacheProfile = $profile; return $this; } public function getHydrationCacheProfile(): QueryCacheProfile|null { return $this->hydrationCacheProfile; } /** * Set a cache profile for the result cache. * * If no result cache driver is set in the QueryCacheProfile, the default * result cache driver is used from the configuration. * * @return $this */ public function setResultCacheProfile(QueryCacheProfile|null $profile): static { if ($profile === null) { $this->queryCacheProfile = null; return $this; } if (! $profile->getResultCache()) { $defaultResultCache = $this->em->getConfiguration()->getResultCache(); if ($defaultResultCache) { $profile = $profile->setResultCache($defaultResultCache); } } $this->queryCacheProfile = $profile; return $this; } /** * Defines a cache driver to be used for caching result sets and implicitly enables caching. */ public function setResultCache(CacheItemPoolInterface|null $resultCache): static { if ($resultCache === null) { if ($this->queryCacheProfile) { $this->queryCacheProfile = new QueryCacheProfile($this->queryCacheProfile->getLifetime(), $this->queryCacheProfile->getCacheKey()); } return $this; } $this->queryCacheProfile = $this->queryCacheProfile ? $this->queryCacheProfile->setResultCache($resultCache) : new QueryCacheProfile(0, null, $resultCache); return $this; } /** * Enables caching of the results of this query, for given or default amount of seconds * and optionally specifies which ID to use for the cache entry. * * @param int|null $lifetime How long the cache entry is valid, in seconds. * @param string|null $resultCacheId ID to use for the cache entry. * * @return $this */ public function enableResultCache(int|null $lifetime = null, string|null $resultCacheId = null): static { $this->setResultCacheLifetime($lifetime); $this->setResultCacheId($resultCacheId); return $this; } /** * Disables caching of the results of this query. * * @return $this */ public function disableResultCache(): static { $this->queryCacheProfile = null; return $this; } /** * Defines how long the result cache will be active before expire. * * @param int|null $lifetime How long the cache entry is valid, in seconds. * * @return $this */ public function setResultCacheLifetime(int|null $lifetime): static { $lifetime = (int) $lifetime; if ($this->queryCacheProfile) { $this->queryCacheProfile = $this->queryCacheProfile->setLifetime($lifetime); return $this; } $this->queryCacheProfile = new QueryCacheProfile($lifetime); $cache = $this->em->getConfiguration()->getResultCache(); if (! $cache) { return $this; } $this->queryCacheProfile = $this->queryCacheProfile->setResultCache($cache); return $this; } /** * Defines if the result cache is active or not. * * @param bool $expire Whether or not to force resultset cache expiration. * * @return $this */ public function expireResultCache(bool $expire = true): static { $this->expireResultCache = $expire; return $this; } /** * Retrieves if the resultset cache is active or not. */ public function getExpireResultCache(): bool { return $this->expireResultCache; } public function getQueryCacheProfile(): QueryCacheProfile|null { return $this->queryCacheProfile; } /** * Change the default fetch mode of an association for this query. * * @param class-string $class * @psalm-param Mapping\ClassMetadata::FETCH_EAGER|Mapping\ClassMetadata::FETCH_LAZY $fetchMode */ public function setFetchMode(string $class, string $assocName, int $fetchMode): static { $this->hints['fetchMode'][$class][$assocName] = $fetchMode; return $this; } /** * Defines the processing mode to be used during hydration / result set transformation. * * @param string|int $hydrationMode Doctrine processing mode to be used during hydration process. * One of the Query::HYDRATE_* constants. * @psalm-param string|AbstractQuery::HYDRATE_* $hydrationMode * * @return $this */ public function setHydrationMode(string|int $hydrationMode): static { $this->hydrationMode = $hydrationMode; return $this; } /** * Gets the hydration mode currently used by the query. * * @psalm-return string|AbstractQuery::HYDRATE_* */ public function getHydrationMode(): string|int { return $this->hydrationMode; } /** * Gets the list of results for the query. * * Alias for execute(null, $hydrationMode = HYDRATE_OBJECT). * * @psalm-param string|AbstractQuery::HYDRATE_* $hydrationMode */ public function getResult(string|int $hydrationMode = self::HYDRATE_OBJECT): mixed { return $this->execute(null, $hydrationMode); } /** * Gets the array of results for the query. * * Alias for execute(null, HYDRATE_ARRAY). * * @return mixed[] */ public function getArrayResult(): array { return $this->execute(null, self::HYDRATE_ARRAY); } /** * Gets one-dimensional array of results for the query. * * Alias for execute(null, HYDRATE_SCALAR_COLUMN). * * @return mixed[] */ public function getSingleColumnResult(): array { return $this->execute(null, self::HYDRATE_SCALAR_COLUMN); } /** * Gets the scalar results for the query. * * Alias for execute(null, HYDRATE_SCALAR). * * @return mixed[] */ public function getScalarResult(): array { return $this->execute(null, self::HYDRATE_SCALAR); } /** * Get exactly one result or null. * * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode * * @throws NonUniqueResultException */ public function getOneOrNullResult(string|int|null $hydrationMode = null): mixed { try { $result = $this->execute(null, $hydrationMode); } catch (NoResultException) { return null; } if ($this->hydrationMode !== self::HYDRATE_SINGLE_SCALAR && ! $result) { return null; } if (! is_array($result)) { return $result; } if (count($result) > 1) { throw new NonUniqueResultException(); } return array_shift($result); } /** * Gets the single result of the query. * * Enforces the presence as well as the uniqueness of the result. * * If the result is not unique, a NonUniqueResultException is thrown. * If there is no result, a NoResultException is thrown. * * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode * * @throws NonUniqueResultException If the query result is not unique. * @throws NoResultException If the query returned no result. */ public function getSingleResult(string|int|null $hydrationMode = null): mixed { $result = $this->execute(null, $hydrationMode); if ($this->hydrationMode !== self::HYDRATE_SINGLE_SCALAR && ! $result) { throw new NoResultException(); } if (! is_array($result)) { return $result; } if (count($result) > 1) { throw new NonUniqueResultException(); } return array_shift($result); } /** * Gets the single scalar result of the query. * * Alias for getSingleResult(HYDRATE_SINGLE_SCALAR). * * @return bool|float|int|string|null The scalar result. * * @throws NoResultException If the query returned no result. * @throws NonUniqueResultException If the query result is not unique. */ public function getSingleScalarResult(): mixed { return $this->getSingleResult(self::HYDRATE_SINGLE_SCALAR); } /** * Sets a query hint. If the hint name is not recognized, it is silently ignored. * * @return $this */ public function setHint(string $name, mixed $value): static { $this->hints[$name] = $value; return $this; } /** * Gets the value of a query hint. If the hint name is not recognized, FALSE is returned. * * @return mixed The value of the hint or FALSE, if the hint name is not recognized. */ public function getHint(string $name): mixed { return $this->hints[$name] ?? false; } public function hasHint(string $name): bool { return isset($this->hints[$name]); } /** * Return the key value map of query hints that are currently set. * * @return array */ public function getHints(): array { return $this->hints; } /** * Executes the query and returns an iterable that can be used to incrementally * iterate over the result. * * @psalm-param ArrayCollection|mixed[] $parameters * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode * * @return iterable */ public function toIterable( ArrayCollection|array $parameters = [], string|int|null $hydrationMode = null, ): iterable { if ($hydrationMode !== null) { $this->setHydrationMode($hydrationMode); } if (count($parameters) !== 0) { $this->setParameters($parameters); } $rsm = $this->getResultSetMapping(); if ($rsm === null) { throw new LogicException('Uninitialized result set mapping.'); } if ($rsm->isMixed && count($rsm->scalarMappings) > 0) { throw QueryException::iterateWithMixedResultNotAllowed(); } $stmt = $this->_doExecute(); return $this->em->newHydrator($this->hydrationMode)->toIterable($stmt, $rsm, $this->hints); } /** * Executes the query. * * @psalm-param ArrayCollection|mixed[]|null $parameters * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode */ public function execute( ArrayCollection|array|null $parameters = null, string|int|null $hydrationMode = null, ): mixed { if ($this->cacheable && $this->isCacheEnabled()) { return $this->executeUsingQueryCache($parameters, $hydrationMode); } return $this->executeIgnoreQueryCache($parameters, $hydrationMode); } /** * Execute query ignoring second level cache. * * @psalm-param ArrayCollection|mixed[]|null $parameters * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode */ private function executeIgnoreQueryCache( ArrayCollection|array|null $parameters = null, string|int|null $hydrationMode = null, ): mixed { if ($hydrationMode !== null) { $this->setHydrationMode($hydrationMode); } if (! empty($parameters)) { $this->setParameters($parameters); } $setCacheEntry = static function ($data): void { }; if ($this->hydrationCacheProfile !== null) { [$cacheKey, $realCacheKey] = $this->getHydrationCacheId(); $cache = $this->getHydrationCache(); $cacheItem = $cache->getItem($cacheKey); $result = $cacheItem->isHit() ? $cacheItem->get() : []; if (isset($result[$realCacheKey])) { return $result[$realCacheKey]; } if (! $result) { $result = []; } $setCacheEntry = static function ($data) use ($cache, $result, $cacheItem, $realCacheKey): void { $cache->save($cacheItem->set($result + [$realCacheKey => $data])); }; } $stmt = $this->_doExecute(); if (is_numeric($stmt)) { $setCacheEntry($stmt); return $stmt; } $rsm = $this->getResultSetMapping(); if ($rsm === null) { throw new LogicException('Uninitialized result set mapping.'); } $data = $this->em->newHydrator($this->hydrationMode)->hydrateAll($stmt, $rsm, $this->hints); $setCacheEntry($data); return $data; } private function getHydrationCache(): CacheItemPoolInterface { assert($this->hydrationCacheProfile !== null); $cache = $this->hydrationCacheProfile->getResultCache(); assert($cache !== null); return $cache; } /** * Load from second level cache or executes the query and put into cache. * * @psalm-param ArrayCollection|mixed[]|null $parameters * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode */ private function executeUsingQueryCache( ArrayCollection|array|null $parameters = null, string|int|null $hydrationMode = null, ): mixed { $rsm = $this->getResultSetMapping(); if ($rsm === null) { throw new LogicException('Uninitialized result set mapping.'); } $queryCache = $this->em->getCache()->getQueryCache($this->cacheRegion); $queryKey = new QueryCacheKey( $this->getHash(), $this->lifetime, $this->cacheMode ?: Cache::MODE_NORMAL, $this->getTimestampKey(), ); $result = $queryCache->get($queryKey, $rsm, $this->hints); if ($result !== null) { if ($this->cacheLogger) { $this->cacheLogger->queryCacheHit($queryCache->getRegion()->getName(), $queryKey); } return $result; } $result = $this->executeIgnoreQueryCache($parameters, $hydrationMode); $cached = $queryCache->put($queryKey, $rsm, $result, $this->hints); if ($this->cacheLogger) { $this->cacheLogger->queryCacheMiss($queryCache->getRegion()->getName(), $queryKey); if ($cached) { $this->cacheLogger->queryCachePut($queryCache->getRegion()->getName(), $queryKey); } } return $result; } private function getTimestampKey(): TimestampCacheKey|null { assert($this->resultSetMapping !== null); $entityName = reset($this->resultSetMapping->aliasMap); if (empty($entityName)) { return null; } $metadata = $this->em->getClassMetadata($entityName); return new TimestampCacheKey($metadata->rootEntityName); } /** * Get the result cache id to use to store the result set cache entry. * Will return the configured id if it exists otherwise a hash will be * automatically generated for you. * * @return string[] ($key, $hash) * @psalm-return array{string, string} ($key, $hash) */ protected function getHydrationCacheId(): array { $parameters = []; $types = []; foreach ($this->getParameters() as $parameter) { $parameters[$parameter->getName()] = $this->processParameterValue($parameter->getValue()); $types[$parameter->getName()] = $parameter->getType(); } $sql = $this->getSQL(); assert(is_string($sql)); $queryCacheProfile = $this->getHydrationCacheProfile(); $hints = $this->getHints(); $hints['hydrationMode'] = $this->getHydrationMode(); ksort($hints); assert($queryCacheProfile !== null); return $queryCacheProfile->generateCacheKeys($sql, $parameters, $types, $hints); } /** * Set the result cache id to use to store the result set cache entry. * If this is not explicitly set by the developer then a hash is automatically * generated for you. */ public function setResultCacheId(string|null $id): static { if (! $this->queryCacheProfile) { return $this->setResultCacheProfile(new QueryCacheProfile(0, $id)); } $this->queryCacheProfile = $this->queryCacheProfile->setCacheKey($id); return $this; } /** * Executes the query and returns a the resulting Statement object. * * @return Result|int The executed database statement that holds * the results, or an integer indicating how * many rows were affected. */ abstract protected function _doExecute(): Result|int; /** * Cleanup Query resource when clone is called. */ public function __clone() { $this->parameters = new ArrayCollection(); $this->hints = []; $this->hints = $this->em->getConfiguration()->getDefaultQueryHints(); } /** * Generates a string of currently query to use for the cache second level cache. */ protected function getHash(): string { $query = $this->getSQL(); assert(is_string($query)); $hints = $this->getHints(); $params = array_map(function (Parameter $parameter) { $value = $parameter->getValue(); // Small optimization // Does not invoke processParameterValue for scalar value if (is_scalar($value)) { return $value; } return $this->processParameterValue($value); }, $this->parameters->getValues()); ksort($hints); return sha1($query . '-' . serialize($params) . '-' . serialize($hints)); } }