Як працювати з асоціаціями / відносинами 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.

Код всередині __construct() важливий: властивість $products має бути об'єктом колекції, який реалізує інтерфейс Doctrine Collection. В цьому випадку, використовується об'єкт ArrayCollection. Він виглядає та діє майже так само як масив, але має додаткову гнучкість. Просто уявіть, що це array, і все буде добре.

Ваша база даних налаштована! Тепер, виконайте міграції як зазвичай:

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 турбується про все інше при збереженні.

Чи можете ви викликати $category->addProduct() для зміни видносин? Да, але тільки тому, що команда make:entity допомогла нам. Для деталей див.: associations-inverse-side.

Вилучення пов'язаних об'єктів

Коли вам потрібно повернути асоційовані об'єкти, ваш хід роботи виглядає так само, як і раніше. Спочатку викличте об'єкт $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.

Таке "ліниве завантаження" можливе тому, що коли це необхідно, Doctrine повертає "проксі-об'єкт" замість справжнього об'єкта. Ще раз подивіться на приклад вище:

1
2
3
4
5
6
7
$product = $productRepository->find($id);

$category = $product->getCategory();

// виводить "Proxies\AppEntityCategoryProxy"
dump(get_class($category));
die();

Цей проксі-об'єкт розширює справжній об'єкт Category, і виглядає та поводить себе точно так само. Різниця в тому, що використовуючи об'єкт проксі, Doctrine може вікладсти запит справжніх даних Category до тих пір, поки вам вони дійсно не знадобляться (наприклад, ви викличете $category->getName()).

Класи проксі генеруються Doctrine і зберігаються в каталозі кешу. Ви скоріш за все ніколи не помітите, що ваш об'єкт $category насправді - об'єкт проксі.

В наступній частині, коли ви будете повертати дані продукту та категорії одночасно (за допомогою join), Doctrine буде повертати справжній об'єкт Category, так як ліниве завантаження ні для чого не знадобиться.

Об'єднання пов'язаних записів

У прикладах вище було зроблено два запити - один до оригінального об'єкта (наприклад, 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), який не згадано в документації.