Міграція з Eloquent на Doctrine: покрокова стратегія
Міграція між ORM — одна з найскладніших задач у PHP-проєктах. Проте стратегія може бути дуже простою, якщо дотримуватися чіткої послідовності дій:
- Тести на існуючий код (функціональні).
- Додавання гетерів і сетерів на всі поля моделей.
- Створення виділених репозиторіїв для кожної з моделей.
- Інжект репозиторіїв як залежності в місця використання.
- Винесення функціоналу роботи з БД в репозиторії.
- Використання репозиторіїв замість викликів статики Eloquent.
- Оновлення (міграція) тестів.
- Маппінг моделей в Doctrine та оновлення методів репозиторія без зміни сигнатур.
Вітаю, ви перейшли з Eloquent на Doctrine. Тепер розберемо кожен крок докладніше.
Початковий стан
Для прикладу візьмемо спрощений функціонал оновлення сутності:
class ItemController {
public function updateItemAction(UpdateItemDTO $updateData)
{
try {
$currentItem = Item::findOrFail($updateData->id);
} catch (ModelNotFoundException $exception) {
return new Response(‘Item not found’, Response::HTTP*NOT*FOUND);
}
if(null !== $updateData->name) {
$currentItem->name = $updateData->name;
}
$currentItem->save();
return new JsonResponse($currentItem);
}
}
class Item extends Model
{
protected $table = 'item';
}
Тут все приховане «магією» Eloquent. Почнемо міграцію.
Крок 1. Тести на існуючий код
Звісно, перед початком будь-якої роботі нам необхдно додати тести на існуючий код оскільки тут іде робота з базою даних, то тести мають бути як мінімум функціональні з заздалегідь піднятими фікстурами.
class UpdateItemActionTestCase extends KernelTestCase {
public function testWhenItemNotFound()
{
$updateData = new UpdateItemDTO();
$updateData->id = ‘not_exists_id’;
$controller = self::getContainer()->get(ItemController::class);
$result = $controller->updateItemAction($updateData);
self::assertEquals(Response::HTTP*NOT*FOUND, $result->getStatusCode());
}
public function testSuccess()
{
$updateData = new UpdateItemDTO();
$updateData->id = ‘exists_id’;
$updateData->name = ‘New Name’;
$controller = self::getContainer()->get(ItemController::class);
$result = $controller->updateItemAction($updateData);
$item = json_decode($result->getContent());
self::assertEquals(Response::OK, $result->getStatusCode());
self::assertEquals($item->name, $updateData->name);
}
public function testSuccessWithEmptyName()
{
$updateData = new UpdateItemDTO();
$updateData->id = ‘exists_id’;
$updateData->name = null;
$controller = self::getContainer()->get(ItemController::class);
$result = $controller->updateItemAction($updateData);
$item = json_decode($result->getContent());
self::assertEquals(Response::OK, $result->getStatusCode());
self::assertNotNull($item->name);
}
}
Крок 2. Додавання гетерів і сетерів на всі поля моделей
Оновлюється модель Item — замісь магічних викликів додаються явні гетери і сетери (з явними ж типами даних)
class Item extends Model
{
protected $table = 'item';
public function __construct(
string $id,
string $name
) {
parent::__construct([
‘id’ => $id,
‘name’ => $name
]);
}
public function getId(): string
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}
class ItemController
{
public function updateItemAction(UpdateItemDTO $updateData)
{
try {
$currentItem = Item::findOrFail($updateData->id);
} catch (ModelNotFoundException $exception) {
return new Response(‘Item not found’, Response::HTTP_NOT_FOUND);
}
if(null !== $updateData->name) {
$currentItem->setName($updateData->name);
}
$currentItem->save();
return new JsonResponse($currentItem);
}
}
Створені раніше тести мають працювати без змін і не падати.
Крок 3. Створення виділених репозиторіїв для кожної з моделей
Додаємо клас репозиторія на кожну модель, поки що репозиторії пусті:
class ItemRepository
{
}
Крок 4. Інжект репозиторіїв
Додайте репозиториії як залежність всюди, де вони мають використовуватись.
class ItemController
{
public function __construct(readonly private ItemRepository $itemRepo){}
…
}
Крок 5. Винесення будь-якого функціоналу роботи моделей з БД в репозиторії
Тепер виносимо пошук, збереження, оновлення і все, що стосується логіки роботи з БД загалом і Eloquent зокрема в репозиторій і додаємо на кожен з методів достатні функциональні.
class ItemRepository {
public function findOrFail(string $id): Item
{
return Item::findOrFail($id);
}
public function save(Item $item): void
{
$item->save();
}
}
class ItemRepositoryTest extends KernelTestCase
{
public function testNotExistsModelShouldThrowsException(): void
{
$this->expectException(ModelNotFoundException::class);
$id = ‘not_exists_id’;
$repository = self::getContainer()->get(ItemRepository::class);
$repository->findOrFail($id);
}
public function testSuccessFindOrFail(): void
{
$id = ‘exists_id’;
$repository = self::getContainer()->get(ItemRepository::class);
$item = $repository->findOrFail($id);
self::assertInstanceOf(Item::class, $item);
self::assertEquals($id, $item->getId());
}
public function testItemIsFoundableAfterSaving(): void
{
$id = ‘new_id’;
$name = ‘New Item Name’;
$item = new Item($id, $name);
$repository = self::getContainer()->get(ItemRepository::class);
$repository->save($item);
$foundItem = $repository->findOrFail($id);
self::assertInstanceOf(Item::class, $foundItem);
self::assertEquals($id, $foundItem->getId());
self::assertEquals($name, $foundItem->getName());
}
}
Крок 6. Використання репозиторіїв замість викликів статики елоквенту.
Замініть всюди в коді виклики елоквенту на запити до репозиторіїв
class ItemController
{
public function __construct(readonly private ItemRepository $itemRepo){}
public function updateItemAction(UpdateItemDTO $updateData)
{
try {
$currentItem = $this->itemRepo->findOrFail($updateData->id);
} catch (ModelNotFoundException $exception) {
return new Response(‘Item not found’, Response::HTTP_NOT_FOUND);
}
if(null !== $updateData->name) {
$currentItem->setName($updateData->name);
}
$this->itemRepo->save($currentItem);
return new JsonResponse($currentItem);
}
}
Крок 7. Оновлення (міграція) тестів
Оскільки репозиторії вже покриті функціональними тестами, тест контролера можна мігрувати в Unit-тест з використанням Mock-об’єктів. Це значно пришвидшить роботу тестів.
class UpdateItemActionTestCase extends TestCase
{
public function testWhenItemNotFound()
{
$updateData = new UpdateItemDTO();
$updateData->id = ‘not_exists_id’;
$repository = $this->createMock(ItemRepository::class);
$repository->expects(self::once())
->method(‘findOrFail’)
->with($updateData->id)
->willThrowException(new ModelNotFoundException());
$controller = new ItemController($repository);
$result = $controller->updateItemAction($updateData);
self::assertEquals(Response::HTTP_NOT_FOUND, $result->getStatusCode());
}
public function testSuccess()
{
$updateData = new UpdateItemDTO();
$updateData->id = ‘exists_id’;
$updateData->name = ‘New Name’;
$itemMock = $this->createMock(Item::class);
$itemMock->expects(self::once())
->method(‘setName’)
->with(‘New Name’);
$repository = $this->createMock(ItemRepository::class);
$repository->expects(self::once())
->method(‘findOrFail’)
->with($updateData->id)
->willReturn($itemMock);
$repository->expects(self::once())
->method(‘save’)
->with($itemMock);
$controller = new ItemController($repository);
$result = $controller->updateItemAction($updateData);
$item = json_decode($result->getContent());
self::assertEquals(Response::OK, $result->getStatusCode());
self::assertEquals($item->name, $updateData->name);
}
public function testSuccessWithEmptyName()
{
$updateData = new UpdateItemDTO();
$updateData->id = ‘exists_id’;
$updateData->name = null;
$itemMock = $this->createMock(Item::class);
$itemMock->expects(self::never())
->method(‘setName’);
$repository = $this->createMock(ItemRepository::class);
$repository->expects(self::once())
->method(‘findOrFail’)
->with($updateData->id)
->willReturn($item);
$repository->expects(self::once())
->method(‘save’)
->with($itemMock);
$controller = new ItemController($repository);
$result = $controller->updateItemAction($updateData);
$item = json_decode($result->getContent());
self::assertEquals(Response::OK, $result->getStatusCode());
self::assertNotNull($item->name);
}
}
Крок 8. Маппінг моделей в Doctrine та оновлення методів репозиторіїв
На цьому етапі ми змінюємо внутрішню реалізацію, не чіпаючи сигнатури методів та тести.
#[ORM\Entity(repositoryClass: GameRepository::class)]
#[ORM\Table(name: 'item')]
class Item
{
#[ORM\Id]
#[ORM\Column(length: 255)]
private string $id;
#[ORM\Column(length: 255)]
private string $name;
public function __construct(
string $id,
string $name
) {
$this->id = $id;
$this->name = $name;
}
public function getId(): string
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}
Оновлюємо репозиторій:
class ItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Item::class);
}
public function findOrFail(string $id): Item
{
$item = $this->find($id);
if (null === $item) {
throw new ModelNotFoundException();
}
}
public function save(Item $item): void
{
$this->getEntityManager()->persist($item);
$this->getEntityManager()->flush(); //be careful here, cuz it flushes all changes
}
}
Після цієї зміни всі тести мають проходити. Якщо це так — вітаю, ви успішно змінили двигун ORM, не зламавши бізнес-логіку.
для остаточного видалення Eloquent з коду, вже буде необхідна косметика наприклад замінити eloquent ModelNotFoundException на доктрінівський EntityNotFoundException додати toArray() методи до ваших моделей, якщо це потрібно, та прибрати інші залишки згадувань Eloquent.
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівЦікаво було б дізнатися ще, чим був обгрунтований сам перехід, і що покращилось, які метрики змінились і в який бік
ну обгрунтування переходу доволі прозаїчне — весь проект переїхав на симфу, лишився лише елоквент. то ж і його перевезли.
Якщо ви зав’язані на ORM, то вам буде складно перейти на іншу, тому що багато вселяких прибамбасів розкидано по коду.
В своїй статті dou.ua/forums/topic/58294 я показую як писати код без прив’язки к фреймворку. Оце дійсно незалежний код. Нажаль ORM — це зайва деталь в цьому механізмі.
Ви частково праві — код має бути незалежним. Саме тому варто відмовитись від фішек ларки чи фішек доктріни і використовувати чисті паттерні як от «дата маппер» та «репозиторій». Саме це я і описав в статті.
А писати свою ОРМку взагалі не варто.
Тут все ще є витік Eloquent у бізнес-логіку. Якщо ми вводимо репозиторій як boundary, він має повертати об’єкт доменного/application-рівня, а ще краще інтерфейс чогось доменого рівня, а не ORM model. Інакше абстракція фактично не ізолює persistence, а просто маскує його.
У хорошому дизайні логіці має бути байдуже, як саме зберігаються дані: через Eloquent, SQL, файл чи зовнішній сервіс. Це деталь infrastructure layer.
А взагалі, усі ці Laravel-фічі — зло на проєктах, більших за hello world. Потім все заплутується, баги-барабаги та повільна робота.
тобто Doctrine
так, тут не описано як використовується модель — чи є фона повноцінною доменною моделю, чи просто массивчик з даними, але це вже не не елоквент чи доктріну.
які саме фічі і чого саме від них все заплутається?
в будь-якому проекті складнішому за hello world не можна юзати active record?