Doctrine ORM

← Doctrine ORM

Ashterix

docs(articles): SEO titles, intros and a section preview image for From Magento to Symfony

uk/articles/magento-to-symfony/data/doctrine.md +4 −0

Doctrine ORM

Найважча частина переходу, бо змінюється сама модель роботи з даними. Magento-модель схожа на Active Record: об'єкт знає, як себе зберегти, а save() одразу виконує SQL. Doctrine — це Data Mapper з Unit of Work: сутність — звичайний PHP-об'єкт без знання про БД, а EntityManager відстежує зміни всіх завантажених сутностей і записує їх разом при flush().

Аспект Magento Doctrine
Файли на одну таблицю Model, ResourceModel, Collection, Api/Data інтерфейс, Repository, db_schema.xml Entity, Repository, міграція
Дані об'єкта масив $_data, getData('title') типізовані private властивості
Збереження save() одразу пише рядок persist() реєструє, flush() пише всі зміни
Оновлення load, setX, save завантажили, змінили, flush. persist для вже керованої сутності не потрібен
Зв'язки лише id-колонки, join у колекції вручну ManyToOne, OneToMany, ManyToMany з лінивим завантаженням
Той самий рядок двічі два різні об'єкти той самий об'єкт (identity map)
Схема БД декларативна db_schema.xml мапінг атрибутами + версіоновані міграції
Сирий SQL ResourceConnection DBAL Connection

Сутність

Symfony · src/Entity/Question.php

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: QuestionRepository::class)]
#[ORM\Table(name: 'faq_question')]
class Question
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, unique: true)]
    private string $slug;

    #[ORM\Column]
    private bool $isActive = true;

    #[ORM\Column]
    private int $position = 0;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    public function __construct(
        #[ORM\Column(length: 255)]
        private string $title,
        #[ORM\Column(type: Types::TEXT)]
        private string $answer,
        #[ORM\ManyToOne(inversedBy: 'questions')]
        #[ORM\JoinColumn(nullable: false)]
        private Category $category,
    )
    {
        $this->slug = (new AsciiSlugger())->slug($title)->lower()->toString();
        $this->createdAt = new \DateTimeImmutable();
    }

    public function deactivate(): void
    {
        $this->isActive = false;
    }

    // геттери…
}

Сутність не зобов'язана мати сетер на кожне поле. Обов'язкове передається в конструктор, зміни стану оформлюються методами з назвою дії (deactivate(), rename()). Генератор bin/console make:entity створює геттери й сетери — для старту нормально, потім прибирайте зайве.

Запити: Collection → QueryBuilder

Magento · колекція

$collection = $this->collectionFactory->create()
    ->addFieldToFilter('category_id', $categoryId)
    ->addFieldToFilter('is_active', 1)
    ->setOrder('position', 'ASC')
    ->setPageSize(20);

foreach ($collection as $question) {
    echo $question->getData('title');
}

Symfony · метод репозиторію

/** @extends ServiceEntityRepository<Question> */
class QuestionRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Question::class);
    }

    /** @return list<Question> */
    public function findActiveInCategory(Category $category): array
    {
        return $this->createQueryBuilder('q')
            ->andWhere('q.category = :category')
            ->andWhere('q.isActive = true')
            ->setParameter('category', $category)
            ->orderBy('q.position', 'ASC')
            ->setMaxResults(20)
            ->getQuery()
            ->getResult();
    }
}

У QueryBuilder і DQL пишуть імена властивостей і класів, а не колонок і таблиць: q.isActive, а не is_active. Прості запити є готові: find($id), findOneBy(['slug' => $slug]), findBy([...], ['position' => 'ASC']).

Збереження: save() → persist() + flush()

Magento

$question = $this->questionFactory->create();
$question->setTitle($title);
$question->setCategoryId($categoryId);
$this->questionRepository->save($question);

$other = $this->questionRepository->getById(7);
$other->setIsActive(false);
$this->questionRepository->save($other);

Symfony

$question = new Question($title, $answer, $category);
$this->em->persist($question);

$other = $this->questions->find(7);
$other->deactivate();
// persist() для $other не потрібен: вона вже керована

$this->em->flush();   // один INSERT і один UPDATE в одній транзакції
flush() — не save()

flush() записує всі зміни всіх сутностей, які знає UnitOfWork, а не одну передану. Якщо десь вище по стеку змінили іншу сутність і не хотіли її зберігати, вона теж запишеться. У Doctrine ORM 3 виклик flush($entity) прибрали, тож старий код, що покладався на «часткове» збереження, треба переглядати, а не просто видаляти аргумент.

Хто відповідає за flush, вирішують на рівні застосунку: зазвичай це контролер, обробник повідомлення або команда, тобто точка входу, а не репозиторій глибоко всередині.

Міграції замість db_schema.xml

  • Змінили мапінг → bin/console make:migration (або doctrine:migrations:diff) генерує клас у migrations/ з SQL різниці між мапінгом і поточною БД.
  • Перегляньте згенерований SQL перед комітом: перейменування колонки diff бачить як DROP + ADD.
  • doctrine:migrations:migrate на деплої — аналог setup:upgrade для схеми.
  • doctrine:schema:validate перевіряє, що мапінг коректний і збігається з БД.
  • Тестові й демо-дані — doctrine/doctrine-fixtures-bundle або Zenstruck Foundry, команда doctrine:fixtures:load.

Зв'язки і N+1

Зв'язки ліниві: $question->getCategory()->getName() у циклі по 50 питаннях дасть 50 додаткових запитів. Профайлер покаже це на вкладці Doctrine. Рішення — забрати зв'язок одним запитом:

Symfony · fetch join

return $this->createQueryBuilder('q')
    ->addSelect('c')
    ->join('q.category', 'c')
    ->andWhere('q.isActive = true')
    ->getQuery()
    ->getResult();

У двосторонньому зв'язку в БД записується лише owning side (сторона з ManyToOne / JoinColumn). Додавання в колекцію $category->getQuestions()->add($q) без виставлення $q->category нічого не збереже, тому методи-хелпери оновлюють обидві сторони.

Масова обробка

Symfony · імпорт/перерахунок без витоку пам'яті

$i = 0;
$query = $this->questions->createQueryBuilder('q')->getQuery();

foreach ($query->toIterable() as $question) {
    $question->recalculateRank();
    if (++$i % 200 === 0) {
        $this->em->flush();
        $this->em->clear();
    }
}
$this->em->flush();
$this->em->clear();

Identity map тримає кожну завантажену сутність, доки не викличете clear(). Після clear() усі раніше отримані об'єкти від'єднані: їхні зміни більше не збережуться, а зв'язки на них треба перечитати. Для по-справжньому масових змін швидше працювати через DBAL або DQL UPDATE.