Звʼязки сутностей — Symfony
Скрінкаст
Віддаєте перевагу відеоурокам? Перегляньте серію скрінкастів Mastering Doctrine Relations.
Є два основні типи звʼязків/асоціацій:
ManyToOne / OneToMany
: Найпоширеніший звʼязок, який у базі даних відображається колонкою зовнішнього ключа (наприклад, колонкою category_id у таблиці product). Насправді це лише один тип асоціації, але побачений з двох різних боків звʼязку.
ManyToMany
: Використовує проміжну таблицю і потрібен тоді, коли кожен бік звʼязку може мати багато обʼєктів іншого боку (наприклад, «студенти» і «класи»: кожен студент відвідує багато класів, і в кожному класі багато студентів).
Спершу потрібно визначити, який звʼязок використовувати. Якщо обидва боки звʼязку міститимуть багато обʼєктів іншого боку (наприклад, «студенти» і «класи»), потрібен звʼязок ManyToMany. Інакше вам, найімовірніше, потрібен ManyToOne.
Порада
Існує також звʼязок OneToOne (наприклад, один User має один Profile і навпаки). На практиці його використання схоже на
ManyToOne.
Асоціація ManyToOne / OneToMany
Припустімо, що кожен продукт у вашому застосунку належить рівно до однієї категорії. У цьому випадку вам знадобиться клас Category і спосіб повʼязати обʼєкт Product з обʼєктом Category.
Почніть зі створення сутності Category з полем name:
$ 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)
Це згенерує новий клас сутності:
// 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;
// ... геттери та сеттери
}
Порада
Ви можете передати команді
make:entityпараметр--with-uuidабо--with-ulid. Використовуючи компонент Symfony Uid, це згенерує сутність, у якої типidбуде Uuid або Ulid замістьint.
Мапінг звʼязку ManyToOne
У цьому прикладі кожна категорія може бути повʼязана з багатьма продуктами. Але кожен продукт може бути повʼязаний лише з однією категорією. Цей звʼязок можна підсумувати так: багато продуктів до однієї категорії (або, що те саме, одна категорія до багатьох продуктів).
З погляду сутності Product це звʼязок many-to-one. З погляду сутності Category це звʼязок one-to-many.
Щоб це змапити, спершу створіть властивість category у класі Product з атрибутом ManyToOne. Це можна зробити вручну або за допомогою команди make:entity, яка поставить вам кілька запитань про ваш звʼязок. Якщо ви не впевнені у відповіді — не хвилюйтеся! Ви завжди зможете змінити налаштування пізніше:
$ 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)
Це внесло зміни у дві сутності. По-перше, до сутності Product додано нову властивість category (а також методи-геттер і сеттер):
PHP-атрибути
// 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;
}
}
YAML
# src/Resources/config/doctrine/Product.orm.yml
App\Entity\Product:
type: entity
# ...
manyToOne:
category:
targetEntity: App\Entity\Category
inversedBy: products
joinColumn:
nullable: false
XML
<!-- 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>
Цей мапінг ManyToOne є обовʼязковим. Він каже Doctrine використовувати колонку category_id у таблиці product, щоб повʼязати кожен запис цієї таблиці із записом у таблиці category.
Далі, оскільки один обʼєкт Category буде повʼязаний з багатьма обʼєктами Product, команда make:entity також додала до класу Category властивість products, яка міститиме ці обʼєкти:
PHP-атрибути
// 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() також були додані
}
YAML
# src/Resources/config/doctrine/Category.orm.yml
App\Entity\Category:
type: entity
# ...
oneToMany:
products:
targetEntity: App\Entity\Product
mappedBy: category
# Не забудьте ініціалізувати колекцію в
# методі __construct() сутності
XML
<!-- 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"/>
<!--
не забудьте ініціалізувати колекцію в
методі __construct() сутності
-->
</entity>
</doctrine-mapping>
Показаний раніше мапінг ManyToOne є обовʼязковим. Але цей OneToMany — необовʼязковий: додавайте його, лише якщо хочете мати доступ до продуктів, повʼязаних із категорією (це одне із запитань, які ставить make:entity). У цьому прикладі можливість викликати $category->getProducts() буде корисною. Якщо вам це не потрібно, то не потрібні й налаштування inversedBy чи mappedBy.
Що це за штука ArrayCollection?
Код усередині
__construct()важливий: властивість$productsмає бути обʼєктом-колекцією, що реалізує інтерфейсCollectionвід Doctrine. У цьому випадку використовується обʼєкт ArrayCollection. Він виглядає і поводиться майже точно як масив, але має додаткову гнучкість. Просто уявіть, що цеarray, — і все буде гаразд.
Вашу базу даних налаштовано! Тепер запустіть міграції як зазвичай:
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
Завдяки звʼязку це створює колонку зовнішнього ключа category_id у таблиці product. Doctrine готова зберігати ваш звʼязок!
Збереження повʼязаних сутностей
Тепер ви можете побачити цей новий код у дії! Уявіть, що ви всередині контролера:
// 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 для нового продукту отримає значення id нової категорії. Doctrine керує збереженням цього звʼязку за вас:
Якщо ви новачок в ORM, це найважча концепція: вам треба перестати думати про базу даних і натомість думати лише про свої обʼєкти. Замість того щоб записувати цілочисельний id категорії в Product, ви записуєте цілий обʼєкт Category. Про решту Doctrine подбає під час збереження.
Оновлення звʼязку з оберненого боку
Чи можна також викликати
$category->addProduct(), щоб змінити звʼязок? Так, але лише тому, що вам допомогла командаmake:entity. Детальніше див.: Встановлення інформації з оберненого боку.
Отримання повʼязаних обʼєктів
Коли вам потрібно отримати повʼязані обʼєкти, ваш робочий процес виглядає так само, як і раніше. Спершу отримайте обʼєкт $product, а потім зверніться до повʼязаного з ним обʼєкта Category:
// 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. Це виконує запит, який отримує лише дані продукту й гідрує $product. Пізніше, коли ви викликаєте $product->getCategory()->getName(), Doctrine непомітно виконує другий запит, щоб знайти Category, повʼязану з цим Product. Вона готує обʼєкт $category і повертає його вам.
Важливо те, що ви маєте доступ до категорії, повʼязаної з продуктом, але дані категорії насправді не отримуються, доки ви не запитаєте категорію (тобто вони завантажуються «ліниво»).
Оскільки ми змапили необовʼязковий бік OneToMany, ви можете робити запити й у зворотному напрямку:
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function showProducts(CategoryRepository $categoryRepository, int $id): Response
{
$category = $categoryRepository->find($id);
$products = $category->getProducts();
// ...
}
}
У цьому випадку відбувається те саме: спершу ви робите запит на один обʼєкт Category. Потім, тільки коли (і якщо) ви звертаєтеся до продуктів, Doctrine виконує другий запит, щоб отримати повʼязані обʼєкти Product. Цього додаткового запиту можна уникнути, додавши JOIN.
Звʼязки та проксі-класи
Це «ліниве завантаження» можливе тому, що за потреби Doctrine повертає «проксі»-обʼєкт замість справжнього обʼєкта. Погляньте ще раз на приклад вище:
$product = $productRepository->find($id); $category = $product->getCategory(); // виводить "Proxies\AppEntityCategoryProxy" dump($category::class); die();Цей проксі-обʼєкт розширює справжній обʼєкт
Category, виглядає і поводиться точно як він. Різниця в тому, що завдяки проксі-обʼєкту Doctrine може відкласти запит справжніх данихCategoryдоти, доки ці дані вам справді знадобляться (наприклад, доки ви не викличете$category->getName()).Проксі-класи генеруються Doctrine і зберігаються в теці кешу. Ви, ймовірно, навіть не помітите, що ваш обʼєкт
$categoryнасправді є проксі-обʼєктом.У наступному розділі, коли ви отримуватимете дані продукту й категорії одразу (через join), Doctrine поверне справжній обʼєкт
Category, оскільки нічого не потрібно завантажувати ліниво.
Обʼєднання повʼязаних записів
У прикладах вище виконувалося два запити — один для початкового обʼєкта (наприклад, Category) і один для повʼязаного обʼєкта чи обʼєктів (наприклад, обʼєктів Product).
Порада
Памʼятайте, що всі запити, виконані під час запиту, можна побачити у веб-панелі налагодження (web debug toolbar).
Якщо ви наперед знаєте, що вам знадобиться доступ до обох обʼєктів, ви можете уникнути другого запиту, зробивши join у початковому запиті. Додайте наступний метод до класу ProductRepository:
// 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:
// 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 — це володіючий (owning) бік, а Category.products — обернений (inverse) бік.
Щоб оновити звʼязок у базі даних, ви мусите встановити звʼязок на володіючому боці. Володіючий бік — це завжди той, де задано мапінг ManyToOne (для звʼязку ManyToMany ви можете обрати, який бік буде володіючим).
Чи означає це, що неможливо викликати $category->addProduct() або $category->removeProduct(), щоб оновити базу даних? Насправді це можливо — завдяки хитрому коду, який згенерувала команда make:entity:
// 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), що встановлює володіючий бік. Завдяки цьому під час збереження звʼязок таки оновиться в базі даних.
А як щодо видалення Product з Category? Команда make:entity також згенерувала метод removeProduct():
// 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 у базі даних.
Попередження
Зверніть увагу, що обернений бік може бути повʼязаний із великою кількістю записів. Тобто може існувати велика кількість продуктів з однаковою категорією. У такому випадку
$this->products->contains($product)може призвести до небажаних запитів до бази даних і дуже високого споживання памʼяті з ризиком складних для налагодження помилок «Out of memory».Тому переконайтеся, що обернений бік вам справді потрібен, і перевірте, чи не може згенерований код спричинити такі проблеми.
Але що, якщо замість встановлення category_id у null ви хочете, щоб Product було видалено, якщо він стає «сиротою» (тобто без Category)? Щоб обрати таку поведінку, використайте опцію orphanRemoval всередині Category:
PHP-атрибути
// src/Entity/Category.php
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)]
private array $products;
Завдяки цьому, якщо Product буде видалено з Category, він буде повністю видалений з бази даних.
Докладніше про асоціації
Цей розділ був вступом до одного поширеного типу звʼязків між сутностями — звʼязку one-to-many. Докладніші відомості та приклади використання інших типів звʼязків (наприклад, one-to-one, many-to-many) див. у документації Doctrine з мапінгу асоціацій.
Зверніть увагу
Якщо ви використовуєте атрибути, до всіх атрибутів потрібно додавати префікс
#[ORM\](наприклад,#[ORM\OneToMany]), що не відображено в документації Doctrine.
Перекладаємо з офіційної документації, розділ за розділом, і не ховаємо недоперекладене. Помітили неточність у терміні чи реченні: напишіть, виправимо.