Нюансы работы внешних ключей на примере 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-схем и выборок на основе их.
Doctrine говорит нам, что для двусторонней связи двух объектов, нужно использовать атрибут:
Я обычно пишу так:
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();
Комментарии
А здесь - на тебе: вылезают на белый свет отношения между строками в таблицах.