Datenbanken & SQL

Daten dauerhaft ablegen, abfragen und ändern: std.db spricht SQLite über die C-Bibliothek, PostgreSQL, MySQL und Redis dagegen nativ über das jeweilige Netzprotokoll — ohne Client-Bibliothek. Diese Seite zeigt den Arbeitsweg je Backend, die gemeinsamen Muster (Prepared Statements, Transaktionen, Ressourcen) und benennt, was heute trägt.

std.db — Unit-Referenz · Netzwerk-Guide · Capabilities


1. Welches Backend?

Aufgabe Unit Verbindung Zustand
Eingebettete Datenbank, eine Datei std.db.sqlite libsqlite3 einsatzfähig — außer REAL, siehe Abschnitt 5
Relationale Datenbank im Netz std.db.postgres natives Protokoll v3 einsatzfähig — Host nur als IPv4
MySQL / MariaDB std.db.mysql natives Protokoll 4.1 Abfragen ja, Prepared Statements nein — Abschnitt 8
Cache, Warteschlange, Zähler std.db.redis RESP ⚠️ lokaler Server nicht erreichbar, Abschnitt 9
Sehr kleiner Redis-Zugriff std.db.redis_simple RESP nur Set/Get/Ping
Graphdaten (Knoten, Kanten) std.graphdb eigene Datei eigenständige Familie, 7 Units
MongoDB std.net.mongo Wire Protocol liegt bei den Netz-Units, nicht unter db

Faustregel: SQLite, solange es geht. Eine Datei, kein Server, kein Netz, keine Zugangsdaten — und für die allermeisten Anwendungen schnell genug. Ein Server-Backend lohnt, wenn mehrere Prozesse gleichzeitig schreiben oder die Daten getrennt vom Programm leben sollen.


2. SQLite — der vollständige Weg

import std.io;
import std.db.sqlite;

fn main(): int64 {
    var db: int64 := SQLiteOpen("kunden.db"c);       // ":memory:" für flüchtige DB
    if (db == 0) { PrintLn("Öffnen fehlgeschlagen"); return 1; }

    if (!SQLiteExec(db, "CREATE TABLE IF NOT EXISTS kunden (id INTEGER PRIMARY KEY, name TEXT, umsatz_cent INTEGER)"c)) {
        PrintLn(SQLiteErrmsg(db));
        SQLiteClose(db);
        return 1;
    }

    // Einfügen mit Prepared Statement
    var stmt: int64 := SQLiteStmtPrepare(db, "INSERT INTO kunden (name, umsatz_cent) VALUES (?, ?)"c);
    if (stmt == 0) { PrintLn(SQLiteErrmsg(db)); SQLiteClose(db); return 1; }

    SQLiteBindStr(stmt, 1, "Meier GmbH"c);
    SQLiteBindInt(stmt, 2, 125050);
    SQLiteStmtStep(stmt);

    SQLiteStmtReset(stmt);           // dasselbe Statement erneut verwenden
    SQLiteClearBindings(stmt);
    SQLiteBindStr(stmt, 1, "Schulze AG"c);
    SQLiteBindInt(stmt, 2, 89000);
    SQLiteStmtStep(stmt);
    SQLiteStmtFinalize(stmt);

    Print("letzte ID: ");  PrintLn(IntToStr(SQLiteLastInsertId(db)));
    Print("geaendert: ");  PrintLn(IntToStr(SQLiteChanges(db)));

    // Abfragen
    var q: int64 := SQLiteStmtPrepare(db, "SELECT id, name FROM kunden WHERE umsatz_cent > ? ORDER BY umsatz_cent DESC"c);
    SQLiteBindInt(q, 1, 50000);
    while (SQLiteStmtStep(q) == SQLITE_ROW) {
        var name: pchar := SQLiteColumnText(q, 1);
        Print(IntToStr(SQLiteColumnInt(q, 0))); Print(" | "); PrintLn(name);
    }
    SQLiteStmtFinalize(q);

    SQLiteClose(db);
    return 0;
}

Ausgabe:

letzte ID: 2
geaendert: 1
1 | Meier GmbH
2 | Schulze AG

Die vier Schritte eines Statements

  1. SQLiteStmtPrepare übersetzt das SQL einmal. Rückgabe 0 heißt Fehler — SQLiteErrmsg nennt den Grund.
  2. SQLiteBind… setzt die Werte. Die Parameter sind 1-basiert.
  3. SQLiteStmtStep führt aus. SQLITE_ROW (100) heißt „eine Zeile liegt bereit„, SQLITE_DONE (101) „fertig“.
  4. SQLiteStmtFinalize gibt das Statement frei. Ohne diesen Aufruf bleibt es am Verbindungsobjekt hängen.

Die Spalten in SQLiteColumn… sind dagegen 0-basiert. Diese Asymmetrie stammt aus SQLite selbst und ist die häufigste Fehlerquelle beim Einstieg.

Werte setzen Werte lesen
SQLiteBindInt, SQLiteBindStr, SQLiteBindFloat, SQLiteBindNull, SQLiteBindBlob SQLiteColumnInt, SQLiteColumnText, SQLiteColumnFloat, SQLiteColumnBlob, SQLiteColumnBytes
SQLiteColumnIsNull, SQLiteColumnType, SQLiteColumnCount, SQLiteColumnName

Ohne Ergebnismenge

SQLiteExec(db, sql) führt ein Statement in einem Schritt aus — richtig für CREATE TABLE, PRAGMA, DELETE ohne Parameter. Niemals mit selbst zusammengesetztem SQL aus fremden Daten (Abschnitt 4).

Verwaltung

Funktion Zweck
SQLiteLastInsertId ID der zuletzt eingefügten Zeile
SQLiteChanges Zahl der geänderten Zeilen der letzten Anweisung
SQLiteTableExists, SQLiteDropTable Schema prüfen und aufräumen
SQLiteSetJournalMode(db, „WAL“c) WAL statt Rollback-Journal — mehrere Leser neben einem Schreiber
SQLiteSetCacheSize Seiten im Cache
SQLiteVacuum Datei verdichten
SQLiteErrmsg, SQLiteErrno Klartext und Code des letzten Fehlers

Für jeden Dienst, der nebenläufig liest, gehört SQLiteSetJournalMode(db, „WAL“c) direkt hinter das SQLiteOpen.


3. Transaktionen

if (!SQLiteBegin(db)) { PrintLn(SQLiteErrmsg(db)); return 1; }

var ok: bool := SQLiteExec(db, "UPDATE konto SET stand = stand - 100 WHERE id = 1"c);
if (ok) { ok := SQLiteExec(db, "UPDATE konto SET stand = stand + 100 WHERE id = 2"c); }

if (ok) { SQLiteCommit(db); }
else    { SQLiteRollback(db); PrintLn(SQLiteErrmsg(db)); }

  • SQLiteBegin beginnt verzögert (die Sperre entsteht erst beim ersten Schreiben). SQLiteBeginImmediate nimmt die Schreibsperre sofort — richtig, wenn zwei Prozesse um dieselbe Datei konkurrieren, sonst droht SQLITE_BUSY mitten in der Transaktion. SQLiteBeginExclusive sperrt die ganze Datei.
  • SQLiteInTransaction beantwortet, ob eine läuft.
  • Jeder Fehlerpfad braucht sein Rollback. Ein früher return zwischen Begin und Commit hinterlässt eine offene Transaktion und damit eine gesperrte Datei.
  • Ein Massenimport gehört in eine Transaktion. Ohne sie schreibt SQLite jede einzelne Zeile mit eigener Synchronisierung auf die Platte — der Unterschied liegt bei Faktor Hundert.

PostgreSQL kennt zusätzlich Sicherungspunkte: PGSavepoint, PGRollbackTo, PGReleaseSavepoint, dazu PGBeginReadOnly und PGBeginSerializable.


4. SQL-Injection: Prepared Statements sind nicht optional

Falsch — und zwar unabhängig davon, wie sicher die Quelle wirkt:

// NIEMALS so:
var sql: int64 := alloc(512);
StrReplace(sql as pchar, "SELECT * FROM kunden WHERE name = 'X'"c, "X"c, eingabe);
SQLiteExec(db, sql as pchar);

Richtig:

var q: int64 := SQLiteStmtPrepare(db, "SELECT id FROM kunden WHERE name = ?"c);
SQLiteBindStr(q, 1, eingabe);
while (SQLiteStmtStep(q) == SQLITE_ROW) { … }
SQLiteStmtFinalize(q);

Der Unterschied ist nicht Bequemlichkeit, sondern die Grenze zwischen Code und Daten: Ein gebundener Wert kann das Statement nicht mehr verändern, egal was darin steht. Ein zusammengesetzter String kann es immer.

Dieselbe Regel bei PostgreSQL: PGStmtPrepare + PGBind… + PGStmtExecute. Bei Redis sind Schlüssel und Werte ohnehin getrennte Protokollfelder — dort entsteht das Problem nicht. Bei MySQL/MariaDB steht dieser Schutz derzeit nicht zur Verfügung, siehe Abschnitt 8.

Ausnahme, die keine ist: Tabellen- und Spaltennamen lassen sich nicht binden. Wo sie variabel sein müssen, gehören sie gegen eine feste Liste erlaubter Namen geprüft, nicht maskiert.


5. Zahlen mit Nachkommastellen

 
REAL-Spalten verlieren ihren Wert. SQLiteBindFloat(st, 1, 1250.5) speichert 0.0, und SQLiteColumnFloat liefert 0 — auch bei einem Wert, den SQL selbst korrekt sieht (SELECT CAST(f*10 AS INTEGER) rechnet richtig). Betroffen sind beide Richtungen; INTEGER und TEXT arbeiten einwandfrei. Geprüft mit lyxc 1.0.16F und 1.0.16F.

Bis zur Behebung: Beträge und Messwerte als ganze Zahlen führen — Geld in Cent, Längen in Millimetern, Prozent in Hundertsteln. Das ist bei Geld ohnehin die richtige Wahl, weil binäre Gleitkommazahlen 0,10 € nicht exakt darstellen können.
(Issue #1329)

// statt umsatz REAL:
CREATE TABLE kunden (id INTEGER PRIMARY KEY, name TEXT, umsatz_cent INTEGER)

// Anzeige: 125050 -> "1250,50"
var euro: int64 := cent / 100;
var rest: int64 := cent % 100;


6. Capabilities und die Sandbox

 
Mit @capabilities stirbt std.db.sqlite beim ersten Zugriff. Der Prozess endet mit Ungültiger Betriebssystemaufruf (Exit 159) — der seccomp-Filter kennt fcntl nicht, das SQLite für seine Dateisperren braucht. Gewährte fs-Capabilities ändern daran nichts.

Ein SQLite-Programm läuft damit heute entweder gehärtet oder überhaupt — nicht beides. Für Server-Backends (PostgreSQL, MySQL, Redis) gilt das nicht; dort genügen die Netz-Capabilities.
(Issue #1276)

Für die Netz-Backends:

@capabilities([system.exit, system.memory.heap, network.tcp.connect])
import std.io          grant [system.write];
import std.db.postgres grant [network.tcp.connect, system.memory.heap];

→ Die vollständige Capability-Matrix steht im Netzwerk-Guide.


7. PostgreSQL

std.db.postgres spricht das Frontend/Backend-Protokoll v3 selbst — inklusive MD5-Authentifizierung, Prepared Statements und Transaktionssteuerung.

import std.io;
import std.db.postgres;

fn main(): int64 {
    var conn: int64 := PGConnect("127.0.0.1"c, 5432, "app"c, "geheim"c, "kundendb"c);
    if (conn == 0 || PGIsConnected(conn) == 0) {
        PrintLn(PGError(conn));
        return 1;
    }
    var v: pchar := PGServerVersion(conn);
    Print("Server: "); PrintLn(v);

    var res: int64 := PGQuery(conn, "SELECT id, name FROM kunden ORDER BY id"c);
    if (res == 0) { PrintLn(PGError(conn)); PGClose(conn); return 1; }

    Print("Zeilen: "); PrintLn(IntToStr(PGNumRows(res)));
    while (PGFetchRow(res) != 0) {
        var name: pchar := PGGetStr(res, 1);
        Print(IntToStr(PGGetInt(res, 0))); Print(" | "); PrintLn(name);
    }
    PGFreeResult(res);
    PGClose(conn);
    return 0;
}

 
Der Host muss eine IPv4-Adresse in Textform sein. pgTCPConnect zerlegt die Zeichenkette Byte für Byte (pgParseIPv4Byte) und fragt kein DNS„localhost“ oder „db.example.com“ führen zu keiner Verbindung. Namen also vorher selbst auflösen (DNSResolveGoogle aus std.net.dns, siehe Netzwerk-Guide) oder die Adresse konfigurieren.
Aufgabe Funktion
Verbindung PGConnect, PGClose, PGIsConnected, PGServerVersion, PGBackendPID
Abfrage PGQuery, PGFreeResult
Ergebnis PGNumRows, PGNumFields, PGAffectedRows, PGFetchRow, PGDataSeek
Werte PGGetStr, PGGetInt, PGGetFloat, PGGetBool, PGIsNull
Spalten PGGetFieldName, PGGetFieldTypeOid
Prepared PGStmtPrepare, PGBindInt/Str/Float/Bool/Null, PGStmtExecute, PGStmtClose, PGStmtFree
Transaktion PGBegin, PGCommit, PGRollback, PGBeginReadOnly, PGBeginSerializable, PGSavepoint, PGRollbackTo, PGReleaseSavepoint
Fehler PGError, PGErrno, PGGetTxStatus

Ein Prepared Statement bekommt hier einen Namen und lebt auf dem Server: PGStmtPrepare(conn, „kunde_nach_id“c, „SELECT name FROM kunden WHERE id = $1“c). PostgreSQL nummeriert die Platzhalter ($1, $2) statt sie mit ? zu schreiben.


8. MySQL / MariaDB

Geprüft gegen MariaDB 10.11 über TCP.

import std.io;
import std.db.mysql;

fn main(): int64 {
    var conn: MySQLConn := MySQLConnect("127.0.0.1"c, 3306, "maria"c, "geheim"c, "maria"c);
    if (conn.fd < 0) { PrintLn(MySQLError(conn)); return 1; }

    var res: MySQLResult := MySQLQuery(conn, "SELECT id, name, umsatz_cent FROM kunden ORDER BY id"c);
    Print("Zeilen: "); PrintLn(IntToStr(MySQLNumRows(res)));

    var row: int64 := MySQLFetchRow(res);
    while (row != 0) {
        Print(IntToStr(MySQLGetRowInt(res, row, 0))); Print(" | ");
        var name: pchar := MySQLGetRowStr(res, row, 1);
        Print(name); Print(" | ");
        PrintLn(IntToStr(MySQLGetRowInt(res, row, 2)));
        row := MySQLFetchRow(res);
    }
    MySQLFreeResult(res);
    MySQLClose(conn);
    return 0;
}

Ausgabe:

Zeilen: 2
1 | Meier GmbH | 125050
2 | Schulze AG | 89000

 
Das zweite Argument von MySQLGetRowStr ist ein Zeiger, kein Index. Es erwartet den Rückgabewert von MySQLFetchRow. Wer dort eine Zeilennummer übergibt, bekommt bei 0 eine leere Zeichenkette und ab 1 einen Speicherzugriffsfehler — die Zahl wird als Adresse gelesen.

Der gelieferte Zeiger zeigt auf einen gemeinsamen Puffer und gilt nur bis zum nächsten Wertzugriff; auch MySQLGetRowInt überschreibt ihn. Ein vorab geholter Name ist nach dem nächsten Aufruf ein anderer Wert. Entweder sofort verwenden oder wegkopieren.

Was gegen MariaDB 10.11 trägt

Bereich Zustand
MySQLConnect (mysql_native_password), MySQLClose
MySQLQuery, MySQLNumRows, MySQLNumFields, MySQLFreeResult
MySQLFetchRow + MySQLGetRowStr/MySQLGetRowInt
MySQLAffectedRows, MySQLInsertId
MySQLBegin/Commit/RollbackWirkung
MySQLBegin/Commit/RollbackRückgabewert ❌ liefert false, obwohl die Transaktion greift
MySQLStmtPrepare und die ganze Prepared-Familie ❌ Server antwortet #08S01 Unknown command
MySQLGetFieldName, …Type, …Length ❌ leere Hüllen: „“ bzw. 0
 
Prepared Statements arbeiten gegen MySQL/MariaDB nicht. MySQLStmtPrepare liefert ein Statement mit stmt_id = 0 und param_count = 0, jedes MySQLStmtBind… gibt false zurück, und MySQLStmtExecute fügt nichts ein — der Server lehnt das Paket mit #08S01 Unknown command ab.

Damit fehlt bei diesem Backend der Schutz aus Abschnitt 4. Wer Werte aus fremder Quelle verarbeitet, nimmt bis auf Weiteres SQLite oder PostgreSQL — dort arbeiten Prepared Statements. Geht das nicht, muss jede Eingabe vor dem Einsetzen gegen eine enge Positivliste geprüft werden (Ziffern, feste Auswahl); Maskieren allein reicht nicht.
(Issue #1332)
 
Der Host muss eine IPv4-Adresse in Textform sein — ein Name lässt die Verbindung hängen. MySQLConnect zerlegt host mit parseIPv4Byte und kennt kein DNS; „localhost“ ergibt 0.0.0.0, und sys_connect wartet daraufhin unbegrenzt, statt einen Fehler zu melden. Namen also selbst auflösen und die Adresse übergeben.
(Issue #1332)
Aufgabe Funktion
Verbindung MySQLConnect, MySQLClose, MySQLError, MySQLErrno
Abfrage MySQLQuery, MySQLFreeResult, MySQLAffectedRows, MySQLInsertId
Ergebnis MySQLNumRows, MySQLNumFields, MySQLFetchRow, MySQLGetRowStr, MySQLGetRowInt, MySQLIsNull, MySQLDataSeek
Transaktion MySQLBegin, MySQLCommit, MySQLRollback, MySQLSetAutoCommit, MySQLInTransaction

Die Ergebnismenge liegt vollständig im Speicher — bei großen Abfragen also LIMIT setzen.


9. Redis

Kein SQL, sondern Schlüssel-Wert-Speicher: Cache, Warteschlange, Zähler, Sitzungsdaten.

import std.io;
import std.db.redis;

fn main(): int64 {
    var conn: RedisConn := RedisConnect("db.example.com"c, 6379);
    if (conn.fd < 0) { PrintLn("Redis nicht erreichbar"); return 1; }

    RedisSet(conn, "kunde:1"c, "Meier GmbH"c);
    var r: RedisReply := RedisGet(conn, "kunde:1"c);
    if (r.kind == REDIS_REPLY_BULK) {
        var s: pchar := r.str_val;
        PrintLn(s);
    }

    RedisLPush(conn, "queue"c, "job1"c);
    Print("Laenge: "); PrintLn(IntToStr(RedisLLen(conn, "queue"c)));

    RedisClose(conn);
    return 0;
}

 
Ein Redis auf demselben Rechner ist nicht erreichbar. RedisConnect löst jeden Host über Google DNS auf (GetHostByNameDNSResolveGoogle). Damit scheitern sowohl „127.0.0.1“ — eine IP-Adresse wird als Name abgefragt — als auch „localhost“, weil /etc/hosts nicht gelesen wird. Nur öffentlich auflösbare Namen funktionieren, und jede Verbindung kostet zusätzlich eine externe DNS-Anfrage.

Zu beachten: std.db.postgres und std.db.mysql machen das Gegenteil und akzeptieren nur eine literale IPv4. Der host-Parameter bedeutet also je nach Unit etwas anderes.
(Issue #1330)
Bereich Funktionen
Verbindung RedisConnect, RedisClose, RedisPing
Schlüssel RedisSet, RedisGet, RedisDel, RedisExists
Listen RedisLPush, RedisRPush, RedisLPop, RedisRPop, RedisLRange, RedisLLen
Hashes RedisHSet, RedisHGet, RedisHGetAll, RedisHDel

Die Antwort kommt als RedisReply mit kind (REDIS_REPLY_NIL, …_BULK, …_INTEGER, …_ARRAY, …_ERROR, …_STATUS), int_val, str_val und str_len. kind zuerst prüfen — ein fehlender Schlüssel liefert REDIS_REPLY_NIL, nicht etwa eine leere Zeichenkette.

std.db.redis_simple ist die Sparversion mit RedisConnect, RedisClose, RedisPing, RedisSet, RedisGet — nützlich, wenn nur ein Wert abgelegt werden soll.


10. Wiederkehrende Muster

Alles wieder freigeben

Angelegt mit Freigegeben mit
SQLiteOpen SQLiteClose
SQLiteStmtPrepare SQLiteStmtFinalize — auch im Fehlerfall
PGConnect PGClose
PGQuery PGFreeResult
PGStmtPrepare PGStmtClose (Server) und PGStmtFree (Speicher)
MySQLConnect MySQLClose
MySQLQuery MySQLFreeResult
RedisConnect RedisClose

Ein Dienst, der pro Anfrage eine Verbindung öffnet und nicht schließt, läuft nach einigen tausend Anfragen in das Deskriptorlimit — und zwar erst im Betrieb, nicht im Test.

Fehler auf drei Ebenen

Ebene Erkennbar an Beispiel
Verbindung 0 bzw. fd < 0 Server aus, Passwort falsch, Datei nicht schreibbar
Anweisung Prepare gibt 0, Exec gibt false Syntaxfehler, Tabelle fehlt, Constraint verletzt
Ergebnis SQLITE_DONE statt SQLITE_ROW, NumRows = 0 die Abfrage lief, fand aber nichts

Die dritte Ebene ist kein Fehler. Wer „keine Zeile„ wie „Fehler“ behandelt, meldet Störungen, wo nur nichts zu finden war.

Die Frage nach dem Schema

Lyx bringt kein Migrationswerkzeug mit. Bewährt hat sich eine Versionsnummer in der Datenbank selbst:

SQLiteExec(db, "CREATE TABLE IF NOT EXISTS schema_version (v INTEGER)"c);
// beim Start lesen, mit der erwarteten Version vergleichen, gezielt nachziehen


11. Zustand auf einen Blick

Unit Zustand
std.db.sqlite — Öffnen, Exec, Prepare/Bind/Step, Transaktionen, Verwaltung ✅ geprüft
std.db.sqliteREAL (BindFloat/ColumnFloat) ❌ Wert geht verloren (#1329)
std.db.sqlite — unter @capabilities ❌ Exit 159, fcntl fehlt im Filter (#1276)
std.db.postgres — Protokoll, Abfragen, Prepared, Transaktionen ✅ aus dem Quelltext; Host nur als IPv4
std.db.mysql — Verbindung, Abfragen, Zeilenzugriff, Transaktionswirkung ✅ gegen MariaDB 10.11 geprüft
std.db.mysql — Prepared Statements, Feld-Metadaten, Transaktions-Rückgabewerte ❌ (#1332)
std.db.redis — Befehle ✅; Verbindung nur zu öffentlich auflösbaren Namen (#1330)

Die SQLite- und MySQL-Beispiele dieser Seite sind übersetzt und gegen eine echte Datenbank ausgeführt worden (SQLite-Datei bzw. MariaDB 10.11); der PostgreSQL-Abschnitt folgt dem Quelltext der Unit, ohne laufenden Server geprüft zu sein.


12. Weiterführend