Django – kopiowanie danych między projektami, czyli bardziej zaawansowane migracje.

Framework Django daje możliwość ręcznego budowania migracji. Jest to bardzo przydatne jeśli potrzebujemy dodać do aplikacji jakieś dane na początek. Ja wykorzystałam ostatnio tę możliwość do skopiowania danych ze starej do nowej wersji aplikacji. Do tej operacji potrzebna mi była jeszcze umiejętność łączenia się z dwiema bazami jednocześnie, oraz uruchamiania zapytań SQL, czyli pracy na bazie z pominięciem mechanizmów oferowanych przez ORM. Jak do tego doszło?

Nowa wersja aplikacji Django

Od dłuższego czasu pracuję nad portalem poświęconym edukacji domowej i adresowanym do praktyków i sympatyków tej formy kształcenia (proszę nie mylić z obecną edukacją zdalną ;) ) Portal został napisany już kilka lat temu i od tamtego czasu jest rozwijany. Niestety jest oparty o Django 1.11, co wtedy było oczywistym rozwiązaniem. Niestety, z biegiem lat Django 1.11 stał się już przestarzałą wersją i pod różnymi względami blokuje dalszy rozwój aplikacji, głównie z powodu niemożności skorzystania z niektórych gotowych bibliotek. Zapadłą więc decyzja o przejściu na wersje Django 3.1. Można by powiedzieć: “Ale w czym problem? Wystarczy zrobić migrację ,poprawić kod tam, gdzie to będzie konieczne i już.”. Jednak z biegiem lat widać również pewne błędy, jakie zostały popełnione przy konstrukcji aplikacji. Teraz mam większą wiedzę, więcej doświadczenia i teraz pewne rzeczy napisałabym inaczej.
I właśnie takie przejście z projektem na nowszą wersję frameworka jest znakomitym momentem, żeby pewne rzeczy napisać inaczej. To jednak rodzi nowe problemy. Projekt jest opublikowany, żyje, ma sporo treści w bazie. Byłoby bez sensu ręczne przepisywanie jej zawartości. Dlatego podczas pisania nowej wersji portalu piszę również migracje, które pozwalają skopiować treści ze starszej wersji do nowej, nawet z uwzględnieniem nieco zmienionej struktury modeli.

Jeden projekt dwie bazy

Na szczęście Django udostępnia szereg poręcznych funkcjonalności, które powodują, że taka operacja jest w ogóle możliwa. Jedną z nich jest możliwość połączenia projektu z kilkoma bazami danych. Standardowe ustawienie połączenia z bazą, po zainicjowaniu projektu w Django wygląda tak:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': 'mydatabase',
    }
}

i oznacza domyślne połączenie projektu z bazą sqlite3. Najczęściej jednak od razu zamienia się to ustawienie, łącząc projekt z bazą PostreSQL albo jak to jest w moim przypadku MySQL

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME':'base_name',
        'USER':'user_name',
        'PASSWORD':'password',
        'HOST':'localhost',
        'OPTIONS': {
            "init_command": "SET foreign_key_checks = 0;",
        },
    },
}

Wtedy wszystkie modele są podczas migracji rzutowane na wskazaną bazę, tam też są przechowywane wszystkie dane. Jak podłączyć inną bazę do projektu? Nic prostszego. Trzeba dołożyć inną konfigurację i nadać jej inną nazwę niż ‘default’

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME':'base_name',
        'USER':'bnase_user',
        'PASSWORD':'password',
        'HOST':'localhost',
        'OPTIONS': {
            "init_command": "SET foreign_key_checks = 0;",
        },
    },
    'old': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME':'old_base_name',
        'USER':'old_user_name',
        'PASSWORD':'old_password',
        'HOST':'localhost',
        'OPTIONS': {
            "init_command": "SET foreign_key_checks = 0;",
        },
    },
}

Prawda, że proste? Tylko jak teraz tego używać? Django oferuje możliwość zdefiniowania routingu i automatyczną obsługę tych połączeń i można o tym poczytać w oficjalnej dokumentacji (Django – multiple databases). Nam jednak będzie potrzebna ręczna obsługa tych połączeń, dlatego tylko na niej się skupię. O automatycznej napiszę może kiedyś kiedy przyjdzie mi jej w jakimś projekcie używać. No ale po kolei.

Migracje ręczne

Żeby użyć połączenia ze starą bazą do kopiowania danych do nowej, potrzebne nam jest jeszcze jedno narzędzie udostępnione przez framework Django, migracje ręczne. Kiedy napiszemy nowy model, albo zmienimy jakiś wcześniej zmigrowany model wystarczy uruchomić w konsoli polecenie:

$ python manage.py makemigrations

i automatycznie zostaną utworzone wszystkie potrzebne pliki migracji z niezbędną zawartością i zależnościami. Kolejna komenda spowoduje zapisanie wprowadzonych zmian w bazie danych:

$ python manage.py migrate

Oczywiście stworzone wcześniej pliki można edytować, dodając do nich kod, który wprowadzi dodatkowe zmiany w bazie, ja jednak wolę takie zmiany wprowadzać w osobnych plikach, zawierających wyłącznie to, co zmieniam ręczne. Żeby to zrobić należy wywołać w terminalu komendę:

$ python manage.py makemigrations –empty app_name –name file_name

W wyniku tego w aplikacji app_name w katalogu migrations pojawi się plik 000x_file_name.py, gdzie x to kolejny numer migracji, znastępującą zawartością:

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('app_name', 'previous_migration_name'),
    ]

    operations = [
    ]

Resztę trzeba dopisać samodzielnie. Po pierwsze należy stworzyć funkcję, która będzie coś robić w bazie, w naszym przypadku kopiować z jednej bazy i wrzucać do drugiej. Ta funkcja musi spełniać dwa warunki:

  1. przyjmuje dwa argumenty: apps i schema_editor,
  2. nie zwraca żadnej wartości.

Zatem zalążek takiej funkcji mógłby wyglądać tak:

def copy_thumbnail(apps, schema_editor):
    pass

Tę funkcję należy wywołać w sekcji operations migracji w następujący sposób:

from django.db import migrations

def copy_thumbnail(apps, schema_editor):
    pass


class Migration(migrations.Migration):

    dependencies = [
        ('app_name', 'previous_migration_name'),
    ]

    operations = [
        migrations.RunPython(copy_thumbnail),
    ]

Po uruchomieniu takiej migracji poleceniem:

$ python manage.py migrate

zostanie wywołana nasza funkcja i zrobi to co jej każemy, czyli na razie nic. W dalszej części tutoriala pokażę, co można kazać jej zrobić, żeby przekopiować dane ze starej bazy do nowej.

Kopiowanie danych za pomocą ORM

Na początek zajmiemy się sytuacją, w której mamy do przeniesienia dane z modelu, który w starej i nowej bazie ma identyczną budowę. To naprawdę bułka z masłem. Do takich danych ze starej bazy możemy się odwołać przez ORM, używając modelu z naszej nowej aplikacji (skoro są identyczne) i pobrać wszystkie dane. Trzeba mu tylko powiedzieć, że chcemy dane z innej bazy niż domyślna. Służy do tego metoda using(). Ja użyłam tego mechanizmu między innymi do przekopiowania zawartości tabeli zawierającej definicje miniatur, pochodzącej z użytego przeze mnie pluginu filer (wygodne narzędzie do zarządzania obrazkami i innymi plikami) i na tym przykładzie pokażę, jak to działa. Wczytanie wszystkich rekordów ze starej bazy będzie wyglądać tak:

thumbnails = ThumbnailOption.objects.using('old').all()

Naprawdę wystarczy tylko wskazać za pomocą metody using(), że chodzi o bazę, do której połączenie zdefiniowane jest w ustawieniach pod nazwą ‘old’ i po krzyku. Teraz trzeba tylko każdy z wierszy zapisać w nowej bazie. Ty razem w metodzie save() trzeba wskazać, że rekordy będą zapisywane w nowej bazie, czyli tej, do której połączenie mamy zdefiniowane pod nazwą ‘default’:

for item in thumbnails:
    item.save(using='default')

A nie mówiłam, że bułka z masłem? Muszę jeszcze, przy okazji, napisać o pewnych trickach, bez których się jednak nie obędzie i trzeba je znać, jak się chce pisać migracje ręcznie i korzystać z modeli. Najpierw zamieszczę pełen kod naszej migracji:

from django.db import migrations


def copy_thumbnail(apps, schema_editor):
    ThumbnailOption = apps.get_model('filer', 'ThumbnailOption')
    thumbnails = ThumbnailOption.objects.using('old').all()
    for item in thumbnails:
        item.save(using='default')


class Migration(migrations.Migration):

    dependencies = [
        ('app_name', 'previous_migration_name'),
    ]

    operations = [
        migrations.RunPython(copy_thumbnail),
    ]

Chcę zwrócić uwagę na to, że nie można korzystać z modeli (w naszym przypadku ThumbnailOption), tak jak to się normalnie robi w aplikacji, czyli przez dołączanie ich poleceniem include. Skrypt nie będzie działał prawidłowo, choć nie wykaże błędu przy samym include. Trzeba to zrobić inaczej.

Model definiuje się za pomocą funkcji apps.get_model(), której jako parametr podaje się nazwę aplikacji i nazwę modelu, tak jak zrobiłam to w powyższym kodzie w wierszu nr 5. I to już koniec sztuczek. Naprawdę można w ten sposób kopiować modele, które pozostały identyczne. Sprawa się tylko troszkę komplikuje jeśli chcemy kopiować zawartość tabeli, które posiadają jakieś powiązania z innymi, a my chcemy te relacja, ale nad tym nie będę się rozpisywać. Przynajmniej nie tym razem.

Czysty SQL w migracjach

Oczywiście, tak jak napisałam na wstępie, mnie najbardziej interesowało, jak skopiować zawartość bazy do modeli, które w nowej wersji projektu mają zmienioną konstrukcję. ORM nie przyjdzie nam z pomocą bo nie możemy się nowymi modelami dobrać do starych baz o innej budowie. Co nam zatem pozostaje? Zawsze możemy użyć czystego SQLa. Uch, na szczęście Django przewiduje taką możliwość.

Na potrzeby tego artykułu rozpatrzę nieskomplikowany przykład kopiowania tabeli ze słowami kluczowymi. W starej wersji była to prosta tabela powiązana relacją one2one z pewną tabelą. W nowej wersji postawiłam na relację many2many (będe ją musiała potem ręcznie ustawić przypisując słowa kluczowe odpowiednim treścom), ale dla odmiany postanowiłam, że model obsługujący słowa kluczowe będzie się opierał na drzewku mptt, co z założenia ma mi ułatwić zachowanie pewnej struktury i porządku w słowach kluczowych. Wyjaśniem to tyko dlatego, żeby przedstawiony niżej kod był bardziej zrozumiały.

Podobnie jak w poprzednim przykładzie należy przygotować sobie ręcznie plik migracji i zdefiniować funkcję oraz jej wywołanie.

from django.db import migrations, connections

def copy_kaywords(apps, schema_editor):
    pass

class Migration(migrations.Migration):

    dependencies = [
        ('app_name', 'previous_migration_name'),
    ]

    operations = [
        migrations.RunPython(copy_kaywords),
    ]

Następnie wczytamy wszystkie słowa kluczowe ze starej bazy za pomocą czystego SQLa ręcznie ustawiając połączenie z bazą.

with connections['old'].cursor() as cursor:
    cursor.execute("SELECT name FROM seo_keyword")
    keywords = cursor.fetchall()

Powyższy kod wykonuje kolejno następujące czynności:

  1. Uruchamia połączenie ze starą bazą na podstawie definicji zapisanej w ustawieniach pod nazwą ‘old’ i inicjuje kursor;
  2. Za pomocą kursora wywołuje zdefiniowaną komendę SQL (w tym wypadku pobiera z bazy jedynie wartość name z tabeli zawierającej słowa kluczowe;
  3. Wczytuje wszystkie wiersze z zapytania za pomocą metody fetchall().

Tym sposobem mamy wczytane wszystkie interesujące nas dane ze starej bazy do zmiennej, którą się daje iterować. Następnie wystarczy tylko w pętli pracowicie zapisać wszystkie wczytane rekordy do nowej struktury:

for idx, item in enumerate(keywords):
    Keyword.objects.create(name=item[0], tree_id=idx+1, lft=1,  rght=2, level=0)

Użyłam pętli z indeksem, gdyż struktura mptt wymaga podania kolejnego numeru drzewa jeśli wstawiane rekordy mają być wszystkie na tym samym poziomie sruktury, w naszym przypadku 0. Należy zwrócić uwagę, że do kolejnych pól wczytanych za pomoca SELECT odwołujemy się poprzez kolejne indeksy jak w krotce.

Podany przykład jest prosty, żeby nie powiedzieć prymitywny. Łatwo jednak wyobrazić sobie, że da się w ten sposób kopiować o wiele bardziej skomplikowane struktury włącznie z relacjami, które możemy skopiować używając klauzuli JOIN w zapytaniu SQL a potem odtwarzać podczas dodawania rekordów do nowej bazy.

Leave a Reply

%d bloggers like this: