Как работать с ассоциациями / отношениями Doctrine
Дата обновления перевода 2024-07-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
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
> name
Тип поля (введите ?, чтобы увидеть все типы) [string]:
> string
Длина поля [255]:
> 255
Может ли это поле быть null в базе данных (nullable) (да/нет) [no]:
> no
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
>
(нажмите enter снова, чтобы закончить)
Это сгенерирует новый класс entity (сущности):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Entity/Category.php
namespace App\Entity;
// ...
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private $id;
#[ORM\Column]
private string $name;
// ... геттеры и сеттеры
}
Tip
Начиная с MakerBundle: v1.57.0 - Вы можете передавать --with-uuid
или
--with-ulid
в make:entity
. Используя преимущества Компонента Uid от Symfony,
создается сущность с типом id
в виде :ref:ad1ac5efbb1083be88d2bc3c24cb082c0ced98f1int``.
Отображение отношения ManyToOne
В этом примере, каждая категория может быть ассоциирована со многими продуктами. Но каждый продукт может быть ассоциирован только с одной категорией. Это отношение можно подытожить так: многие продукты к одной категории (или, равнозначно - одна категория ко многим продуктам).
С точки зрения entity Product
- это отношения многие-к-одному. С точки зрения
сущности Category
- это отношения один-ко-многим.
Чтобы отобразить это в 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
Имя класса сущности для создания или обновления (например, BraveChef):
> Product
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
> category
Тип поля (введите ?, чтобы увидеть все типы) [string]:
> relation
К какому классу должна относиться эта сущность?:
> Category
Какой тип отношений? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
> ManyToOne
Может ли свойство Product.category быть null (nullable)? (да/нет) [yes]:
> no
Хотите ли вы добавить новое свойство в Категорию, чтобы вы могли иметь доступ
или обновлять объекты Продуктов из него - например, $category->getProducts()? (да/нет) [yes]:
> yes
Новое имя поля внутри Категории [products]:
> products
Хотите ли вы автоматически удалять ненужные объекты App\Entity\Product
(orphanRemoval)? (да/нет) [no]:
> no
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
>
(нажмите enter снова, чтобы закончить)
Это внесло изменения в две сущности. Сначала добавилось свойство category
в сущность Product
(и методы getters/setters):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// src/Entity/Product.php
namespace App\Entity;
// ...
class Product
{
// ...
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')]
private Category $category;
public function getCategory(): ?Category
{
return $this->category;
}
public function setCategory(?Category $category): self
{
$this->category = $category;
return $this;
}
}
Это отображение ManyToOne
является обязательным. Оно говорит Doctrine
использовать колонку category_id
таблицы product
, чтобы соотнести каждую запись в этой
таблице с записью в таблице category
.
Далее, так как один объект Category
будет относиться ко многим объектам
Product
, команда make:entity
также добавит свойство products
к классу
Category
, который будет содержать эти объекты:
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
// src/Entity/Category.php
namespace App\Entity;
// ...
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
class Category
{
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category')]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
/**
* @return Collection<int, Product>
*/
public function getProducts(): Collection
{
return $this->products;
}
// addProduct() и removeProduct() также были добавлены
}
Отображение 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 36
// src/Controller/ProductController.php
namespace App\Controller;
// ...
use App\Entity\Category;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController
{
#[Route('/product', name: 'product')]
public function index(EntityManagerInterface $entityManager): Response
{
$category = new Category();
$category->setName('Computer Peripherals');
$product = new Product();
$product->setName('Keyboard');
$product->setPrice(19.99);
$product->setDescription('Ergonomic and stylish!');
// относит этот продукт к категории
$product->setCategory($category);
$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 16 17 18
// src/Controller/ProductController.php
namespace App\Controller;
use App\Entity\Product;
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->find($id);
// ...
$categoryName = $product->getCategory()->getName();
// ...
}
}
В этом примере, вы вначале запрашиваете объект Product
, основываясь на его
id
. Это запускает запрос только для данных продукта и насыщает (hydrates) объект
$product
. Позже, когда вы вызовете $product->getCategory()->getName()
,
Doctrine молча создаст второй запрос, чтобы найти Category
, которая связана с этим
Product
. Она подготавливает объект $category
и возвращает его вам.
Важно то, что у вас есть доступ к category, связанной с product, но данные category на самом деле не запрашиваются, пока вы не спросите о них (т.е. "ленивая загрузка").
Так как мы отобразили необязательную сторону OneToMany
, то вы также
можете запросить в обратном направлении:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function showProducts(CategoryRepository $categoryRepository, int $id): Response
{
$category = $categoryRepository->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 15 16 17 18 19
// src/Repository/ProductRepository.php
// ...
class ProductRepository extends ServiceEntityRepository
{
public function findOneByIdJoinedToCategory(int $productId): ?Product
{
$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 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->findOneByIdJoinedToCategory($id);
$category = $product->getCategory();
// ...
}
}
Установка информации с обратной стороны
До этого момента вы обновляли отношения, вызывая $product->setCategory($category)
.
Это не случайно! Каждое отношение имеет две стороны: в этом примере Product.category
- это владеющая сторона, а Category.products
- обратная сторона.
Для обновления отношения в 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 21
// src/Entity/Category.php
namespace App\Entity;
// ...
class Category
{
// ...
public function removeProduct(Product $product): self
{
if ($this->products->contains($product)) {
$this->products->removeElement($product);
// установить владеющую сторону как null (если ещё не изменена)
if ($product->getCategory() === $this) {
$product->setCategory(null);
}
}
return $this;
}
}
Благодаря этому, если вы вызовете $category->removeProduct($product)
, category_id
в этом Product
будет установлен null
в DB.
Warning
Пожалуйста, имейте в виду, что обратная сторона может быть связана с большим количеством записей.
То есть может быть большое количество товаров с одной и той же категорией.
В этом случае $this->products->contains($product)
может привести к нежелательным запросам к базе данных
и очень большому потреблению памяти, с риском возникновения трудноотлаживаемых ошибок «Out of memory».
Поэтому убедитесь, нужна ли вам обратная сторона, и проверьте, может ли сгенерированный код привести к подобным проблемам.
Но, вместо установки category_id
как null, что, если вы хотите, чтобы Product
был удалён, если он станет "сиротой" (orphan) (т.е. без Category
)? Чтобы выбрать такое
поведение, используйте опцию orphanRemoval внутри Category
:
1 2 3 4 5 6
// src/Entity/Category.php
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)]
private array $products;
Благодаря этому, если Product
удаляется из Category
, он будет
полностью удалён из базы данных.
Больше информации об ассоциациях
Этот раздел был вступлением к одному распространённому типу отношений entity, отношению один-ко-многим. Для более подробных деталей и примеров того, как использовать другие типы отношений (например, один-к-одному, многие-ко-многим), смотрите документацию об Association Mapping Doctrine.
Note
Если вы используете аннотации, вам понадобится добавить ко всем
аннотациям префикс @ORM\
(например, @ORM\OneToMany
), который
не упомянут в документации Doctrine.