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

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

Начните с создания сущности Category. Так как вы знаете, что в итоге вам нужно сохранять объекты категории через Doctrine, вы можете позволить Doctrine создать этот класс для вас.

1
2
3
$ php bin/console doctrine:generate:entity --no-interaction \
    --entity="AppBundle:Category" \
    --fields="name:string(255)"

Эта команда генерирует для вас сущность Category с полями id и name ис ассоциированными геттер и сеттер функциями.

Метаданные отображения отношений

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

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

Чтобы создать отношения между сущностями Product и Category, просто создайте свойство класса Product, со следующими аннотациями:

  • Annotations
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    // src/AppBundle/Entity/Product.php
    
    // ...
    class Product
    {
        // ...
    
        /**
         * @ORM\ManyToOne(targetEntity="Category", inversedBy="products")
         * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
         */
        private $category;
    }
    
  • YAML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    # src/AppBundle/Resources/config/doctrine/Product.orm.yml
    AppBundle\Entity\Product:
        type: entity
        # ...
        manyToOne:
            category:
                targetEntity: Category
                inversedBy: products
                joinColumn:
                    name: category_id
                    referencedColumnName: id
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <!-- src/AppBundle/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="AppBundle\Entity\Product">
            <!-- ... -->
            <many-to-one
                field="category"
                target-entity="Category"
                inversed-by="products"
                join-column="category">
    
                <join-column name="category_id" referenced-column-name="id" />
            </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
    // src/AppBundle/Entity/Category.php
    
    // ...
    use Doctrine\Common\Collections\ArrayCollection;
    
    class Category
    {
        // ...
    
        /**
         * @ORM\OneToMany(targetEntity="Product", mappedBy="category")
         */
        private $products;
    
        public function __construct()
        {
            $this->products = new ArrayCollection();
        }
    }
    
  • YAML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    # src/AppBundle/Resources/config/doctrine/Category.orm.yml
    AppBundle\Entity\Category:
        type: entity
        # ...
        oneToMany:
            products:
                targetEntity: 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/AppBundle/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="AppBundle\Entity\Category">
            <!-- ... -->
            <one-to-many
                field="products"
                target-entity="Product"
                mapped-by="category" />
    
            <!--
                don't forget to init the collection in
                the __construct() method of the entity
            -->
        </entity>
    </doctrine-mapping>
    

В то время, как отображение многие-к-одному, показанное ранее, было обязательным, это отображение один-ко-многим не обязательное. Оно включено здесь, чтобы помочь продемонстрировать весь спектр возможностей усправления отношениями Doctrine. К тому же, в контексте этого приложения, скорее всего будет удобно, чтобы каждый объект Category``автоматически имел коллекцию, связанных с ним объектов ``Product.

Note

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

Чтобы понять использование inversedBy и mappedBy, смотрите документацию Doctrine `Обновления ассоциаций`_.

Tip

Значение targetEntity в метаданных, использованное выше, может ссылаться на любую сущность с валидным пространством имён, а не только на сущности, определённые в том же пространстве имён. Чтобы создать отношения с сущностью, определённой в другом классе или пакете, введите полное пространство имён в качестве targetEntity.

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

1
$ php bin/console doctrine:generate:entities AppBundle

На секунду забудьте о метаданных Doctrine. Теперь у вас есть два класса - Product и Category, с природным отношением многие-к-одному. Класс Product содержит единственный объект Category, а класс Category содержит коллекцию объектов Product. Другими словами, вы построили ваши классы так, как это имеет смысл для вашего приложения. Тот факт, что данные должны сохраняться в базу данных, всегда вторичен.

Теперь, пересмотрите метаданные над свойством $category сущности Product. Они сообщают Doctrine, что связанный класс - это Category, и что id связанной записи категории должен храниться в поле category_id таблицы product.

Другими словами, связанный объект Category будет храниться в свойстве $category, но за кулисами Doctrine будет сохранять эти отношения, храня id категории в столбце category_id таблицы product.

../_images/mapping_relations.png

Метаданные над свойством $products сущности Category менее сложные. Они просто говорят Doctrine посмотреть на свойство Product.category, чтобы понять, как отображены отношения.

Перед тем, как продолжить, убедитесь в том, что вы сообщили Doctrine, чтобы она дбавила новую таблицу``category``, новый столбец product.category_id, и новый внешний ключ:

1
$ php bin/console doctrine:schema:update --force

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

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

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

use AppBundle\Entity\Category;
use AppBundle\Entity\Product;
use Symfony\Component\HttpFoundation\Response;
use Doctrine\ORM\EntityManagerInterface;

class DefaultController extends Controller
{
    public function createProductAction(EntityManagerInterface $em)
    {
        $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->persist($category);
        $em->persist($product);
        $em->flush();

        return new Response(
            'Saved new product with id: '.$product->getId()
            .' and new category with id: '.$category->getId()
        );
    }
}

Теперь к таблицам category и product добавлена одна строка. Столбец product.category_id нового продукта установлен так же, как и id новой категории. Doctrine управляет сохранением этих отношений для вас.

Возвращение связанных объектов

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use Doctrine\ORM\EntityManagerInterface;

public function showAction($productId, EntityManagerInterface $em)
{
    $product = $em->getRepository('AppBundle:Product')
        ->find($productId);

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

    // ...
}

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

../_images/mapping_relations_proxy.png

Важен также тот факт, что у вас есть лёгкий доступ к связанным категориям продукта, но данные категории на самом деле не изымаются, пока вы не запросите категорию (т.е. происходит "ленивая загрузка").

Вы также можете сделать запрос в другом направлении:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use Doctrine\ORM\EntityManagerInterface;

public function showProductsAction($categoryId, EntityManagerInterface $em)
{
    $category = $em->getRepository('AppBundle:Category')
        ->find($categoryId);

    $products = $category->getProducts();

    // ...
}

В этом случае, происходят те же вещи: вы вначале запрашиваете один объект Category, а потом Doctrine создаёт второй запрос, чтобы извлечь связанные объекты Product, но только когда/если вы их запросите (например, когда вы вызываете getProducts()). Переменная $products - это массив всех объектов Product, которые имеют отношение к данному объекту Category через их значение category_id.

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

1
2
3
4
5
6
7
8
$product = $em->getRepository('AppBundle:Product')
    ->find($productId);

$category = $product->getCategory();

// отображает "Proxies\AppBundleEntityCategoryProxy"
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
14
15
16
17
// src/AppBundle/Repository/ProductRepository.php
use Doctrine\ORM\EntityManagerInterface;

public function findOneByIdJoinedToCategory($productId)
{
    $query = $em->createQuery(
        'SELECT p, c FROM AppBundle:Product p
        JOIN p.category c
        WHERE p.id = :id'
    )->setParameter('id', $productId);

    try {
        return $query->getSingleResult();
    } catch (\Doctrine\ORM\NoResultException $e) {
        return null;
    }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use Doctrine\ORM\EntityManagerInterface;

public function showAction($productId, EntityManagerInterface $em)
{
    $product = $em->getRepository('AppBundle:Product')
        ->findOneByIdJoinedToCategory($productId);

    $category = $product->getCategory();

    // ...
}

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

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

Note

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

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