Regeln für die Erzeugung datenbankspezifischer DDL-Statements aus dem neutralen Modell
Dokumenttyp: Spezifikation / Referenz
DDL-Statements werden in einer definierten Reihenfolge erzeugt, um Abhängigkeiten zu respektieren:
1. Custom Types (CREATE TYPE — nur PostgreSQL)
2. Sequences (CREATE SEQUENCE — nur PostgreSQL)
3. Tabellen (CREATE TABLE — topologisch sortiert nach Foreign Keys)
4. Indizes (CREATE INDEX — nach den zugehörigen Tabellen)
5. Views (CREATE VIEW — topologisch sortiert nach Abhängigkeiten)
6. Functions (CREATE FUNCTION — nach Views)
7. Procedures (CREATE PROCEDURE — nach Functions)
8. Triggers (CREATE TRIGGER — nach Tabellen und Functions)
Topologische Sortierung: Tabellen mit Foreign Keys werden nach den referenzierten Tabellen erzeugt. Bei zirkulären Referenzen:
- Tabellen ohne FK-Constraints erzeugen
- FK-Constraints nachträglich via
ALTER TABLE ... ADD CONSTRAINThinzufügen
Jedes generierte DDL beginnt mit einem Header-Kommentar:
-- Generated by d-migrate <version>
-- Source: neutral schema v<schema_version> "<schema_name>"
-- Target: <dialect> | Generated: <ISO-8601-timestamp>Tool-Export-Determinismus (0.7.0): Bei d-migrate export flyway|liquibase| django|knex wird der Generated: <ISO-8601-timestamp> Laufzeit-Timestamp
nicht in die Tool-Artefakte übernommen. Gleiches Schema + gleiche Flags muss
identische Artefaktinhalte erzeugen. Provenienz bleibt im Report oder in
stabilen, nicht zeitabhängigen Metadaten sichtbar.
schema generate-Determinismus (0.9.5):
- Ohne weitere Flags bleibt der Header wie oben beschrieben und nutzt einen Laufzeit-Timestamp.
- Ist
SOURCE_DATE_EPOCHgesetzt, bleibt die Header-Form erhalten, aberGenerated:wird aus diesem Unix-Epoch-Sekundenwert als UTC-Instantabgeleitet. - Mit
--deterministicwird der Laufzeit-Timestamp aus DDL, JSON-DDL-Feldern und Sidecar-Report entfernt. Der Header endet dann bei-- Target: <dialect>. - Ist
--deterministiczusammen mitSOURCE_DATE_EPOCHgesetzt, bestimmt--deterministicweiterhin die Output-Policy; der stabile Zeitwert wird nicht als volatile Provenienz ausgegeben.
- Generierte Dateien sind immer UTF-8 ohne BOM
- Zeilenumbrüche:
\n(LF), nicht\r\n(CRLF)
| Regel | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Quote-Zeichen | " (Double Quote) |
` (Backtick) |
" (Double Quote) |
| Wann quoten | Reservierte Wörter, Sonderzeichen, Groß-/Kleinschreibung | Immer (konsistent) | Reservierte Wörter, Sonderzeichen |
| Case-Sensitivity | Unquoted → lowercase | Systemabhängig | Case-insensitive |
Aktueller Vertrag: Immer quoten (fest verdrahtetes Defensive Quoting)
Begründung:
- Konsistentes Verhalten über alle Dialekte
- Keine Überraschungen bei reservierten Wörtern
- Zukunftssicher (neue reservierte Wörter in DB-Updates)
Späterer Milestone: Nur reservierte Wörter quoten (geplante Option ddl.quote_identifiers: reserved_only)
Hinweis: Der aktuelle Implementierungsstand bietet keine öffentliche
Quoting-Konfigurationsoption. Der Generator quotet derzeit immer.
reserved_only ist nur als zukünftige Option dokumentiert und noch nicht im
Generator, im Optionsmodell oder im CLI-/Config-Pfad verdrahtet.
In diesem Modus werden Identifier nur gequotet, wenn:
- Der Name ein reserviertes Wort des Zieldialekts ist (je Dialekt gepflegte Liste)
- Der Name Sonderzeichen enthält (Leerzeichen, Bindestriche, Punkte)
- Der Name mit einer Ziffer beginnt
- Der Name Groß-/Kleinschreibung erfordert, die vom Dialekt nicht default ist
Reservierte-Wörter-Listen werden pro Dialekt mitgeliefert (Quelle: offizielle Dokumentation der jeweiligen DB-Version).
-- PostgreSQL
CREATE TABLE "customers" (
"id" SERIAL,
"email" VARCHAR(254) NOT NULL
);
-- MySQL
CREATE TABLE `customers` (
`id` INT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(254) NOT NULL
);
-- SQLite
CREATE TABLE "customers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL
);| Zeichen im Identifier | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Quote-Zeichen selbst | "" (verdoppeln) |
`` (verdoppeln) |
"" (verdoppeln) |
| Sonstige Sonderzeichen | Innerhalb Quotes erlaubt | Innerhalb Backticks erlaubt | Innerhalb Quotes erlaubt |
Beispiel:
-- Spalte heißt "user's name"
-- PostgreSQL/SQLite:
"user's name"
-- MySQL:
`user's name`CREATE TABLE <quoted_name> (
<spalten>,
<inline_constraints>
) <table_options>;
Reihenfolge innerhalb einer Spalte:
<quoted_name> <type> [NOT NULL] [DEFAULT <value>] [UNIQUE] [<inline_constraint>]
CREATE TABLE "orders" (
"id" SERIAL,
"customer_id" INTEGER NOT NULL,
"order_date" TIMESTAMP NOT NULL,
"total_amount" DECIMAL(10,2),
"status" TEXT DEFAULT 'pending',
"is_archived" BOOLEAN DEFAULT FALSE,
PRIMARY KEY ("id"),
CONSTRAINT "fk_orders_customer_id" FOREIGN KEY ("customer_id")
REFERENCES "customers" ("id") ON DELETE RESTRICT
);Besonderheiten:
identifier→SERIAL(auto-increment via Sequence)boolean→BOOLEAN(native Unterstützung)json→JSONB(binäres JSON, performanter)enummitref_type→ separaterCREATE TYPE(vor der Tabelle)enuminline →TEXT+CHECKConstraintdatetimemittimezone: true→TIMESTAMP WITH TIME ZONE
CREATE TABLE `orders` (
`id` INT NOT NULL AUTO_INCREMENT,
`customer_id` INT NOT NULL,
`order_date` DATETIME NOT NULL,
`total_amount` DECIMAL(10,2),
`status` ENUM('pending','processing','shipped','delivered','cancelled')
DEFAULT 'pending',
`is_archived` TINYINT(1) DEFAULT 0,
PRIMARY KEY (`id`),
CONSTRAINT `fk_orders_customer_id` FOREIGN KEY (`customer_id`)
REFERENCES `customers` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;Besonderheiten:
identifier→INT NOT NULL AUTO_INCREMENTboolean→TINYINT(1), Defaultstrue/false→1/0json→JSON(kein JSONB)enum→ inlineENUM(...)(kein separater Type)datetimemittimezone: true→DATETIME+ Warnung W100 (kein TZ-Support)- Immer
ENGINE=InnoDB(FK-Support) - Immer
DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
CREATE TABLE "orders" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"customer_id" INTEGER NOT NULL REFERENCES "customers"("id") ON DELETE RESTRICT,
"order_date" TEXT NOT NULL,
"total_amount" REAL,
"status" TEXT DEFAULT 'pending'
CHECK ("status" IN ('pending','processing','shipped','delivered','cancelled')),
"is_archived" INTEGER DEFAULT 0
);Besonderheiten:
identifier→INTEGER PRIMARY KEY AUTOINCREMENTboolean→INTEGER, Defaultstrue/false→1/0json→TEXT(JSON-Funktionen verfügbar ab 3.38)datetime,date,time→TEXT(ISO 8601 Format)decimal→REAL+ Warnung W200 (Präzisionsverlust)enum→TEXT+CHECKConstraint- Foreign Keys inline oder als
CONSTRAINT - Kein
ALTER TABLE ADD COLUMN ... REFERENCES(FK müssen inline sein)
Hinweis: Dieser Abschnitt beschreibt den geplanten diff-basierten
schema migrate-Pfad eines spaeteren Milestones. Der Befehl ist im aktuellen
CLI-Umfang noch nicht verfuegbar.
Für schema migrate erzeugt der Generator ALTER TABLE-Statements. Die Syntax variiert pro Dialekt:
-- PostgreSQL
ALTER TABLE "orders" ADD COLUMN "priority" SMALLINT DEFAULT 0;
-- MySQL
ALTER TABLE `orders` ADD COLUMN `priority` SMALLINT DEFAULT 0;
-- SQLite (ab 3.2.0, eingeschränkt: kein NOT NULL ohne Default, kein REFERENCES)
ALTER TABLE "orders" ADD COLUMN "priority" INTEGER DEFAULT 0;-- PostgreSQL
ALTER TABLE "orders" ALTER COLUMN "notes" TYPE TEXT;
ALTER TABLE "orders" ALTER COLUMN "status" SET DEFAULT 'pending';
ALTER TABLE "orders" ALTER COLUMN "email" SET NOT NULL;
-- MySQL
ALTER TABLE `orders` MODIFY COLUMN `notes` TEXT;
ALTER TABLE `orders` ALTER COLUMN `status` SET DEFAULT 'pending';
ALTER TABLE `orders` MODIFY COLUMN `email` VARCHAR(254) NOT NULL;
-- SQLite: ALTER COLUMN TYPE ist NICHT unterstützt.
-- Workaround: Tabelle neu erstellen (Rename → Create → Copy → Drop)
-- Dieser Workaround wird automatisch generiert mit Warnung.-- PostgreSQL
ALTER TABLE "orders" DROP COLUMN "legacy_field";
-- MySQL
ALTER TABLE `orders` DROP COLUMN `legacy_field`;
-- SQLite (ab 3.35.0)
ALTER TABLE "orders" DROP COLUMN "legacy_field";
-- Für SQLite < 3.35.0: Tabellen-Rebuild-Workaround-- PostgreSQL
ALTER TABLE "orders" ADD CONSTRAINT "fk_orders_product" FOREIGN KEY ("product_id")
REFERENCES "products" ("id");
ALTER TABLE "orders" DROP CONSTRAINT "fk_orders_product";
-- MySQL
ALTER TABLE `orders` ADD CONSTRAINT `fk_orders_product` FOREIGN KEY (`product_id`)
REFERENCES `products` (`id`);
ALTER TABLE `orders` DROP FOREIGN KEY `fk_orders_product`;
-- SQLite: ADD/DROP CONSTRAINT ist NICHT unterstützt.
-- Workaround: Tabellen-Rebuild (Rename → Create mit neuen Constraints → Copy → Drop)SQLite hat die restriktivsten ALTER TABLE-Fähigkeiten. Der DDL-Generator muss diese Einschränkungen kennen:
| Operation | SQLite-Support | Workaround |
|---|---|---|
| ADD COLUMN | Ab 3.2.0 (eingeschränkt) | — |
| ADD COLUMN NOT NULL (ohne Default) | Nicht unterstützt | Default-Wert erzwingen |
| ADD COLUMN REFERENCES | Nicht unterstützt | Tabellen-Rebuild |
| DROP COLUMN | Ab 3.35.0 | Tabellen-Rebuild für ältere Versionen |
| ALTER COLUMN TYPE | Nicht unterstützt | Tabellen-Rebuild |
| ADD CONSTRAINT | Nicht unterstützt | Tabellen-Rebuild |
| DROP CONSTRAINT | Nicht unterstützt | Tabellen-Rebuild |
| RENAME COLUMN | Ab 3.25.0 | Tabellen-Rebuild für ältere Versionen |
Tabellen-Rebuild-Strategie (automatisch generiert):
-- 1. Temporäre Tabelle erstellen
ALTER TABLE "orders" RENAME TO "_orders_old";
-- 2. Neue Tabelle mit gewünschter Struktur
CREATE TABLE "orders" (
-- ... neue Spaltendefinitionen ...
);
-- 3. Daten kopieren
INSERT INTO "orders" (col1, col2, ...)
SELECT col1, col2, ... FROM "_orders_old";
-- 4. Alte Tabelle löschen
DROP TABLE "_orders_old";Wann wird der Rebuild erzeugt?
| Kommando | Verhalten |
|---|---|
schema generate |
Kein Rebuild — erzeugt nur CREATE TABLE DDL (Neuerstellung). Der Rebuild ist nur bei Schema-Änderungen an bestehenden Tabellen relevant. |
schema migrate (0.5.0) |
Rebuild wird generiert wenn eine ALTER-Operation für SQLite nicht unterstützt wird (z.B. ALTER COLUMN TYPE, ADD CONSTRAINT). |
Hinweis: Der hier beschriebene Rebuild-Workaround gehoert zum geplanten
schema migrate-Pfad eines spaeteren Milestones und ist nicht Teil des
aktuellen CLI-Funktionsumfangs.
Für schema generate (0.2.0) ist der Rebuild-Workaround also nicht relevant — er wird erst mit schema migrate (0.5.0) implementiert.
| Variante | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Einfach | PRIMARY KEY ("id") |
PRIMARY KEY (\id`)` |
"id" INTEGER PRIMARY KEY |
| Composite | PRIMARY KEY ("a", "b") |
PRIMARY KEY (\a`, `b`)` |
PRIMARY KEY ("a", "b") |
| Mit Name | CONSTRAINT "pk_orders" PRIMARY KEY ("id") |
— (MySQL ignoriert PK-Name) | — |
-- PostgreSQL
CONSTRAINT "fk_orders_customer_id" FOREIGN KEY ("customer_id")
REFERENCES "customers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
-- MySQL
CONSTRAINT `fk_orders_customer_id` FOREIGN KEY (`customer_id`)
REFERENCES `customers` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
-- SQLite (inline bevorzugt)
"customer_id" INTEGER NOT NULL REFERENCES "customers"("id")
ON DELETE RESTRICT ON UPDATE CASCADEON DELETE / ON UPDATE Aktionen:
| Neutral | SQL |
|---|---|
restrict |
RESTRICT |
cascade |
CASCADE |
set_null |
SET NULL |
set_default |
SET DEFAULT |
no_action |
NO ACTION |
Trusted Input:
constraint.expressionwird als Raw-SQL-Fragment direkt in die DDL-Ausgabe interpoliert. Der Wert stammt aus der Schema-YAML-Datei, die vom Schema-Autor kontrolliert wird. Keine Sanitization findet statt.Diff-Migrationen (Plan-2 §F.5):
CHECK- undEXCLUDE-Constraints werden im Diff-Pfad nur konservativ per SQL-Text verglichen. Der Vergleich normalisiert Zeilenenden auf LF und ignoriert umgebenden Whitespace, fuehrt aber keine semantische SQL-Kanonisierung durch. Unveraenderte Constraints blockieren andere Tabellenoperationen nicht. Hinzugefuegte, entfernte oder geaenderteCHECK-/EXCLUDE-Constraints bleiben mitCONSTRAINT_NOT_DIFFABLEblockierend, bis ein dialektspezifischer Render-, Enforcement- und Daten-Preflight-Vertrag existiert.
-- PostgreSQL
CONSTRAINT "chk_total_positive" CHECK ("total_amount" >= 0)
-- MySQL (ab 8.0.16)
CONSTRAINT `chk_total_positive` CHECK (`total_amount` >= 0)
-- SQLite
CHECK ("total_amount" >= 0)-- Als Spalten-Attribut (einfache Spalte)
"email" VARCHAR(254) NOT NULL UNIQUE
-- Als benannter Constraint (multi-column)
CONSTRAINT "uq_customer_date" UNIQUE ("customer_id", "order_date")| Constraint-Typ | Position |
|---|---|
| NOT NULL | Inline an der Spalte |
| DEFAULT | Inline an der Spalte |
| UNIQUE (einspaltig) | Inline an der Spalte |
| PRIMARY KEY (einspaltig, ohne identifier) | Am Ende der Tabelle |
| PRIMARY KEY (identifier) | Inline an der Spalte (SQLite) oder am Ende |
| FOREIGN KEY | Am Ende der Tabelle (PostgreSQL, MySQL) oder inline (SQLite) |
| CHECK | Am Ende der Tabelle |
| UNIQUE (mehrspaltig) | Am Ende der Tabelle |
CREATE INDEX "<name>" ON "<table>" ("<col1>", "<col2>");Indexspalten koennen optional eine Richtung tragen:
CREATE INDEX "<name>" ON "<table>" ("<col1>" DESC, "<col2>" ASC);Ohne explizite Richtung wird nur der Identifier gerendert. Reverse-Reader
normalisieren aufsteigende/default Metadaten zu keiner expliziten Richtung und
transportieren DESC verlustarm.
| Neutral | PostgreSQL | MySQL (InnoDB) | SQLite |
|---|---|---|---|
btree |
USING BTREE (Default) |
USING BTREE (Default) |
Default |
hash |
USING HASH |
Explizites USING HASH auf InnoDB nicht unterstützt → BTREE + W102 |
Nicht unterstützt → Default + W102 |
gin |
USING GIN |
Nicht unterstützt → Weglassen + W102 | Nicht unterstützt → Weglassen + W102 |
gist |
USING GIST |
Nicht unterstützt → Weglassen + W102 | Nicht unterstützt → Weglassen + W102 |
brin |
USING BRIN |
Nicht unterstützt → Weglassen + W102 | Nicht unterstützt → Weglassen + W102 |
CREATE UNIQUE INDEX "idx_customers_email" ON "customers" ("email");-- PostgreSQL
CREATE INDEX "idx_active_orders" ON "orders" ("status") WHERE "status" != 'cancelled';
-- SQLite
CREATE INDEX "idx_active_orders" ON "orders" ("status") WHERE "status" != 'cancelled';Das neutrale Feld index.where ist ein Trusted-Input Raw-SQL-Praedikat fuer
die Ziel-Engine. Es wird nicht geparst oder dialektuebergreifend transformiert.
| Ziel | Verhalten |
|---|---|
| PostgreSQL | WHERE <predicate> wird gerendert und per Reverse gelesen |
| SQLite | WHERE <predicate> wird gerendert und per Reverse aus sqlite_master.sql gelesen |
| MySQL | Kein stiller Predicate-Verlust: Index wird uebersprungen und action_required E057 erzeugt |
Partial-UNIQUE wird genauso behandelt. MySQL darf daraus keinen normalen Unique-Index erzeugen, weil das strenger waere als die Quelle.
-- PostgreSQL (separater Typ vor Tabellen)
CREATE TYPE "order_status" AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');
-- Verwendung in Tabelle:
"status" "order_status" DEFAULT 'pending'-- MySQL (inline in Tabelle)
`status` ENUM('pending','processing','shipped','delivered','cancelled') DEFAULT 'pending'-- SQLite (TEXT + CHECK)
"status" TEXT DEFAULT 'pending' CHECK ("status" IN ('pending','processing','shipped','delivered','cancelled'))-- PostgreSQL (native Unterstützung)
CREATE TYPE "address" AS (
"street" VARCHAR(200),
"city" VARCHAR(100),
"zip" CHAR(10),
"country" CHAR(2)
);Für MySQL/SQLite: Kein nativer Support. Verhalten abhängig von konfigurierter Fallback-Strategie:
json: JSON-Spalte verwendenflatten: Einzelne Spalten pro Feld erzeugenaction_required: Fehler mit Hinweis (Default)
-- PostgreSQL
CREATE DOMAIN "positive_amount" AS DECIMAL(10,2) CHECK (VALUE >= 0);Für MySQL/SQLite: Als Basistyp + CHECK Constraint inline an der Spalte.
-- PostgreSQL (native Unterstützung)
CREATE SEQUENCE "invoice_number_seq"
START WITH 10000
INCREMENT BY 1
MINVALUE 10000
MAXVALUE 99999999
NO CYCLE
CACHE 20;MySQL: Gesteuert ueber --mysql-named-sequences (seit 0.9.3):
action_required(Default): Sequences werden mit E056 uebersprungen.helper_table: Emulation ueber kanonische Hilfsobjekte:- Tabelle
dmg_sequencesmit Metadaten pro Sequence - Routinen
dmg_nextval(seq_name)unddmg_setval(seq_name, value) BEFORE INSERT-Trigger pro sequence-basierter Spalte- Lossy-Mapping-Warnung W115 (explizites NULL vs. ausgelassener Wert)
- Cache-Warnung W114 (Preallocation nicht emuliert)
- Transaktionswarnung W117 (Rollback retrahiert Inkremente)
- Tabelle
Details: mysql-sequence-emulation-plan.md.
SQLite: Keine nativen benannten Sequenzen. Standard ist action_required
(E056-Skip). Mit --sqlite-named-sequences helper_table (0.9.7) wird die
Emulation eingeschaltet:
dmg_sequences-Hilfstabelle (TEXT-/INTEGER-Spalten gemäß Plan §3.2:managed_by,format_version,name,next_value,last_returned_value,exhausted,increment_by,min_value,max_value,cycle_enabled,cache_size)- Seed-INSERT pro
SequenceDefinition(managed_by = 'd-migrate',format_version = 'sqlite-sequence-v1') - kanonisches
BEFORE INSERT/AFTER INSERT-Trigger-Paar (dmg_seq_<table16>_<col16>_<hash10>_{bi,ai}) proDefaultValue.SequenceNextVal-Spalte: BEFORE INSERT reserviert und inkrementiert atomar, AFTER INSERT schreibt den reservierten Wert perUPDATE … WHERE ROWID = NEW.ROWIDin die Zeile (WITHOUT ROWID→E057-Skip) - Cache-Warnung
W114(SQLite ist Single-Writer, keine echte Preallocation;cache_sizewird nur als Metadaten gespeichert) - W115 lossy-NULL-Semantik · W117 transaktions-bound · W119
NOT-NULL- und CHECK-
IS NOT NULL-Suppression auf sequence-getragenen Spalten · W121 Conflict-Gap-INFO (ON CONFLICT DO UPDATE/DO NOTHING, INSERT OR IGNORE, INSERT OR FAIL multi-row) · W122 Warnung wenn auf derselben Tabelle nutzerdefinierte UPDATE-Trigger existieren (recursive_triggers = ON-Risiko) - Rollback-Inversion entfernt das Trigger-Paar und
dmg_sequences - E058-Rollback-Preflight blockt den DROP wenn fremde Objekte
dmg_sequencesreferenzieren; E124 blockt die Generierung wenn das neutrale Schema bereits Objekte mit reservierten Hilfsnamen (dmg_sequencesoder dem kanonischendmg_seq_*_{bi,ai}-Pattern) enthält - Reverse erkennt die Hilfsobjekte über den
/* d-migrate:sqlite-sequence-v1 … */-Marker (primär) oder über das 5-Kriterien-Sekundär-Matching (Name, Event/Timing, WHEN-Klausel, Token-basierte Body-Prüfung) und faltet sie aufschema.sequences+DefaultValue.SequenceNextValzurück; degradierte Fälle landen unter W116 (Sekundär-Match), W120 (Marker ok, Body modifiziert) oder W124 (User-BEFORE-INSERT-Trigger maskiert das Sequence-Trigger-Paar)
Details: sqlite-sequence-emulation-plan.md.
Diff-Migrationen (Plan-2 §E.3): PostgreSQL rendert im diffbasierten Migrationspfad deklarative
CREATE SEQUENCE,ALTER SEQUENCEundDROP SEQUENCE-Operationen fuer die neutralen Attributestart,increment,minValue,maxValue,cycleundcache.sequence_nextval-Defaults werden im Plan nach einer im selben Diff erzeugten Sequence sortiert. Der live aktuelle Sequence-Wert wird in diesem Slice nicht uebernommen oder zurueckgesetzt; MySQL- und SQLite-Sequence-Migrationen sind seit 0.9.7 imhelper_table-Modus live:CreateSequenceemittiert einenINSERT INTO dmg_sequences,AlterSequenceeinUPDATE,DropSequenceeinDELETE(mit E058-Preflight + gebundenen Trigger-DROPs),RenameSequenceeinUPDATE name+Trigger-Pair-Rebuild undAlterSequenceCurrentValueeinUPDATE next_value.AddColumnmitSequenceNextValemittiert das Trigger-Paar gleich mit.supportsCurrentValue- Preserveist im 0.9.7-E.3-Folge-Slice fuer SQLite auftruegesetzt: derSqliteSequenceCurrentValueProbe-Adapter liestdmg_sequences.next_valuelive, derSequencePreserveStageenthaelt SQLite in der Allowlist und blockt ohne--sqlite-named-sequences helper_tablemitSEQUENCE_PRESERVE_OPT_IN_REQUIRED(Mapper aufMANUAL_ACTION_REQUIRED). Mit Opt-in emittiertAlterSequenceCurrentValueUp einUPDATE dmg_sequences SET next_value = <probedValue> WHERE name = '<applyRef>'und Down ein spiegelgleichesUPDATEgegenprobeSequenceRef.name(bei Rename der vor-Rename-Name). Ein fehlenderrestoreValue(typisch fuerCreateSequenceohne deterministischen Vorzustand) surfaced alsSQLITE_SEQUENCE_CURRENT_VALUE_DOWN_ROLLBACK_IMPOSSIBLE-Skip, kein stillerUPDATE. Details siehesqlite-sequence-emulation-plan.md§6.2 und Phasen F/G, plus den 0.9.7-E.3-Folge-SliceImpPlan-0.9.7-sqlite-sequence-preserve-current-value.md.
-- PostgreSQL
CREATE OR REPLACE VIEW "active_orders" AS
SELECT o.*, c."name" AS customer_name
FROM "orders" o
JOIN "customers" c ON o."customer_id" = c."id"
WHERE o."status" NOT IN ('delivered', 'cancelled');
-- MySQL
CREATE OR REPLACE VIEW `active_orders` AS
SELECT o.*, c.`name` AS customer_name
FROM `orders` o
JOIN `customers` c ON o.`customer_id` = c.`id`
WHERE o.`status` NOT IN ('delivered', 'cancelled');
-- SQLite
CREATE VIEW IF NOT EXISTS "active_orders" AS
SELECT o.*, c."name" AS customer_name
FROM "orders" o
JOIN "customers" c ON o."customer_id" = c."id"
WHERE o."status" NOT IN ('delivered', 'cancelled');-- PostgreSQL (native Unterstützung)
CREATE MATERIALIZED VIEW "monthly_revenue" AS
SELECT ...;
-- MySQL/SQLite: Nicht unterstützt → Standard-View + W103Im diff-basierten schema migrate-Pfad werden Materialized Views nicht als
normale Views gerendert. Operationen mit materialized: true blockieren, bis
ein ausführbarer Refresh-/Staleness-Vertrag existiert. Der Migrationsreport
weist diese Operationen als MATERIALIZED_VIEW aus und enthält unter
materializedViews[] die Felder stalenessAfterUp, refreshSteps,
locking und rollback; vor einem ausführbaren Vertrag steht
stalenessAfterUp auf UNKNOWN_BLOCKED und der einzige geplante
Refresh-Schritt ist BLOCKED_REFRESH_CONTRACT_REQUIRED.
View-Queries können dialektspezifische Funktionen enthalten. Der DDL-Generator führt regelbasierte Textsubstitution auf dem Query-String durch.
| Funktion | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
NOW() |
Nativ | Nativ | datetime('now') |
CURRENT_TIMESTAMP |
Nativ | Nativ | datetime('now') |
CURRENT_DATE |
Nativ | CURDATE() |
date('now') |
CURRENT_TIME |
Nativ | CURTIME() |
time('now') |
DATE_TRUNC('month', col) |
Nativ | DATE_FORMAT(col, '%Y-%m-01') |
strftime('%Y-%m-01', col) |
DATE_TRUNC('year', col) |
Nativ | DATE_FORMAT(col, '%Y-01-01') |
strftime('%Y-01-01', col) |
DATE_TRUNC('day', col) |
Nativ | DATE(col) |
date(col) |
EXTRACT(YEAR FROM col) |
Nativ | YEAR(col) |
CAST(strftime('%Y', col) AS INTEGER) |
EXTRACT(MONTH FROM col) |
Nativ | MONTH(col) |
CAST(strftime('%m', col) AS INTEGER) |
COALESCE(a, b) |
Nativ | Nativ | Nativ |
NULLIF(a, b) |
Nativ | Nativ | Nativ |
CAST(x AS type) |
Nativ | Nativ (Typ-Mapping) | Nativ (Typ-Mapping) |
CONCAT(a, b) |
Nativ | Nativ | a || b |
LENGTH(s) |
Nativ | CHAR_LENGTH(s) |
LENGTH(s) |
SUBSTRING(s FROM n FOR m) |
Nativ | SUBSTRING(s, n, m) |
SUBSTR(s, n, m) |
BOOLEAN Literale (TRUE/FALSE) |
Nativ | 1/0 |
1/0 |
Diese Funktionen werden nicht transformiert, da sie überall unterstützt werden:
COUNT, SUM, AVG, MIN, MAX, ABS, ROUND, UPPER, LOWER, TRIM, REPLACE, LIKE, IN, BETWEEN, CASE...WHEN, GROUP BY, ORDER BY, HAVING, LIMIT, OFFSET, DISTINCT, JOIN, LEFT JOIN, RIGHT JOIN, UNION, EXISTS, NOT EXISTS.
Funktionen die nicht in der obigen Tabelle stehen und dialektspezifisch sind, werden nicht automatisch transformiert. Verhalten:
| Situation | Verhalten |
|---|---|
source_dialect = Ziel-Dialekt |
Query wird 1:1 übernommen |
source_dialect ≠ Ziel-Dialekt, unbekannte Funktion erkannt |
Query wird 1:1 übernommen + Warnung W111: "View query may contain dialect-specific functions" |
Kein source_dialect gesetzt |
Query wird 1:1 übernommen (Annahme: Standard-SQL) |
Die Erkennung unbekannter Funktionen erfolgt über eine einfache Heuristik: Wenn der Query Funktionsnamen enthält, die weder in der Transformationstabelle noch in der transparenten Liste stehen, wird W111 erzeugt. Für 0.2.0 ist dies eine Best-Effort-Prüfung — false positives sind akzeptabel.
Identifier in View-Queries werden gemäß Ziel-Dialekt gequotet (§2). Der Query-String wird dafür nicht vollständig geparst, sondern nur die bekannten Tabellen- und Spaltennamen (aus dependencies) werden ersetzt.
CREATE TABLE "orders" (
"id" SERIAL,
"order_date" TIMESTAMP NOT NULL,
"amount" DECIMAL(10,2),
PRIMARY KEY ("id", "order_date")
) PARTITION BY RANGE ("order_date");
CREATE TABLE "orders_2024" PARTITION OF "orders"
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE "orders_2025" PARTITION OF "orders"
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');CREATE TABLE `orders` (
`id` INT NOT NULL AUTO_INCREMENT,
`order_date` DATETIME NOT NULL,
`amount` DECIMAL(10,2),
PRIMARY KEY (`id`, `order_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE (YEAR(`order_date`)) (
PARTITION orders_2024 VALUES LESS THAN (2025),
PARTITION orders_2025 VALUES LESS THAN (2026)
);Hinweis: MySQL erfordert, dass der Partitionsschlüssel Teil des Primary Key ist.
SQLite unterstützt keine native Partitionierung. Bei partitioning-Konfiguration wird action_required (E055) erzeugt.
| Neutral | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
range |
PARTITION BY RANGE |
PARTITION BY RANGE |
Nicht unterstützt |
list |
PARTITION BY LIST |
PARTITION BY LIST |
Nicht unterstützt |
hash |
PARTITION BY HASH |
PARTITION BY HASH |
Nicht unterstützt |
CREATE OR REPLACE FUNCTION "trg_fn_orders_updated_at"()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "trg_orders_updated_at"
BEFORE UPDATE ON "orders"
FOR EACH ROW
EXECUTE FUNCTION "trg_fn_orders_updated_at"();PostgreSQL-Besonderheit: Trigger-Logik liegt in einer separaten Trigger-Function.
DELIMITER //
CREATE TRIGGER `trg_orders_updated_at`
BEFORE UPDATE ON `orders`
FOR EACH ROW
BEGIN
SET NEW.updated_at = CURRENT_TIMESTAMP;
END //
DELIMITER ;DELIMITER wird für alle Statements benötigt, die einen BEGIN...END-Block enthalten:
| Objekt-Typ | DELIMITER nötig | Begründung |
|---|---|---|
| CREATE TABLE | Nein | Kein BEGIN...END |
| CREATE INDEX | Nein | Kein BEGIN...END |
| CREATE VIEW | Nein | Kein BEGIN...END |
| CREATE TRIGGER | Ja | Enthält BEGIN...END |
| CREATE FUNCTION | Ja | Enthält BEGIN...END |
| CREATE PROCEDURE | Ja | Enthält BEGIN...END |
| CREATE EVENT | Ja | Kann BEGIN...END enthalten |
Gruppierung: DELIMITER-Wechsel werden pro Statement gesetzt, nicht für die gesamte Datei:
-- Tabellen, Indizes, Views (ohne DELIMITER)
CREATE TABLE `customers` (...);
CREATE INDEX `idx_email` ON `customers` (`email`);
-- Trigger, Functions, Procedures (mit DELIMITER)
DELIMITER //
CREATE TRIGGER `trg_updated` ... BEGIN ... END //
DELIMITER ;
DELIMITER //
CREATE FUNCTION `calc_total`(...) ... BEGIN ... END //
DELIMITER ;CREATE TRIGGER "trg_orders_updated_at"
BEFORE UPDATE ON "orders"
FOR EACH ROW
BEGIN
UPDATE "orders" SET "updated_at" = datetime('now')
WHERE rowid = NEW.rowid;
END;SQLite-Besonderheit: Kein direktes NEW.col = value; stattdessen UPDATE-Statement nötig.
Trigger-Bodies können dialektspezifische prozedurale Logik enthalten. Es gelten die gleichen Regeln wie für Functions/Procedures (§11):
| Situation | Verhalten |
|---|---|
source_dialect = Ziel-Dialekt |
Body wird 1:1 übernommen |
source_dialect ≠ Ziel-Dialekt, Body vorhanden |
Body wird übersprungen, action_required (E053) erzeugt |
source_dialect ≠ Ziel-Dialekt, Body leer/null |
action_required (E053): Trigger muss manuell implementiert werden |
Kein source_dialect angegeben |
Body wird 1:1 übernommen (Annahme: dialektneutral) |
PostgreSQL-Sonderfall: Trigger-Logik liegt in einer separaten Function. Wenn ein Trigger nach PostgreSQL generiert wird, erzeugt der Generator automatisch eine Trigger-Function aus dem Body:
Trigger-Body → CREATE FUNCTION trg_fn_<name>() ... + CREATE TRIGGER ... EXECUTE FUNCTION trg_fn_<name>()
Wenn ein PostgreSQL-Trigger in einen anderen Dialekt transformiert werden soll, wird die Trigger-Function aufgelöst und der Body in den Trigger integriert (oder action_required bei komplexer Logik).
Function- und Procedure-Bodys enthalten dialektspezifische prozedurale Logik (PL/pgSQL, MySQL-Procedural SQL, etc.), die nicht rein regelbasiert transformiert werden kann.
Strategie:
- Wenn
source_dialect=target_dialect: Body wird 1:1 übernommen - Wenn Dialekte unterschiedlich: KI-gestützte Transformation erforderlich (siehe design.md §4 und Beispiel Stored Procedure Migration)
- Fallback ohne KI:
action_required(E053) wird erzeugt mit Hinweis aufd-migrate transform procedure
Die Hülle (CREATE FUNCTION/PROCEDURE, Parameter, Return-Typ) wird regelbasiert generiert:
-- PostgreSQL
CREATE OR REPLACE FUNCTION "calculate_total"("p_order_id" INTEGER)
RETURNS DECIMAL(10,2) AS $$
<body>
$$ LANGUAGE plpgsql;
-- MySQL
DELIMITER //
CREATE FUNCTION `calculate_total`(p_order_id INT)
RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
<body>
END //
DELIMITER ;Für jedes Up-Statement wird ein inverses Down-Statement erzeugt:
| Up-Statement | Down-Statement |
|---|---|
CREATE TABLE "x" |
DROP TABLE IF EXISTS "x" |
CREATE INDEX "i" ON "x" |
DROP INDEX IF EXISTS "i" |
CREATE TYPE "t" |
DROP TYPE IF EXISTS "t" |
CREATE VIEW "v" |
DROP VIEW IF EXISTS "v" |
CREATE FUNCTION "f" |
DROP FUNCTION IF EXISTS "f" |
CREATE SEQUENCE "s" |
DROP SEQUENCE IF EXISTS "s" |
ALTER TABLE ADD COLUMN "c" |
ALTER TABLE DROP COLUMN "c" |
ALTER TABLE ADD CONSTRAINT "k" |
ALTER TABLE DROP CONSTRAINT "k" |
ALTER TABLE ALTER COLUMN "c" TYPE t |
ALTER TABLE ALTER COLUMN "c" TYPE <alter_typ> |
Execution-Reports gruppieren gerenderte Statements unabhängig vom SQL-Text:
Jede Gruppe trägt eine stabile statementGroupId, Operation-IDs,
Statement-Indexrange, transactionScope und transactionBoundary. Gemischte
Transaction-Scope-Streams werden vor Ausführung blockiert; Renderer dürfen
keine still gemischten Runner-/Stream-/No-Transaction-Statements erzeugen.
Nicht-reversible Operationen erzeugen einen Kommentar:
-- WARNING: Irreversible operation. Original data cannot be recovered.
-- Original column type was: VARCHAR(100)
DROP COLUMN "legacy_field";Diff-basierte Rollback-Artefakte aus schema migrate --generate-rollback
werden als ausführbarer SQL-Body plus d-migrate rollback-sql v2-
Metadatenblock geschrieben. Der Body bleibt das einzige ausführbare SQL. Der
Header enthält statementIndex[] mit UTF-8-Byte-Ranges, Statement-Hashes,
Operation-IDs, Phase, Risiko und transactionScope. Zusätzlich bindet der
Header rollbackComplete, partialRollback und skippedOperationIds[].
Partielle Rollback-Artefakte müssen partialRollback=true,
rollbackComplete=false und mindestens eine ausgelassene Operation
maschinenlesbar ausweisen. schema rollback --execute rekonstruiert
Statements aus diesen validierten Body-Ranges; ein Split anhand leerer Zeilen
ist für v2 verboten. rollback-sql v1 wird nur als Legacy-Lesepfad
unterstützt.
- Schlüsselwörter: UPPERCASE (
CREATE TABLE,NOT NULL,REFERENCES) - Identifikatoren: lowercase (gequotet)
- Einrückung: 4 Spaces (keine Tabs)
- Eine Spalte pro Zeile
- Constraints am Ende der Tabelle, jeweils eigene Zeile
- Leere Zeile zwischen Tabellen-Definitionen
- Kommentare:
--Präfix
-- Generated by d-migrate 0.1.0
-- Source: neutral schema v1.0.0 "E-Commerce System"
-- Target: postgresql | Generated: 2026-04-05T10:30:00Z
CREATE TYPE "order_status" AS ENUM (
'pending',
'processing',
'shipped',
'delivered',
'cancelled'
);
CREATE TABLE "customers" (
"id" SERIAL,
"email" VARCHAR(254) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"metadata" JSONB,
"created_at" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("id"),
UNIQUE ("email")
);
CREATE INDEX "idx_customers_email" ON "customers" ("email");
CREATE TABLE "orders" (
"id" SERIAL,
"customer_id" INTEGER NOT NULL,
"order_date" TIMESTAMP NOT NULL,
"total_amount" DECIMAL(10,2),
"status" "order_status" DEFAULT 'pending',
"notes" TEXT,
PRIMARY KEY ("id"),
CONSTRAINT "fk_orders_customer_id" FOREIGN KEY ("customer_id")
REFERENCES "customers" ("id") ON DELETE RESTRICT,
CONSTRAINT "chk_total_positive" CHECK ("total_amount" >= 0)
);
CREATE INDEX "idx_orders_customer_date" ON "orders" ("customer_id", "order_date" DESC);Hinweise werden als SQL-Kommentare direkt vor dem betroffenen Statement eingefügt:
-- [W102] HASH index not supported on InnoDB, using BTREE
CREATE INDEX `idx_orders_status` ON `orders` (`status`);
-- [E053] action_required: Function body requires KI-assisted transformation
-- Use: d-migrate transform procedure --procedure calculate_total --ai-backend ollama
-- Skipped: CREATE FUNCTION `calculate_total` (source_dialect: postgresql, target: mysql)Wenn --output verwendet wird, erzeugt der Generator neben der DDL-Datei einen Report:
--output schema.sql → schema.sql + schema.report.yaml
--output out/ddl.sql → out/ddl.sql + out/ddl.report.yaml
Report-Schema (YAML):
source:
schema: "E-Commerce System"
version: "1.0.0"
file: "schema.yaml"
target:
dialect: mysql
generated_at: "2026-04-05T10:30:00Z"
generator: "d-migrate 0.2.0"
summary:
statements: 12
notes: 4
warnings: 2
action_required: 1
skipped_objects: 1
notes:
- type: warning
code: W102
object: "idx_orders_status"
message: "HASH index not supported on InnoDB, using BTREE"
- type: action_required
code: E053
object: "calculate_total"
message: "Function body requires KI-assisted transformation"
hint: "d-migrate transform procedure --procedure calculate_total --ai-backend ollama"
skipped_objects:
- type: function
name: "calculate_total"
reason: "source_dialect (postgresql) differs from target (mysql), KI transformation required"
code: E053
hint: "d-migrate transform procedure --procedure calculate_total --ai-backend ollama"Der Report ist bewusst statement- und diagnoseorientiert. Er zaehlt die
erzeugten DDL-Statements sowie Notes / Warnings / action_required /
skipped_objects, aber keine objektartbezogenen Summen wie Tabellen, Indizes,
Views oder Funktionen.
| Situation | DDL-Output | Report | Exit-Code | stderr |
|---|---|---|---|---|
| Nur info/warning | Vollständig generiert | Alle Notes | 0 |
Warnungen ausgeben |
| action_required vorhanden | Generiert ohne übersprungene Objekte | Alle Notes + skipped_objects | 0 |
Warnungen + Hinweise auf übersprungene Objekte |
| Fehler (z.B. ungültiges Schema) | Nicht generiert | Nicht erzeugt | 3 |
Fehler ausgeben |
Wichtig: action_required ist kein Fehler — die DDL-Generierung wird fortgesetzt, das betroffene Objekt (Function, Trigger-Body, etc.) wird übersprungen und im Report dokumentiert. Der Exit-Code bleibt 0, damit CI/CD-Pipelines nicht abbrechen. Die skipped_objects-Liste im Report macht die Lücken transparent.
stderr-Ausgabe bei action_required:
⚠ Warning [E053]: Function 'calculate_total' skipped — requires KI-assisted transformation
→ Hint: d-migrate transform procedure --procedure calculate_total --ai-backend ollama
stdout: DDL-Output (oder in --output-Datei)
stderr: Warnungen und action_required-Hinweise
Bei --output-format json wird die DDL als ddl-Feld im JSON eingebettet:
{
"command": "schema.generate",
"status": "completed",
"exit_code": 0,
"target": "mysql",
"ddl": "CREATE TABLE `customers` (...);\\n...",
"notes": [...],
"skipped_objects": [...],
"warnings": 2,
"action_required": 1
}adapters/driven/formats/src/test/resources/fixtures/
├── schemas/ # Eingabe-Schemas (bestehend aus 0.1.0)
│ ├── minimal.yaml
│ ├── e-commerce.yaml
│ ├── all-types.yaml
│ └── full-featured.yaml
│
└── ddl/ # Erwartete DDL-Ausgaben (Golden Masters)
├── minimal.postgresql.sql
├── minimal.mysql.sql
├── minimal.sqlite.sql
├── e-commerce.postgresql.sql
├── e-commerce.mysql.sql
├── e-commerce.sqlite.sql
├── all-types.postgresql.sql
├── all-types.mysql.sql
└── all-types.sqlite.sql
Namenskonvention: <schema>.<dialekt>.sql
| Schema | Feature-Schwerpunkt | PG | MY | SQ |
|---|---|---|---|---|
| minimal | Basis (1 Tabelle, PK, 2 Spalten) | ✓ | ✓ | ✓ |
| e-commerce | FK, Enum (ref_type), Indizes, CHECK, Defaults | ✓ | ✓ | ✓ |
| all-types | Alle 18 neutralen Typen | ✓ | ✓ | ✓ |
| full-featured | Custom Types, Partitioning, Procedures, Functions, Views, Triggers, Sequences | ✓ | ✓ | ✓ |
Gesamt: 4 Schemas × 3 Dialekte = 12 Golden-Master-Dateien
class DdlGoldenMasterTest : FunSpec({
val schemas = listOf("minimal", "e-commerce", "all-types", "full-featured")
val dialects = listOf("postgresql", "mysql", "sqlite")
for (schema in schemas) {
for (dialect in dialects) {
test("$schema generates correct $dialect DDL") {
val input = loadSchema("schemas/$schema.yaml")
val expected = loadGoldenMaster("ddl/$schema.$dialect.sql")
val actual = generateDdl(input, dialect)
actual shouldBe expected
}
}
}
})Der DDL-Header enthält im normalen Modus einen Timestamp (Generated: <ISO-8601>).
Für Golden-Master-Tests:
- Option A (empfohlen):
schema generate --deterministicbzw.DdlGenerationOptions(deterministic = true)verwenden. - Option B:
SOURCE_DATE_EPOCHbzw.DdlGenerationOptions(generatedAt = ...)auf einen festen Wert setzen. - Option C: Header-Zeilen bei Vergleich ignorieren, insbesondere die
-- Target: ... | Generated: ...-Zeile.
Bei gewollten DDL-Änderungen:
# Golden Masters neu generieren
./gradlew :adapters:driven:driver-common:updateGoldenMasters
# Diff prüfen
git diff adapters/driven/formats/src/test/resources/fixtures/ddl/
# Committen wenn korrekt
git add adapters/driven/formats/src/test/resources/fixtures/ddl/Dieser Abschnitt beschreibt verbindlich, wie schema generate Spalten mit
type: geometry in datenbankspezifisches DDL ueberfuehrt. Er gilt fuer
Milestone 0.5.5 (Spatial Phase 1).
Nicht Teil dieses Abschnitts: type: geography, z, m, Spatial-Indizes
und automatische Erkennung oder Installation von Datenbankerweiterungen.
Da schema generate ohne Live-Datenbankverbindung laeuft, kann die
Verfuegbarkeit von PostGIS oder SpatiaLite nicht automatisch geprueft werden.
Stattdessen steuert ein explizites Spatial-Profil die DDL-Generierung fuer
geometry-Spalten. Das Profil ist Teil der Generator-Konfiguration — es
gehoert nicht zum neutralen Schema-Modell und wird nicht in YAML-Feldern
abgelegt.
Zulässige Werte und Defaults je Zieldialekt:
| Zieldialekt | Zulässige Profile | Default |
|---|---|---|
postgresql |
postgis, none |
postgis |
mysql |
native, none |
native |
sqlite |
spatialite, none |
none |
Bedeutung der Profilwerte:
postgis: PostgreSQL-DDL wird mit PostGIS-kompatiblen Geometrietypen erzeugt.native: MySQL-DDL wird mit den nativen Spatial Data Types von MySQL erzeugt.spatialite: SQLite-DDL verwendet dieAddGeometryColumn()-Strategie von SpatiaLite.none:geometry-Spalten werden nicht generiert; die gesamte Tabelle wird alsaction_required(E052) markiert. Das ist fuer PostgreSQL, MySQL und SQLite ein zulaessiger Generatorpfad, wenn Spatial-DDL bewusst unterdrueckt werden soll.
Unzulaessige Dialekt/Profil-Kombinationen (z.B. --target mysql --spatial-profile postgis)
erzeugen einen Nutzungsfehler (Exit-Code 2) noch vor der DDL-Generierung.
geometry-Spalten werden als PostGIS-geometry-Typ mit explizitem
Subtyp und SRID generiert.
Typ-Syntax:
geometry(<GeometryType>, <srid>)
geometry_type wird in PostGIS-Schreibweise mit grossem Anfangsbuchstaben
emittiert (z.B. Point, Polygon, MultiPolygon). Wenn geometry_type
nicht angegeben ist, gilt der Default geometry (lowercase im neutralen
Modell, grossgeschrieben im PostGIS-Ausdruck: Geometry).
Wenn srid nicht angegeben ist, wird 0 als Platzhalter verwendet.
Beispiel:
CREATE TABLE "places" (
"id" SERIAL,
"location" geometry(Point, 4326),
PRIMARY KEY ("id")
);Regeln:
- Ohne explizite Extension-Install-Policy emittiert der Migrate-Pfad kein
automatisches
CREATE EXTENSION IF NOT EXISTS postgis;. Stattdessen blockieren extension-abhaengige Operationen, wenn die Zielverfuegbarkeit nichtVERIFIED_PRESENTist. - Mit
schema migrate --allow-extension-installdarf PostgreSQL vor der ersten PostGIS-abhaengigen OperationCREATE EXTENSION IF NOT EXISTS "postgis";rendern, auch wenn die AvailabilityMISSINGoderUNKNOWNist. Das Statement erscheint im Report unterextensionInstallStatementsund wird als Side-Effect mit manueller Bestaetigung markiert. - Rollback-Statement:
ALTER TABLE "<table>" DROP COLUMN "<column>";
Wenn das Spatial-Profil fuer PostgreSQL auf none gesetzt ist, wird die
gesamte Tabelle blockiert. Es wird keine partielle DDL ohne die betroffene
Spatial-Spalte erzeugt.
Stattdessen wird die Tabelle als action_required mit Code E052 markiert und
in skipped_objects des Reports aufgefuehrt. Der Rest der DDL-Generierung
wird fortgesetzt (andere Tabellen werden normal erzeugt).
Report-Eintrag (Beispiel):
notes:
- type: action_required
code: E052
object: places
message: "Table blocked: spatial column 'location' cannot be generated with
PostgreSQL spatial profile 'none'"
hint: "Use --spatial-profile postgis or map the column manually"
skipped_objects:
- type: table
name: places
reason: "Spatial profile 'none': geometry column 'location' requires manual handling"MySQL unterstuetzt native Spatial Data Types. geometry_type wird direkt auf
den passenden nativen MySQL-Typ abgebildet.
Typ-Mapping:
geometry_type im neutralen Modell |
MySQL-DDL-Typ |
|---|---|
geometry |
GEOMETRY |
point |
POINT |
linestring |
LINESTRING |
polygon |
POLYGON |
multipoint |
MULTIPOINT |
multilinestring |
MULTILINESTRING |
multipolygon |
MULTIPOLYGON |
geometrycollection |
GEOMETRYCOLLECTION |
MySQL hat kein Profil none — native ist der einzige zulaessige Wert.
SRID-Handling: MySQL unterstuetzt SRID-Constraints ab Version 8.0. Falls die SRID-Information nicht im gewuenschten DDL-Stil ausgedrueckt werden kann, wird der Typ ohne explizite SRID-Angabe emittiert und der Transformationsreport enthaelt eine Warnung W120.
Beispiel ohne W120 (SRID korrekt uebernommen):
CREATE TABLE `places` (
`id` INT NOT NULL AUTO_INCREMENT,
`location` POINT SRID 4326,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;Rollback-Statement: ALTER TABLE `<table>` DROP COLUMN `<column>`;
SpatiaLite registriert Geometriespalten ueber eine Metadaten-Funktion statt
direkt in CREATE TABLE. Deshalb wird eine zweistufige Strategie verwendet:
CREATE TABLEohne diegeometry-Spalte erzeugen.- Fuer jede
geometry-Spalte einSELECT AddGeometryColumn(...)emittieren.
Beispiel:
CREATE TABLE "places" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT
);
SELECT AddGeometryColumn('places', 'location', 4326, 'POINT', 'XY');AddGeometryColumn-Signatur: (table_name, column_name, srid, type, coord_dimension)
srid: Der Wert aus dem neutralen Modell; fehltsrid, wird0verwendet.type:geometry_typein Grossbuchstaben (z.B.POINT,POLYGON).coord_dimension: Immer'XY'in Phase 1 (Z/M sind nicht Teil von 0.5.5).
Rollback: Das Rollback-Statement fuer AddGeometryColumn ist
SELECT DiscardGeometryColumn('<table>', '<column>');. Fuer die zugehoerige
Tabelle gilt das normale DROP TABLE IF EXISTS.
Wenn das Spatial-Profil fuer SQLite auf none gesetzt ist (das ist der
Default), wird die gesamte Tabelle blockiert. Es wird kein stiller Fallback
auf TEXT oder BLOB erzeugt.
Die Tabelle wird als action_required mit Code E052 markiert, analog zu
§16.3 (PostgreSQL mit Profil none). Partielle DDL ohne die Spatial-Spalte
ist kein zulaessiger 0.5.5-Pfad.
| Generiertes Statement | Rollback-Statement |
|---|---|
CREATE TABLE "t" (...) ohne geometry |
DROP TABLE IF EXISTS "t" |
SELECT AddGeometryColumn('t', 'c', ...) |
SELECT DiscardGeometryColumn('t', 'c') |
geometry(Point, 4326) als Spalte in CREATE TABLE |
Teil des normalen DROP TABLE IF EXISTS "t" |
Kanonische Rollback-Semantik fuer PostgreSQL und MySQL (0.5.5):
Da Geometry-Spalten in 0.5.5 ausschliesslich ueber CREATE TABLE (nicht ueber
ALTER TABLE ADD COLUMN) erzeugt werden, ist der Rollback-Pfad fuer
PostgreSQL und MySQL in 0.5.5 immer DROP TABLE IF EXISTS. Spaltenbezogene
Rollback-Pfade (ALTER TABLE ... DROP COLUMN) sind erst bei spaeterer
Migrationsunterstuetzung relevant und nicht Teil von 0.5.5.
Wenn Rollback generiert wird (--generate-rollback) und das Profil
spatialite ist, werden die DiscardGeometryColumn-Aufrufe in umgekehrter
Reihenfolge vor dem DROP TABLE emittiert.
Diese Codes ergaenzen die allgemeinen Codes aus §4. Die Codes E020, E120 und E121
entstehen bei schema validate (Schema-/Modellregeln); E052 bis E057 sowie W113 und W120
entstehen bei schema generate (Generator-/Report-Regeln).
| Code | Typ | Ebene | Meldung |
|---|---|---|---|
| E120 | Validierungsfehler | schema validate |
Unknown geometry_type value |
| E121 | Validierungsfehler | schema validate |
srid must be greater than 0 |
| E020 | Validierungsfehler | schema validate |
Declared view dependency references non-existent view |
| E052 | action_required | schema generate |
Spatial object cannot be generated with the chosen spatial profile |
| E053 | action_required | schema generate |
Dialect-specific SQL content requires manual transformation or implementation |
| E054 | action_required | schema generate |
Object type is not supported in the target dialect |
| E055 | action_required | schema generate |
Partitioning is not supported in the target dialect |
| E056 | action_required | schema generate |
Named sequence cannot be generated natively and needs emulation/manual handling |
| E057 | action_required | schema generate |
Feature combination not generable: MySQL — partial index predicate cannot be generated; SQLite (helper_table) — WITHOUT ROWID table with SequenceNextVal column (AFTER INSERT trigger requires ROWID) |
| E058 | action_required | schema rollback |
SQLite (helper_table): external objects reference dmg_sequences; rollback aborts before any DROP to avoid dangling references |
| E060 | Diagnostik | schema generate --split / schema rollback |
Phasenkonflikt im Split-Modus; SQLite zusätzlich: ATTACHed Datenbanken detektiert beim Rollback-Preflight |
| W113 | Warnung | schema generate |
View dependencies could not be fully topologically sorted; original order is used for the remaining views |
| W114 | Warnung | schema generate |
Sequence cache value stored but not emulated as preallocation (MySQL + SQLite helper_table; SQLite ist Single-Writer und profitiert nicht von Caching) |
| W115 | Warnung | schema generate |
SequenceNextVal uses lossy trigger semantics (MySQL + SQLite helper_table); explicit NULL is treated like omitted value |
| W116 | Warnung | schema reverse |
Sequence metadata reconstructed, but required support objects (routines/triggers) are missing or degraded (MySQL + SQLite) |
| W117 | Warnung | schema generate |
Sequence values are transaction-bound in helper-table mode; rollback retracts increments (MySQL + SQLite) |
| W119 | Warnung | schema generate |
SQLite (helper_table): NOT NULL und CHECK-IS NOT NULL auf sequence-getragener Spalte werden unterdrückt, weil der _bi-Trigger NULL injizieren muss; Wert wird vom _ai-Trigger garantiert |
| W120 | Warnung | schema generate / schema reverse |
MySQL: SRID could not be fully transferred. SQLite (helper_table-Reverse): Marker stimmt, aber Trigger-Body wurde modifiziert; Sequence-Zuordnung bleibt, aber Emulation evtl. nicht funktional |
| W121 | Info | schema generate |
SQLite (helper_table): Conflict-Gap-INFO — ON CONFLICT DO UPDATE/DO NOTHING, INSERT OR IGNORE, INSERT OR FAIL (multi-row) verbrauchen einen Sequence-Wert ohne Insert |
| W122 | Warnung | schema generate |
SQLite (helper_table): AFTER INSERT-Sequence-Trigger führt UPDATE auf der Zieltabelle aus; bei PRAGMA recursive_triggers = ON feuern bestehende UPDATE-Trigger auf derselben Tabelle |
| W123 | Warnung | schema rollback |
SQLite (helper_table): ATTACHed Datenbanken detektiert; Rollback kann Abhängigkeiten über Schemen nicht prüfen — --force-rollback erforderlich |
| W124 | Warnung | schema reverse |
SQLite (helper_table): nutzerdefinierter BEFORE INSERT-Trigger auf der Zieltabelle ist vor dem kanonischen _bi-Trigger erzeugt worden; Sequence-Vergabe kann maskiert werden |
E120: Wird erzeugt, wenn geometry_type einen Wert enthaelt, der nicht in
der zulaessigen Wertemenge liegt: geometry, point, linestring, polygon,
multipoint, multilinestring, multipolygon, geometrycollection.
E121: Wird erzeugt, wenn srid angegeben ist und den Wert 0 oder einen
negativen Wert hat. Eine fehlende srid ist zulaessig und erzeugt keinen Fehler.
E020: Wird erzeugt, wenn dependencies.views auf eine View verweist, die
im neutralen Schema nicht existiert.
E052 (Spatial): Wird erzeugt, wenn ein Spatial-Objekt mit dem gewählten Spatial-Profil nicht generiert werden kann. Die gesamte betroffene Tabelle wird blockiert — partielle DDL ohne die Spatial-Spalte wird nicht erzeugt.
E053 (Dialekt-Transformation): Wird erzeugt, wenn View-Query, Function-/Procedure-Body oder Trigger-Body nicht automatisch zwischen Dialekten transformiert werden kann oder ein Body manuell ergänzt werden muss.
E054 (Nicht unterstützter Objekttyp): Wird erzeugt, wenn der Zieldialekt den Objekttyp nicht unterstützt, z. B. Composite Types, EXCLUDE-Constraints, SQLite-Functions oder SQLite-Procedures.
E055 (Partitionierung): Wird erzeugt, wenn die konfigurierte Partitionierung im Zieldialekt nicht unterstützt wird.
E056 (Sequence-/Emulationsfall): Wird erzeugt, wenn eine benannte Sequence im Zieldialekt nicht nativ erzeugt werden kann und manuelle Emulation oder Nacharbeit erforderlich ist.
E057 (Partial Index): Wird erzeugt, wenn ein Index ein where-Praedikat
traegt, der Zieldialekt aber keine native Partial-Index-Semantik unterstuetzt.
Der Index wird uebersprungen; insbesondere Partial-UNIQUE darf nicht als
normaler Unique-Index ausgegeben werden.
W113: Wird erzeugt, wenn deklarierte oder best-effort abgeleitete View-Abhaengigkeiten keine vollstaendige topologische Sortierung erlauben. Die verbleibenden Views werden dann in ihrer Originalreihenfolge emittiert.
W120: Wird erzeugt, wenn srid-Metadaten nicht vollstaendig in die
Ziel-DDL uebertragen werden konnten (Best-Effort-Pfad, insbesondere bei MySQL
fuer aeltere Server-Versionen). Die DDL wird trotzdem erzeugt; der Hinweis
erscheint im Report und auf stderr.
Ab 0.9.2 traegt jedes DDL-Statement eine DdlPhase (PRE_DATA oder
POST_DATA). Im Default-Modus (--split single) hat das keine Auswirkung —
alle Statements werden wie bisher in einer Datei ausgegeben. Mit
--split pre-post werden die Statements nach Phase getrennt.
| Phase | Objekte | Begruendung |
|---|---|---|
PRE_DATA |
Custom Types, Sequences, Tabellen (topologisch sortiert), Indizes, Constraints, einfache Views | Strukturelle DDL, die vor einem Datenimport stehen muss |
POST_DATA |
Functions, Procedures, Triggers, Views mit Routinen-Abhaengigkeiten | Ausfuehrbare Objekte, die bei aktivem Datenimport stoeren koennen |
PRE_DATA (analog zur bisherigen Gesamtreihenfolge aus §1.1):
1. Custom Types (CREATE TYPE)
2. Sequences (CREATE SEQUENCE)
3. Tabellen (CREATE TABLE — topologisch sortiert nach FK)
4. Indizes (CREATE INDEX)
5. Circular-FK-Nachzuegler (ALTER TABLE ADD CONSTRAINT)
6. Views ohne Routinen-Abhaengigkeit (CREATE VIEW)
POST_DATA:
1. Views mit Routinen-Abhaengigkeit (CREATE VIEW)
2. Functions (CREATE FUNCTION)
3. Procedures (CREATE PROCEDURE)
4. Triggers (CREATE TRIGGER)
Views werden ueber den ViewPhaseClassifier einer Phase zugewiesen:
- Deklarierte Abhaengigkeiten: Hat eine View
dependencies.functions, wird siePOST_DATAzugeordnet. - Inferierte Funktionsaufrufe: Der Query-Text wird auf Funktionsnamen
geparst, die im Schema als Functions definiert sind. Treffer →
POST_DATA. - Transitive Propagation: Views, die von einer
POST_DATA-View abhaengen (ueberdependencies.views), werden ebenfallsPOST_DATA. - Fallback: Views ohne Query-Text und ohne deklarierte
dependencies.functionserzeugen bei vorhandenen Functions im Schema den Fehlercode E060.
| Modus | Textausgabe (--output) |
JSON-Ausgabe (--output-format json) |
|---|---|---|
single |
<name>.sql (alle Statements) |
"ddl": "..." |
pre-post |
<name>.pre-data.sql, <name>.post-data.sql |
"ddl_parts": { "pre_data": "...", "post_data": "..." } |
Bei --split pre-post wird die Originaldatei (<name>.sql) nicht geschrieben.
Der Sidecar-Report bleibt ein einzelnes Artefakt mit split_mode: pre-post.
Notes und skipped_objects tragen im Split-Modus optional ein phase-Feld
("pre-data" oder "post-data", Kebab-Case).
--split pre-posterfordert--outputoder--output-format json(Exit 2)--split pre-postkann nicht mit--generate-rollbackkombiniert werden (Exit 2)- E060 bei nicht zuordenbaren Views fuehrt zu Exit 2
- Neutrales-Modell-Spezifikation — Typsystem §3, Validierung §13, Transformationshinweise §11
- Design — Domänenmodell §2, Rollback §7
- Architektur — TypeMapper §3.4, SchemaWriter §3.1
- CLI-Spezifikation — Exit-Codes, Fehler-Codes
Version: 1.2 Stand: 2026-04-20 Status: Spezifikation fuer 0.9.2 (§17 Phasenbezogene DDL-Ordnung hinzugefuegt)