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.