Дата обновления перевода: 2020-01-19
Как работать с ассоциациями / отношениями Doctrine¶
Screencast
Предпочитаете видео? Посмотрите серию скринкастов Mastering Doctrine Relations
Существует два основных типа отношений / ассоциаций:
ManyToOne
/OneToMany
- Наиболее распространённые отношения, отображённые в DB с помощью столбца с
foreign key (например, столбца
category_id
в таблицеproduct
). На самом деле, это один тип ассоциации, рассматриваемый с двух разных сторон отношений. ManyToMany
- Использует промежуточную таблицу и нужен, когда обе стороны отношений могут иметь множество с другой стороны (например, "ученики" и "предметы": каждый ученик во многих предметах, и каждый класс имеет множество учеников).
Для начала, вам нужно определить, какое отношение использовать. Если обе стороны
отношений будут содержать множество с другой стороны (например, "ученики" и
"предметы"), то вам нужно использовать отношение ManyToMany
. В других случаях,
вам скорее нужен ManyToOne
.
Tip
Также существуют отношения OneToOne (например, один User
имеет один Profile и наоборот). На практике, их использование схоже
с ManyToOne
.
Ассоциация ManyToOne / OneToMany¶
Представьте, что каждый продукт в вашем приложении принадлежит только к одной
конкретной категории. В этом случае, вам понадобится класс Category
, и способ
связать объект Product
с объектом Category
.
Начните с создания сущности Category
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | $ php bin/console make:entity Category
New property name (press <return> to stop adding fields):
> name
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 255
Can this field be null in the database (nullable) (yes/no) [no]:
> no
New property name (press <return> to stop adding fields):
>
(press enter again to finish)
|
Это сгенерирует новый класс entity (сущности):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // src/Entity/Category.php
// ...
class Category
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string")
*/
private $name;
// ... getters and setters
}
|
Mapping (отображение) отношения ManyToOne¶
В этом примере, каждая категория может быть ассоциирована со многими продуктами. Но каждый продукт может быть ассоциирован только с одной категорией. Это отношение можно подытожить так: многие продукты к одной категории (или, равнозначно - одна категория ко многим продуктам).
С точки зрения entity Product
- это отношения многие-к-одному. С точки зрения
сущности Category
- это отношения один-ко-многим.
Чтобы отобразить (map) это в DB, для начала создайте свойство category
в класее Product
с аннотацией ManyToOne
. Вы можете сделать это вручную или используя команду make:entity
, которая задаст несколько вопросов о вашем отношении. Если вы не знаете, что ответить, не волнуйтесь! Можно поменять настройки позже:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | $ php bin/console make:entity
Class name of the entity to create or update (e.g. BraveChef):
> Product
New property name (press <return> to stop adding fields):
> category
Field type (enter ? to see all types) [string]:
> relation
What class should this entity be related to?:
> Category
Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
> ManyToOne
Is the Product.category property allowed to be null (nullable)? (yes/no) [yes]:
> no
Do you want to add a new property to Category so that you can access/update
Product objects from it - e.g. $category->getProducts()? (yes/no) [yes]:
> yes
New field name inside Category [products]:
> products
Do you want to automatically delete orphaned App\Entity\Product objects
(orphanRemoval)? (yes/no) [no]:
> no
New property name (press <return> to stop adding fields):
>
(press enter again to finish)
|
Это внесло изменения в две entity. Сначала добавилось свойство category
в entity Product
(и методы getters/setters):
- Annotations
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// src/Entity/Product.php // ... class Product { // ... /** * @ORM\ManyToOne(targetEntity="App\Entity\Category", inversedBy="products") */ private $category; public function getCategory(): ?Category { return $this->category; } public function setCategory(?Category $category): self { $this->category = $category; return $this; } }
- YAML
1 2 3 4 5 6 7 8 9 10
# src/Resources/config/doctrine/Product.orm.yml App\Entity\Product: type: entity # ... manyToOne: category: targetEntity: App\Entity\Category inversedBy: products joinColumn: nullable: false
- XML
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
<!-- src/Resources/config/doctrine/Product.orm.xml --> <?xml version="1.0" encoding="UTF-8" ?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="App\Entity\Product"> <!-- ... --> <many-to-one field="category" target-entity="App\Entity\Category" inversed-by="products"> <join-column nullable="false"/> </many-to-one> </entity> </doctrine-mapping>
Это отображение многие-к-одному является обязательным. Оно говорит Doctrine
использовать колонку category_id
таблицы product
, чтобы соотнести каждую запись в этой
таблице с записью в таблице category
.
Далее, так как один объект Category
будет относиться ко многим объектам
Product
, команда make:entity
также добавит свойство products
к классу
Category
, который будет содержать эти объекты:
- Annotations
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// src/Entity/Category.php // ... use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; class Category { // ... /** * @ORM\OneToMany(targetEntity="App\Entity\Product", mappedBy="category") */ private $products; public function __construct() { $this->products = new ArrayCollection(); } /** * @return Collection|Product[] */ public function getProducts(): Collection { return $this->products; } // addProduct() and removeProduct() were also added }
- YAML
1 2 3 4 5 6 7 8 9 10
# src/Resources/config/doctrine/Category.orm.yml App\Entity\Category: type: entity # ... oneToMany: products: targetEntity: App\Entity\Product mappedBy: category # Don't forget to initialize the collection in # the __construct() method of the entity
- XML
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
<!-- src/Resources/config/doctrine/Category.orm.xml --> <?xml version="1.0" encoding="UTF-8" ?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="App\Entity\Category"> <!-- ... --> <one-to-many field="products" target-entity="App\Entity\Product" mapped-by="category"/> <!-- don't forget to init the collection in the __construct() method of the entity --> </entity> </doctrine-mapping>
Отображение ManyToOne
, показанное ранее, обязательно. Но, отношение
OneToMany
- необязательно: добавляйте его только если вы хотите иметь
доступ к products, которые связаны с category (это один из вопросов, который
make:entity
задаёт вам). В этом примере, будет полезно
иметь возможность вызвать $category->getProducts()
. Если вы не хотите этого,
то вам также не нужна настройка inversedBy
или mappedBy
.
Ваша DB настроена! Теперь, выполните миграции, как обычно:
1 2 | $ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
|
Благодрая отношениям, это создаст столбец с foreign key category_id
в
таблице product
. Doctrine готова сохранять наши отношения!
Сохранение связанных сущностей¶
Теперь вы можете увидеть этот новый код в действии! Представьте, что вы внутри контроллера:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | // ...
use App\Entity\Category;
use App\Entity\Product;
use Symfony\Component\HttpFoundation\Response;
class ProductController extends AbstractController
{
/**
* @Route("/product", name="product")
*/
public function index()
{
$category = new Category();
$category->setName('Computer Peripherals');
$product = new Product();
$product->setName('Keyboard');
$product->setPrice(19.99);
$product->setDescription('Ergonomic and stylish!');
// relates this product to the category
$product->setCategory($category);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($category);
$entityManager->persist($product);
$entityManager->flush();
return new Response(
'Saved new product with id: '.$product->getId()
.' and new category with id: '.$category->getId()
);
}
}
|
Когда вы переходите в /product
, к таблицам category
и product
добавляется
одна строчка. Столбец product.category_id
для нового product устанавливается, как
id
новой category. Doctrine управляет сохранением этих отношений за вас:

Если вы новичок в ORM, то это самый сложный концепт: вам нужно перестать думать
о вашей DB, а вместо этого думать только о ваших объектах. Вместо установки числового
id категории в Product
, вы устанавливаете весь объект Category
. Doctrine
заботится обо всём остальном при сохранении.
Извлечение связанных объектов¶
Когда вам надо вернуть ассоциированные объекты, ваш ход работы выглядит так же,
как и раньше. Вначале, вызовите объект $product
, а потом получите доступ к
связанному с ним объекту Category
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | use App\Entity\Product;
// ...
public function show($id)
{
$product = $this->getDoctrine()
->getRepository(Product::class)
->find($id);
// ...
$categoryName = $product->getCategory()->getName();
// ...
}
|
В этом примере, вы вначале запрашиваете объект Product
, основываясь на его
id
. Это запускает запрос только для данных продукта и насыщает (hydrates) объект
$product
. Позже, когда вы вызовете $product->getCategory()->getName()
,
Doctrine молча создаст второй запрос, чтобы найти Category
, которая связана с этим
Product
. Она подготавливает объект $category
и возвращает его вам.

Важно то, что у вас есть доступ к category, связанной с product, но данные category на самом деле не запрашиваются, пока вы не спросите о них ("ленивая загрузка" - lazy load).
Так как мы отобразили необязательную сторону OneToMany
, то вы также
можете запросить в обратном направлении:
1 2 3 4 5 6 7 8 9 10 | public function showProducts($id)
{
$category = $this->getDoctrine()
->getRepository(Category::class)
->find($id);
$products = $category->getProducts();
// ...
}
|
В этом случае, происходят те же вещи: вы вначале запрашиваете один объект
Category
. Далее, только когда (и если) вы запрашиваете products,
Doctrine делает второй запрос, чтобы получить связанные объекты Product
.
Этого дополнительного запроса можно избежать, добавив JOIN.
Объединение связанных записей¶
В вышеописанных примерах, было сделано два запроса - один к оригинальному
объекту (например, Category
) и один к связанному(ым) объекту(ам),
(например объектам Product
).
Tip
Помните, что вы можете увидеть все запросы, сделанные во время запроса с помощью панели инструментов веб-отладки (web debug toolbar).
Конечно, если вы заранее знаете, что вам понадобится получить доступ к обоим
объектам, вы можете избежать ворого запроса, путём создания join (объединения) в
оригинальном запросе. Добавьте следующий метод к классу ProductRepository
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // src/Repository/ProductRepository.php
public function findOneByIdJoinedToCategory($productId)
{
$entityManager = $this->getEntityManager();
$query = $entityManager->createQuery(
'SELECT p, c
FROM App\Entity\Product p
INNER JOIN p.category c
WHERE p.id = :id'
)->setParameter('id', $productId);
return $query->getOneOrNullResult();
}
|
Это всё равно вернёт массив объектов Product
. Но теперь, когда вы
вызываете $product->getCategory()
и используете эти данные, второй
запрос не создаётся.
Теперь вы можете использовать этот метод в вашем контроллере, чтобы создать
запрос к объекту Product
и связанному с ним Category
с помощью
единого запроса:
1 2 3 4 5 6 7 8 9 10 | public function show($id)
{
$product = $this->getDoctrine()
->getRepository(Product::class)
->findOneByIdJoinedToCategory($id);
$category = $product->getCategory();
// ...
}
|
Установка информации с обратной стороны¶
До этого момента вы обновляли отношения, вызывая $product->setCategory($category)
.
Это не случайно! Каждое отношение имеет две стороны: в этом примере Product.category
- это владеющая (owning) сторона, а Category.products
- инверсная (inverse) сторона.
Для обновления отношения в DB, вам нужно установить отношение на владеющей стороне.
Владеющая сторона - всегда та, где уствновлена связь ManyToOne
(для отношений ManyToMany
вы можете выбрать какая сторона будет владеющей).
Значит ли это, что невозможно вызвать $category->addProduct()
или
$category->removeProduct()
для обновления DB? На самом деле, это возможно
благодаря умному коду, который сгенерировала команда make:entity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // src/Entity/Category.php
// ...
class Category
{
// ...
public function addProduct(Product $product): self
{
if (!$this->products->contains($product)) {
$this->products[] = $product;
$product->setCategory($this);
}
return $this;
}
}
|
Ключевым является код $product->setCategory($this)
, который обновляет
владеющую сторону. Теперь, когда вы сохраняетесь, отношения будут обновляться в DB.
Что на счёт удаления Product
из Category
? Комана make:entity
также сгенерировала метод removeProduct()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // src/Entity/Category.php
// ...
class Category
{
// ...
public function removeProduct(Product $product): self
{
if ($this->products->contains($product)) {
$this->products->removeElement($product);
// set the owning side to null (unless already changed)
if ($product->getCategory() === $this) {
$product->setCategory(null);
}
}
return $this;
}
}
|
Благодаря этому, если вы вызовете $category->removeProduct($product)
, category_id
в этом Product
будет установлен null
в DB.
Но, вместо установки category_id
как null, что, если вы хотите, чтобы Product
был удалён, если он станет "сиротой" (orphan) (т.е. без Category
)? Чтобы выбрать такое
поведение, используйте опцию orphanRemoval внутри Category
:
1 2 3 4 5 6 7 8 | // src/Entity/Category.php
// ...
/**
* @ORM\OneToMany(targetEntity="App\Entity\Product", mappedBy="category", orphanRemoval=true)
*/
private $products;
|
Благодаря этому, если Product
удаляется из Category
, он будет
полностью удалён из базы данных.
Больше информации об ассоциациях¶
Этот раздел был вступлением к одному распространённому типу отношений entity, отношению один-ко-многим. Для более подробных деталей и примеров того, как использовать другие типы отношений (например, один-к-одному, многие-ко-многим), смотрите документацию об Association Mapping Doctrine.
Note
Если вы используете аннотации, вам понадобится добавить ко всем
аннотациям префикс @ORM\
(например, @ORM\OneToMany
), который
не упомянут в документации Doctrine.
Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.