Bezpieczna migracja z SQLite na MySQL

Zgodnie z najlepszymi praktykami programistycznymi, zalecane jest korzystanie z dedykowanych baz danych (np MySQL, postgresql). Co jeśli już stworzyliśmy stronę opartą o SQLite i zapełnimy ją treścią? Bazując na własnych testach, zauważyłem że wiele narzędzi nie radzi sobie, z obsługą kluczy między bazami bądź z polskimi znakami.

Czas na własne rozwiązanie :)


Migracja pod kontrolą

1. Na samym początku dodajemy import naszej biblioteki która pozwoli skorzystać nam z .env jak z tablicy konfiguracyjnej oraz moduł pymysql do obsługi baz danych. 

blog/project/settings.py

from decouple import config
import pymysql

2. W samym module definiujemy który dokładnie driver wybieramy. W tym przypadku zależy nam aby zintegrować się z MySQL.

blog/project/settings.py

pymysql.install_as_MySQLdb()

3. Następnie, dla zachowania dobrych praktyk, wynosimy nasz secret_key oraz flagę debug, do naszego .env.

blog/project/.settings.py

SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', default=False, cast=bool)

4. Dodajemy teraz nasz nowy driver dla bazy danych, w nowym namespace. Będzie dostępny, tylko gdy określimy dla aplikacji że chcemy z niego korzystać. W innych przypadkach Django skorzysta z "default".

blog/project/settings.py

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': path.join(BASE_DIR, 'db.sqlite3'),
},
'mysql': {
'ENGINE': 'django.db.backends.mysql',
'NAME': config('DB'),
'USER': config('DB_USER'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='3306'),
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_0900_ai_ci'",
},
},
}

5. Dla zachowania bezpieczeństwa, definiujemy nasze .env, z wszystkimi sekretami poza głównym plikiem settings.

blog/project/.env

SECRET_KEY=<klucz z django>
DEBUG=True
DB_NAME=migracja
DB_USER=test
DB_PASSWORD=test
DB_HOST=localhost
DB_PORT=3306

6. Dodajemy teraz nasz .env do .gitignore, tak aby nie przesyłać naszych kluczy do publicznego repozytorium GIT.

blog/.gitignore

.env

7. Następnie uruchamiamy nasz plik migracyjny.

Uwagi:

* Pamiętaj aby wypełnić:

  • SOURCE_DB - nazwą drivera przypisanego do SQLite 
  • DESTINATION_DB - nazwą drivera przypisanego do SQLite 
  • os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') - poprawić nazwę naszego projektu

* Pamiętaj aby założyć pustą bazę danych na docelowym serwerze MySQL.

blog/migracja.py

import os
import django
from django.apps import apps
from django.db import IntegrityError, OperationalError
from django.db.models import ForeignKey
from django.core.management import call_command

SOURCE_DB = 'default' # Nazwa bazy źródłowej (SOURCE_DB)
DESTINATION_DB = 'mysql' # Nazwa bazy docelowej (MySQL)

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') # Nazwa projektu
django.setup()


def run_migrations():
print("[INFO] Wykonywanie migracji na bazie DESTINATION_DB...")
call_command('migrate', database=DESTINATION_DB)


def get_model_dependencies(model):
dependencies = []
for field in model._meta.fields:
if isinstance(field, ForeignKey):
dependencies.append(field.related_model)
return dependencies


def topological_sort(models):
visited = set()
sorted_models = []

def visit(model):
if model in visited:
return
visited.add(model)
for dep in get_model_dependencies(model):
visit(dep)
sorted_models.append(model)

for m in models:
visit(m)

unique_models = []
seen = set()
for m in sorted_models:
if m not in seen:
unique_models.append(m)
seen.add(m)

return unique_models


def migrate_all_models():
print("[INFO] Pobieranie listy modeli...")
all_models = apps.get_models()
models_to_migrate = topological_sort(all_models)

for model in models_to_migrate:
name = model.__name__
label = model._meta.app_label

try:
queryset = model.objects.using(SOURCE_DB).all()
total = queryset.count()
print(f"[INFO] {label}.{name}: {total} rekordów do migracji")

migrated = 0
for obj in queryset:
model.objects.using('default').filter(pk=obj.pk).delete()
try:
obj.save(using='default')
migrated += 1
except IntegrityError as e:
print(f"[ERROR] {label}.{name}: duplikat lub brak FK — {e}")
except Exception as e:
print(f"[ERROR] {label}.{name}: błąd zapisu — {e}")

print(f"[OK] {label}.{name}: zakończono ({migrated}/{total})")

except OperationalError as e:
print(f"[ERROR] {label}.{name}: błąd połączenia — {e}")
except Exception as e:
print(f"[ERROR] {label}.{name}: nieoczekiwany błąd — {e}")


if __name__ == '__main__':
run_migrations()
migrate_all_models()

Poniżej przestawiam Ci, wykonanie naszego skryptu.

programista$ python migracja.py
[INFO] Wykonywanie migracji na bazie DESTINATION_DB...
Operations to perform:
Apply all migrations: admin, auth, blog, contenttypes, django_summernote, newsletter, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying blog.0001_initial... OK
Applying blog.0002_comment... OK
Applying blog.0003_comment_post... OK
Applying blog.0004_mainpagecategory_alter_comment_content_and_more... OK
Applying blog.0005_alter_mainpagecategory_options_and_more... OK
Applying django_summernote.0001_initial... OK
Applying django_summernote.0002_update-help_text... OK
Applying newsletter.0001_initial... OK
Applying newsletter.0002_newsletter_checked... OK
Applying newsletter.0003_alter_newsletter_options_alter_newsletter_checked... OK
Applying sessions.0001_initial... OK

[INFO] Pobieranie listy modeli...
[INFO] blog.MainPageCategory: 5 rekordów do migracji
[OK] blog.MainPageCategory: zakończono (5/5)
[INFO] blog.Category: 15 rekordów do migracji
[OK] blog.Category: zakończono (15/15)
[INFO] auth.User: 1 rekordów do migracji
[OK] auth.User: zakończono (1/1)
[INFO] blog.Post: 58 rekordów do migracji
[OK] blog.Post: zakończono (58/58)
[INFO] blog.Comment: 0 rekordów do migracji
[OK] blog.Comment: zakończono (0/0)
[INFO] newsletter.Newsletter: 0 rekordów do migracji
[OK] newsletter.Newsletter: zakończono (0/0)
[INFO] django_summernote.Attachment: 59 rekordów do migracji
[OK] django_summernote.Attachment: zakończono (59/59)
[INFO] contenttypes.ContentType: 12 rekordów do migracji
[OK] contenttypes.ContentType: zakończono (12/12)
[INFO] admin.LogEntry: 0 rekordów do migracji
[OK] admin.LogEntry: zakończono (0/0)
[INFO] auth.Permission: 48 rekordów do migracji
[OK] auth.Permission: zakończono (48/48)
[INFO] auth.Group: 0 rekordów do migracji
[OK] auth.Group: zakończono (0/0)
[INFO] sessions.Session: 0 rekordów do migracji
[OK] sessions.Session: zakończono (0/0)

8. Teraz, po wykonanym imporcie możemy zauważyć że:

  1. nasza baza danych MySQL została utworzona z migracji,
  2. a następnie uzupełniona z zachowaniem wszelkich znaków specjalnych.

9. Na koniec, w naszym settings.py zmieniamy domyślny driver na mysql-owy wedle poniższego szablonu.

blog/project/settings.py

DATABASES = {
'sqlite': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': path.join(BASE_DIR, 'db.sqlite3'),
},
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': config('DB'),
'USER': config('DB_USER'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='3306'),
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_0900_ai_ci'",
},
},
}

Aktualny blog skorzystał z tej metody :)


Mówię sprawdzam

W jednym pliku definiuję nasze kategorie:

class MainPageCategory(models.Model):
name = models.CharField(_('Name'), max_length=120)

class Category(models.Model):
name = models.CharField(_('Name'), max_length=120)
main_category = models.ForeignKey(MainPageCategory, on_delete=models.CASCADE, verbose_name=_('main_category'), null=True, blank=True)

A w drugim nasz post.

class Post(models.Model):
title = models.CharField(_('Tytuł'), max_length=120)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Autor'), null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, verbose_name=_('Category'))

Jak widzimy, import zachował odpowiednią kolejność naszych postów blogowych jak i momentu kiedy był potrzebny import użytkowników.

[INFO] blog.MainPageCategory: 5 rekordów do migracji
[OK] blog.MainPageCategory: zakończono (5/5)
[INFO] blog.Category: 15 rekordów do migracji
[OK] blog.Category: zakończono (15/15)
[INFO] auth.User: 1 rekordów do migracji
[OK] auth.User: zakończono (1/1)
[INFO] blog.Post: 58 rekordów do migracji
[OK] blog.Post: zakończono (58/58)

Kamil Mirończuk

I kiedy czegoś gorąco pragniesz, to cały wszechświat sprzyja potajemnie twojemu pragnieniu
~Paulo Coelho

Komentarze

Zostaw komentarz

Twój adres mailowy NIE zostanie opublikowany. W razie otrzymania zapytania, otrzymasz na niego odpowiedź.
Wymagane pola są oznaczone jako *