Як працювати з асоціаціями / відносинами Doctrine
Дата оновлення перекладу 2024-05-27
Як працювати з асоціаціями / відносинами Doctrine
Screencast
Надаєте перевагу відео? Подвість серію скрінкастів Mastering Doctrine Relations
Існує два основних типи відносин / асоціацій:
ManyToOne
/OneToMany
-
Найбільш розповсюджені відносини, відображені в базі даних за допомогою
стовпчику з іноземним ключем (наприклад, стовпчику
category_id
в таблиціproduct
). Насправді, це один тип асоціації, який розглядається з двох різних сторін відносин. ManyToMany
- Використовує проміжкову таблицю та необхідний, коли обидві сторони відносин можуть мати множину з іншої сторони (наприклад, "учні" та "предмети": кожний учень в багатьох предметах, і кожний клас має множину учнів).
Для початку, вам необхідно визначити, які відносини використовувати. Якщо обидві
сторони відносин будуть містити множину з іншої сторони (наприклад, "учні" та
"предмети"), то вам необхідно використовувати відносини ManyToMany
. В інших
випадках, вам скоріше потрібні ManyToOne
.
Tip
Також існують відносини OneToOne (наприклад, один Користувач має один
Профіль, та навпаки). На практиці, їх використання схоже на ManyToOne
.
Асоціація ManyToOne / OneToMany
Уявіть, що кожний продукт у вашому додатку належить тільки до однієї конкретної
категорії. В цьому випадку, вам знадобиться клас Category
, та спосіб зв'язати
об'єкт Product
з об'єктом Category
.
Почніть зі створення сутності Category
з полем name
:
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 знов, щоб закінчити)
Це згенерує новий клас сутності:
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
. Використовуючи Компонент Symfony Uid,
це згенерує сутність з типом id
як Uuid
або Ulid замість int
.
Мапування відносин ManyToOne
В цьому прикладі, кожна категорія може бути асоційована з багатьма продуктами. Але кожний продукт може бути асоційований лише з однією категорією. Ці відносини можна підсумувати так: багато продуктів до однієї категорії (або, рівнозначно - одна категорія бо багатьох продуктів).
З точки зору сутності Product
- це відносини багато-до-одного. З точки зору
сутності Category
- це відносини один-до-багатьох.
Щоб відобразити це в базі даних, спочатку створіть властивість 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
(і методи гетерів/сетерів):
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() and removeProduct() were also added
}
Мапування ManyToOne
, продемонстроване раніше, обов'язкове. Але, відносини
OneToMany
- необов'язкові: додавайте їх лише якщо ви хочете мати доступ до
продуктів, які пов'язані з категорією (це одне з питань, яке ставить вам make:entity
).
В цьому прикладі буде корисно мати можливість викликати $category->getProducts()
.
Якщо ви не хочете цього, то вам не потрібне налаштування inversedBy
або mappedBy
.
Ваша база даних налаштована! Тепер, виконайте міграції як зазвичай:
1 2
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
Завдяки відносинам, це створить стовпчик з іноземним ключем 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
для нового продукту встановлюється як
id
нової категорії. Doctrine керує збереженням цих відносин за вас:
Якщо ви новачок в ORM, то це найкладніший концепт: вам необхідно перестати думати про
вашу базу даних, а замість цього думати лише про ваші об'єкти. Замість установки
чисельного 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
.
Це запускає запит лише для даних продукту та насичує об'єкт $product
. Пізніше, коли
ви викличете $product->getCategory()->getName()
, Doctrine мовчки створить другий запит,
щоб знайти 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
. Далі, лише коли (і якщо) ви запитуєте продукти, Doctrine робить
другий запит, щоб отримати пов'язані об'єкти Product
. Цього додаткового запиту
можна уникнути, додавши JOIN.
Об'єднання пов'язаних записів
У прикладах вище було зроблено два запити - один до оригінального об'єкта
(наприклад, Category
) та один до пов'язаного(их) об'єкта(ів), (наприклад,
об'єктам Product
).
Tip
Пам'ятайте, що ви можете побачити всі запити, зроблені під час запиту, за допомогою панелі інструментів веб-налагодження.
Авжеж, якщо ви заздалегідь знаєте, що вам знадобиться отримати доступ до обох
об'єтків, ви можете уникнути другого запиту, шляхом створення об'єднання в
оригінальному запиті. Додайте наступний метод до класу 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
- інверсна сторона.
Для оновлення відносин в базі даних, вам потрібно встановити відносини на володіючій
стороні. Володіюча сторона - завжди та, де встановлений зв'язок ManyToOne
(для
відносин ManyToMany
ви можете обрати яка сторона буде володіючою).
Чи означає це, що неможливо викликати $category->addProduct()
або
$category->removeProduct()
для оновлення бази даних? Насправді, це
можливо завдяки розумному коду, який згенерувала команда 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)
, який оновлює володіючу сторону.
Тепер, коли ви зберігаєтеся, відносини будуть оновлюватися в базі даних.
Що на рахунок видалення 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);
// 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
в базі даних.
Warning
Зверніть увагу, що інверсна сторона може бути пов'язана з великою кількістю записів.
Тобто, може бути велика кількість продуктів з однаковою категорією.
У цьому випадку $this->products->contains($product)
може призвести до небажаних запитів до
бази даних і дуже високого споживання пам'яті з ризиком виникнення помилок "Out of memory",
які важко налагодити.
Тому переконайтеся, що вам потрібна інверсна частина, і перевірте, чи може згенерований код призвести до таких проблем.
Але, замість установки category_id
як null, що якщо ви хочете, щоб Product
було
видалено, якщо він стане "сиротою" (тобто без 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
, він буде повністю
видалений з бази даних.
Більше інформації про асоціації
Цей розділ був вступом до одного розповсюдженого типу відносин сутностей, відносинам один-до-багатьох. Для більш докладних деталей та прикладів того, як використовувати інші типи відносин (наприклад, один-до-одного, багато-до-багатьох), дивіться документацію про відображення асоціацій Doctrine.
Note
Якщо ви використовуєте анотації, вам знадобиться додати до всіх анотацій
префікс @ORM\
(наприклад, @ORM\OneToMany
), який не згадано в
документації.