Дата обновления перевода: 2020-01-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

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)

Это сгенерирует новый класс entity (сущности):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/Entity/Category.php
// ...

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

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

    // ... getters and setters
}

Mapping (отображение) отношения 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

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)

Это внесло изменения в две 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
    // src/Entity/Product.php
    
    // ...
    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;
        }
    }
    
  • 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>
    

Это отображение многие-к-одному является обязательным. Оно говорит 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
    // src/Entity/Category.php
    
    // ...
    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() and removeProduct() were also added
    }
    
  • 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"/>
    
            <!--
                don't forget to init the collection in
                the __construct() method of the entity
            -->
        </entity>
    </doctrine-mapping>
    

Отображение 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
// ...

 use App\Entity\Category;
 use App\Entity\Product;
 use Symfony\Component\HttpFoundation\Response;

 class ProductController extends AbstractController
 {
     /**
      * @Route("/product", name="product")
      */
     public function index()
     {
         $category = new Category();
         $category->setName('Computer Peripherals');

         $product = new Product();
         $product->setName('Keyboard');
         $product->setPrice(19.99);
         $product->setDescription('Ergonomic and stylish!');

         // relates this product to the category
         $product->setCategory($category);

         $entityManager = $this->getDoctrine()->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 заботится обо всём остальном при сохранении.

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use App\Entity\Product;
// ...

public function show($id)
{
    $product = $this->getDoctrine()
        ->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, то вы также можете запросить в обратном направлении:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public function showProducts($id)
{
    $category = $this->getDoctrine()
        ->getRepository(Category::class)
        ->find($id);

    $products = $category->getProducts();

    // ...
}

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

Такая "ленивая загрузка" возможна потому что, когда это необходимо, Doctrine возвращает "прокси-объект" вместо настоящего объекта. Еще раз посмотрите на пример, показанный выше:

1
2
3
4
5
6
7
8
9
$product = $this->getDoctrine()
    ->getRepository(Product::class)
    ->find($id);

$category = $product->getCategory();

// prints "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
// src/Repository/ProductRepository.php
public function findOneByIdJoinedToCategory($productId)
{
    $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
public function show($id)
{
    $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

 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
// src/Entity/Category.php

// ...
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:

1
2
3
4
5
6
7
8
// src/Entity/Category.php

// ...

/**
 * @ORM\OneToMany(targetEntity="App\Entity\Product", mappedBy="category", orphanRemoval=true)
 */
private $products;

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

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

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

Note

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

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