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 которыми и будем продолжать работу далее.
Начнем с простого, выберем все департаменты из базы и спроецируем их на Department:
Метод addEntityResult указывает, на какой класс будет проецироваться наша выборка, а методы addFieldResult указывают на сопоставление столбцов выборки и полей объектов. SQL запрос, передаваемый в метод createNativeQuery будет передан в БД именно в таком виде и Doctrine не будет никаким образом его изменять.
Стоит помнить, что Entity обязательно обладает уникальным идентификатором, который должен быть включен в fieldResult.
Выберем департаменты, в которых присутствуют сотрудники, нанятые после начала 2000 года, вместе с этими сотрудниками.
Как видно, работа с непосредственно объектом ResultSetMapping несколько сложна и заставляет крайне детально описывать сопоставление выборки и объектов. К частью, Doctrine предоставляет более удобный инструмент — ResultSetMappingBuilder, являющийся оберткой над RSM и добавляющий больше удобных методов для работы с маппингом. Например, метод generateSelectClause, позволяющий создать параметр для SELECT части запроса с описанием необходимых для выборки полей. Предыдущий запрос можно переписать в более простом виде.
Стоит обратить внимание, что если не указать все поля создаваемого объекта, то Doctrine вернет partial object, использование которых может быть оправдано в некоторых случаях, но их поведение опасно и не рекомендуется. Вместо этого, ResultSetMappingBuilder позволяет не указывать каждое поле итогового класса, а использовать описанные через аннотации (yaml, xml конфигурации) сущности. Перепишем наш RSM из предыдущего запроса с учетом этих методов:
Повсеместное использование описанных entity не оправдано, может привести к проблемам с производительностью, так как Doctrine требуется создать множество объектов, которые в дальнейшем не будут использоваться полноценно. Для таких случаев предоставляется инструмент маппинга скалярных результатов — addScalarResult.
Выберем среднюю зарплату по каждому департаменту:
Первым аргументом метода addScalarResult служит имя колонки в результирующей выборке, вторым — ключ массива результатов, которые вернет Doctrine. Третий параметр — тип значения результата. Возвращаемым значением всегда будет массив массивов.
Но работать с массивами, особенно когда те имеют сложную структуру не очень удобно. Нужно держать в голове названия ключей, типы полей. В Doctrine DQL есть возможность использовать в результатах выборки простые DTO, например:
А что же в случае Native Query и RSM? Doctrine не предоставляет документированных возможностей для создания новых DTO, но путем использования свойства newObjectMappings мы можем указать объекты, в которые хотим маппить результаты выборки. В отличие от Entity, эти объекты не будут контролироваться UnitOfWork’ом, и не обязаны находиться в неймспейсах, указанных в конфигурации.
Дополним RSM из предыдущего примера:
Ключ в массиве поля newObjectMappings указывает на столбец результирующий выборки, значением же его является другой массив, в котором описывается создаваемый объект. Ключ className определяет имя класса нового объекта, argIndex — порядок аргумента в конструкторе объекта, objIndex — порядок объекта, если мы хотим получить несколько объектов из каждой строки выборки.
Meta result используется для получения мета-данных колонок, таких как foreign keys или discriminator columns. К сожалению, я не смог придумать пример на основе employees базы данных, потому придется ограничиться описанием и примерами из документации.
В данном случае, методом addMetaResult мы указываем Doctine, что таблица users имеет внешний ключ на addresses, но вместо того, чтобы загружать ассоциацию в память (eager load), мы создаем proxy-object, который хранит идентификатор сущности, и при обращении к нему загрузит ее из БД.
Классические реляционные базы данных не предлагают механизмов наследования таблиц, в то же время в объектной модели наследование широко распространено. Результат нашей выборки может проецироваться на иерархию классов, по некоему признаку, являющемуся значением колонки <discriminator_column> в результирующей выборке. В этом случае, мы можем указать RSM, по какой колонке Doctrine должна определять инстанцируемый класс посредством метода setDiscriminatorColumn.
Doctrine весьма богата различными возможностями, в том числе и такими, о которых знают не все разработчики. В данном посте я постарался ознакомить с пониманием работы одного из ключевых компонентов ORM — ResultSetMapping в комбинации с Native Query. Использовать действительно сложные и платформо-зависимые запросы, сохранив доступность и понятность примеров было бы сложной задачей, потому акцент сделан именно на понимание работы ResultSetMapping. После этого вы сможете использовать его в действительно сложных запросах к вашим базам данных.
В основе 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();
}
}
<?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;
}
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. После этого вы сможете использовать его в действительно сложных запросах к вашим базам данных.