Doctrine 2 для Symfony 3.3.6: Создание сущности, ассоциации и рекурсивные связи

Создание сущности

Буду все консольные команды писать в манере, как если Composer не установлен в системе.

Установим для начала Symfony:

# Не забудьте на этом этапе указать верные пароль и логин для базы данных.
php composer.phar create-project symfony/framework-standard-edition ./gentlemans_set "v3.3.6"
# Переходим в папку проекта после установки
cd gentlemans_set/
# Запускаем создание базы данных
php bin/console doctrine:database:create

Кто как привык, но я не люблю писать собственноручно много кода. Если есть возможность использовать автогенерацию чего либо в Symfony, то я ее использую и вам советую, так как это минимизация человеческого фактора, да и просто разгружает ваш ум. Сгенерируем две простенькие сущности через консольные команды Symfony:

# Создаем сущность User
php bin/console doctrine:generate:entity --entity=AppBundle:User --fields="username:string(127) password:string(127)" -q
# Создаем сущность Product
php bin/console doctrine:generate:entity --entity=AppBundle:Product --fields="name:string(127) description:text" -q

В результате у нас создались два класса сущности:

  • src/AppBundle/Entity/Product.php
  • src/AppBundle/Entity/User.php

И два класса репозиториев для соответствующих сущностей:

  • src/AppBundle/Repository/ProductRepository.php
  • src/AppBundle/Repository/UserRepository.php

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

Сущность src/AppBundle/Entity/Product.php сразу после генерации:

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

Почищенный вариант

На что я хотел обратить внимание приводя такой пример? В подавляющем 
большинстве случаев мы создаем структуру базы данных автоматически используя при этом сущность как образец. Я удалил все части аннотаций, где явно указывалось как назвать таблицу для этой сущности и как назвать каждое из полей. Эти конфигурационные данные нужны только когда мы работаем с уже существующей базой данных и, по какому-то стечению обстоятельств, имена полей не соответствуют наименованию свойств в сущности. Если же их не указывать, то Doctrine назовет таблицу в соответствии с именем класса сущности, и поля назовет в соответствии с наименованием свойств сущности. И это правильный подход, так как это разработка по пути наименьшего удивления — имена полей в базе данных совпадают со свойствами сущности и при этом нет какой-то третьей стороны, которая на это влияет и может «удивить».

Но вы скажете, что мы тогда лишаемся уровня абстракции и при каждом рефакторинге сущности нам придется обновлять и структуры баз данных. Ответом на это, будет: как ни крути, но неисправленное расхождение названий свойств сущности и полей в базе данных в результате рефакторинга — это лишний пункт в копилку технического долга проекта

Утрирую ситуацию: по прошествую пары лет, когда ваш проект разросся до мирового масштаба и вы уже разнесли базу данных по целому облаку серверов, чтобы справляться с непосильной нагрузкой, вы можете обнаружить в базе данных таблицу с именем ‘this_may_work’ и полями: ‘id’, ‘foo’, ‘bar’ и ‘some_field_2’. Оправдание, что в сопоставленной сущности названия имеют более глубокий смысл, окажется несущественным.

Запускаем генерацию структуры базы данных:

php bin/console doctrine:schema:create

Теперь у нас есть две сущности, сопоставленные с созданными таблицами в базе данных. Мы можем создавать их экземпляры, сохранять в базе данных, а потом производить выборку из базы данных. Для демонстрации создания экземпляров сущностей в этой статье я решил использовать фикстуры, а выборку буду демонстрировать в методах репозиториев сущностей. Репозитории сущностей у нас уже имеются, а механизма работы с фикстурными данными у нас еще нет.

Устанавливаем фикстуры

Затягиваем зависимость doctrine/doctrine-fixtures-bundle в наш проект:

php composer.phar require --dev doctrine/doctrine-fixtures-bundle

Подключаем бандл зависимости в ядре Symfony:

    // app/AppKernel.php
    // ...
    if (in_array($this->getEnvironment(), array('dev', 'test'))) {
	  // ...
        $bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle();
    }
    // ...

Теперь мы готовы писать фикстуры. Создадим для них директорию:

mkdir -p src/AppBundle/DataFixtures/ORM

И первоначальный вид фикстурных данных:

src/AppBundle/DataFixtures/ORM/LoadCommonData.php

Теперь такой командой мы можем загрузить эти фикстурные данные в базу данных:

php bin/console doctrine:fixtures:load

 

Двунаправленная связь «один к одному»

Выше мы получили приложение с двумя несвязанными сущностями. Что, по сути, делает это приложение абсолютно бессмысленным. Большинство данных в реальных приложениях так или иначе связано с другими данными. Каждый ваш палец вверх, каждый комментарий на странице социальных сетей связан с сущностью пользователя, иначе они бесполезны.

Связь «один к одному» — мне в проектах встречается еще реже чем связь «многие ко многим». Что естественно, если следовать нормальным формам. Но знать как она реализуется нужно.

Для примера создадим сущность продавца Seller, которая будет связана один к одному с сущностью пользователя User, если данный пользователь является продавцом:

php bin/console doctrine:generate:entity --entity=AppBundle:Seller --fields="company:string(127) contacts:text" -q

Затем изменяем сущность пользователя, для создания связи с сущностью продавца:

src/AppBundle/Entity/User.php

Изменяем сущность продавца, добавляя связь с сущностью пользователя:

src/AppBundle/Entity/Seller.php

Заметьте, что я сразу привел пример двунаправленной связи… вообще не вижу смысла делать однонаправленные связи. Базу данных двунаправленная связь не меняет, а в программном коде дает большое удобство. По моему, возможно субъективному, мнению даже в плане оптимизации по быстродействию и использованию ОЗУ однонаправленная связь ничего не дает. (Оговорка: в последнем разделе статьи, где рассказываю про рекурсивную связь, я привожу единственно мне известный удачный пример однонаправленной связи.)

Так же заметьте, что я опять опустил аннотации. На этот раз аннотации @JoinColumn. Эти аннотации нужны в точности для того же, для чего нужны были и удаленные мною ранее аннотации — для указания с каким именем создастся поле в базе данных и на какое именно поле в базе данных будет создан внешний ключ. Все это сделается и без этой аннотации в лучшем виде. 

Запускаем автогенерацию getter/setter методов во всех сущностях нашего бандла. Эти методы создадутся для новых свойств сущностей:

php bin/console doctrine:generate:entities AppBundle

Так же в нужно привести структуру базы данных в соответствие с нашими сущностями:

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

Последнюю команду стоит использовать с осторожностью, если в базе данных есть какие-то данные, которые вы не хотите потерять. На производственном сервере вообще эта команда не должна использоваться. Изучите самостоятельно что такое БД миграции.

Ну и в фикстурах создадим еще одного пользователя, который уже будет связан с сущностью продавца.

src/AppBundle/DataFixtures/ORM/LoadCommonData.php

Запускаем загрузку фикстурных данных и в базе данных можем полюбоваться на созданные записи.

Если у вас проблемы с пониманием каким образом этими аннотациями была создана связь между сущностями, и если официальная документация Doctrine 2 вам не помогла, то смотрим на картинку:

Здесь стрелочками указано откуда что бралось, чтобы быть вписанным в аннотацию.

Стоит отметить, что аннотации в обоих сущностях получились очень похожи. Синтаксически они отличаются лишь атрибутами mappedBy и inversedBy. Но это отличие принципиальное. Сущность со стороны которой стоит атрибут inversedBy обычно считается подчиненной сущности, у которой стоит атрибут mappedBy. Выходит так, что у нас сущность User подчинена сущности Seller. Выражается это в том, что в базе данных именно таблица user содержит внешний ключ на таблицу seller, а не наоборот. Так же это влияет и на тот код, который мы написали в фикстурных данных — заметьте, что мы назначили продавца пользователю методом setSeller, а не пользователя продавцу. Последний вариант попросту никаким образом не отобразится в базе данных. То есть надо понимать, что именно объекту подчиненной сущности указывается с кем она связана.

Двунаправленная связь «один ко многим»

Самый распространенный вид связи. Следовательно нужно уметь реализовать его даже в состоянии алкогольного опьянения без Интернета где-нибудь на тропических островах будучи заваленным в системе сообщающихся пещер без компьютера, без рук, без головы. В общем одним своим существованием вы должны нести связь «один ко многим» в проекты на Symfony.

Как и было выше — будем писать пример двунаправленной связи. Для этого свяжем, уже существующие сущности: Product и Seller. 

Отвечаем на вопрос: кого будет в этой связи много, а кого один. Обычно получается, что много продуктов у одного продавца. Таким образом у нового свойства сущности Seller будет аннотация @ORM\OneToMany, а свойства сущности Product — @ORM\ManyToOne. В остальном все так же как и у вида связи «один к одному». Однако тут уже не получится свободно менять местами атрибуты mappedBy и inversedBy, так как сущность со стороны связи «много» всегда подчинена сущности со стороны «один». Соответственно и внешние ключи в базе данных всегда будут только у продуктов. Продолжая логику: именно продукту, как подчиненной сущности, будут назначаться продавцы методом setSeller, который мы напишем ниже, чтобы это назначение сохранилось в базе данных.

Изменяем сущность продавца, для создания связи с сущностью продукта:

src/AppBundle/Entity/Seller.php

Изменяем сущность продукта, добавляя связь с сущностью продавца:

src/AppBundle/Entity/Product.php

Снова запускаем автогенерацию getter/setter методов во всех сущностях нашего бандла.
Так же опять запускаем команду на обновление структуры базы данных. (Смотреть выше, если не запомнили команды).

Назначаем продавца продукту, который уже создается у нас в фикстурных данных:

// src/AppBundle/DataFixtures/ORM/LoadCommonData.php
// ...
$product = new Product();
        $product
            ->setSeller($seller)
            ->setName('Подушка для программиста')
            ->setDescription('Подушка специально для программистов. Мягкая и периодически издает звук успешно собранного билда.');
// ...

Запускаем загрузку фикстурных данных и в базе данных можем полюбоваться на то, что запись продукта ‘Подушка для программиста’ обзавелась значением в поле seller_id. Это то чего мы и добивались — теперь в нашем продукте у каждого продавца может быть много товаров.

Код для проверки в любом экшэне любого контроллера:

        // ...
        $product = $this->getDoctrine()
            ->getRepository("AppBundle:Product")
            ->find(6);
        dump($product->getSeller()->getCompany());
        // ...

6 — это id моего продукта на момент написания статьи (при запуске загрузки фикстурных данных старые записи в таблицах базы данных удаляются, но автоинкремент первичного ключа не сбрасывается). Здесь мы получили продукт из репозитория по значению его первичного ключа (поле id), и методом getSeller получили связанную с ним сущность продавца. На выходе получаем:
«Рога и копыта»

Попробуем теперь найти все продукты продавца. В пример код для любого экшэна любого контроллера:

        // ...
        $seller = $this->getDoctrine()
            ->getRepository("AppBundle:Seller")
            ->find(5);
        $names = [];
        foreach($seller->getProducts() as $product) {
            $names[] = $product->getName();
        }
        dump($names);
        // ...

Вывод функции dump:
array:1 [▼
0 => "Подушка для программиста"
]

Как по мне, результат достигнут.

Двунаправленная связь «многие ко многим»

Очень полезно знать как такая связь организовывается, хотя она и встречается гораздо реже связи «один ко многим». Полагаю вы уже должны знать, что связь «многие ко многим» в базе данных организовывается путем связывания двух таблиц посредством третьей, сводной таблицы. Сущность для этой третьей таблицы создавать ненужно — Doctrine самостоятельно создаст нам эту таблицу при запуске команды на создание или на обновление структуры базы данных.

В нашем разрастающемся проекте мы создадим сущность Category и свяжем ее «многие ко многим» с Product. Тем самым мы создадим возможность одному продукту быть сразу в нескольких категориях. Связь «многие ко многим» тут выявляется просто: многие продукты могут лежать в одной категории, один продукт может лежать во многих категориях — что со стороны продукта, что со стороны категории стоит знак «много», значит нужна связь «многие ко многим».

Создаем сущность категории:

php bin/console doctrine:generate:entity --entity=AppBundle:Category --fields="name:string(127)" -q

Изменяем сущность категории, для создания связи с сущностью продукта:

src/AppBundle/Entity/Category.php

Изменяем сущность продукта, добавляя связь с сущностью категории:

src/AppBundle/Entity/Product.php

Запускаем автогенерацию getter/setter методов во всех сущностях нашего бандла. Так же опять запускаем команду на обновление структуры базы данных. (Смотреть выше, если не запомнили команды).

Фикстуры дополняются созданием двух категорий создадим еще один продукт и назначим категории продуктам. Вышло так, что все продукты лежат во всех категориях. Да простит нас Бог Маркетинга, но это было сделано просто для наглядности.

src/AppBundle/DataFixtures/ORM/LoadCommonData.php

Тут важно заметить, что не смотря на равенство сущностей участвующих в связи, мы все равно должны использовать атрибуты mappedBy и inversedBy. И это неожиданно, но поведение, которое мы наблюдали при создании связи «один ко многим» здесь сохраняется — объект сущности, со стороны которой был указан атрибут mappedBy, должна назначаться объекту сущности, со стороны которой указан атрибут inversedBy. Иначе записи в сводной таблице не появятся. Выходит так, что волей-неволей нам приходится выделять и держать в уме какая из сущностей в этой связи является подчиненной и именно ее объектам назначать объекты второй сущности. В данном случае подчиненная сущность — Product и мы ее объектам назначаем объекты сущности Category. Если кто знает как это обойти без костылей, изменяя только аннотации — напишите в комментариях. Я же обычно обхожусь небольшой модификацией setter-а главной сущности (как я недавно обнаружил — в официальной документации Doctrine2 описывается такой же вариант решения проблемы):

    // ...
    /**
     * Add product
     *
     * @param \AppBundle\Entity\Product $product
     *
     * @return Category
     */
    public function addProduct(\AppBundle\Entity\Product $product)
    {
        $product->addCategory($this);  // Эта строчка дает мне уверенность, что не только категории назначен продукт, но продукту назначена категория
        $this->products[] = $product;
        return $this;
    }
    // ...

Запускаем загрузку фикстурных данных в базу данных, проверяем базу данных и видим, что сводная таблица product_category содержит 4 записи каждая из которых устанавливает связь между определенной категорией и определенным продуктом:

Рекурсивные связи

Рекурсивной связь называется, если она выстроена у сущности с ней же самой. И эта связь может быть «один к одному», «один ко многим» и «многие ко многим». Есть где разгуляться. Разберем для начала вариант, который не затрагивается в официальной документации — однонаправленная рекурсивная связь «многие ко многим».

Изменяем сущность пользователя, добавляя связь «многие ко многим» с самой собой:

src/AppBundle/Entity/User.php

Это пример удачного использования однонаправленной связи, так сопоставление сущности происходит с самой собой по первичному ключу посредством сводной таблицы — значит и двунаправленности не требуется. Запустите генерацию сущностей для бандла, обновление структуры данных и можно будет дополнить наши фикстуры строками:

// ...
    public function load(ObjectManager $manager)
    {
        // ...
        $user->addFriend($seller_user);
        $seller_user->addFriend($user);
        $manager->flush();
    }
// ...

Теперь каждый пользователь у нас может иметь сколько угодно пользователей-друзей. Это прекрасный пример неиерархичной структуры когда нет отношения главный/подчиненный, родитель/потомок как в древовидной структуре.

А теперь разберем древовидную структуру, путем создания рекурсивной связи. Изменяем сущность категории, добавляя связь «один ко многим» с самой собой:

src/AppBundle/Entity/Category.php

Думаю, что тут все и так наглядно — у категории появились родитель и потомки.
После обновления структуры базы данных и генерации getter/setter методов дополните фикстурные данные указанием кто кому является родителем и кто кому является потомком:

// ...
    public function load(ObjectManager $manager)
    {
        // ...
        $category2->setParent($category);
        $category->addChild($category2);
        $manager->flush();
    }
// ...

Рекурсивную связь «один к одному» разбирать не буду — если такое потребуется, то методом аналогии, думаю, не проблема будет и самим написать. 
Построение иерархической недревовидной структуры, где родителей как и потомков может быть больше одного, методом той же аналогии сделать, думаю, тоже никакой проблемы нет.

Полезно

Пользуйтесь панелью инструментов Symfony, которая отображается в нижней части страницы, когда вы запускаете ваше приложение в окружении разработчика:

Если вы напутали с аннотациями в сущностях, то с большой вероятностью Doctrine это заметит и выдаст вам здесь ошибку. Кликните по этой иконке и сможете детально почитать об ошибке, что должно вас вывести из затруднений.

На посошок

Планировал затронуть темы полиморфных связей вместе с Signle Table Inheritance, но статья и без того выросла непомерно. Так что оставлю все это про запас. Пишите в комментариях, если я где чего напутал, при таком объеме текста глаз сильно мылится. 

Проект, получившийся в результате написания статьи, выложу тут:

Но тем, кто только изучает тему связей между сущностями, советую проделать всю работу самому, а не брать проект готовым — это неплохая тренировка. В статье есть все, чтобы сделать проект с нуля.