Odzyskaj kontrolę nad swoimi migracjami! Sortowanie migracji w Doctrine Migrations Bundle.

Jeżeli na co dzień pracujesz z frameworkiem Symfony, to myślę, że ikonicznego Doctrine Migrations Bundle nie muszę Ci przedstawiać. Jeżeli jednak pierwszy raz się spotykasz z tą nazwą, to śpieszę z wyjaśnieniem, że jest to paczka kompatybilna z frameworkiem Symfony i Doctrine ORM, za pomocą której możemy uruchamiać, tworzyć i zarządzać migracjami bazodanowymi.

W tym artykule przedstawię problem, który może zaistnieć w aplikacjach używających doctrine/migrations-bundle. Ten problem zazwyczaj będzie dotyczył projektów bazujących na kodzie zewnętrznych platform, które ze względu na dynamicznie zmieniające się wymagania interesariuszy, muszą modyfikować kod dostawców 3rd party.

Czym są migracje bazodanowe?

W cyklu wytwarzania oprogramowania aplikacji, która jest połączoną z bazą danych, zupełnie naturalnym zjawiskiem jest fakt, że schemat bazy danych będzie się zmieniał. Migracje bazodanowe są mechanizmem, który umożliwia kontrolowane i automatyczne wprowadzanie zmian w strukturze bazy danych. Dzięki migracjom możemy definiować i śledzić wersje schematu bazy danych w postaci plików migracyjnych. Umożliwia nam to tworzenie, aktualizację i cofanie zmian w bazie danych w sposób uporządkowany i bezpieczny. Aby móc te zmiany później zaaplikować na środowiskach o różnym przeznaczeniu, czyli np. stagingowym, produkcyjnym, jak i również na środowiskach twoich kolegów z biurka obok, musisz napisać migrację. Migracja ta zostanie później uruchomiona za pomocą narzędzia takiego jak doctrine-migrations-bundle.

Nie wiesz czym są migracje bazodanowe w Doctrine ORM?


W tym artykule skupię się na pewnym bardzo konkretnym problemie w ramach migracji bazodanowych przy użyciu doctrine/migrations-bundle i zakładam, że wiesz w jaki sposób to działa.

Jeżeli jednak chcesz dowiedzieć się więcej na ten temat, to chciałbym Ci serdecznie polecić artykuł Marcina Kuklińskiego, który w bardzo przystępny sposób może wprowadzić Cię w temat w ramach poniższego artykułu:

Każda migracja, którą stworzymy, jest potem wersjonowana. Zazwyczaj, wersjonowanie to odbywa się poprzez dodanie do nazwy klasy daty, w której została ona stworzona. Na przykład migracja może wyglądać w sposób następujący:

namespace MyApp\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20201015121224 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
       $this->addSql('CREATE TABLE cats (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL);');
    }

    public function down(Schema $schema): void
    {
       $this->addSql('DROP TABLE cats;');
    }
}

Nazwa klasy migracji, będzie wskazywać na to, że jest to migracja, która została utworzona 15 października 2020 roku o godzinie 12:12:24. Wraz z tym, jak aplikacja powstaje, dochodzą do niej kolejne migracje.

Tło problemu

Rozważmy zatem aplikację, która łączy się z bazą danych SQL. Aplikacja ta jest oparta o framework Symfony i Doctrine ORM. Aplikacja ta posiada już pewne migracje.

Migracje aplikacji

Ze względu na to, że nie chcę nadwerężać Twoich oczu, to pozwolę sobie skrócić nieco daty w nazwach tych migracji ( ͡° ͜ʖ ͡°).

MyApp\Migrations\Version20201015
MyApp\Migrations\Version20201016
MyApp\Migrations\Version20201017

Standardowo doctrine-migrations-bundle uruchomi migracje posortowane w sposób alfabetyczny:

$ bin/console doctrine:migrations:migrate -v

[info] ++ migrating MyApp\Migrations\Version20201015
[info] ++ migrating MyApp\Migrations\Version20201016
[info] ++ migrating MyApp\Migrations\Version20201017

Prosta sprawa! Takie podejście może jak najbardziej działać przez lata i nie będzie z tym absolutnie żadnego problemu.

Migracje aplikacji vs. migracje platformy

Załóżmy, że aplikacja, którą rozważamy, bazuje na jakiejś platformie, takiej jak na przykład Sylius czy Akeneo. Platformy te dostarczają nam masę przydatnych funkcjonalności dostępnych out of the box. Oprócz tego posiadają również własne migracje bazujące na doctrine-migrations-bundle ( ͡° ͜ʖ ͡°)…

Okazuje się więc, że nasza aplikacja musi uruchomić następujące migracje:

CoolPlatform\Migrations\Version20190120
CoolPlatform\Migrations\Version20190121
CoolPlatform\Migrations\Version20190122
MyApp\Migrations\Version20201015
MyApp\Migrations\Version20201016
MyApp\Migrations\Version20201017

Doctrine Migrations Bundle uruchomi to w następującej kolejności:

[info] ++ migrating CoolPlatform\Migrations\Version20190120
[info] ++ migrating CoolPlatform\Migrations\Version20190121
[info] ++ migrating CoolPlatform\Migrations\Version20190122
[info] ++ migrating MyApp\Migrations\Version20201015
[info] ++ migrating MyApp\Migrations\Version20201016
[info] ++ migrating MyApp\Migrations\Version20201017

Migracje 3rd party

Żeby było ciekawiej, załóżmy, że do naszej platformy doinstalowaliśmy sobie jakiś plugin 3rd party. Ten plugin również posiada swoje własne migracje.

namespace FabulousPlugin\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20200314 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
       $this->addSql('CREATE TABLE great_feature_table (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL);');
    }

    public function down(Schema $schema): void
    {
       $this->addSql('DROP TABLE great_feature_table;');
    }
}

Migracje zostaną uruchomione w następującej kolejności:

[info] ++ migrating CoolPlatform\Migrations\Version20190120
[info] ++ migrating CoolPlatform\Migrations\Version20190121
[info] ++ migrating CoolPlatform\Migrations\Version20190122
[info] ++ migrating FabulousPlugin\Migrations\Version20200314
[info] ++ migrating MyApp\Migrations\Version20201015
[info] ++ migrating MyApp\Migrations\Version20201016
[info] ++ migrating MyApp\Migrations\Version20201017

Zastosowane podejście wciąż się sprawdza. Migracje, które zostały dodane z perspektywy aplikacji, wciąż są uporządkowane w sposób prawidłowy.

Aplikacja od dawna została już zdeployowana na produkcję. Wszyscy zarabiają pieniądze i są szczęśliwi. Aż tu nagle…

Migracje on-top innych migracji

Developer pracujący nad naszą hipotetyczną aplikacją otrzymuje nowe zadanie. Zadanie to polega na zmodyfikowaniu sposobu działania pluginu, który został wcześniej zainstalowany. Aby zrealizować zadanie, musi on zaingerować w schemę bazy danych, utworzoną wcześniej przez plugin.

namespace MyApp\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20210501 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
       $this->addSql('ALTER TABLE great_feature_table ADD status VARCHAR(255) DEFAULT NULL;');
    }

    public function down(Schema $schema): void
    {
       $this->addSql('ALTER TABLE great_feature_table DROP status;');
    }
}

Kolejność wykonania migracji wygląda w sposób następujący:

[info] ++ migrating CoolPlatform\Migrations\Version20190120
[info] ++ migrating CoolPlatform\Migrations\Version20190121
[info] ++ migrating CoolPlatform\Migrations\Version20190122
[info] ++ migrating FabulousPlugin\Migrations\Version20200314
[info] ++ migrating MyApp\Migrations\Version20201015
[info] ++ migrating MyApp\Migrations\Version20201016
[info] ++ migrating MyApp\Migrations\Version20201017
[info] ++ migrating MyApp\Migrations\Version20210501

Kolejność wykonywania migracji wciąż jest prawidłowa!

Problem sortowania migracji

Czas mija nieubłaganie, a w aplikacji przybywa coraz to więcej migracji. Przezorni developerzy cyklicznie aktualizują platformę i pluginy do najnowszych wersji. Ze względu na to, że migracje były uruchamiane wyłącznie na ich lokalnych środowiskach oraz za pomocą CI/CD na systemie produkcyjnym i stagingowym, nikt nie zwrócił uwagi na to, że kolejność wykonywania migracji wygląda teraz w sposób następujący:

[info] ++ migrating CoolPlatform\Migrations\Version20190120
[info] ++ migrating CoolPlatform\Migrations\Version20190121
[info] ++ migrating CoolPlatform\Migrations\Version20190122
[info] ++ migrating CoolPlatform\Migrations\Version20201129
[info] ++ migrating CoolPlatform\Migrations\Version20211202
[info] ++ migrating FabulousPlugin\Migrations\Version20200314
[info] ++ migrating FabulousPlugin\Migrations\Version20220703
[info] ++ migrating MyApp\Migrations\Version20201015
[info] ++ migrating MyApp\Migrations\Version20201016
[info] ++ migrating MyApp\Migrations\Version20201017
[info] ++ migrating MyApp\Migrations\Version20210501
[info] ++ migrating MyApp\Migrations\Version20220514

Wprawione oko obserwatora zauważy, że migracje, które zostały uruchomione, nie są posortowane po dacie, lecz alfabetycznynie po nazwach namespace’ów klas migracji.

Załóżmy teraz, że nowa migracja plugina, który zainstalowaliśmy, robi następującą rzecz:

namespace FabulousPlugin\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20220703 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
    // verify
       $this->addSql('RENAME TABLE great_feature_table TO fabulous_feature_table;');
    }

    public function down(Schema $schema): void
    {
       $this->addSql('RENAME TABLE fabulous_feature_table TO great_feature_table;');
    }
}

Developerzy naszej aplikacji wciąż dodawali kolejne migracje:

[info] ++ migrating CoolPlatform\Migrations\Version20190120
[info] ++ migrating CoolPlatform\Migrations\Version20190121
[info] ++ migrating CoolPlatform\Migrations\Version20190122
[info] ++ migrating CoolPlatform\Migrations\Version20201129
[info] ++ migrating CoolPlatform\Migrations\Version20211202
[info] ++ migrating FabulousPlugin\Migrations\Version20200314
[info] ++ migrating FabulousPlugin\Migrations\Version20220703
[info] ++ migrating MyApp\Migrations\Version20201015
[info] ++ migrating MyApp\Migrations\Version20201016
[info] ++ migrating MyApp\Migrations\Version20201017
[info] ++ migrating MyApp\Migrations\Version20210501
[info] ++ migrating MyApp\Migrations\Version20220514
[info] ++ migrating MyApp\Migrations\Version20230515

I wtedy developerzy dostają nowe zadanie. Muszą uruchomić aplikację na nowym środowisku dla innego klienta. Z pomocą DevOpsów uruchamiają aplikację na nowym środowisku przy pomocy niezawodnych dotychczas skryptów zawartych w ramach procesu CI/CD.

W kontekście bazy danych plan jest taki, aby utworzyć zupełnie „czystą” schemę bazy danych, czyli po prostu uruchomić migracje od stanu „zerowego”.

Jak się okazuje, nie jest to możliwe:

[info] ++ migrating CoolPlatform\Migrations\Version20190120
[info] ++ migrating CoolPlatform\Migrations\Version20190121
[info] ++ migrating CoolPlatform\Migrations\Version20190122
[info] ++ migrating CoolPlatform\Migrations\Version20201129
[info] ++ migrating CoolPlatform\Migrations\Version20211202
[info] ++ migrating FabulousPlugin\Migrations\Version20200314
[info] ++ migrating FabulousPlugin\Migrations\Version20220703
[info] ++ migrating MyApp\Migrations\Version20201015
[info] ++ migrating MyApp\Migrations\Version20201016
[info] ++ migrating MyApp\Migrations\Version20201017
[info] ++ migrating MyApp\Migrations\Version20210501
[error] Migration MyApp\Version20210501 failed during Execution. Error: 

"An exception occurred while executing 'ALTER TABLE great_feature_table ADD status VARCHAR(255) DEFAULT NULL;':

SQLSTATE[42S02]: Base table or view not found: 1146 Table 'great_feature_table' doesn't exist"

[info] ++ migrating MyApp\Migrations\Version20220514
[info] ++ migrating MyApp\Migrations\Version20230515

Jedna z migracji rzuciła nam błąd. Oczywiście jest to feralna migracja, w której jeden z developerów zaingerował w schemę bazy danych jednego z pluginów. Niefortunnie twórcy plugina zmienili później nazwę tej tabeli. Doctrine, który w trybie domyślnym uruchamia migracje poprzez sortowanie klas migracji alfabetycznie, nie był w stanie samodzielnie zadecydować o tym, w jaki sposób uruchomić migracje w sposób prawidłowy.

Problem

Problemem, który rozważamy, jest fakt, że doctrine-migrations-bundle uruchamia migracje poprzez alfabetyczne posortowanie nazwy klasy i jej namespace’a.

Na podstawie tła, które przedstawiłem, możemy uprościć nasz problem do następującej postaci:

ModuleA\Migrations\V01
ModuleA\Migrations\V02
ModuleB\Migrations\V03
ModuleB\Migrations\V06
ModuleC\Migrations\V04
ModuleC\Migrations\V05

Z perspektywy aplikacji jest dla nas logiczne, że chcielibyśmy, aby migracje zostały uruchomione, poprzez sprawdzenie jedynie ostatniego członu namespace’a. A więc w następującej kolejności:

ModuleA\Migrations\V01
ModuleA\Migrations\V02
ModuleB\Migrations\V03
ModuleC\Migrations\V04
ModuleB\Migrations\V06
ModuleC\Migrations\V05

Z perspektywy doctrine/migrations-bundle jest to nielogiczne, ponieważ nazwy te nie są posortowane alfabetycznie.

Rozwiązanie

W obliczu tego problemu najprostszym rozwiązaniem jest użycie następującej implementacji.

Doctrine używa własnej implementacji odpowiedzialnej za porównywanie wersji w algorytmie sortowania:

// vendor/doctrine/migrations/lib/Doctrine/Migrations/Version/AlphabeticalComparator.php
<?php

declare(strict_types=1);

namespace Doctrine\Migrations\Version;

use function strcmp;

final class AlphabeticalComparator implements Comparator
{
    public function compare(Version $a, Version $b): int
    {
        return strcmp((string) $a, (string) $b);
    }
}

Możemy zatem stworzyć własną implementację Comparatora:

<?php

namespace MyApp\Doctrine\DateVersionComparator;

use Doctrine\Migrations\Version\Comparator;
use Doctrine\Migrations\Version\Version;

class DateVersionComparator implements Comparator
{
    public function compare(Version $a, Version $b): int
    {
        return strcmp($this->versionWithoutNamespace($a), $this->versionWithoutNamespace($b));
    }

    private function versionWithoutNamespace(Version $version): string
    {
        $path = explode('\\', (string) $version);

        return array_pop($path);
    }
}

A następnie dokonać prostej konfiguracji:

//config/packages/doctrine_migrations.yaml
doctrine_migrations:
    // ...
    services:
        'Doctrine\Migrations\Version\Comparator': MyApp\Doctrine\DateVersionComparator

Po odświeżeniu cache’u migracje zostaną uruchomione w pożądany sposób:

ModuleA\Migrations\V01
ModuleA\Migrations\V02
ModuleB\Migrations\V03
ModuleC\Migrations\V04
ModuleB\Migrations\V06
ModuleC\Migrations\V05

Inne problemy i rozwiązania

W tym artykule przedstawiłem jedynie wierzchołek góry lodowej. W rzeczywistości problemy z kolejnością uruchamiania migracji bazodanowych mogą być zupełnie inne. Wszystko zależy od pożądanego rezultatu i tego, w jaki sposób w aplikacji były dodawane kolejne migracje.

Częstą sytuacją, z którą możemy się spotkać, jest to, że migracje z jednego modułu będziemy chcieli uruchomić przed migracjami z innego. Czyli zamiast:

ModuleA\Migrations\V01
ModuleB\Migrations\V02

Chcemy uzyskać następujący rezultat:

ModuleB\Migrations\V02
ModuleA\Migrations\V01

Oczywiście podobnie jak w proponowanym przeze mnie rozwiązaniu możemy pisać własną implementację Comparatora. Jednak po co to robić, skoro ktoś już to zrobił za nas? Istnieje świetna implementacja SyliusLabs/DoctrineMigrationsExtraBundle, która pozwoli nam na utworzenie hierarchii między migracjami modułów.

Podsumowanie

Mam nadzieję, że artykuł ci się podobał i zwróciłem Twoją uwagę na problem kolejności uruchamiania migracji. W różnych sytuacjach i kontekstach rozwiązanie tego problemu może się wydawać nieoczywiste. Warto zapoznać się z dostępnymi możliwościami customizacji zachowania doctrine/migrations-bundle.

Czy zdarzyły Ci się jakieś ciekawe problemy z migracjami bazodanowymi? A jeżeli tak, to w jaki sposób udało Ci się je rozwiązać? Daj znać w komentarzu!

Podziel się

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *