Migrations
Das Modul wippy/migration stellt ein Datenbankmigrations-Framework bereit mit einer kleinen DSL zur Definition von Schema-Änderungen, einem Runner, der sie entdeckt und ausführt, sowie einem Bootloader, der ausstehende Migrationen für jede im Projekt registrierte target_db ausführt.
Migrationen unterstützen SQLite, PostgreSQL und MySQL mit treiberspezifischen up/down-Implementierungen, die nebeneinander definiert werden.
Einrichtung
Füge das Modul deinem Projekt hinzu:
wippy add wippy/migration
wippy install
Deklariere die Abhängigkeit und die Anwendungsdatenbank, auf die die Migrationen abzielen:
version: "1.0"
namespace: app
entries:
- name: app_db
kind: db.sql.sqlite
path: ./data/app.db
- name: dep.migration
kind: ns.dependency
component: wippy/migration
version: "*"
Der Migrations-Bootloader registriert sich bei wippy/bootloader mit Order 20. Wenn die Anwendung startet, entdeckt er jeden Migrationseintrag in der Registry, gruppiert sie nach meta.target_db und führt ausstehende Migrationen gegen jede Datenbank aus.
Eine Migration definieren
Eine Migration ist ein function.lua-Eintrag mit meta.type: migration. Der Eintrag gibt eine von migration.define(...) erzeugte Funktion zurück.
entries:
- name: 01_create_users_table
kind: function.lua
meta:
type: migration
target_db: app:app_db
timestamp: "2025-01-15T10:00:00Z"
source: file://01_create_users_table.lua
imports:
migration: wippy.migration:migration
return require("migration").define(function()
migration("Create users table", function()
database("sqlite", function()
up(function(db)
local ok, err = db:execute([[
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)
]])
if err then error(err) end
end)
down(function(db)
db:execute("DROP TABLE IF EXISTS users")
end)
end)
database("postgres", function()
up(function(db)
db:execute([[
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)
]])
end)
down(function(db)
db:execute("DROP TABLE IF EXISTS users")
end)
end)
end)
end)
Erforderliche Metadaten
| Feld | Erforderlich | Beschreibung |
|---|---|---|
meta.type |
ja | Muss für die Erkennung "migration" sein |
meta.target_db |
ja | Registry-ID der Zieldatenbank |
meta.timestamp |
nein | ISO-8601-Zeitstempel zur Sortierung, wenn mehrere Migrationen dieselbe Datenbank betreffen |
meta.tags |
nein | Array von Tags; der Runner kann Migrationen nach Tag filtern |
Migrationen für eine Datenbank laufen in aufsteigender meta.timestamp-Reihenfolge. meta.timestamp ist optional; die vollständige Entry-ID entscheidet bei Gleichstand, sodass Migrationen mit gleichem oder fehlendem Zeitstempel dennoch in stabiler, deterministischer Reihenfolge laufen.
DSL
Innerhalb der an migration.define übergebenen Funktion stehen die folgenden verschachtelten Funktionen zur Verfügung:
| Funktion | Beschreibung |
|---|---|
migration(description, fn) |
Eine neue Migration mit menschenlesbarer Beschreibung öffnen |
database(type, fn) |
Eine Implementierung für "sqlite", "postgres" oder "mysql" deklarieren |
up(fn) / down(fn) |
Vorwärts- und Rollback-Funktionen definieren |
after(fn) |
Optionaler Post-Migration-Hook (gleiche Transaktion) |
Jede up/down/after-Funktion erhält ein Transaktionsobjekt, keine Rohverbindung. Alle drei Operationen laufen in einer einzigen Transaktion, die bei Fehlern zurückgerollt wird.
Transaktionsmethoden
local rows, err = db:query(sql, params) -- SELECT, gibt ein Array von Zeilen zurück
local result, err = db:execute(sql, params) -- INSERT/UPDATE/DDL, gibt { rows_affected, last_insert_id } zurück
local stmt, err = db:prepare(sql) -- Prepared Statement
Verwende stets parametrisierte Abfragen:
db:execute("INSERT INTO users (name, email) VALUES (?, ?)", { "Alice", "alice@example.com" })
Fehlerbehandlung
Ein Aufruf von error(...) bricht die Migration ab und rollt die Transaktion zurück. Umhülle jedes Statement, das fehlschlagen kann:
up(function(db)
local _, err = db:execute("CREATE TABLE ...")
if err then error(err) end
end)
Runner-API
Der Runner wird als Bibliothek für die programmatische Nutzung bereitgestellt:
imports:
runner: wippy.migration:runner
local runner = require("runner").setup("app:app_db")
local result = runner:run() -- alle ausstehenden Migrationen anwenden
local result = runner:run_next() -- die nächste ausstehende Migration anwenden
local result = runner:rollback() -- die zuletzt angewendete Migration zurückrollen
local status = runner:status() -- angewendete und ausstehende Migrationen auflisten
runner:run(options)
Wendet jede ausstehende Migration für die konfigurierte Datenbank an. Gibt eine Zusammenfassung zurück:
{
status = "complete", -- "complete" oder "error"
migrations_found = 3,
migrations_applied = 2,
migrations_skipped = 1,
migrations_failed = 0,
duration = 0.123,
migrations = { ... }, -- Status je Migration
skipped_details = { ... },
}
Optionen:
| Option | Beschreibung |
|---|---|
tags |
Array von Tags; nur Migrationen, deren meta.tags sich schneiden, werden berücksichtigt |
runner:rollback(options)
Rollt angewendete Migrationen in umgekehrter Reihenfolge ihrer Anwendung zurück. Ohne Optionen wird genau die zuletzt angewendete Migration rückgängig gemacht:
runner:rollback() -- die letzte Migration zurückrollen
runner:rollback({ count = 3 }) -- die letzten 3 zurückrollen
runner:rollback({ allowed_ids = { "app:01_create_users_table" } }) -- auf bestimmte IDs beschränken
Optionen:
| Option | Beschreibung |
|---|---|
count |
Anzahl der zurückzurollenden Migrationen; Standardwert ist 1 |
allowed_ids |
Array von Migrations-IDs; nur diese kommen für ein Rollback in Frage |
runner:status(options)
Gibt einen Statusbericht zurück, der jede Migration der Datenbank beschreibt:
{
database_id = "app:app_db",
db_type = "sqlite",
total_migrations = 3,
applied_migrations = 2,
pending_migrations = 1,
migrations = {
{ id = "app:01_...", description = "...", timestamp = "...",
tags = {}, status = "applied", applied_at = ... },
-- ...
},
}
Angewendete Migrationen werden zuerst aufgelistet (sortiert nach applied_at), gefolgt von den ausstehenden (sortiert nach meta.timestamp, dann nach ID).
Registry-API
wippy.migration:registry bietet direkte Registry-Abfragen:
| Funktion | Beschreibung |
|---|---|
registry.find({ target_db, tags }) |
Alle Migrationseinträge zurückgeben, die den Kriterien entsprechen |
registry.get(id) |
Einen einzelnen Migrationseintrag per ID zurückgeben |
registry.get_target_dbs() |
Jede eindeutige meta.target_db zurückgeben, die in Migrationen vorkommt |
registry.get_tags() |
Jeden eindeutigen Tag zurückgeben, der in Migrationen vorkommt |
Der Bootloader verwendet diese, um beim Start die vollständige Menge an Zieldatenbanken zu entdecken.
Migrations-Tracking
Der Runner erzeugt bei der ersten Ausführung in jeder Zieldatenbank eine Tabelle _migrations. Angewendete Migrationen werden per ID aufgezeichnet, sodass nachfolgende Läufe sie überspringen. Die Tracking-Tabelle wird automatisch erzeugt; schreibe keine eigene Migration, um sie anzulegen.
Best Practices
- Eine logische Änderung pro Migration - eine Tabelle anlegen, eine Spalte hinzufügen, einen Index erstellen.
- Ein echtes
downschreiben - wenn ein Rollback unmöglich ist (Datenverlust), dokumentiere das und wirf einen Fehler, anstatt stillschweigend Erfolg zurückzumelden. - Idempotenz bevorzugen -
CREATE TABLE IF NOT EXISTSundDROP TABLE IF EXISTSüberstehen wiederholte Ausführungen ohne besondere Behandlung. - DDL und DML trennen - vermeide es nach Möglichkeit, Daten in derselben Migration zu seeden, die eine Tabelle anlegt.
- Beide Richtungen testen - wende die Migration an, rolle sie zurück und verifiziere, dass das Schema dem Ausgangszustand entspricht.
Siehe auch
- SQL-Treiber - Konfiguration der Datenbank-Ressource
- Bootloader - Bootloader-Reihenfolge und Hooks
- Framework-Übersicht - Nutzung der Framework-Module