Zum Inhalt

Migrationsmatrix

Was passiert, wenn Du ein SQL-Modell änderst: was automatisch migriert wird, wo Daten reduziert werden, und die Fälle, die Handarbeit brauchen.

Alle Kommandos aus dem Verzeichnis, in dem alembic.ini liegt:

# 1. Model ändern, dann:
alembic revision --autogenerate -m "tags single"
# 2. Die erzeugte Datei lesen — Autogenerate erklärt jede Entscheidung
alembic upgrade head
alembic check          # Modelle == Schema?

Die fünf Klassen

Klasse Bedeutung
Vollautomatisch Generieren, lesen, anwenden. Keine Daten gehen verloren.
Automatisch, reduziert Läuft ohne Zutun, verwirft aber Daten — und sagt, wie viele Zeilen betroffen sind.
Eine Zeile von Dir Der Generator schreibt einen Stub, die Revision verweigert den Start bis er gefüllt ist.
Handarbeit Wird nicht erkannt oder nicht erzeugt. Revision von Hand korrigieren.
Keine Revision Kein Schema-, kein Datenbezug. Autogenerate meldet „no changes".

Vollautomatisch (14 Fälle)

Nichts zu tun außer die Revision zu lesen.

Änderung Erzeugt Anmerkung
Neues optionales Feld add_column Alembic-Standard.
Neues Pflichtfeld add_column (nullable) + fill_column Der Füllwert kommt aus dem Model selbst — default, sonst emptyvalue. Ohne den Dreischritt scheitert es an Cannot add a NOT NULL column.
Feld entfernt drop_column Der Verlust ist gewollt.
Neues Model create_table Inklusive FKs und Composite-PKs in Abhängigkeitsreihenfolge.
Index dazu oder weg create_index / drop_index Aus Field(index=…).
Unique-Constraint dazu create_unique_constraint Der Constraint bekommt automatisch einen Namen — SQLite lehnt unbenannte im Batch-Modus ab. Auf befüllten Tabellen die Falle unten beachten.
Neues Feld im LinkModel (using) add_column + fill_column Auf der Link-Tabelle, sonst wie ein normales neues Feld.
strText coerce_text Alembic allein sieht diese Änderung gar nicht. Text lässt max_length weg, und Alembic behandelt „keine Länge" als „keine Meinung" — die Spalte behielte still ihr Limit.
max_length größer alter_column Verlustfreie Erweiterung.
intfloat coerce_numeric Rundet auf die neue Precision (round(float(v), p)).
single → multiple expand_multiple Verlustfrei: eine Link-Zeile pro vorhandenem Wert. Auch bei Umbenennung tagtags. NOT NULL-Payload-Spalten eines Association-Links werden aus dem Model-Default gefüllt (payload_defaults; Ellipsis-Stub, wenn es keinen gibt).
einsprachig → mehrsprachig expand_languages Der Wert landet unter languages[0], die übrigen Sprachen bleiben leer.
Enum-/Select-Option dazu add_enum_values Postgres braucht das (ALTER TYPE … ADD VALUE, sonst invalid input value for enum zur Laufzeit); auf SQLite ist die Spalte ein unbeschränktes VARCHAR und die Op ein No-op.
Select → bool coerce_bool Textuelle Regel aus parse.bool: "yes" wird True, "active" wird False.

Automatisch, aber reduziert (4 Fälle)

Läuft ohne Zutun. Die Auswahl, welcher Wert überlebt, ist dieselbe, die viur-core beim Lesen trifft — und sie steht sichtbar in der Revision, also korrigierbar.

Änderung Erzeugt Was überlebt
multiple → single collapse_multiple Der erste Eintrag, wie loadVal[0] im Bone. Beim Anwenden gemeldet: 3 row(s) had several targets. Auch bei Umbenennung tagstag.
mehrsprachig → einsprachig reduce_languages Eine Sprache, per keep='de' in der Revision sichtbar. Default ist die erste deklarierte Sprache.
floatint coerce_numeric Schneidet ab, rundet nicht3.7 wird 3. Das ist die Bone-Regel.
max_length kleiner alter_column Bestehende Werte werden nicht gekürzt — genauso wenig wie die Bones das tun. Postgres kann die Änderung dann beim Commit ablehnen; SQLite nicht.

Eine Zeile von Dir (1 Fall)

Der einzige Übergang, für den viur-core keine brauchbare Regel hat: SelectBone.singleValueUnserialize sucht ein Enum-Member mit passendem value, ein gespeichertes True trifft nichts. Im schemalosen Datastore fällt das nicht auf, eine typisierte Spalte kann es nicht halten.

Änderung Erzeugt Was Du tust
bool → Select remap_values mit {True: ..., False: ...} Das Dict ausfüllen — mit den gespeicherten Labels (Enum-Member-Namen wie YES), die in der Autogenerate-Ausgabe stehen, nicht mit den Client-Werten (yes): die Spalte speichert den Namen, das Wire-Format den Wert. Die Revision verweigert den Start, solange Ellipsis drinsteht, statt still etwas Plausibles zu schreiben.

Handarbeit (5 Fälle)

Hier wird bewusst nicht geraten. Bei den ersten beiden gibt es nichts zu erkennen; bei den letzten drei bleibt die Spalte technisch gültig, nur ihr Inhalt passt nicht mehr.

Änderung Autogenerate Was Du tust
Spalte umbenannt drop + add — verliert Daten Von Hand auf alter_column(…, new_column_name=…) korrigieren. Kein Werkzeug erkennt Umbenennungen; nur der Relations-Wechsel multiple↔single wird gepaart.
Tabelle umbenannt drop + create Von Hand auf op.rename_table(…) korrigieren.
Record-Feld dazu oder weg (Record) nichts Der Record lebt in einer JSON-Spalte, die sich nicht ändert. Die gespeicherten JSON-Objekte behalten ihre alte Struktur. Wenn das stört: eigene op.execute(…)-Migration.
Enum-Option entfernt Warnung, keine Op Zeilen mit dem alten Wert bleiben stehen, und Postgres kann einen Enum-Wert nicht löschen (Typ müsste neu aufgebaut werden). Per op.remap_values(…) umschreiben; der Generator warnt.
Relationsziel geändert FK-Wechsel, keine Datenmigration Die FK-Werte zeigen auf IDs der alten Zieltabelle. Es gibt keine ableitbare Zuordnung — Umschreiben ist projektspezifisch.

Keine Revision nötig (3 Fälle)

Autogenerate meldet „no changes in schema detected" — korrekt.

Änderung Warum
descr, visible, readonly, params, format Reine Client-Metadaten. Ändern die Bone-Structure, aber weder Schema noch Daten — der Admin sieht sie beim nächsten Request.
@computed_field dazu oder weg Hat keine Spalte. Der Wert wird beim Dump berechnet.
viur_ref_keys geändert Betrifft die dest-Snapshots von Cross-Store-Referenzen, nicht das Schema. Auffrischen über refresh_crossstore(Model) bzw. refresh_for_target(key).

Drei Regeln, die überraschen

Alle drei sind viur-cores Regeln.

Sprachen sind asymmetrisch. Runter gilt conf.i18n.default_language, hoch gilt languages[0] — die erste deklarierte Sprache. Das ist nicht dasselbe. Und ist die gewünschte Sprache als Schlüssel vorhanden, gewinnt ihr Wert, auch wenn er leer ist.

floatint schneidet ab. int(float(3.7)) ergibt 3, nicht 4. Nur precision > 0 rundet. Beides ist NumericBone._convert_to_numeric.

Bones kürzen nie. max_length wird ausschließlich in fromClient validiert, nie beim Lesen. Ein verkleinertes Limit lässt lange Bestandswerte also stehen — coerce_text macht es genauso.


Fallen

Pflichtfeld + UNIQUE auf einer befüllten Tabelle. Ein Füllwert für alle Zeilen verletzt UNIQUE, sobald mehr als eine Zeile existiert. Der Generator kann keine eindeutigen Werte erfinden, warnt aber vorher statt beim Commit zu scheitern. Den fill_column-Wert dann durch zeilenweise Werte ersetzen.

Abgebrochene Migration auf SQLite. Jeder Batch-Block baut die Tabelle über _alembic_tmp_<table> neu. Scheitert eine Migration mittendrin, bleibt diese Tabelle liegen und blockiert den nächsten Versuch mit table _alembic_tmp_x already exists — von Hand löschen. Inhärent in Alembics Batch-Modus.

Snapshots gehören ins Repository. Unter migrations/structures/<revision>.json liegt die Bone-Structure pro Revision — die Erkennungsgrundlage. Fehlt ein Snapshot, wird kein Übergang erkannt (still, ohne Fehler). Nachträglich einführen ist gefahrlos: ein fehlender Snapshot ergibt einen leeren Diff.

Migration nie beim Instanz-Start. main.py erzeugt kein Schema mehr, es berichtet nur die Revision. Bei mehreren App-Engine-Instanzen liefen sonst mehrere Migrationen gleichzeitig gegen dieselbe Datenbank.


Regeln geprüft gegen viur-core 3.9 · Alembic ≥ 1.13 · verifiziert auf SQLite und Postgres 16. Ausführlich: Migrationen · Runbook: migrations/README.