Skip to content

Instantly share code, notes, and snippets.

@GubaEvgeniy
Last active October 29, 2019 17:34
Show Gist options
  • Select an option

  • Save GubaEvgeniy/8ae646e4d81af124a5570c24f5dba9ee to your computer and use it in GitHub Desktop.

Select an option

Save GubaEvgeniy/8ae646e4d81af124a5570c24f5dba9ee to your computer and use it in GitHub Desktop.

Наверх

Doctrine

Установка

  1. composer require doctrine - скачиваем файлы с пакетного менеджера

  2. В файле .env добавить новую переменную окружения(или будет уже добавленно):

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name

Данная переменная используется в конфиге config/packages/doctrine.yaml. Так же тут есть другие настройки как charset, driver, server_version и т.д.

Команды

bin/console doctrine:database:create - создаст новую базу данных, с именем которое указанно в db_name из пункта №2 выше.

bin/console make:entity - команда сервиса MakerBundle, которая позволяет в полуавтоматическом режиме создавать сущности(Entity) для связи приложение <-> база данных. В итоге будет создан простой php class в котором имя = названию таблицы в базе данных, а поля(свойства) класса - столбцы таблицы. Doctrine для своей работы испльзуется аннотации.

bin/console make:migration - команда, которая создаёт файл миграции на основании Entity. Doctrine читает этот файл и в атоматическом режиме создаёт sql-команды. Каждая новая уникальная миграция создаётся со своим порядковым номером

bin/console doctrine:migrations:migrate - выполнить миграции. После использования этой комманды Doctrine возьмет файлы из src/Migrations/ и выполнит sql-команды которые находятся внутри. Для предотвращения повторной(дублирующей) миграции Docrtine создаёт новую таблицу с именем migration_varsions куда заносит данные о произведенных миграциях.

bin/console make:fixture - создание новой Fixture. Fixture - это такой бандл(инструмент) который позволяет загружать фейковые данные в базу данных. Используется в среде dev для тестирования. Подробнее об этом инструменте написанно ниже

bin/console doctrine:fixture:load - загрузить данные в бд с помощью Fixture. ВНИМАНИЕ перед загрузкой таблица будет очищена!

Сохранение данных

Допустим с помощью команды bin/console make:entity и bin/console make:migration была создана сущность(таблица в БД) Article с полями title, slug, content. Теперь мы можешь создать отдельный класс который будет позволять соранять данные в тиблицу Article - например ArticleAdminController.

Внутри данного класса мы можем оперировать методами класса Article (src/Entity/Article.php), которые позволяют проводить операции с контентом(напр. setTitle), таким образом мы подготоваливаем наши данные(контент) к сохранению.

Для сохранения данных необходимо воспользоваться EntityManagerInterface (см. список всех сервисов bin/console debug:autowiring), установив его через DI. Необходимо всего 2 метода:

  • $em->persist($article); - подготовить данные. Необходимо для возможности подготовить сразу 10 статей - оптимизация процесса.
  • $em->flush(); - непосредственно сохранение данных(команда INSERT)

Запрос данных

Запрос данных делается так же с использование EntityManagerInterface. Для этого нам необходимо передать имя класса в метод getRepository().

$repository = $em->getRepository(Article::class);

Вместо того, что б использовать EntityManagerInterface и получать объект репозитория через getRepository(), можно сразу использовать EntityRepository при подключении через DI(что это за файл см. ниже)

Теперь для $repository нам будут доступны несколько методов:

  • find() - требует $id что б получить одну конкретную статью
  • findAll() - получить все статьи
  • findBy() - получить все статьи, где поле строки соответствует некоторому переданному значению
  • findOneBy() - получить одну статью, где поле строки соответствует некоторому переданному значению

Для примера со статьями используем findOneBy() со поиском по $slug: $article = $repository->findOneBy(['slug' => $slug]);

Статьи с таким $slug может и не быть. В таком случае $article будет равно null и нам необходимо выполнить обработку подобного поведения. Добавим исключение:

if (!$article) {
    throw $this->createNotFoundException(sprintf('No article for slug "%s"', $slug));
}

где $this->createNotFoundException является трейтом, который расширяет класс AbstractController(это основной класс от которого наследуются все контроллеры).

Если распечатать $article то можно увидеть что это объект со свойствами, имя которых соответствует каждому столбцу в таблице.

Entity Repositories

При использовании команды bin/console make:entity для автоматической генерации entity будет создан файл EntityRepository.php. В котором есть возможность для описания собственного запроса данных, помимо предоставленных по умолчанию.

Doctrine позволяет писать пользовательские запросы с помощью Query Builder. Пример нового запроса, который возвращает посты в которые дата публикации присутсвует и с сортировкой:

public function findAllPublishedOrderedByNewest()
    {
        return $this->createQueryBuilder('a')
            ->andWhere('a.publishedAt IS NOT NULL')
            ->orderBy('a.publishedAt', 'DESC')
            ->getQuery()
            ->getResult()
        ;
    }

где:

  • createQueryBuilder('a') - a это псевдомин таблицы
  • andWhere() и orderBy() - методы которые соответствуют AND WHERE и ORDER BY
  • getQuery() - подготавливает запрос
  • getResult() - выполняет запрос. Так еще может существовать getOneOrNullResult который вернет один результат или ничего.

Лайвхаки для повторного использования кода в Entity Repositories

Например есть метод

 public function findAllPublishedOrderedByNewest()
    {
        return $this->createQueryBuilder('a')
            ->andWhere('a.publishedAt IS NOT NULL')
            ->orderBy('a.publishedAt', 'DESC')
            ->getQuery()
            ->getResult()
        ;
    }
  1. Изолируем логику запроса. Для этого сделаем приватный метод addIsPublishedQueryBuilder() и аргументом Doctrine\ORM $qb:
private function addIsPublishedQueryBuilder(QueryBuilder $qb = null)
    {
        return $this->getOrCreateQueryBuilder($qb)
            ->andWhere('a.publishedAt IS NOT NULL');
    }
private function getOrCreateQueryBuilder(QueryBuilder $qb = null)
    {
        return $qb ?: $this->createQueryBuilder('a');
    }

Метод findAllPublishedOrderedByNewest() после всех изменений:

 public function findAllPublishedOrderedByNewest()
    {
        return $this->addIsPublishedQueryBuilder()
            ->orderBy('a.publishedAt', 'DESC')
            ->getQuery()
            ->getResult()
        ;
    }

Добавление новых полей

Для добавления нового поля в уже существующую entity можно воспользоваться 2-я вариантами

  1. добавить вручную в файл src/Entity/Article.php и сделать миграции
  2. автоматически сгенерировать новые поля через команду bin/console make:entity, указав имя класса которое необходимо обновить и указать какие новые поля требуется добавить.

Второй способ естественно предпочтительнее.

После выполнения команды из пункта 2, файл src/Entity/Article.php будет автоматически обновлён и в нём добавяться свойства и методы для работы с новыми полями. Далее требуется выполнить миграции с помощью команд bin/console make:migration и bin/console make:migration

Установка значения по умолчанию

В файле src/Entity/Article.php для требуемого свойства установить присвоить какое-либо значение. Например, мы добавили новое поле $heartCount которое отвечает за количество лайков у конкретного поста, и хотим что б данное значение равнялось 0 при первичном создании поста. В коде это будет так:

/**
 * @ORM\Column(type="integer")
 */
private $heartCount = 0;

Fixture

Это классы php которые предназначены для создания entity и загрузки тестовых данных в базу данных.

composer require --dev orm-fixtures - установка DoctrineFixturesBundle

bin/console make:fixtures - создание fixture. После ввода имени появится файл src/DataFixtures/NameFixtures.php

Использование: в метод load() помещаем создание объекта требуемого entity и заполняем там же данными. Для загрузки используем команду в консоле bin/console doctrine:fixtures:load. Для примера ниже будет загружена всего одна запись.

ОБРАТИТЬ ВНИМАНИЕ использование данной команды удалит все записи в таблице и запишет заново

Пример:

namespace App\DataFixtures;

use App\Entity\Article;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;

class ArticleFixtures extends Fixture
{
    public function load(ObjectManager $manager)
        {
            $article = new Article();

            $article->setTitle('Why Asteroids Taste Like Bacon')
                ->setSlug('why-asteroids-taste-like-bacon-'.rand(100, 999))
                ->setContent(<<<EOF
    Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
    lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
    labore minim pork belly spare ribs cupim short loin in. 
    EOF
                );
            );
            // publish most articles
            if (rand(1, 10) > 2) {
                $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
            }
            $article->setAuthor('Mike Ferengi')
                ->setHeartCount(rand(5, 100))
                ->setImageFilename('asteroid.jpeg')
            ;
            $manager->persist($article);
            $manager->flush();
    }
}

Загрузка нескольких записей в БД

Для создания сразу нескольких статей, можно пойти двумя путями:

С помощью цикла

В методе load() просто использовать цикл

public function load(ObjectManager $manager)
    {
        for ($i = 0; $i < 10; $i++) {
            $article = new Article();      
            $article->setTitle('Why Asteroids Taste Like Bacon')
                ->setSlug('why-asteroids-taste-like-bacon-'.rand(100, 999))
                ...
                ;

            $manager->persist($article);
        }
        
        $manager->flush();
    }
Генерация нескольких записей при помощи абстрактного класса
  1. Создать абстрактный класс BaseFixture, который будет наследоваться от Fixture.
  2. Тут же реализуем метод load(), который принимает в качестве аргумента ObjectManager $manager и устанавливает его как свойство данного класса(BaseFixture)
  3. Создать абстрактный метод loadData() с те же аргументом ObjectManager $manager
  4. в load() делаем вызов loadData()

Должно получиться:

namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;

abstract class BaseFixture extends Fixture
{
    /** @var ObjectManager */
    private $manager;
    
    abstract protected function loadData(ObjectManager $manager);
    
    public function load(ObjectManager $manager)
    {
        $this->manager = $manager;
        $this->loadData($manager);
    }
}
  1. Далее создадим метод который будет отвечать за какую fixtur'e необходимо формировать
protected function createMany(string $className, int $count, callable $factory)
    {
        for ($i = 0; $i < $count; $i++) {
            $entity = new $className();
            $factory($entity, $i);
            
            $this->manager->persist($entity);       
            $this->addReference($className . '_' . $i, $entity);
        }
    }

где:

  • $className - передаём какого класса хотим создать fixture'e
  • $count - количество записей
  • callable $factory - функция обратного вызова, которая будет вызываться каждый раз когда создаётся объект.
  • $this->addReference($className . '_' . $i, $entity); - используется для связи между несколькими fixture. Подброднее ниже
  1. Теперь в файле src/DataFixtures/ArticleFixtures.php (а мы все примеры делаем тут для Article) убираем наследование от Fixture и наследуемся от BaseFixture. Реализуем метод loadData() в котором происходит вызов createMany с передачей необходимых нам аргументов $className, $count и анонимной функции(в которой описываем все манипуляции с данными)
public function loadData(ObjectManager $manager)
    {
        $this->createMany(Article::class, 10, function(Article $article, $count) {
            $article->setTitle('Why Asteroids Taste Like Bacon')
                ->setSlug('why-asteroids-taste-like-bacon-'.$count)
                ...
        });
        $manager->flush();
    }

Еще раз резюмируя:

  1. С помощью команды bin/console doctrine:fixture:load запускается метод load() в классе который наследуется от Fixture, у нас это абстрактный класс BaseFixture, от которого в свою очередь наследуется ArticleFixture.

  2. В BaseFixture содержиться логика

  • для загрузки необходимых нам данных - это собственно метод loadData() и его вызов $this->loadData($manager); (который находиться внутри load()).
  • метод createMany() который принимает в себя Класс, количество итераций и анонимную функцию, в которой описанно в какие поля таблицы нужно заносить какие данные. Т.е. по факту тут происходит абстракция логики - этот метод нужен для подготовки данных $this->manager->persist($entity); в каждой итерации.
  1. В наследуемом класс ArticleFixture описываем логику loadData() для createMany() и завершая запись в БД $manager->flush();

Связь между двумя fixture

При использовании нескольких файлов fixture появляется потребность использовать объекты в разных файлах. Данный метод позволяет создать ссылку на такие объекты, а затем с помощью getRefference() получить этот объект через его имя. Т.е. у нас есть fixture которая отвечает за генерацию Article и для этой сущности использум $this->addReference($className . '_' . $i, $entity);, где $i используется для обозначения каждого объекта. Теперь в другой fixture можно использовать $this->getReference(Article::class.'_'.$i) для того, что б получить конкретный(указав при этом конкретный $i) объект Article.

Настройка порядка загрузки fixture

Doctrine загружает файлы fixture в алфовитном порядке. Однако возникает ситуация, когда фикстура B требует данных с фикстуры D. В таком случае как только класс фикстуры зависит от другого класса, необходимо реализовать интерфейс с именем DependentFixtureInterface.

Вернёмся к нашему примеру. Сейчас у нас есть только фикстура ArticleFixture. Допустим появляется фикстура A0SomethingFixture которая у себя использует объекты ArticleFixture. Для разрешения конфикта реализуем DependentFixtureInterface с методом getDependencies(), где просто вернем массивом метод от которого есть зависимость.

class A0SomethingFixture extends Fixture implements DependentFixtureInterface
{
    public function getDependencies()
    {
        return [ArticleFixtures::class];
    }
}

Extentions for Doctrine

StofDoctrineExtensionsBundle

Генерируем отношения между Entity

В качестве примера у нас будут комментарии которые можно оставить к каждой статье. Данные отношения описываются как Много-к-одному(Many-To-One m-2-o) со стороны комментариев, т.е. много комментариев могут быть на одной записи. И Один-ко-многим(One-To-Many o-2-m) со стороны записи, т.е. каждая запись может иметь много комментариев.

Мы уже создали entity для комментариев, теперь необходимо связать его с Article, путем добавления нового столбца, с именем наподобие articleId. Для этого используем команду bin/console make:entity, но при запросе New property name пишем article, а при Field type - relation. relation - даёт понять, что данное поле будет обозначать связь между двумя таблицами и запустит специальный интерфейс для описания этой связи.

Первый вопрос - с каким классом должна быть связана эта entity? В нашем примере это Article. Далее необходимо выбрать каким будет эта связь - ManyToOne, OneToMany, ManyToMany, OneToOne. На этот вопрос был дан ответ в первом абзаце - ManyToOne(кроме того интерфейс даёт подсказки как это выглядит).

Далее необходимо ответить на вопрос будет ли добавляемое поле таблицы nullable - no, не может быть т.к. у каждый комментарий так или иначе будет принадлежать к какой-либо статье.

Следующий вопрос касается возможности добавления в Article дополнительных методов по работе с Comments, например нужно ли добавлять метод $article->getComments() - yes, да нужно, т.к. мы хотим иметь возможность получить все комментарии для определённой записи. После необходимо ввести имя нового свойства для Article которое будет соответствовать Comments - comments.

!!! В конце будет вопрос является ли это связь orphanRemoval, что означает, если в Article не будет ссылок на Comments, то последние должны быть удалены при удалении Article - ПЕРЕФРАЗИРОВАТЬ !!!

В итоге:

  • в файле src/Entity/Comment.php появится новое свойство article с необходимо аннотацией:
/**
 * @ORM\ManyToOne(targetEntity="App\Entity\Article", inversedBy="comments")
 * @ORM\JoinColumn(nullable=false)
 */
private $article;
  • в файле src/Entity/Article.php - comments и методы для работы с entity Comments
/**
 * @ORM\OneToMany(targetEntity="App\Entity\Comment", mappedBy="article")
 */
private $comments;
/**
 * @return Collection|Comment[]
 */
public function getComments(): Collection
{
    return $this->comments;
}
public function addComment(Comment $comment): self
{
    if (!$this->comments->contains($comment)) {
        $this->comments[] = $comment;
        $comment->setArticle($this);
    }
    return $this;
}
public function removeComment(Comment $comment): self
{
    if ($this->comments->contains($comment)) {
        $this->comments->removeElement($comment);
        // set the owning side to null (unless already changed)
        if ($comment->getArticle() === $this) {
            $comment->setArticle(null);
        }
    }
    return $this;
}

есть особенность: так же необходимо создать конструктор который объявляет свойство comments как new ArrayCollection()

public function __construct()
    {
        $this->comments = new ArrayCollection();
    }

В конце делаем миграции и связь между двумя таблицами установлена!

Важно знать при выборке данных из базы

Т.к. при генерации отношения в связываемом объекте, мы добавили метод $article->getComments(), Symfony будет использовать LazyLoad для сущности Comment при вызове сущности Article.

Т.е. когда идет обращение к сущности Article Doctrine и Symfony знают о том, что Article и Comment связаны между собой, и запрос к базе данных не будет выполен до тех пор, пока комменатрии явно не будут вызваны. Если есть вызов, то запрос в БД произойдет в фоновом режиме.

Важно знать при сохранении данных в базу

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

Т.е. если мы захотим сохранить комментарий через сущность article(ниже код из темы про fixture):

public function loadData(ObjectManager $manager)
{
    $this->createMany(Article::class, 10, function(Article $article, $count) {
        $article->setTitle('Why Asteroids Taste Like Bacon')
            ...
            $comment1 = new Comment();
            $comment1->setAuthorName('Mike Ferengi');
            $comment1->setContent('I ate a normal rock once. It did NOT taste like bacon!');               

            $comment1 = new Comment();
            $comment1->setAuthorName('Mike Ferengi');
            $comment1->setContent('I ate a normal rock once. It did NOT taste like bacon!');

            $article->addComment($comment1);
            $article->addComment($comment2);
    });
    $manager->flush();
}

то внутри entity Article будет вызван метод addComment:

public function addComment(Comment $comment): self
{
    if (!$this->comments->contains($comment)) {
        $this->comments[] = $comment;
        $comment->setArticle($this);
    }

    return $this;
}

который сперва проверит не будет ли дублироваться новый комментарий, а во-вторых передаст управление сохранением комментария в сущность Comment.

Работа с Request: создаем простую форму поиска

Допустим у нас есть форма, которая просто посылает GET-запрос. На стороне контроллера мы можем поймать этот запрос с помощью Symfony\Component\HttpFoundation\Request. конечно использовав его как подсказка аргумента в методе:

public function index(CommentRepository $repository, Request $request)
{
    ...
}

Не забудь! В подсказку аргумента метода передаются: type-hint services, type-hint entities и type-hint the Request class.

Теперь можно получить значение переданного запроса

public function index(CommentRepository $repository, Request $request)
{
    $q = $request->query->get('q');
    ...
}

где $request->query - отвечает за $_GET. Так же можно использвовать $request->headers для заголовков, $request->cookies, $request->files и так далее.

Создание пользовательского запроса в Repository

Теперь необходимо построить запрос в базу данных по полученному значению. Что б данный запрос был универсальным, добавим возможность выборки и при нулевом значении $requst. Создаём новый запрос в src/Repository/CommentRepository.php (т.к. мы вообще тут с данной Entity и работает)

public function findAllWithSearch(?string $term)
{
}

Внутри:

  1. Начнем создавать запрос - $qb = $this->createQueryBuilder('c');
  2. Если $term передан сформируем строку запроса для поиска(используем LIKE) фразы по контенту или по автору:
if ($term) {
    $qb->andWhere('c.content LIKE :term OR c.authorName LIKE :term')
        ->setParameter('term', '%' . $term . '%')
    ;
}

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

  1. Делаем сортировку и получаем результат. Полный метод:
public function findAllWithSearch(?string $term)
{
    $qb = $this->createQueryBuilder('c');
    if ($term) {
        $qb->andWhere('c.content LIKE :term OR c.authorName LIKE :term')
            ->setParameter('term', '%' . $term . '%')
        ;
    }
    return $qb
        ->orderBy('c.createdAt', 'DESC')
        ->getQuery()
        ->getResult()
    ;
}

Запрос поиска будет произведен только по тексту и авторам комментариев, т.е. только в сущности Comment

Получение результат поиска

В итоге первоначальный метод index() примет такой вид:

public function index(CommentRepository $repository, Request $request)
{
    $q = $request->query->get('q');
    $comments = $repository->findAllWithSearch($q);
    
    return $this->render('comment_admin/index.html.twig', [
        'comments' => $comments,
    ]);
}

Включение поиска по заголовкам статей. JOIN

Добавим возможность поиска и по заголовкам статей. В этом случае мы хотим присоеденится от comment к article. Сделать это можно с помощью ->innerJoin('c.article', 'a'). Теперь запрос так же выбирает и статьи. Используем это внутри andWhere():

public function findAllWithSearch(?string $term)
{
    $qb = $this->createQueryBuilder('c')
        ->innerJoin('c.article', 'a');
    
    if ($term) {
        $qb->andWhere('c.content LIKE :term OR c.authorName LIKE :term OR a.title LIKE :term')
            ->setParameter('term', '%' . $term . '%')
        ;
    }
    return $qb
        ->orderBy('c.createdAt', 'DESC')
        ->getQuery()
        ->getResult()
    ;
}

Касательно производительности и так называемого N+1 запроса

Используй ->addSelect('a');.

!!! ОПИСАТЬ БОЛЬШЕ !!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment