Doctrine ORM предоставляет разработчику удобные средства выборки данных. Это и мощный DQL для работы в объектно-ориентированном ключе, и удобный Query Builder, простой и понятный в использовании. Они покрывают большую часть потребностей, но иногда возникает необходимость использовать SQL запросы, оптимизированные или специфичные для конкретной СУБД. Для работы с результатами запросов в коде важно понимание того, как работает маппинг в Doctrine.



В основе Doctrine ORM лежит паттерн Data Mapper, изолирующий реляционное представление от объектного, и конвертирующий данные между ними. Одним из ключевых компонентов этого процесса является объект ResultSetMapping, с помощью которого описывается, как именно преобразовывать результаты запроса из реляционной модели в объектную. Doctrine всегда использует ResultSetMapping для представления результатов запроса, но обычно этот объект создается на основе аннотаций или yaml, xml конфигов, остается скрыт от глаз разработчика, потому о его возможностях знают далеко не все.

Для примеров работы с ResultSetMapping я буду использовать MySQL и employees sample database.

Структура БД


Опишем сущности Department, Employee, Salary, c которыми и будем продолжать работу далее.

Deparment
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Departments
 *
 * @ORM\Table(name="departments", uniqueConstraints={@ORM\UniqueConstraint(name="dept_name", columns={"dept_name"})})
 * @ORM\Entity
 */
class Department
{
    /**
     * @var string
     *
     * @ORM\Column(name="dept_no", type="string", length=4, nullable=false, options={"fixed"=true})
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $deptNo;

    /**
     * @var string
     *
     * @ORM\Column(name="dept_name", type="string", length=40, nullable=false)
     */
    private $deptName;

    /**
     * @var \Doctrine\Common\Collections\Collection
     *
     * @ORM\ManyToMany(targetEntity="Employee", mappedBy="deptNo")
     */
    private $empNo;

    /**
     * Constructor
     */
    public function __construct()
    {
        $this->empNo = new \Doctrine\Common\Collections\ArrayCollection();
    }
}



Employee

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* Employees
*
* ORM\Table(name=«employees»)
* ORM\Entity
*/
class Employee
{
/**
* var int
*
* ORM\Column(name=«emp_no», type=«integer», nullable=false)
* ORM\Id
* ORM\GeneratedValue(strategy=«IDENTITY»)
*/
private $empNo;

/**
* var \DateTime
*
* ORM\Column(name=«birth_date», type=«date», nullable=false)
*/
private $birthDate;

/**
* var string
*
* ORM\Column(name=«first_name», type=«string», length=14, nullable=false)
*/
private $firstName;

/**
* var string
*
* ORM\Column(name=«last_name», type=«string», length=16, nullable=false)
*/
private $lastName;

/**
* var string
*
* ORM\Column(name=«gender», type=«string», length=0, nullable=false)
*/
private $gender;

/**
* var \DateTime
*
* ORM\Column(name=«hire_date», type=«date», nullable=false)
*/
private $hireDate;

/**
* var \Doctrine\Common\Collections\Collection
*
* ORM\ManyToMany(targetEntity=«Department», inversedBy=«empNo»)
* ORM\JoinTable(name=«dept_manager»,
* joinColumns={
* ORM\JoinColumn(name=«emp_no», referencedColumnName=«emp_no»)
* },
* inverseJoinColumns={
* ORM\JoinColumn(name=«dept_no», referencedColumnName=«dept_no»)
* }
* )
*/
private $deptNo;

/**
* Constructor
*/
public function __construct()
{
$this->deptNo = new \Doctrine\Common\Collections\ArrayCollection();
}

}


Salary
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* Salaries
*
* ORM\Table(name=«salaries», indexes={@ORM\Index(name=«IDX_E6EEB84BA2F57F47», columns={«emp_no»})})
* ORM\Entity
*/
class Salary
{
/**
* var \DateTime
*
* ORM\Column(name=«from_date», type=«date», nullable=false)
* ORM\Id
* ORM\GeneratedValue(strategy=«NONE»)
*/
private $fromDate;

/**
* var int
*
* ORM\Column(name=«salary», type=«integer», nullable=false)
*/
private $salary;

/**
* var \DateTime
*
* ORM\Column(name=«to_date», type=«date», nullable=false)
*/
private $toDate;

/**
* var Employee
*
* ORM\Id
* ORM\OneToOne(targetEntity=«Employee»)
* ORM\JoinColumns({
* ORM\JoinColumn(name=«emp_no», referencedColumnName=«emp_no»)
* })
*/
private $empNo;

/**
* var Employee
*
*/
private $employee;

}



Entity result


Начнем с простого, выберем все департаменты из базы и спроецируем их на Department:

        $rsm = new Query\ResultSetMapping();
        $rsm->addEntityResult(Department::class, 'd');
        $rsm->addFieldResult('d', 'dept_no', 'deptNo');
        $rsm->addFieldResult('d', 'dept_name', 'deptName');

        $sql = 'SELECT * FROM departments';

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

        $result = $query->getResult();


Метод addEntityResult указывает, на какой класс будет проецироваться наша выборка, а методы addFieldResult указывают на сопоставление столбцов выборки и полей объектов. SQL запрос, передаваемый в метод createNativeQuery будет передан в БД именно в таком виде и Doctrine не будет никаким образом его изменять.

Стоит помнить, что Entity обязательно обладает уникальным идентификатором, который должен быть включен в fieldResult.

Joined Entity result


Выберем департаменты, в которых присутствуют сотрудники, нанятые после начала 2000 года, вместе с этими сотрудниками.

        $rsm = new Query\ResultSetMapping();
        $rsm->addEntityResult(Department::class, 'd');
        $rsm->addFieldResult('d', 'dept_no', 'deptNo');
        $rsm->addFieldResult('d', 'dept_name', 'deptName');

        $rsm->addJoinedEntityResult(Employee::class, 'e', 'd', 'empNo');
        $rsm->addFieldResult('e', 'first_name', 'firstName');
        $rsm->addFieldResult('e', 'last_name', 'lastName');
        $rsm->addFieldResult('e', 'birth_date', 'birthDate');
        $rsm->addFieldResult('e', 'gender', 'gender');
        $rsm->addFieldResult('e', 'hire_date', 'hireDate');

        $sql = "
        SELECT *
            FROM departments d
            JOIN dept_emp ON d.dept_no = dept_emp.dept_no
            JOIN employees e on dept_emp.emp_no = e.emp_no
        WHERE e.hire_date > DATE ('1999-12-31')
       ";

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

        $result = $query->getResult();

ResultSetMappingBuilder


Как видно, работа с непосредственно объектом ResultSetMapping несколько сложна и заставляет крайне детально описывать сопоставление выборки и объектов. К частью, Doctrine предоставляет более удобный инструмент — ResultSetMappingBuilder, являющийся оберткой над RSM и добавляющий больше удобных методов для работы с маппингом. Например, метод generateSelectClause, позволяющий создать параметр для SELECT части запроса с описанием необходимых для выборки полей. Предыдущий запрос можно переписать в более простом виде.

        $sql = "
        SELECT {$rsm->generateSelectClause()}
            FROM departments d
            JOIN dept_emp ON d.dept_no = dept_emp.dept_no
            JOIN employees e on dept_emp.emp_no = e.emp_no
        WHERE e.hire_date > DATE ('1999-12-31')
       ";

Стоит обратить внимание, что если не указать все поля создаваемого объекта, то Doctrine вернет partial object, использование которых может быть оправдано в некоторых случаях, но их поведение опасно и не рекомендуется. Вместо этого, ResultSetMappingBuilder позволяет не указывать каждое поле итогового класса, а использовать описанные через аннотации (yaml, xml конфигурации) сущности. Перепишем наш RSM из предыдущего запроса с учетом этих методов:

        $rsm = new Query\ResultSetMappingBuilder($this->entityManager);
        $rsm->addRootEntityFromClassMetadata(Department::class, 'd');
        $rsm->addJoinedEntityFromClassMetadata(Employee::class, 'e', 'd', 'empNo');

Scalar result


Повсеместное использование описанных entity не оправдано, может привести к проблемам с производительностью, так как Doctrine требуется создать множество объектов, которые в дальнейшем не будут использоваться полноценно. Для таких случаев предоставляется инструмент маппинга скалярных результатов — addScalarResult.

Выберем среднюю зарплату по каждому департаменту:

        $rsm = new ResultSetMappingBuilder($this->entityManager);

        $rsm->addScalarResult('dept_name', 'department', 'string');
        $rsm->addScalarResult('avg_salary', 'salary', 'integer');

        $sql = "
            SELECT d.dept_name, AVG(s.salary) AS avg_salary
            FROM departments d
            JOIN dept_emp de on d.dept_no = de.dept_no
            JOIN employees e on de.emp_no = e.emp_no
            JOIN salaries s on e.emp_no = s.emp_no
            GROUP BY d.dept_name
        ";

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

        $result = $query->getResult();

Первым аргументом метода addScalarResult служит имя колонки в результирующей выборке, вторым — ключ массива результатов, которые вернет Doctrine. Третий параметр — тип значения результата. Возвращаемым значением всегда будет массив массивов.

Маппинг на DTO


Но работать с массивами, особенно когда те имеют сложную структуру не очень удобно. Нужно держать в голове названия ключей, типы полей. В Doctrine DQL есть возможность использовать в результатах выборки простые DTO, например:

    SELECT NEW DepartmentSalary(d.dept_no, avg_salary) FROM …

А что же в случае Native Query и RSM? Doctrine не предоставляет документированных возможностей для создания новых DTO, но путем использования свойства newObjectMappings мы можем указать объекты, в которые хотим маппить результаты выборки. В отличие от Entity, эти объекты не будут контролироваться UnitOfWork’ом, и не обязаны находиться в неймспейсах, указанных в конфигурации.

Дополним RSM из предыдущего примера:

        $rsm->newObjectMappings['dept_name'] = [
            'className' => DepartmentSalary::class,
            'argIndex' => 0,
            'objIndex' => 0,
        ];

        $rsm->newObjectMappings['avg_salary'] = [
            'className' => DepartmentSalary::class,
            'argIndex' => 1,
            'objIndex' => 0,
        ];

Ключ в массиве поля newObjectMappings указывает на столбец результирующий выборки, значением же его является другой массив, в котором описывается создаваемый объект. Ключ className определяет имя класса нового объекта, argIndex — порядок аргумента в конструкторе объекта, objIndex — порядок объекта, если мы хотим получить несколько объектов из каждой строки выборки.

Meta result


Meta result используется для получения мета-данных колонок, таких как foreign keys или discriminator columns. К сожалению, я не смог придумать пример на основе employees базы данных, потому придется ограничиться описанием и примерами из документации.

        $rsm = new ResultSetMapping;
        $rsm->addEntityResult('User', 'u');
        $rsm->addFieldResult('u', 'id', 'id');
        $rsm->addFieldResult('u', 'name', 'name');
        $rsm->addMetaResult('u', 'address_id', 'address_id');

        $query = $this->_em->createNativeQuery('SELECT id, name, address_id FROM users WHERE name = ?', $rsm);

В данном случае, методом addMetaResult мы указываем Doctine, что таблица users имеет внешний ключ на addresses, но вместо того, чтобы загружать ассоциацию в память (eager load), мы создаем proxy-object, который хранит идентификатор сущности, и при обращении к нему загрузит ее из БД.

Классические реляционные базы данных не предлагают механизмов наследования таблиц, в то же время в объектной модели наследование широко распространено. Результат нашей выборки может проецироваться на иерархию классов, по некоему признаку, являющемуся значением колонки <discriminator_column> в результирующей выборке. В этом случае, мы можем указать RSM, по какой колонке Doctrine должна определять инстанцируемый класс посредством метода setDiscriminatorColumn.

Заключение


Doctrine весьма богата различными возможностями, в том числе и такими, о которых знают не все разработчики. В данном посте я постарался ознакомить с пониманием работы одного из ключевых компонентов ORM — ResultSetMapping в комбинации с Native Query. Использовать действительно сложные и платформо-зависимые запросы, сохранив доступность и понятность примеров было бы сложной задачей, потому акцент сделан именно на понимание работы ResultSetMapping. После этого вы сможете использовать его в действительно сложных запросах к вашим базам данных.