Doctrine ORM

← Doctrine ORM

Ashterix

docs(articles): From Magento to Symfony, a requalification guide grouped into sections

uk/articles/magento-to-symfony/data/doctrine.md новий файл +189 −0

1 + # Doctrine ORM
2 +
3 + Найважча частина переходу, бо змінюється сама модель роботи з даними. Magento-модель схожа на Active Record: об'єкт знає, як себе зберегти, а `save()` одразу виконує SQL. Doctrine — це Data Mapper з Unit of Work: сутність — звичайний PHP-об'єкт без знання про БД, а EntityManager відстежує зміни всіх завантажених сутностей і записує їх разом при `flush()`.
4 +
5 + | Аспект | Magento | Doctrine |
6 + |---|---|---|
7 + | Файли на одну таблицю | Model, ResourceModel, Collection, Api/Data інтерфейс, Repository, db_schema.xml | Entity, Repository, міграція |
8 + | Дані об'єкта | масив $_data, getData('title') | типізовані private властивості |
9 + | Збереження | save() одразу пише рядок | persist() реєструє, flush() пише всі зміни |
10 + | Оновлення | load, setX, save | завантажили, змінили, flush. persist для вже керованої сутності не потрібен |
11 + | Зв'язки | лише id-колонки, join у колекції вручну | ManyToOne, OneToMany, ManyToMany з лінивим завантаженням |
12 + | Той самий рядок двічі | два різні об'єкти | той самий об'єкт (identity map) |
13 + | Схема БД | декларативна db_schema.xml | мапінг атрибутами + версіоновані міграції |
14 + | Сирий SQL | ResourceConnection | DBAL Connection |
15 +
16 + ## Сутність
17 +
18 + **Symfony** · src/Entity/Question.php
19 +
20 + ```php
21 + use Doctrine\ORM\Mapping as ORM;
22 +
23 + #[ORM\Entity(repositoryClass: QuestionRepository::class)]
24 + #[ORM\Table(name: 'faq_question')]
25 + class Question
26 + {
27 + #[ORM\Id, ORM\GeneratedValue, ORM\Column]
28 + private ?int $id = null;
29 +
30 + #[ORM\Column(length: 255, unique: true)]
31 + private string $slug;
32 +
33 + #[ORM\Column]
34 + private bool $isActive = true;
35 +
36 + #[ORM\Column]
37 + private int $position = 0;
38 +
39 + #[ORM\Column]
40 + private \DateTimeImmutable $createdAt;
41 +
42 + public function __construct(
43 + #[ORM\Column(length: 255)]
44 + private string $title,
45 + #[ORM\Column(type: Types::TEXT)]
46 + private string $answer,
47 + #[ORM\ManyToOne(inversedBy: 'questions')]
48 + #[ORM\JoinColumn(nullable: false)]
49 + private Category $category,
50 + )
51 + {
52 + $this->slug = (new AsciiSlugger())->slug($title)->lower()->toString();
53 + $this->createdAt = new \DateTimeImmutable();
54 + }
55 +
56 + public function deactivate(): void
57 + {
58 + $this->isActive = false;
59 + }
60 +
61 + // геттери…
62 + }
63 + ```
64 +
65 + Сутність не зобов'язана мати сетер на кожне поле. Обов'язкове передається в конструктор, зміни стану оформлюються методами з назвою дії (`deactivate()`, `rename()`). Генератор `bin/console make:entity` створює геттери й сетери — для старту нормально, потім прибирайте зайве.
66 +
67 + ## Запити: Collection → QueryBuilder
68 +
69 + **Magento** · колекція
70 +
71 + ```php
72 + $collection = $this->collectionFactory->create()
73 + ->addFieldToFilter('category_id', $categoryId)
74 + ->addFieldToFilter('is_active', 1)
75 + ->setOrder('position', 'ASC')
76 + ->setPageSize(20);
77 +
78 + foreach ($collection as $question) {
79 + echo $question->getData('title');
80 + }
81 + ```
82 +
83 + **Symfony** · метод репозиторію
84 +
85 + ```php
86 + /** @extends ServiceEntityRepository<Question> */
87 + class QuestionRepository extends ServiceEntityRepository
88 + {
89 + public function __construct(ManagerRegistry $registry)
90 + {
91 + parent::__construct($registry, Question::class);
92 + }
93 +
94 + /** @return list<Question> */
95 + public function findActiveInCategory(Category $category): array
96 + {
97 + return $this->createQueryBuilder('q')
98 + ->andWhere('q.category = :category')
99 + ->andWhere('q.isActive = true')
100 + ->setParameter('category', $category)
101 + ->orderBy('q.position', 'ASC')
102 + ->setMaxResults(20)
103 + ->getQuery()
104 + ->getResult();
105 + }
106 + }
107 + ```
108 +
109 + У QueryBuilder і DQL пишуть імена властивостей і класів, а не колонок і таблиць: `q.isActive`, а не `is_active`. Прості запити є готові: `find($id)`, `findOneBy(['slug' => $slug])`, `findBy([...], ['position' => 'ASC'])`.
110 +
111 + ## Збереження: save() → persist() + flush()
112 +
113 + **Magento** ·
114 +
115 + ```php
116 + $question = $this->questionFactory->create();
117 + $question->setTitle($title);
118 + $question->setCategoryId($categoryId);
119 + $this->questionRepository->save($question);
120 +
121 + $other = $this->questionRepository->getById(7);
122 + $other->setIsActive(false);
123 + $this->questionRepository->save($other);
124 + ```
125 +
126 + **Symfony** ·
127 +
128 + ```php
129 + $question = new Question($title, $answer, $category);
130 + $this->em->persist($question);
131 +
132 + $other = $this->questions->find(7);
133 + $other->deactivate();
134 + // persist() для $other не потрібен: вона вже керована
135 +
136 + $this->em->flush(); // один INSERT і один UPDATE в одній транзакції
137 + ```
138 +
139 + :::danger subtitle="flush() — не save()"
140 + `flush()` записує всі зміни всіх сутностей, які знає UnitOfWork, а не одну передану. Якщо десь вище по стеку змінили іншу сутність і не хотіли її зберігати, вона теж запишеться. У Doctrine ORM 3 виклик `flush($entity)` прибрали, тож старий код, що покладався на «часткове» збереження, треба переглядати, а не просто видаляти аргумент.
141 +
142 + Хто відповідає за flush, вирішують на рівні застосунку: зазвичай це контролер, обробник повідомлення або команда, тобто точка входу, а не репозиторій глибоко всередині.
143 + :::
144 +
145 + ## Міграції замість db_schema.xml
146 +
147 + - Змінили мапінг → `bin/console make:migration` (або `doctrine:migrations:diff`) генерує клас у `migrations/` з SQL різниці між мапінгом і поточною БД.
148 + - Перегляньте згенерований SQL перед комітом: перейменування колонки diff бачить як DROP + ADD.
149 + - `doctrine:migrations:migrate` на деплої — аналог `setup:upgrade` для схеми.
150 + - `doctrine:schema:validate` перевіряє, що мапінг коректний і збігається з БД.
151 + - Тестові й демо-дані — `doctrine/doctrine-fixtures-bundle` або Zenstruck Foundry, команда `doctrine:fixtures:load`.
152 +
153 + ## Зв'язки і N+1
154 +
155 + Зв'язки ліниві: `$question->getCategory()->getName()` у циклі по 50 питаннях дасть 50 додаткових запитів. Профайлер покаже це на вкладці Doctrine. Рішення — забрати зв'язок одним запитом:
156 +
157 + **Symfony** · fetch join
158 +
159 + ```php
160 + return $this->createQueryBuilder('q')
161 + ->addSelect('c')
162 + ->join('q.category', 'c')
163 + ->andWhere('q.isActive = true')
164 + ->getQuery()
165 + ->getResult();
166 + ```
167 +
168 + У двосторонньому зв'язку в БД записується лише owning side (сторона з `ManyToOne` / `JoinColumn`). Додавання в колекцію `$category->getQuestions()->add($q)` без виставлення `$q->category` нічого не збереже, тому методи-хелпери оновлюють обидві сторони.
169 +
170 + ## Масова обробка
171 +
172 + **Symfony** · імпорт/перерахунок без витоку пам'яті
173 +
174 + ```php
175 + $i = 0;
176 + $query = $this->questions->createQueryBuilder('q')->getQuery();
177 +
178 + foreach ($query->toIterable() as $question) {
179 + $question->recalculateRank();
180 + if (++$i % 200 === 0) {
181 + $this->em->flush();
182 + $this->em->clear();
183 + }
184 + }
185 + $this->em->flush();
186 + $this->em->clear();
187 + ```
188 +
189 + Identity map тримає кожну завантажену сутність, доки не викличете `clear()`. Після `clear()` усі раніше отримані об'єкти від'єднані: їхні зміни більше не збережуться, а зв'язки на них треба перечитати. Для по-справжньому масових змін швидше працювати через DBAL або DQL `UPDATE`.