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() записує всі зміни всіх сутностей, які знає 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.