Как работать с ассоциациями / отношениями 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.

Код внутри __construct() важен: свойство $products должно быть объектом коллекции, реализующим интерфейс Doctrine Collection. В этом случае, используется объект ArrayCollection. Он выглядит и действует почти так же, как массив, но имеет дополнительную гибкость. Просто представьте, что это array, и всё будет хорошо.

Ваша 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 заботится обо всём остальном при сохранении.

Можтеле ли вы вызвать $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. Это запускает запрос только для данных продукта и насыщает (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.

Такая "ленивая загрузка" возможна потому что, когда это необходимо, 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 на самом деле - объект прокси.

В следующей части, когда вы будете возвращать данные product и category одновременно (с помощью join), Doctrine будет возвращать настоящий объект Category, так как ленивая загрузка ни для чего не потребуется.

Объединение связанных записей

В вышеописанных примерах, было сделано два запроса - один к оригинальному объекту (например, 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.