Міграція з Eloquent на Doctrine: покрокова стратегія

Міграція між ORM — одна з найскладніших задач у PHP-проєктах. Проте стратегія може бути дуже простою, якщо дотримуватися чіткої послідовності дій:

  1. Тести на існуючий код (функціональні).
  2. Додавання гетерів і сетерів на всі поля моделей.
  3. Створення виділених репозиторіїв для кожної з моделей.
  4. Інжект репозиторіїв як залежності в місця використання.
  5. Винесення функціоналу роботи з БД в репозиторії.
  6. Використання репозиторіїв замість викликів статики Eloquent.
  7. Оновлення (міграція) тестів.
  8. Маппінг моделей в 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.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось2
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Цікаво було б дізнатися ще, чим був обгрунтований сам перехід, і що покращилось, які метрики змінились і в який бік

ну обгрунтування переходу доволі прозаїчне — весь проект переїхав на симфу, лишився лише елоквент. то ж і його перевезли.

Якщо ви зав’язані на ORM, то вам буде складно перейти на іншу, тому що багато вселяких прибамбасів розкидано по коду.
В своїй статті dou.ua/forums/topic/58294 я показую як писати код без прив’язки к фреймворку. Оце дійсно незалежний код. Нажаль ORM — це зайва деталь в цьому механізмі.

Ви частково праві — код має бути незалежним. Саме тому варто відмовитись від фішек ларки чи фішек доктріни і використовувати чисті паттерні як от «дата маппер» та «репозиторій». Саме це я і описав в статті.
А писати свою ОРМку взагалі не варто.

Тут все ще є витік Eloquent у бізнес-логіку. Якщо ми вводимо репозиторій як boundary, він має повертати об’єкт доменного/application-рівня, а ще краще інтерфейс чогось доменого рівня, а не ORM model. Інакше абстракція фактично не ізолює persistence, а просто маскує його.
У хорошому дизайні логіці має бути байдуже, як саме зберігаються дані: через Eloquent, SQL, файл чи зовнішній сервіс. Це деталь infrastructure layer.

А взагалі, усі ці Laravel-фічі — зло на проєктах, більших за hello world. Потім все заплутується, баги-барабаги та повільна робота.

так, тут не описано як використовується модель — чи є фона повноцінною доменною моделю, чи просто массивчик з даними, але це вже не не елоквент чи доктріну.

А взагалі, усі ці Laravel-фічі — зло на проєктах, більших за hello world.

які саме фічі і чого саме від них все заплутається?
в будь-якому проекті складнішому за hello world не можна юзати active record?

Підписатись на коментарі