Можно ли использовать результат функции SQL в качестве поля в Doctrine?

Предположим, что у меня есть Product сущности и Review сущности, привязанные к продуктам. Можно ли прикрепить поля к объекту Product на основе некоторого результата, возвращаемого SQL-запросом? Подобно прикреплению поля ReviewsCount, равного COUNT(Reviews.ID) as ReviewsCount.

Я знаю, что это можно сделать в такой функции, как

public function getReviewsCount() {
    return count($this->Reviews);
}

Но я хочу сделать это с помощью SQL, чтобы свести к минимуму количество запросов к базе данных и повысить производительность, так как обычно мне может не понадобиться загружать сотни отзывов, но все равно нужно знать номер. Я думаю, что работающий SQL COUNT будет намного быстрее, чем через 100 продуктов и вычислять 100 отзывов для каждого. Более того, это просто пример, на практике мне нужны более сложные функции, и я думаю, что MySQL будет работать быстрее. Исправьте меня, если я ошибаюсь.

Ответ 1

Вы можете отобразить результат одного столбца в поле сущности - посмотрите на собственные запросы и ResultSetMapping, чтобы добиться этого. В качестве простого примера:

use Doctrine\ORM\Query\ResultSetMapping;

$sql = '
    SELECT p.*, COUNT(r.id)
    FROM products p
    LEFT JOIN reviews r ON p.id = r.product_id
';

$rsm = new ResultSetMapping;
$rsm->addEntityResult('AppBundle\Entity\Product', 'p');
$rsm->addFieldResult('p', 'COUNT(id)', 'reviewsCount');

$query   = $this->getEntityManager()->createNativeQuery($sql, $rsm);
$results = $query->getResult();

Тогда в вашей сущности Product у вас будет поле $reviewsCount, и количество будет сопоставлено с этим. Обратите внимание, что это будет работать только в том случае, если в метаданных Doctrine определен столбец, например:

/**
 * @ORM\Column(type="integer")
 */
private $reviewsCount;

public function getReviewsCount()
{
    return $this->reviewsCount;
}

Это то, что предлагается в документации Доктрина Общих полей. Проблема здесь в том, что вы, по сути, заставляете Doctrine думать, что в вашей базе данных есть еще один столбец под названием reviews_count, чего вы не хотите. Таким образом, это все равно будет работать без физического добавления этого столбца, но если вы когда-нибудь запустите doctrine:schema:update, он добавит этот столбец для вас. К сожалению, Doctrine на самом деле не разрешает виртуальные свойства, поэтому другим решением было бы написать свой собственный гидратор или, возможно, подписаться на событие loadClassMetadata и вручную добавить отображение после загрузки вашей конкретной сущности (или сущностей).

Обратите внимание, что если вы сделаете что-то вроде COUNT(r.id) AS reviewsCount, то вы больше не сможете использовать COUNT(id) в своей функции addFieldResult() и вместо этого должны использовать псевдоним reviewsCount для этого второго параметра.

Вы также можете использовать ResultSetMappingBuilder в качестве начала использования сопоставления набора результатов.

Мое реальное предложение - сделать это вручную, а не проходить через все эти дополнительные вещи. По сути, создайте обычный запрос, который возвращает и вашу сущность, и скалярные результаты в массив, затем установите для скалярного результата соответствующее не отображенное поле вашей сущности и верните сущность.

Ответ 2

После подробного расследования я обнаружил, что есть несколько способов сделать что-то близкое к тому, что я хотел, включая перечисленные в других ответах, но у всех из них есть минусы. Наконец, я решил использовать CustomHydrators. Кажется, что свойства, не управляемые с помощью ORM, не могут быть сопоставлены с ResultSetMapping как поля, но могут быть получены как скаляры и привязаны к объекту вручную (так как PHP позволяет привязывать свойства объекта на лету). Однако результат, полученный вами от доктрины, остается в кеше. Это означает, что свойства, установленные таким образом, могут быть reset, если вы сделаете другой запрос, который также будет содержать эти сущности.

Другой способ сделать это - добавить это поле непосредственно в кэш метаданных доктрины. Я попытался сделать это в CustomHydrator:

protected function getClassMetadata($className)
{
    if ( ! isset($this->_metadataCache[$className])) {
        $this->_metadataCache[$className] = $this->_em->getClassMetadata($className);

        if ($className === "SomeBundle\Entity\Product") {
            $this->insertField($className, "ReviewsCount");
        }
    }

    return $this->_metadataCache[$className];
}

protected function insertField($className, $fieldName) {
    $this->_metadataCache[$className]->fieldMappings[$fieldName] = ["fieldName" => $fieldName, "type" => "text", "scale" => 0, "length" => null, "unique" => false, "nullable" => true, "precision" => 0];
    $this->_metadataCache[$className]->reflFields[$fieldName] = new \ReflectionProperty($className, $fieldName);

    return $this->_metadataCache[$className];
}

Однако этот метод также имел проблемы с свойствами объектов reset. Итак, моим окончательным решением было просто использовать stdClass для получения той же структуры, но не управляемой доктриной:

namespace SomeBundle;

use PDO;
use Doctrine\ORM\Query\ResultSetMapping;

class CustomHydrator extends \Doctrine\ORM\Internal\Hydration\ObjectHydrator {
    public function hydrateAll($stmt, $resultSetMapping, array $hints = array()) {
        $data = $stmt->fetchAll(PDO::FETCH_ASSOC);

        $result = [];

        foreach($resultSetMapping->entityMappings as $root => $something) {
            $rootIDField = $this->getIDFieldName($root, $resultSetMapping);

            foreach($data as $row) {
                $key = $this->findEntityByID($result, $row[$rootIDField]);

                if ($key === null) {
                    $result[] = new \stdClass();
                    end($result);
                    $key = key($result);
                }

                foreach ($row as $column => $field)
                    if (isset($resultSetMapping->columnOwnerMap[$column]))
                        $this->attach($result[$key], $field, $this->getPath($root, $resultSetMapping, $column));
            }
        }


        return $result;
    }

    private function getIDFieldName($entityAlias, ResultSetMapping $rsm) {
        foreach ($rsm->fieldMappings as $key => $field)
            if ($field === 'ID' && $rsm->columnOwnerMap[$key] === $entityAlias) return $key;

            return null;
    }

    private function findEntityByID($array, $ID) {
        foreach($array as $index => $entity)
            if (isset($entity->ID) && $entity->ID === $ID) return $index;

        return null;
    }

    private function getPath($root, ResultSetMapping $rsm, $column) {
        $path = [$rsm->fieldMappings[$column]];
        if ($rsm->columnOwnerMap[$column] !== $root) 
            array_splice($path, 0, 0, $this->getParent($root, $rsm, $rsm->columnOwnerMap[$column]));

        return $path;
    }

    private function getParent($root, ResultSetMapping $rsm, $entityAlias) {
        $path = [];
        if (isset($rsm->parentAliasMap[$entityAlias])) {
            $path[] = $rsm->relationMap[$entityAlias];
            array_splice($path, 0, 0, $this->getParent($root, $rsm, array_search($rsm->parentAliasMap[$entityAlias], $rsm->relationMap)));
        }

        return $path;
    }

    private function attach($object, $field, $place) {
        if (count($place) > 1) {
            $prop = $place[0];
            array_splice($place, 0, 1);
            if (!isset($object->{$prop})) $object->{$prop} = new \stdClass();
            $this->attach($object->{$prop}, $field, $place);
        } else {
            $prop = $place[0];
            $object->{$prop} = $field;
        }
    }
}

С помощью этого класса вы можете получить любую структуру и прикрепить любые объекты, которые вам нравятся:

$sql = '
    SELECT p.*, COUNT(r.id)
    FROM products p
    LEFT JOIN reviews r ON p.id = r.product_id
';

$em = $this->getDoctrine()->getManager();

$rsm = new ResultSetMapping();
$rsm->addEntityResult('SomeBundle\Entity\Product', 'p');
$rsm->addFieldResult('p', 'COUNT(id)', 'reviewsCount');

$query = $em->createNativeQuery($sql, $rsm);

$em->getConfiguration()->addCustomHydrationMode('CustomHydrator', 'SomeBundle\CustomHydrator');
$results = $query->getResult('CustomHydrator');

Надеюсь, что это может кому-то помочь:)

Ответ 3

Да, возможно, вам нужно использовать QueryBuilder для достижения этого:

$result = $em->getRepository('AppBundle:Product')
    ->createQueryBuilder('p')
    ->select('p, count(r.id) as countResult')
    ->leftJoin('p.Review', 'r')
    ->groupBy('r.id')
    ->getQuery()
    ->getArrayResult();

и теперь вы можете сделать что-то вроде:

foreach ($result as $row) {
    echo $row['countResult'];
    echo $row['anyOtherProductField'];
}

Ответ 4

Если вы работаете с Doctrine 2.1+, подумайте об использовании ассоциаций EXTRA_LAZY:

Они позволяют реализовать такой метод, как ваш, в вашей сущности, делая прямой подсчет в ассоциации вместо того, чтобы извлекать все сущности в нем:

/**
* @ORM\OneToMany(targetEntity="Review", mappedBy="Product" fetch="EXTRA_LAZY")
*/
private $Reviews;

public function getReviewsCount() {
    return $this->Reviews->count();
}