Дата обновления перевода: 2022-01-09

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

// src/Entity/Category.php
namespace App\Entity;

// ...

class Category
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string")
     */
    private $name;

    // ... геттеры и сеттеры
}

Отображение отношения ManyToOne

В этом примере, каждая категория может быть ассоциирована со многими продуктами. Но каждый продукт может быть ассоциирован только с одной категорией. Это отношение можно подытожить так: многие продукты к одной категории (или, равнозначно - одна категория ко многим продуктам).

С точки зрения entity Product - это отношения многие-к-одному. С точки зрения сущности Category - это отношения один-ко-многим.

Чтобы отобразить (map) это в 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 снова, чтобы закончить)

Это внесло изменения в две entity. Сначала добавилось свойство category в entity Product (и методы getters/setters):

  • Annotations
     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
    // src/Entity/Product.php
    namespace App\Entity;
    
    // ...
    class Product
    {
        // ...
    
        /**
         * @ORM\ManyToOne(targetEntity="App\Entity\Category", inversedBy="products")
         */
        private $category;
    
        public function getCategory(): ?Category
        {
            return $this->category;
        }
    
        public function setCategory(?Category $category): self
        {
            $this->category = $category;
    
            return $this;
        }
    }
    
  • Attributes
     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;
    
        public function getCategory(): ?Category
        {
            return $this->category;
        }
    
        public function setCategory(?Category $category): self
        {
            $this->category = $category;
    
            return $this;
        }
    }
    
  • YAML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    # src/Resources/config/doctrine/Product.orm.yml
    App\Entity\Product:
        type: entity
        # ...
        manyToOne:
            category:
                targetEntity: App\Entity\Category
                inversedBy: products
                joinColumn:
                    nullable: false
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    <!-- 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 также добавит свойство products к классу Category, который будет содержать эти объекты:

  • Annotations
     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
    // src/Entity/Category.php
    namespace App\Entity;
    
    // ...
    use Doctrine\Common\Collections\ArrayCollection;
    use Doctrine\Common\Collections\Collection;
    
    class Category
    {
        // ...
    
        /**
         * @ORM\OneToMany(targetEntity="App\Entity\Product", mappedBy="category")
         */
        private $products;
    
        public function __construct()
        {
            $this->products = new ArrayCollection();
        }
    
        /**
         * @return Collection|Product[]
         */
        public function getProducts(): Collection
        {
            return $this->products;
        }
    
        // addProduct() и removeProduct() также были добавлены
    }
    
  • Attributes
     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 $products;
    
        public function __construct()
        {
            $this->products = new ArrayCollection();
        }
    
        /**
         * @return Collection|Product[]
         */
        public function getProducts(): Collection
        {
            return $this->products;
        }
    
        // addProduct() и removeProduct() также были добавлены
    }
    
  • YAML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    # src/Resources/config/doctrine/Category.orm.yml
    App\Entity\Category:
        type: entity
        # ...
        oneToMany:
            products:
                targetEntity: App\Entity\Product
                mappedBy: category
    # Don't forget to initialize the collection in
    # the __construct() method of the entity
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!-- 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 - необязательно: добавляйте его только если вы хотите иметь доступ к 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 готова сохранять наши отношения!

Сохранение связанных сущностей

Теперь вы можете увидеть этот новый код в действии! Представьте, что вы внутри контроллера:

// src/Controller/ProductController.php
namespace App\Controller;

// ...
use App\Entity\Category;
use App\Entity\Product;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\HttpFoundation\Response;

class ProductController extends AbstractController
{
    /**
     * @Route("/product", name="product")
     */
    public function index(ManagerRegistry $doctrine): 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 = $doctrine->getManager();
        $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 управляет сохранением этих отношений за вас:

../_images/mapping_relations.png

Если вы новичок в ORM, то это самый сложный концепт: вам нужно перестать думать о вашей DB, а вместо этого думать только о ваших объектах. Вместо установки числового id категории в Product, вы устанавливаете весь объект Category. Doctrine заботится обо всём остальном при сохранении.

Извлечение связанных объектов

Когда вам надо вернуть ассоциированные объекты, ваш ход работы выглядит так же, как и раньше. Вначале, вызовите объект $product, а потом получите доступ к связанному с ним объекту Category:

// src/Controller/ProductController.php
namespace App\Controller;

use App\Entity\Product;
// ...

class ProductController extends AbstractController
{
    public function show(ManagerRegistry $doctrine, int $id): Response
    {
        $product = $doctrine->getRepository(Product::class)->find($id);
        // ...

        $categoryName = $product->getCategory()->getName();

        // ...
    }
}

В этом примере, вы вначале запрашиваете объект Product, основываясь на его id. Это запускает запрос только для данных продукта и насыщает (hydrates) объект $product. Позже, когда вы вызовете $product->getCategory()->getName(), Doctrine молча создаст второй запрос, чтобы найти Category, которая связана с этим Product. Она подготавливает объект $category и возвращает его вам.

../_images/mapping_relations_proxy.png

Важно то, что у вас есть доступ к category, связанной с product, но данные category на самом деле не запрашиваются, пока вы не спросите о них (“ленивая загрузка” - lazy load).

Так как мы отобразили необязательную сторону OneToMany, то вы также можете запросить в обратном направлении:

// src/Controller/ProductController.php

// ...
class ProductController extends AbstractController
{
    public function showProducts(ManagerRegistry $doctrine, int $id): Response
    {
        $category = $doctrine->getRepository(Category::class)->find($id);

        $products = $category->getProducts();

        // ...
    }
}

В этом случае, происходят те же вещи: вы вначале запрашиваете один объект Category. Далее, только когда (и если) вы запрашиваете products, Doctrine делает второй запрос, чтобы получить связанные объекты Product. Этого дополнительного запроса можно избежать, добавив JOIN.

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

В вышеописанных примерах, было сделано два запроса - один к оригинальному объекту (например, Category) и один к связанному(ым) объекту(ам), (например объектам Product).

Tip

Помните, что вы можете увидеть все запросы, сделанные во время запроса с помощью панели инструментов веб-отладки (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(int $id): Response
    {
        $product = $this->getDoctrine()
            ->getRepository(Product::class)
            ->findOneByIdJoinedToCategory($id);

        $category = $product->getCategory();

        // ...
    }
}

Установка информации с обратной стороны

До этого момента вы обновляли отношения, вызывая $product->setCategory($category). Это не случайно! Каждое отношение имеет две стороны: в этом примере Product.category - это владеющая (owning) сторона, а Category.products - инверсная (inverse) сторона.

Для обновления отношения в DB, вам нужно установить отношение на владеющей стороне. Владеющая сторона - всегда та, где уствновлена связь ManyToOne (для отношений ManyToMany вы можете выбрать какая сторона будет владеющей).

Значит ли это, что невозможно вызвать $category->addProduct() или $category->removeProduct() для обновления DB? На самом деле, это возможно благодаря умному коду, который сгенерировала команда 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), который обновляет владеющую сторону. Теперь, когда вы сохраняетесь, отношения будут обновляться в DB.

Что на счёт удаления 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);
            // 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 в DB.

Но, вместо установки category_id как null, что, если вы хотите, чтобы Product был удалён, если он станет “сиротой” (orphan) (т.е. без Category)? Чтобы выбрать такое поведение, используйте опцию orphanRemoval внутри Category:

  • Annotations
    1
    2
    3
    4
    5
    6
    7
    8
    // src/Entity/Category.php
    
    // ...
    
    /**
     * @ORM\OneToMany(targetEntity="App\Entity\Product", mappedBy="category", orphanRemoval=true)
     */
    private $products;
    
  • Attributes
    1
    2
    3
    4
    5
    6
    // src/Entity/Category.php
    
    // ...
    
    #[ORM\OneToMany(targetEntity: Product::class, mappedBy: "category", orphanRemoval: true)]
    private $products;
    

Благодаря этому, если Product удаляется из Category, он будет полностью удалён из базы данных.

Больше информации об ассоциациях

Этот раздел был вступлением к одному распространённому типу отношений entity, отношению один-ко-многим. Для более подробных деталей и примеров того, как использовать другие типы отношений (например, один-к-одному, многие-ко-многим), смотрите документацию об Association Mapping Doctrine.

Note

Если вы используете аннотации, вам понадобится добавить ко всем аннотациям префикс @ORM\ (например, @ORM\OneToMany), который не упомянут в документации Doctrine.

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.