Как работать с ассоциациями / отношениями Doctrine

Существует два основных типа отношений / ассциаций:

ManyToOne / OneToMany
Наиболее распространённые отношения, отображённые в БД с помощью простого внешнего столбца ключей (например, столбца category_id в таблице product). На самом деле, это один тип ассоциации, рассматриваемый с двух разных сторон отношений.
ManyToMany
Использует промежуточную таблицу и нужен, когда обе стороны отношений могут иметь множество с другой стороны (например, "ученики" и "предметы": каждый ученик во многих предметах, и каждый класс имеет множество учеников).

Для начала, вам нужно определить, какое отношение использовать. Если обе стороны отношений будут содержать множество с другой стороны (например, "ученики" и "предметы"), то вам нужно использовать отношение ManyToMany. В других случаях, вам скорее нужен ManyToOne.

Tip

Также существуют отношения OneToOne (например, один Пользователь имеет один Профиль и наоборот). На практике, их использование схоже с ManyToOne.

Ассоциация ManyToOne / OneToMany

Представьте, что каждый продукт в вашем приложении принадлежит только к одной конкретной категории. В этом случае, вам понадобится класс Category, и способ связать объект Product с объектом Category.

Начните с создания сущности Category:

1
$ php bin/console make:entity Category

Далее, добавьте поле name к новому классу Category:

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

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

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

    // ... getters and setters
}

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

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

С точки зрения сущности Product - это отношения многие-к-одному. С точки зрения сущности Category - это отношения один-ко-многим. Это важно, так как относительная природа отношений определяет, какие метаданные использовать для отображения. Она также определяет, какой класс должен содержать отсылку к другому классу.

Чтобы отобразить это, для начала создайте свойство category в класее Product с аннотацией ManyToOne:

  • Annotations
     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
    
    // ...
    class Product
    {
        // ...
    
        /**
         * @ORM\ManyToOne(targetEntity="App\Entity\Category", inversedBy="products")
         * @ORM\JoinColumn(nullable=true)
         */
        private $category;
    
        public function getCategory(): Category
        {
            return $this->category;
        }
    
        public function setCategory(Category $category)
        {
            $this->category = $category;
        }
    }
    
  • 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: true
    
  • 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
            http://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="true" />
            </many-to-one>
        </entity>
    </doctrine-mapping>
    

Это отображение многие-к-одному является обязательным. Оно говорит Doctrine использовать колонку таблицы product, чтобы соотнести каждую запись в этой таблице с записью в таблице category.

Далее, так как один объект Category будет относиться ко многим объектам Product, добавьте свойство 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
    // 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()
        {
            return $this->products;
        }
    }
    
  • 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
            http://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 - необязательно: добавляйте его только если вы хотите иметь доступ к товарам, которые связаны с категорией. В этом примере, будет полезно иметь возможность вызвать $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
// ...

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

class ProductController extends Controller
{
    /**
     * @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!');

        // связывает этот продукт с категорией
        $product->setCategory($category);

        $em = $this->getDoctrine()->getManager();
        $em->persist($category);
        $em->persist($product);
        $em->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 управляет сохранением этих отношений за вас:

../_images/mapping_relations.png

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

Можете ли вы также вызвать $category->setProducts(), чтобы установить отношения? На самом деле - нет! Ранее, вы не добавили метод setProducts() в Category. Это специально: вы можете устанавливать данные только на владеющей стороне отошений. Другими словами, если вы вызовете только $category->setProducts(), то это полностью игнорируется при сохранении. Чтобы узнать больше, см.: 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 showAction($id)
{
    $product = $this->getDoctrine()
        ->getRepository(Product::class)
        ->find($id);

    // ...

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

    // ...
}

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

../_images/mapping_relations_proxy.png

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

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

    $products = $category->getProducts();

    // ...
}

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

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

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

$category = $product->getCategory();

// отображает "Proxies\AppEntityCategoryProxy"
dump(get_class($category));
die();

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

Классы прокси генерируются Doctrine и хранятся в каталоге кеша. Вы скорее всего никогда не заметите, что ваш объект $category на самом деле - объект прокси.

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

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

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

Tip

Помните, что вы можете увидеть все запросы, сделанные во время запроса с помощью панели инструментов веб-отладки.

Конечно, если вы заранее знаете, что вам понадобится получить доступ к обоим объектам, вы можете избежать ворого запроса, путём создания объединения в оригинальном запросе. Добавьте следующий метод к классу ProductRepository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// src/Repository/ProductRepository.php
public function findOneByIdJoinedToCategory($productId)
{
    return $this->createQueryBuilder('p')
        // p.category ссылается на свойство "category" в товаре
        ->innerJoin('p.category', 'c')
        // выбирает все данные категории, чтобы избежать запрос
        ->addSelect('c')
        ->andWhere('p.id = :id')
        ->setParameter('id', $productId)
        ->getQuery()
        ->getOneOrNullResult();
}

Это всё равно вернёт массив объектов Product. Но теперь, когда вы вызываете $product->getCategory() и используете эти данные, второй запрос не создаётся.

Теперь вы можете использовать этот метод в вашем контроллере, чтобы создать запрос к объекту Product и связанному с ним Category с помощью единого запроса:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public function showAction($id)
{
    $product = $this->getDoctrine()
        ->getRepository(Product::class)
        ->findOneByIdJoinedToCategory($id);

    $category = $product->getCategory();

    // ...
}

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

До этого момента вы обновляли отношения, вызывая $product->setCategory($category). Это не случайно: вы должны установить отношения на владеющей стороне. Владеющая стороная всегда находится там, где установлено отображение ManyToOne (для отношения ``ManyToMany``вы можете выбрать, какая сторона является владеющей).

Значит ли это, что вызвать $category->setProducts() невозможно? На самом деле, возможно, с помощью написания умных методов. Для начала, вместо метода setProducts(), создайте метод addProduct():

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

// ...
class Category
{
    // ...

    public function addProduct(Product $product)
    {
        if ($this->products->contains($product)) {
            return;
        }

        $this->products[] = $product;
        // установите *владеющую* сторону!
        $product->setCategory($this);
    }
}

Вот и всё! Ключом является $product->setCategory($this), который устанавливает владеющую стороны. Теперь, когда вы сохраняетесь, отношения будут обновляться в БД.

Что на счёт удаления Product из Category? Добавьте метод removeProduct():

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

// ...
class Category
{
    // ...

    public function removeProduct(Product $product)
    {
        $this->products->removeElement($product);
        // установите владеющую сторону, как null
        $product->setCategory(null);
    }
}

Чтобы это работало, теперь вам нужно позволить, чтобы null передавалось Product::setCategory():

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

// ...
class Product
{
    // ...

-     public function getCategory(): Category
+     public function getCategory(): ?Category
    // ...

-     public function setCategory(Category $category)
+     public function setCategory(Category $category = null)
    {
        $this->category = $category;
    }
}

И это всё! Теперь, если вы вызовете $category->removeProduct($product), category_id в этом Product будет установлен в БД, как null.

Но, вместо установки category_id, как null, что, если вы хотите, чтобы Product был удалён, если он станет "сиротой" (т.е. без 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, он будет полностью удалён из базы данных.

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

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

Note

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

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