Wpisy z kategorii: migracje

Django - migracje z south

South jest jedną z najlepszych aplikacji do prostego tworzenia migracji struktur baz danych w Django. Używam south'a od wersji 0.3. I muszę powiedzieć że jest to naprawdę dobre narzędzie. Ułatwia prace przy tworzeniu i rozwijaniu aplikacji w Django. Normalnie gdy tworzymy aplikacje w Django i korzystamy z syncdb, gdy chcemy zmienić strukturę bazy danych to musimy albo usunąć całą bazę i ponownie wykonać syncdb lub pisać "ręcznie" SQL który zmieni tą strukturę. Dodatkowym problemem jest jeżeli chcemy później te same zmiany struktury bazy danych wykonać na środowisku produkcyjnym. Przy większych projektach jest bardzo uciążliwe i zajmuje bardzo dużo czasu. Chciałbym opisać tutaj swoje doświadczenia z south 0.5 i pokazać jego możliwości.

Instalacja

Jeżeli nie mamy jeszcze zainstalowanego south'a to instalujemy:

easy_install south

Zaczynamy:

Tworzymy projekt który posiada dwie aplikacje books oraz authors.

django-admin.py startproject southtest
manage.py startapp books
manage.py startapp authors

Dopisujemy south oraz aplikacje books i authors do INSTALLED_APPS i robimy syncdb. South stworzy sobie tabele w której będzie trzymać historie migracji.

Dopisujemy modele do aplikacji books i authors

Modele z aplikacji authors:

from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=30)

Modele z aplikacji books:

from django.db import models
from southtest.authors.models import Author

class Book(models.Model):
    name = models.CharField(max_length=30)
    author = models.ForeignKey(Author)

Na początku musimy stworzyć pierwsze migracje dla tych dwóch aplikacji:

./manage.py startmigration books --initial
+ Added model 'books.Book'
Created 0001_initial.py.

./manage.py startmigration authors --initial
+ Added model 'books.Book'
Created 0001_initial.py.

South automatycznie stworzy moduł migrations w katalogu aplikacji dla której tworzona jest migracja. W module tym będą trzymane pliki migracji. Stworzone zostaną także migracje które dodadzą tabele reprezentujące modele z obydwu aplikacji. Każda migracja posiada metodę forwards która wykonuje migracje oraz metodę backwards która wycofuje migracje.

Migracja dla modelu Book będzie wyglądać następująco:

from southtest.books.models import *

class Migration:

    def forwards(self, orm):

        # Adding model 'Book'
        db.create_table('books_book', (
            ('author', models.ForeignKey(orm['authors.Author'])),
            ('id', models.AutoField(primary_key=True)),
            ('name', models.CharField(max_length=30)),
        ))
        db.send_create_signal('books', ['Book'])

    def backwards(self, orm):

        # Deleting model 'Book'
        db.delete_table('books_book')

    models = {
        # ... tutaj zamrożony stan bazy danych dla aplikacji books,  w momencie tworzenia migracji
    }

    complete_apps = ['books']

Migracja dla modelu Author:

from south.db import db
from django.db import models
from southtest.authors.models import *

class Migration:

    def forwards(self, orm):

        # Adding model 'Author'
        db.create_table('authors_author', (
            ('id', models.AutoField(primary_key=True)),
            ('name', models.CharField(max_length=30)),
        ))
        db.send_create_signal('authors', ['Author'])

    def backwards(self, orm):

        # Deleting model 'Author'
        db.delete_table('authors_author')

...

South na końcu każdej migracji tworzy stan w jakim znajduje struktura bazy danych dla aplikacji dla której jest migracja. Na tej podstawie potrafi automatycznie przy tworzeniu kolejnej migracji wykryć jakie zmiany zostały dokonane na modelu i stworzyć odpowiednią migracje.

Teraz wykonujemy nasze migracje:

./manage.py migrate
Running migrations for books:
 - Migrating forwards to 0001_initial.
 > books: 0001_initial
   = CREATE TABLE `books_book` (`author_id` integer NOT NULL, `id` integer
   AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(30) NOT NULL); []
   = ALTER TABLE `books_book` ADD CONSTRAINT `author_id_refs_id_5ab0fe2d` 
   FOREIGN KEY (`author_id`) REFERENCES `authors_author` (`id`); []
   = CREATE INDEX `books_book_author_id` ON `books_book` (`author_id`); []
 - Sending post_syncdb signal for books: ['Book']
 - Sending post_syncdb signal for books: ['Book']
 - Loading initial data for books.
Running migrations for authors:
 - Migrating forwards to 0001_initial.
 > authors: 0001_initial
   = CREATE TABLE `authors_author` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(30) NOT NULL); []
 - Sending post_syncdb signal for authors: ['Author']
 - Sending post_syncdb signal for authors: ['Author']
 - Loading initial data for authors.

South wykonał nam wszystkie migracje dla aplikacji dla których mamy stworzone migracje.

Jeżeli mamy jakąś aplikacje na której zostało już wykonane syncdb i tabele już istnieją, a chcemy załączyć dla niej obsługę migracji to tworzymy dla niej pustą migracje za pomocą:

./manage.py startmigration inna_applikacja --initial

Następnie wykonujemy:

./manage.py migrate inna_applikacja --fake

South zapamięta dzięki temu obecny stan bazy danych.

Automatycznie tworzenie migracji

Do modelu book dodajemy nowe pole - category, które będzie kluczem obcym do modelu Category oraz pole published_date. Model Book wygląda teraz tak:

from django.db import models
from southtest.authors.models import Author

class Category(models.Model):
    name = models.CharField(max_length=20)

class Book(models.Model):
    name = models.CharField(max_length=30)
    author = models.ForeignKey(Author)
    category = models.ForeignKey(Category)
    published_date = models.DateField(auto_now_add=True)

Tworzymy nową migracje:

./manage.py startmigration books add_category_and_published_date --auto
+ Added model 'books.category'
+ Added field 'books.book.category'
+ Added field 'books.book.publiched_date'
Created 0002_add_category_and_published_date.py.

South stworzył plik migracji o nazwie 0002_add_category_and_published_date.py. South wykrył że dodaliśmy dwa nowe pola wygenerował odpowiednie migracje. Plik migracji wygląda tak:

from southtest.books.models import *

class Migration:

    def forwards(self, orm):

        # Adding model 'Category'
        db.create_table('books_category', (
            ('id', models.AutoField(primary_key=True)),
            ('name', models.CharField(max_length=20)),
        ))
        db.send_create_signal('books', ['Category'])

        # Adding field 'Book.category'
        db.add_column('books_book', 'category', models.ForeignKey(orm.Category))

        # Adding field 'Book.publiched_date'
        db.add_column('books_book', 'publiched_date', models.DateField(auto_now_add=True))

    def backwards(self, orm):

        # Deleting model 'Category'
        db.delete_table('books_category')

        # Deleting field 'Book.category'
        db.delete_column('books_book', 'category_id')

        # Deleting field 'Book.publiched_date'
        db.delete_column('books_book', 'publiched_date')

South wygenerował nam migracje która stworzy tabele books_category, doda pola category_id oraz publiched_date do tabeli books_book.

Wycofywanie migracji

South umożliwia także wycofywanie migracji do dowolnego stanu bazy danych. Należy tu pamiętać aby jeżeli piszemy migracje "ręcznie", zawsze pisać do nich migracje wycofujące zmiany, gdyż nigdy nie wiadomo czy w czasie pracy nad projektem nie będziemy chcieli wrócić do jakiegoś konkretnego stanu bazy danych.

Aby wycofać do jakiegoś konkretnego stanu bazy danych, używamy polecenia migrate podając nazwę aplikacji oraz nazwę migracji do której chcemy się cofnąć, np:

./manage.py migrate books 0001_initial
Running migrations for books:
 - Migrating backwards to just after 0001_initial.
 < books: 0002_add_category_and_published_date
   = DROP TABLE `books_category` CASCADE; []
   = ALTER TABLE `books_book` DROP COLUMN `category_id` CASCADE; []
   = ALTER TABLE `books_book` DROP COLUMN `publiched_date` CASCADE; []

W tym przykładzie south cofnie się do stanu bazy danych po zaaplikowaniu migracji 0001_initial. Aby całkowicie usunąć wszystkie migracje dla danej aplikacji, używamy:

./manage.py migrate books zero
Running migrations for books:
 - Migrating backwards to just after zero.
 < books: 0001_initial
   = DROP TABLE `books_book` CASCADE; []

Migracje danych

Z migracjami struktur baz danych wiążą się także migracje danych.

Tworzymy szablon migracji do którego wstawimy nasze migracje danych.

./manage.py startmigration books change_category_name
Created 0003_change_category_name.py.

Stworzony został plik migracji:

from south.db import db
from django.db import models
from southtest.books.models import *

class Migration:

    def forwards(self, orm):
        "Write your forwards migration here"

    def backwards(self, orm):
        "Write your backwards migration here"

    models = {
        'books.book': {
            'author': ('models.ForeignKey', ['Author'], {}),
            'category': ('models.ForeignKey', ['Category'], {}),
            'id': ('models.AutoField', [], {'primary_key': 'True'}),
            'name': ('models.CharField', [], {'max_length': '30'}),
            'publiched_date': ('models.DateField', [], {'auto_now_add': 'True'})
        },
        'books.category': {
            'id': ('models.AutoField', [], {'primary_key': 'True'}),
            'name': ('models.CharField', [], {'max_length': '20'}),
            'name2': ('models.CharField', [], {'max_length': '20'})
        },
        'authors.author': {
            '_stub': True,
            'id': ('models.AutoField', [], {'primary_key': 'True'})
        }
    }

    complete_apps = ['books']

South na końcu każdego pliku migracji zamraża obecny stan modeli dla aplikacji dla której została stworzona migracja. Umożliwia to operacje na danych z konkretnego stanu modeli.

W każdej migracji jest dostępny obiekt orm który jest nakładką na prawdziwy ORM Django, który umożliwia operacje na modelach w konkretnym stanie.

Przykład:

Jeżeli chcialibyśmy dodać migracje która np. we wszystkich obiektach typu Category w polu name zamieni znak " " na znak "-" to używając takiego zapisu:

for category in Category.objects.all():    
    category.name = category.name.replace(" ", "-")
    category.save()

To migracja ta będzie działać tylko w momencie pisania tego kodu, jeżeli w przyszłości zmienimy model Book i dopiszemy np. do niego jakieś pole to już migracja przestanie działać. Jeżeli natomiast użyjemy obiektu orm dostępnego w metodzie forwards i backwards każdej klasy migracji to będziemy mogli dokonywać operacji na modelach w konkretnym stanie.

for category in orm.Category.objects.all():    
    category.name = category.name.replace(" ", "-")
    category.save()

Teraz migracja ta będzie działać nawet jak zmieni się model Category.

Należy jednak pamiętać że south domyślenie zamraża tylko stan modeli dla aplikacji dla której tworzona jest migracja. Więc domyślenie w obiekcie orm dostępny będzie tylko model Book, Category i Author, jeżeli chcielibyśmy mieć w tej migracji dostępny także model z innej aplikacji, to musimy przy tworzeniu migracji wskazać dla jakiej aplikacji ma być dodatkowo zamrożony jej stan. Służy do tego opcja --freeze polecenia startmigration. Np. następujące polecenie zamrozi także stan aplikacji auth:

/manage.py startmigration books change_category_name --freeze=auth

Do naszego utworzonego wcześniej szablonu wpisujemy w metodzie forwards kod ktory dokona migracji danych.

class Migration:
    no_dry_run = True
    def forwards(self, orm):
        for category in orm.Category.objects.all():    
            category.name = category.name.replace(" ", "-")
            category.save() 

    def backwards(self, orm):
        for category in orm.Category.objects.all():    
            category.name = category.name.replace("- ", " ")
            category.save() 

models = {
        ...
    }

complete_apps = ['books']

Ponieważ south dla każdej migracji przed jej wykonaniem robi tzn "dry run" czyli próbuję czy uda się dokonać zmian na bazie danych, dlatego trzeba upewnić się że kod naszej migracji danych będzie wykonany tylko gdy nie jest wykonywany tryb "dry run", innaczej zmiany na danych zostną dokonane dwa razy. Można to zrobić na dwa sposoby:

  • dopisując pole: no_dry_run = True do klasy Migration
  • sprawdzając w metodzie forwards czy db.dry_run jest ustawione na False

Pisanie migracji zależnych od innych migracji

Jeżeli mamy jakąś migracje którą chcieli byśmy wykonać przed inną migracją, to do klasy migracji dopisujemy pole depends_on gdzie określamy aplikacje oraz migracje do której mają zostać wykonane wszystkie migracje przed uruchomieniem właściwej migracji. Przykład:

class Migration:

    depends_on = (
        ("authors", "0003_change_name"),
    )

    def forwards(self):
        ....

Rzeczy o których warto pamiętać przy pisaniu migracji

Należy pamiętać aby dla migracji danych także pisać migracje wycofujące zmiany w metodzie backwards. Jest bardzo ważne, z mojego doświadczenia wiem że nie pisanie migracji wstecznych się mści. W czasie tworzenia projektu, na początku mówi się że przecież wystarczy ręcznie usunąć z bazy dwie tabelki, kilka wpisów z tabeli south_migrationhistory i już można zapuścić jeszcze raz migracje, jednak gdy dochodzą migracje z różnych aplikacji to czas wykonania tej operacji się wydłuża. Dodatkowo migracje danych które wykonują się tylko w jedną stronę, mogą w przypadku błędów w kodzie powodować dodatkowe problemy. Więc lepiej zawsze pamiętać o pisaniu migracji wstecznych. Dzięki temu zawsze można wycofać wszystkie migracje i cofnąć się do dowolnego stanu bazy danych.

Bardzo ważną rzeczą jest aby nigdy nie mieszać ze sobą migracji danych i migracji struktur bazy danych. Migracje powinny być oddzielnymi częściami. Jeżeli zacznie się mieszać je ze sobą to może dojść do problemów z poprawnością wykonywania migracji. Np. jeżeli migracja składa się z dodawania kolumny do tabeli w bazie danych oraz operacji na danych to mimo iż kolumna się dopisze poprawnie do bazy danych, to i tak cała migracja może się wyłożyć przy operacji na danych, np. z powodu błędu w kodzie. Migracje powinny się wykonywać poprawnie tylko w całości.

Warto w setings.py dopisać SOUTH_TESTS_MIGRATE = False. Wtedy w trakcie uruchamiania testów będzie działać standardowe syncdb z Django. Inaczej south domyślenie podmienia syncdb z Django swoim własnym które synchronizuje tylko aplikacje dla których nie ma załączonych migracji.