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