Django - migracje bazy, które nie zawsze działają

O kiepskim podejściu do migracji schematów baz danych w Django pisałem już w 2014r. Co jakiś czas jednak temat do mnie wraca, gdyż w niektórych projektach używam (niestety) tego rozwiązania. Powody są różne, a główny to "oszczędność czasu". No bo trudno zaprzeczyć, że automatyczne wygenerowanie plików z migracjami jest wolniejsze od klepania XML-i Liquibase lub plain SQL, prawda? Sam z tego przecież korzystam...

Jednak bywają takie momenty, gdzie zostaję z tymi migracjami w "czterech literach", gdzie nawet nie dochodzi ani jeden promyk światła. I taką sytuacją jest m.in. usuwanie atrybutu z modelu, co generuje operację RemoveField.

Bodaj wszystkie wbudowane w Django operacje posiadają mechanikę pozwalającą wycofać daną operację, RemoveField także. Ta akurat nie zadziała, gdy pole było zadeklarowane jako NOT NULL, ponieważ:

  • Django nie ustawia defaultów na poziomie baz danych, więc nigdy nie wygeneruje defaulta na poziomie SQL (co byłoby rozwiązaniem w sytuacjach, gdy default masz określony)
  • Implementacja backward w RemoveField jest oparta o pseudo-automat, tzw. schema editor, a ten "wie" że ma być NOT NULL (i nikt go nie przekona)
  • Sekwencja rollback dla przypadku usuwania pola jest bardziej złożona i zależna od kontekstu (dodanie pola nullable, wypełnienie danymi, zmiana na not null).

Często jest tak, że sekwencja backward (rollback) jest inna od sekwencji forward i nie zawsze każdej operacji forward odpowiada dokładnie jedna operacja backward (i odwrotnie). To skłania mnie do postawienia tezy:

Sekwencje rollback (backward operations) powinny być definiowane niezależne od forward operations, tj. plik migracji powinien mieć dwie oddzielne ścieżki, które mogą być oczywiście automatycznie generowane. 

To kolejny argument za stwierdzeniem, że wbudowane w Django migracje są zaimplementowane w oparciu o wadliwy koncept. Ale jest na to trochę nieczytelne obejście - operacja RunSQL i puste przebiegi forward.

Rozwiązanie tego konkretnego zagadnienia?

Odstawić czarodzieja schema editor, czyli zamienić operację RemoveField na RunSQL usuwającą kolumnę w forwardzie, ale przywracającą w backwardzie z dozwolonym null. Następnie zadeklarować RunSQL uzupełniający dane usuwanej kolumny w backwardzie, a w forwardzie nie robiący nic (''). Tę operację (lub ich sekwencję) należy umieścić przed alterem usuwającym kolumnę, aby w backwardzie wykonała się po przywróceniu tejże, a zakończyć (rozpocząć) od operacji RunSQL zakładającej w backwardzie not null. Partyzanckie, ale działa.

Czyli:

operations = [ 
    migrations.RemoveField('table', 'attribute'),
]

zamieniamy na:

operations = [
     migrations.RunSQL('', 'alter table <table> alter column <column> set not null'),
     migrations.RunSQL('', 'update <table> set <column> = ....'),
     migrations.RunSQL(
         'alter table <table> drop column <column>',
         'alter table <table> add column <column> [...] NULL'),
]

A na przyszłość usuwając pola z modeli należy wygenerować trzy oddzielne migracje:

  • pierwsza ma ustawiać nullable
  • druga ma być data migration, która w forwadzie nie robi nic (lub robi, jeśli dane przenosimy), a w backwardzie ma kod uzupełniający dane
  • trzecia usuwa kolumnę.

Odwracając proces uzyskamy:

  • dodanie kolumny nullable (schema editor będzie wówczas "mądrzejszy")
  • uzupełnienie kolumny danymi
  • założenie not null constraint.

Prosty automat skończony (FSM) w Python

Na GitHub i PyPi wrzuciłem implementację prostego automatu skończonego https://github.com/marcinn/dsm

Instalacja

pip install dsm

Przykład 1: Stany zamówienia w sklepie

Załóżmy, że mamy do oprogramowania automat stanów zamówienia w sklepie:

Deklaracja

Wystarczy zdefiniować przejścia za pomocą listy krotek ([stan], [wartość], [nowy stan]), aby automat spełniał swoją rolę.

Deklaracja automatu z przykładu wygląda następująco:

import dsm

class OrderFSM(dsm.StateMachine):
    class Meta:
        transitions = (
            ('new', 'accept', 'accepted'),
            ('new', 'cancel', 'cancelled'),
            ('accepted', 'mark_ready', 'ready'),
            ('accepted', 'cancel', 'cancelled'),
            ('ready', 'send', 'sent'),
            ('ready', 'cancel', 'cancelled'),
            ('sent', 'finalize', 'finalized'),
        )
        initial = 'new'

Symulacja

Kod symulacji:

fsm = OrderFSM()

print 'State: `%s`, trying to accept' % fsm.state

fsm.process('accept')
print 'State: `%s`' % fsm.state

print "Can I send order now? - %s." % ('Yes' if fsm.can('send') else 'No')
print 'Marking as ready.'
fsm.process('mark_ready')

print "Can I send order now? - %s." % ('Yes' if fsm.can('send') else 'No')

fsm.process('send')
print "Order is %s" % fsm.state

print "Trying to cancel order..."
fsm.process('cancel')

Wynik:

State: `new`, trying to accept
State: `accepted`
Can I send order now? - No.
Marking as ready.
Can I send order now? - Yes.
Order is sent
Trying to cancel order...
Traceback (most recent call last):
  File "bin/python", line 78, in <module>
    exec(compile(__file__f.read(), __file__, "exec"))
  File "dsmtest.py", line 39, in <module>
    fsm.process('cancel')
  File "/home/marcin/src/projekty/archikat/eggs/dsm-0.2-py2.7.egg/dsm.py", line 97, in process
    new_state = self._transitions.execute(value, self.state)
  File "/home/marcin/src/projekty/archikat/eggs/dsm-0.2-py2.7.egg/dsm.py", line 46, in execute
    raise UnknownTransition('Can not find transition for `%s` in state `%s`' % (value, current_state))
dsm.UnknownTransition: Can not find transition for `cancel` in state `sent`

Użycie z Django

Moduł nie ma żadnych zależności od Django, ale można go łatwo zintegrować z modelem. Na przykład tak:

class Order(models.Model):
    status = models.CharField(max_length=32)

    def change_status(self, operation):
        # process() zwraca nowy stan
        # nowa instancja OrderFSM() zapewnia prawidłowy stan początkowy
        self.status = OrderFSM(initial=self.status).process(operation) 

Wywołanie zmiany stanu zamówienia może być następujące:

order = Order.objects.get(pk=666)

try:
    order.change_status('send')
except OrderFSM.UnknownTransition:
    print "Buuu... :("

Inne zastosowania

W module dsm.py zawarłem przykład automatu sumującego wprowadzane na wejściu cyfry jako przykład użycia w innych celach niż tylko manipulacja stanami jakichś tam dokumentów. DSM emituje zdarzenia i dzięki możliwości rejestrowania callbacków można zbudować coś znacznie ciekawszego.

Przykład 2: Parzysta ilość zer w ciągu

Jako kolejny przykład zaimplementowałem automat sprawdzający, czy liczność zer w ciągu wejściowym jest parzysta, opisany na Wikipedii.

import dsm

class ParityChecker(dsm.StateMachine):
    class Meta:
        transitions = (
                ('even', '0', 'odd'),
                ('even', '1', 'even'),
                ('odd', '1', 'odd'),
                ('odd', '0', 'even'),
            )
        initial = 'even'


pc = ParityChecker()


while True:
    pc.reset()

    digits = raw_input('Podaj ciag zlozony z zer i jedynek: ')

    if not digits:
        break

    pc.process_many(digits)

    print "%sparzysta ilos zer (%s)" % ('Nie' if pc.state=='odd' else '',
            digits.count('0'))