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
SQLiteStmtPrepareübersetzt das SQL einmal. Rückgabe0heißt Fehler —SQLiteErrmsgnennt den Grund.SQLiteBind…setzt die Werte. Die Parameter sind 1-basiert.SQLiteStmtStepführt aus.SQLITE_ROW(100) heißt „eine Zeile liegt bereit„,SQLITE_DONE(101) „fertig“.SQLiteStmtFinalizegibt 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)); }
SQLiteBeginbeginnt verzögert (die Sperre entsteht erst beim ersten Schreiben).SQLiteBeginImmediatenimmt die Schreibsperre sofort — richtig, wenn zwei Prozesse um dieselbe Datei konkurrieren, sonst drohtSQLITE_BUSYmitten in der Transaktion.SQLiteBeginExclusivesperrt die ganze Datei.SQLiteInTransactionbeantwortet, ob eine läuft.- Jeder Fehlerpfad braucht sein
Rollback. Ein früherreturnzwischenBeginundCommithinterlä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)speichert0.0, undSQLiteColumnFloatliefert0— auch bei einem Wert, den SQL selbst korrekt sieht (SELECT CAST(f*10 AS INTEGER)rechnet richtig). Betroffen sind beide Richtungen;INTEGERundTEXTarbeiten 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@capabilitiesstirbtstd.db.sqlitebeim ersten Zugriff. Der Prozess endet mitUngültiger Betriebssystemaufruf(Exit 159) — der seccomp-Filter kenntfcntlnicht, das SQLite für seine Dateisperren braucht. Gewährtefs-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.pgTCPConnectzerlegt 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 (DNSResolveGoogleausstd.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 vonMySQLGetRowStrist ein Zeiger, kein Index. Es erwartet den Rückgabewert vonMySQLFetchRow. Wer dort eine Zeilennummer übergibt, bekommt bei0eine leere Zeichenkette und ab1einen Speicherzugriffsfehler — die Zahl wird als Adresse gelesen.
Der gelieferte Zeiger zeigt auf einen gemeinsamen Puffer und gilt nur bis zum nächsten Wertzugriff; auchMySQLGetRowIntü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/Rollback — Wirkung | ✅ |
MySQLBegin/Commit/Rollback — Rü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.MySQLStmtPrepareliefert ein Statement mitstmt_id = 0undparam_count = 0, jedesMySQLStmtBind…gibtfalsezurück, undMySQLStmtExecutefügt nichts ein — der Server lehnt das Paket mit#08S01 Unknown commandab.
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.MySQLConnectzerlegthostmitparseIPv4Byteund kennt kein DNS;„localhost“ergibt 0.0.0.0, undsys_connectwartet 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.RedisConnectlöst jeden Host über Google DNS auf (GetHostByName→DNSResolveGoogle). Damit scheitern sowohl„127.0.0.1“— eine IP-Adresse wird als Name abgefragt — als auch„localhost“, weil/etc/hostsnicht gelesen wird. Nur öffentlich auflösbare Namen funktionieren, und jede Verbindung kostet zusätzlich eine externe DNS-Anfrage.
Zu beachten:std.db.postgresundstd.db.mysqlmachen das Gegenteil und akzeptieren nur eine literale IPv4. Derhost-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.sqlite — REAL (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
- std.graphdb — Knoten und Kanten statt Tabellen
- std.net.mongo — MongoDB Wire Protocol
- Netzwerk-Guide — Namensauflösung, Capabilities, Timeouts
- Daten & Serialisierung — was aus der Datenbank herausfällt, weiterverarbeiten
- Capabilities · FFI (SQLite bindet libsqlite3 ein)
