<?php

declare(strict_types=1);

namespace Doctrine\ORM;

use BackedEnum;
use Countable;
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\DoctrineProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Cache\QueryCacheProfile;
use Doctrine\DBAL\Result;
use Doctrine\Deprecations\Deprecation;
use Doctrine\ORM\Cache\Exception\InvalidResultCacheDriver;
use Doctrine\ORM\Cache\Logging\CacheLogger;
use Doctrine\ORM\Cache\QueryCacheKey;
use Doctrine\ORM\Cache\TimestampCacheKey;
use Doctrine\ORM\Internal\Hydration\IterableResult;
use Doctrine\ORM\Mapping\MappingException as ORMMappingException;
use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
use Doctrine\ORM\Query\Parameter;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\Persistence\Mapping\MappingException;
use LogicException;
use Psr\Cache\CacheItemPoolInterface;
use Traversable;

use function array_map;
use function array_shift;
use function assert;
use function count;
use function func_num_args;
use function in_array;
use function is_array;
use function is_numeric;
use function is_object;
use function is_scalar;
use function is_string;
use function iterator_count;
use function iterator_to_array;
use function ksort;
use function method_exists;
use function reset;
use function serialize;
use function sha1;

/**
 * Base contract for ORM queries. Base class for Query and NativeQuery.
 *
 * @link    www.doctrine-project.org
 */
abstract class AbstractQuery
{
    /* Hydration mode constants */

    /**
     * Hydrates an object graph. This is the default behavior.
     */
    public const HYDRATE_OBJECT = 1;

    /**
     * Hydrates an array graph.
     */
    public const HYDRATE_ARRAY = 2;

    /**
     * Hydrates a flat, rectangular result set with scalar values.
     */
    public const HYDRATE_SCALAR = 3;

    /**
     * Hydrates a single scalar value.
     */
    public const HYDRATE_SINGLE_SCALAR = 4;

    /**
     * Very simple object hydrator (optimized for performance).
     */
    public const HYDRATE_SIMPLEOBJECT = 5;

    /**
     * Hydrates scalar column value.
     */
    public const HYDRATE_SCALAR_COLUMN = 6;

    /**
     * The parameter map of this query.
     *
     * @var ArrayCollection|Parameter[]
     * @psalm-var ArrayCollection<int, Parameter>
     */
    protected $parameters;

    /**
     * The user-specified ResultSetMapping to use.
     *
     * @var ResultSetMapping|null
     */
    protected $_resultSetMapping;

    /**
     * The entity manager used by this query object.
     *
     * @var EntityManagerInterface
     */
    protected $_em;

    /**
     * The map of query hints.
     *
     * @psalm-var array<string, mixed>
     */
    protected $_hints = [];

    /**
     * The hydration mode.
     *
     * @var string|int
     * @psalm-var string|AbstractQuery::HYDRATE_*
     */
    protected $_hydrationMode = self::HYDRATE_OBJECT;

    /** @var QueryCacheProfile|null */
    protected $_queryCacheProfile;

    /**
     * Whether or not expire the result cache.
     *
     * @var bool
     */
    protected $_expireResultCache = false;

    /** @var QueryCacheProfile|null */
    protected $_hydrationCacheProfile;

    /**
     * Whether to use second level cache, if available.
     *
     * @var bool
     */
    protected $cacheable = false;

    /** @var bool */
    protected $hasCache = false;

    /**
     * Second level cache region name.
     *
     * @var string|null
     */
    protected $cacheRegion;

    /**
     * Second level query cache mode.
     *
     * @var int|null
     * @psalm-var Cache::MODE_*|null
     */
    protected $cacheMode;

    /** @var CacheLogger|null */
    protected $cacheLogger;

    /** @var int */
    protected $lifetime = 0;

    /**
     * Initializes a new instance of a class derived from <tt>AbstractQuery</tt>.
     */
    public function __construct(EntityManagerInterface $em)
    {
        $this->_em        = $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.
     *
     * @param bool $cacheable
     *
     * @return $this
     */
    public function setCacheable($cacheable)
    {
        $this->cacheable = (bool) $cacheable;

        return $this;
    }

    /** @return bool TRUE if the query results are enabled for second level cache, FALSE otherwise. */
    public function isCacheable()
    {
        return $this->cacheable;
    }

    /**
     * @param string $cacheRegion
     *
     * @return $this
     */
    public function setCacheRegion($cacheRegion)
    {
        $this->cacheRegion = (string) $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()
    {
        return $this->cacheRegion;
    }

    /** @return bool TRUE if the query cache and second level cache are enabled, FALSE otherwise. */
    protected function isCacheEnabled()
    {
        return $this->cacheable && $this->hasCache;
    }

    /** @return int */
    public function getLifetime()
    {
        return $this->lifetime;
    }

    /**
     * Sets the life-time for this query into second level cache.
     *
     * @param int $lifetime
     *
     * @return $this
     */
    public function setLifetime($lifetime)
    {
        $this->lifetime = (int) $lifetime;

        return $this;
    }

    /**
     * @return int|null
     * @psalm-return Cache::MODE_*|null
     */
    public function getCacheMode()
    {
        return $this->cacheMode;
    }

    /**
     * @param int $cacheMode
     * @psalm-param Cache::MODE_* $cacheMode
     *
     * @return $this
     */
    public function setCacheMode($cacheMode)
    {
        $this->cacheMode = (int) $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>|string SQL query
     */
    abstract public function getSQL();

    /**
     * Retrieves the associated EntityManager of this Query instance.
     *
     * @return EntityManagerInterface
     */
    public function getEntityManager()
    {
        return $this->_em;
    }

    /**
     * Frees the resources used by the query object.
     *
     * Resets Parameters, Parameter Types and Query Hints.
     *
     * @return void
     */
    public function free()
    {
        $this->parameters = new ArrayCollection();

        $this->_hints = $this->_em->getConfiguration()->getDefaultQueryHints();
    }

    /**
     * Get all defined parameters.
     *
     * @return ArrayCollection The defined query parameters.
     * @psalm-return ArrayCollection<int, Parameter>
     */
    public function getParameters()
    {
        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($key)
    {
        $key = Query\Parameter::normalizeName($key);

        $filteredParameters = $this->parameters->filter(
            static function (Query\Parameter $parameter) use ($key): bool {
                $parameterName = $parameter->getName();

                return $key === $parameterName;
            }
        );

        return ! $filteredParameters->isEmpty() ? $filteredParameters->first() : null;
    }

    /**
     * Sets a collection of query parameters.
     *
     * @param ArrayCollection|mixed[] $parameters
     * @psalm-param ArrayCollection<int, Parameter>|mixed[] $parameters
     *
     * @return $this
     */
    public function setParameters($parameters)
    {
        if (is_array($parameters)) {
            /** @psalm-var ArrayCollection<int, Parameter> $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 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($key, $value, $type = null)
    {
        $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.
     *
     * @param mixed $value
     *
     * @return mixed
     *
     * @throws ORMInvalidArgumentException
     */
    public function processParameterValue($value)
    {
        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 Mapping\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 $e) {
            /* 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
     *
     * @param mixed $value
     *
     * @return mixed
     */
    private function potentiallyProcessIterable($value)
    {
        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(Query\ResultSetMapping $rsm)
    {
        $this->translateNamespaces($rsm);
        $this->_resultSetMapping = $rsm;

        return $this;
    }

    /**
     * Gets the ResultSetMapping used for hydration.
     *
     * @return ResultSetMapping|null
     */
    protected function getResultSetMapping()
    {
        return $this->_resultSetMapping;
    }

    /**
     * Allows to translate entity namespaces to full qualified names.
     */
    private function translateNamespaces(Query\ResultSetMapping $rsm): void
    {
        $translate = function ($alias): string {
            return $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 $profile = null)
    {
        if ($profile === null) {
            if (func_num_args() < 1) {
                Deprecation::trigger(
                    'doctrine/orm',
                    'https://github.com/doctrine/orm/pull/9791',
                    'Calling %s without arguments is deprecated, pass null instead.',
                    __METHOD__
                );
            }

            $this->_hydrationCacheProfile = null;

            return $this;
        }

        // DBAL 2
        if (! method_exists(QueryCacheProfile::class, 'setResultCache')) {
            if (! $profile->getResultCacheDriver()) {
                $defaultHydrationCacheImpl = $this->_em->getConfiguration()->getHydrationCache();
                if ($defaultHydrationCacheImpl) {
                    $profile = $profile->setResultCacheDriver(DoctrineProvider::wrap($defaultHydrationCacheImpl));
                }
            }
        } elseif (! $profile->getResultCache()) {
            $defaultHydrationCacheImpl = $this->_em->getConfiguration()->getHydrationCache();
            if ($defaultHydrationCacheImpl) {
                $profile = $profile->setResultCache($defaultHydrationCacheImpl);
            }
        }

        $this->_hydrationCacheProfile = $profile;

        return $this;
    }

    /** @return QueryCacheProfile|null */
    public function getHydrationCacheProfile()
    {
        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 $profile = null)
    {
        if ($profile === null) {
            if (func_num_args() < 1) {
                Deprecation::trigger(
                    'doctrine/orm',
                    'https://github.com/doctrine/orm/pull/9791',
                    'Calling %s without arguments is deprecated, pass null instead.',
                    __METHOD__
                );
            }

            $this->_queryCacheProfile = null;

            return $this;
        }

        // DBAL 2
        if (! method_exists(QueryCacheProfile::class, 'setResultCache')) {
            if (! $profile->getResultCacheDriver()) {
                $defaultResultCacheDriver = $this->_em->getConfiguration()->getResultCache();
                if ($defaultResultCacheDriver) {
                    $profile = $profile->setResultCacheDriver(DoctrineProvider::wrap($defaultResultCacheDriver));
                }
            }
        } elseif (! $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.
     *
     * @deprecated Use {@see setResultCache()} instead.
     *
     * @param \Doctrine\Common\Cache\Cache|null $resultCacheDriver Cache driver
     *
     * @return $this
     *
     * @throws InvalidResultCacheDriver
     */
    public function setResultCacheDriver($resultCacheDriver = null)
    {
        /** @phpstan-ignore-next-line */
        if ($resultCacheDriver !== null && ! ($resultCacheDriver instanceof \Doctrine\Common\Cache\Cache)) {
            throw InvalidResultCacheDriver::create();
        }

        return $this->setResultCache($resultCacheDriver ? CacheAdapter::wrap($resultCacheDriver) : null);
    }

    /**
     * Defines a cache driver to be used for caching result sets and implicitly enables caching.
     *
     * @return $this
     */
    public function setResultCache(?CacheItemPoolInterface $resultCache = null)
    {
        if ($resultCache === null) {
            if (func_num_args() < 1) {
                Deprecation::trigger(
                    'doctrine/orm',
                    'https://github.com/doctrine/orm/pull/9791',
                    'Calling %s without arguments is deprecated, pass null instead.',
                    __METHOD__
                );
            }

            if ($this->_queryCacheProfile) {
                $this->_queryCacheProfile = new QueryCacheProfile($this->_queryCacheProfile->getLifetime(), $this->_queryCacheProfile->getCacheKey());
            }

            return $this;
        }

        // DBAL 2
        if (! method_exists(QueryCacheProfile::class, 'setResultCache')) {
            $resultCacheDriver = DoctrineProvider::wrap($resultCache);

            $this->_queryCacheProfile = $this->_queryCacheProfile
                ? $this->_queryCacheProfile->setResultCacheDriver($resultCacheDriver)
                : new QueryCacheProfile(0, null, $resultCacheDriver);

            return $this;
        }

        $this->_queryCacheProfile = $this->_queryCacheProfile
            ? $this->_queryCacheProfile->setResultCache($resultCache)
            : new QueryCacheProfile(0, null, $resultCache);

        return $this;
    }

    /**
     * Returns the cache driver used for caching result sets.
     *
     * @deprecated
     *
     * @return \Doctrine\Common\Cache\Cache Cache driver
     */
    public function getResultCacheDriver()
    {
        if ($this->_queryCacheProfile && $this->_queryCacheProfile->getResultCacheDriver()) {
            return $this->_queryCacheProfile->getResultCacheDriver();
        }

        return $this->_em->getConfiguration()->getResultCacheImpl();
    }

    /**
     * Set whether or not to cache the results of this query and if so, for
     * how long and which ID to use for the cache entry.
     *
     * @deprecated 2.7 Use {@see enableResultCache} and {@see disableResultCache} instead.
     *
     * @param bool   $useCache      Whether or not to cache the results of this query.
     * @param int    $lifetime      How long the cache entry is valid, in seconds.
     * @param string $resultCacheId ID to use for the cache entry.
     *
     * @return $this
     */
    public function useResultCache($useCache, $lifetime = null, $resultCacheId = null)
    {
        return $useCache
            ? $this->enableResultCache($lifetime, $resultCacheId)
            : $this->disableResultCache();
    }

    /**
     * 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 $lifetime = null, ?string $resultCacheId = null): self
    {
        $this->setResultCacheLifetime($lifetime);
        $this->setResultCacheId($resultCacheId);

        return $this;
    }

    /**
     * Disables caching of the results of this query.
     *
     * @return $this
     */
    public function disableResultCache(): self
    {
        $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($lifetime)
    {
        $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;
        }

        // Compatibility for DBAL 2
        if (! method_exists($this->_queryCacheProfile, 'setResultCache')) {
            $this->_queryCacheProfile = $this->_queryCacheProfile->setResultCacheDriver(DoctrineProvider::wrap($cache));

            return $this;
        }

        $this->_queryCacheProfile = $this->_queryCacheProfile->setResultCache($cache);

        return $this;
    }

    /**
     * Retrieves the lifetime of resultset cache.
     *
     * @deprecated
     *
     * @return int
     */
    public function getResultCacheLifetime()
    {
        return $this->_queryCacheProfile ? $this->_queryCacheProfile->getLifetime() : 0;
    }

    /**
     * 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($expire = true)
    {
        $this->_expireResultCache = $expire;

        return $this;
    }

    /**
     * Retrieves if the resultset cache is active or not.
     *
     * @return bool
     */
    public function getExpireResultCache()
    {
        return $this->_expireResultCache;
    }

    /** @return QueryCacheProfile|null */
    public function getQueryCacheProfile()
    {
        return $this->_queryCacheProfile;
    }

    /**
     * Change the default fetch mode of an association for this query.
     *
     * @param class-string $class
     * @param string       $assocName
     * @param int          $fetchMode
     * @psalm-param Mapping\ClassMetadata::FETCH_EAGER|Mapping\ClassMetadata::FETCH_LAZY $fetchMode
     *
     * @return $this
     */
    public function setFetchMode($class, $assocName, $fetchMode)
    {
        if (! in_array($fetchMode, [Mapping\ClassMetadata::FETCH_EAGER, Mapping\ClassMetadata::FETCH_LAZY], true)) {
            Deprecation::trigger(
                'doctrine/orm',
                'https://github.com/doctrine/orm/pull/9777',
                'Calling %s() with something else than ClassMetadata::FETCH_EAGER or ClassMetadata::FETCH_LAZY is deprecated.',
                __METHOD__
            );
            $fetchMode = Mapping\ClassMetadata::FETCH_LAZY;
        }

        $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($hydrationMode)
    {
        $this->_hydrationMode = $hydrationMode;

        return $this;
    }

    /**
     * Gets the hydration mode currently used by the query.
     *
     * @return string|int
     * @psalm-return string|AbstractQuery::HYDRATE_*
     */
    public function getHydrationMode()
    {
        return $this->_hydrationMode;
    }

    /**
     * Gets the list of results for the query.
     *
     * Alias for execute(null, $hydrationMode = HYDRATE_OBJECT).
     *
     * @param string|int $hydrationMode
     * @psalm-param string|AbstractQuery::HYDRATE_* $hydrationMode
     *
     * @return mixed
     */
    public function getResult($hydrationMode = self::HYDRATE_OBJECT)
    {
        return $this->execute(null, $hydrationMode);
    }

    /**
     * Gets the array of results for the query.
     *
     * Alias for execute(null, HYDRATE_ARRAY).
     *
     * @return mixed[]
     */
    public function getArrayResult()
    {
        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()
    {
        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()
    {
        return $this->execute(null, self::HYDRATE_SCALAR);
    }

    /**
     * Get exactly one result or null.
     *
     * @param string|int|null $hydrationMode
     * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode
     *
     * @return mixed
     *
     * @throws NonUniqueResultException
     */
    public function getOneOrNullResult($hydrationMode = null)
    {
        try {
            $result = $this->execute(null, $hydrationMode);
        } catch (NoResultException $e) {
            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.
     *
     * @param string|int|null $hydrationMode
     * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode
     *
     * @return mixed
     *
     * @throws NonUniqueResultException If the query result is not unique.
     * @throws NoResultException        If the query returned no result.
     */
    public function getSingleResult($hydrationMode = null)
    {
        $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()
    {
        return $this->getSingleResult(self::HYDRATE_SINGLE_SCALAR);
    }

    /**
     * Sets a query hint. If the hint name is not recognized, it is silently ignored.
     *
     * @param string $name  The name of the hint.
     * @param mixed  $value The value of the hint.
     *
     * @return $this
     */
    public function setHint($name, $value)
    {
        $this->_hints[$name] = $value;

        return $this;
    }

    /**
     * Gets the value of a query hint. If the hint name is not recognized, FALSE is returned.
     *
     * @param string $name The name of the hint.
     *
     * @return mixed The value of the hint or FALSE, if the hint name is not recognized.
     */
    public function getHint($name)
    {
        return $this->_hints[$name] ?? false;
    }

    /**
     * Check if the query has a hint
     *
     * @param string $name The name of the hint
     *
     * @return bool False if the query does not have any hint
     */
    public function hasHint($name)
    {
        return isset($this->_hints[$name]);
    }

    /**
     * Return the key value map of query hints that are currently set.
     *
     * @return array<string,mixed>
     */
    public function getHints()
    {
        return $this->_hints;
    }

    /**
     * Executes the query and returns an IterableResult that can be used to incrementally
     * iterate over the result.
     *
     * @deprecated 2.8 Use {@see toIterable} instead. See https://github.com/doctrine/orm/issues/8463
     *
     * @param ArrayCollection|mixed[]|null $parameters    The query parameters.
     * @param string|int|null              $hydrationMode The hydration mode to use.
     * @psalm-param ArrayCollection<int, Parameter>|array<string, mixed>|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null                      $hydrationMode The hydration mode to use.
     *
     * @return IterableResult
     */
    public function iterate($parameters = null, $hydrationMode = null)
    {
        Deprecation::trigger(
            'doctrine/orm',
            'https://github.com/doctrine/orm/issues/8463',
            'Method %s() is deprecated and will be removed in Doctrine ORM 3.0. Use toIterable() instead.',
            __METHOD__
        );

        if ($hydrationMode !== null) {
            $this->setHydrationMode($hydrationMode);
        }

        if (! empty($parameters)) {
            $this->setParameters($parameters);
        }

        $rsm = $this->getResultSetMapping();
        if ($rsm === null) {
            throw new LogicException('Uninitialized result set mapping.');
        }

        $stmt = $this->_doExecute();

        return $this->_em->newHydrator($this->_hydrationMode)->iterate($stmt, $rsm, $this->_hints);
    }

    /**
     * Executes the query and returns an iterable that can be used to incrementally
     * iterate over the result.
     *
     * @param ArrayCollection|array|mixed[] $parameters    The query parameters.
     * @param string|int|null               $hydrationMode The hydration mode to use.
     * @psalm-param ArrayCollection<int, Parameter>|mixed[] $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null    $hydrationMode
     *
     * @return iterable<mixed>
     */
    public function toIterable(iterable $parameters = [], $hydrationMode = null): iterable
    {
        if ($hydrationMode !== null) {
            $this->setHydrationMode($hydrationMode);
        }

        if (
            ($this->isCountable($parameters) && count($parameters) !== 0)
            || ($parameters instanceof Traversable && iterator_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.
     *
     * @param ArrayCollection|mixed[]|null $parameters    Query parameters.
     * @param string|int|null              $hydrationMode Processing mode to be used during the hydration process.
     * @psalm-param ArrayCollection<int, Parameter>|mixed[]|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null         $hydrationMode
     *
     * @return mixed
     */
    public function execute($parameters = null, $hydrationMode = null)
    {
        if ($this->cacheable && $this->isCacheEnabled()) {
            return $this->executeUsingQueryCache($parameters, $hydrationMode);
        }

        return $this->executeIgnoreQueryCache($parameters, $hydrationMode);
    }

    /**
     * Execute query ignoring second level cache.
     *
     * @param ArrayCollection|mixed[]|null $parameters
     * @param string|int|null              $hydrationMode
     * @psalm-param ArrayCollection<int, Parameter>|mixed[]|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null         $hydrationMode
     *
     * @return mixed
     */
    private function executeIgnoreQueryCache($parameters = null, $hydrationMode = null)
    {
        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);

        // Support for DBAL 2
        if (! method_exists($this->_hydrationCacheProfile, 'getResultCache')) {
            $cacheDriver = $this->_hydrationCacheProfile->getResultCacheDriver();
            assert($cacheDriver !== null);

            return CacheAdapter::wrap($cacheDriver);
        }

        $cache = $this->_hydrationCacheProfile->getResultCache();
        assert($cache !== null);

        return $cache;
    }

    /**
     * Load from second level cache or executes the query and put into cache.
     *
     * @param ArrayCollection|mixed[]|null $parameters
     * @param string|int|null              $hydrationMode
     * @psalm-param ArrayCollection<int, Parameter>|mixed[]|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null         $hydrationMode
     *
     * @return mixed
     */
    private function executeUsingQueryCache($parameters = null, $hydrationMode = null)
    {
        $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
    {
        assert($this->_resultSetMapping !== null);
        $entityName = reset($this->_resultSetMapping->aliasMap);

        if (empty($entityName)) {
            return null;
        }

        $metadata = $this->_em->getClassMetadata($entityName);

        return new Cache\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()
    {
        $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.
     *
     * @param string|null $id
     *
     * @return $this
     */
    public function setResultCacheId($id)
    {
        if (! $this->_queryCacheProfile) {
            return $this->setResultCacheProfile(new QueryCacheProfile(0, $id));
        }

        $this->_queryCacheProfile = $this->_queryCacheProfile->setCacheKey($id);

        return $this;
    }

    /**
     * Get the result cache id to use to store the result set cache entry if set.
     *
     * @deprecated
     *
     * @return string|null
     */
    public function getResultCacheId()
    {
        return $this->_queryCacheProfile ? $this->_queryCacheProfile->getCacheKey() : null;
    }

    /**
     * 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();

    /**
     * Cleanup Query resource when clone is called.
     *
     * @return void
     */
    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.
     *
     * @return string
     */
    protected function getHash()
    {
        $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));
    }

    /** @param iterable<mixed> $subject */
    private function isCountable(iterable $subject): bool
    {
        return $subject instanceof Countable || is_array($subject);
    }
}
