Doctrine2 - один ко многим

Нюансы работы внешних ключей на примере ORM Doctrine2.

Для начала нам понадобятся 2 сущности, например People и News.

Имеем ввиду, что один пользователь может опубликовать несколько новостей (связь один ко многим).

Давайте опишем эти 2 сущности:

<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="people")
 */
class People
{
    /**
     * переменная нужна, чтобы идентифицировать человека
     * @ORM\Id
     * @ORM\Column(type="integer", options={"unsigned": true, "comment" : "ID человека"})
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id = 0;

    /**
     * @ORM\Column(type="string", length=255, options={"default":""})
     */
    private $name = '';

}

и вторая сущность:

<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="news")
 */
class News
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer", options={"unsigned": true, "comment" : "ID новости"})
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id = 0;

    /**
     * @ORM\Column(type="string", length=255, options={"default":""})
     */
    private $title = '';

    /**
     * ID человека
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\People")
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="people_id", referencedColumnName="id")
     * })
     */
    private $people;

}

Обратите внимание, что people это переменная, которая будет содержать объект People, а people_id это имя поля в таблице news.

Теперь создадим таблицы в базе данных:

$ app/console doctrine:schema:update --force

И попробуем добавлять записи в базу:

INSERT INTO people (`name`) VALUES ('ivan');// хорошо, строке присвоен id = 1

INSERT INTO news SET title = 'Привет', people_id = 1;// хорошо, строка добавилась

INSERT INTO news SET title = 'Мир', people_id = 2;// хорошо, строка НЕ добавилась, т.к. в people нет строки с id = 2

INSERT INTO news SET title = 'Труд';// ужас, строка добавилась, поле people_id = NULL

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

Мало того, мы спокойно можем выполнить запросы:

UPDATE news SET people_id = 1 WHERE id = 1;

UPDATE news SET people_id = NULL WHERE id = 1;

Кошмар, значит все новости могут потерять владельцев, и не поверите, но это нормальная ситуация, оказывается NULL это значение по-умолчанию.

Но, решить эту проблему все таки можно, хотя и к сожалению не для тех строк, которые уже были добавлены.

Для решения проблемы, полю people_id нужно указать значение по-умолчанию NOT NULL

В Doctrine2 анотации это делается так:

@ORM\JoinColumn(name="people_id", referencedColumnName="id", nullable=false)

Проверим добавление строки в базу данных:

INSERT INTO news SET title = 'Май';// ура, строка НЕ добавилась

INSERT INTO news SET title = 'Мир', people_id = 0;// ура, строка НЕ добавилась

Теперь консистентность данных в порядке (если не брать в счет тех строк, которые были добавлены до значения по-умолчанию NOT NULL).

Хочется заметить, что обычно не делают анотацию OneToMany (и только по необходимости какие-то более сложных выборок используют ManyToOne):

    /**
     * @var int
     *
     * @ORM\OneToMany(
     *     targetEntity="AppBundle\Entity\Image",
     *     mappedBy="AppBundle\Entity\Album"
     * )
     */
    private $images;

благодаря этому мы сможем поднимать сущности Image очень просто:

$people->getImages()->toArray();

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

Yaml-схемы

Doctrine говорит нам, что для двусторонней связи двух объектов, нужно использовать атрибут:

  • mappedBy для связи OneToMany
  • inversedBy для связи ManyToOne, когда нужно сказать, что родительский объект может быть null
  • joinColumn для связи ManyToOne, когда нужно сказать, что родительский объект не может быть null

Я обычно пишу так:

    booking:
      targetEntity: Booking
      inversedBy: comments
      joinColumn:
        nullable: false
    user:
      targetEntity: Entity\BackofficeUser
      inversedBy: bookingsComments
      joinColumn:
        nullable: false

как Вы поняли, это позволяет не писать имя связующего поля, но говорит, чтобы связующий объект не мог быть null

Итак, создаем yml-файл с описанием схемы

AppBundle\Entity\People:
  type: entity
  table: people
  id:
    id:
      type: integer
      generator:
        strategy: AUTO
  fields:
    name:
      column: name
      type: string
      length: 255
      nullable: false
  oneToMany:
    newsItems:
      targetEntity: News
      mappedBy: peopleItem
      onDelete: CASCADE

Обратите внимание, что мы еще не создали файл описания схемы сущности News, но уже указали peopleItem - это имя, которое мы обязаны указать как обратное (обратите внимание, оно обязано быть уникальным во всех описаниях схем).

AppBundle\Entity\News:
  type: entity
  table: news
  id:
    id:
      type: integer
      generator:
        strategy: AUTO
  fields:
    title:
      column: title
      type: string
      length: 255
  manyToOne:
    peopleItem:
      targetEntity: People
      inversedBy: newsItems
      joinColumn:
        name: people_id
        referencedColumnName: id
        nullable: false

в то же время, мы указываем newsItems как обратную связь, это обязательно. Кстати, описание атрибута joinColumn не является обязательным, т.к. Doctrine может самостоятельно разобраться в именах полей.

name: people_id - имя поля, которое будет использоваться для хранения связи news с people (обратите внимание, что мы не объявили people_id в списке полей, т.к. делать это не нужно, потому что Doctrine умеет строить связи только по поляем типа integer).

referencedColumnName: id - имя поля таблицы people, которое будет проставляться в поле people_id, чтобы сопоставлять строки news с people.

Итак, теперь мы можем выполнить команду:

app/console doctrine:schema:create --dump-sql

которая покажет нам внесенные изменения в схему выше описанных таблиц.

А теперь попробуем сделать выборку данных, согласно выше описанных схем:

$qb = $this->em->createQueryBuilder();
$qb->select('PeopleAlias, NewsAlias')
->from(People::class, 'PeopleAlias')
->innerJoin('People.newsItems', 'NewsAlias')
->getQuery()
->useQueryCache(false)
->useResultCache(false)
->execute();

Обратите внимание, что для выборки новостей, мы использовали указание имени связи из схемы People, а не класса (это очень тонкий момент, который лично до меня дошел далеко не сразу).

Удачи господа.

p.s. В Doctrine есть очень интересная особенность, называется она FETCH_EAGER - подгружать данные связей в момент выборки родительских данных:

$query = $em->createQuery("SELECT p FROM AppBundle\Entity\People p");
$query->setFetchMode("AppBundle\Entity\News", "newsItems", \Doctrine\ORM\Mapping\ClassMetadata::FETCH_EAGER);
$query->execute();

Благодаря такой особенности, Doctrine подгрузит данные News до того, как Вы решите ими воспользоваться (в настоящий момент (2016.02) этот хинт не работает).

Есть и обратный хинт - когда Вам нужно выполнить запрос, но при этом не поднимать связанные сущности:

$query->setHint(\Doctrine\ORM\Query::HINT_FORCE_PARTIAL_LOAD, 1);

Благодаря этому хинту Doctrine поднимет только сущности People и не поднимет связанные сущности News (даже если Вы в своей схеме укажите fetch="EAGER")

p.s. 2 хочу обратить Ваше внимание, что для people мы использовали инструкцию Delete: CASCADE - это означает, что при удалении объекта People, Doctrine будет удалять сущности News связанные с People. Насколько это хорошая идея - решать вам, но лично мне кажется, что если пхп-скрипт упадет, то мы получим неконсистентность данных и поэтому вместо Delete: CASCADE я предпочитаю использование cascade: ["persist", "remove"]

AppBundle\Entity\People:
  type: entity
  table: people
  id:
    id:
      type: integer
      generator:
        strategy: AUTO
  fields:
    name:
      column: name
      type: string
      length: 255
      nullable: false
  oneToMany:
    newsItems:
      targetEntity: News
      mappedBy: peopleItem
      cascade: ["remove"]

cascade: ["remove"] - говорит Doctrine, что для people и news нужно использовать Foreign Key. Т.е. выполнив команду:

app/console doctrine:schema:create --dump-sql

Вы увидите, что Doctrine попытается создать FK:

ALTER TABLE news ADD CONSTRAINT FK_1 FOREIGN KEY (people_id) REFERENCES people (id)

что означает, что за консистентность данных будет отвечать база данных, а не Doctrine.

p.s. 3: cascade: ["persist"] - означает, что все данные, которые будут связаны, не нужно сохранять в Doctrine $manager->persist($news); перед использованием $manager->flush();

Полезное: 1 - 2


04.11.2014 13:56

Комментарии

"Doctrine2 - один ко многим"... то, что вообще возможна такая фраза - уже говорит о том, что ORM - во многих случаях - излишество. Ведь цель использования ORM - скрыть от разработчика все то, что присуще реляционным БД, а оставить только классы, объекты...

А здесь - на тебе: вылезают на белый свет отношения между строками в таблицах.
--- | 08.02.2017 07:18