Abschnitt 12 · Lektion 2
Die erste Tabelle
Eine SQLite-Datenbank aufzusetzen dauert eine Zeile. Es gibt keinen Dienst, den man startet, keine Zugangsdaten und keinen Port. Du sagst, wie die Datei heißen soll, und das war es.
Eine Datei, eine Tabelle
import { DatabaseSync } from "node:sqlite";
import { mkdirSync, statSync } from "node:fs";
mkdirSync("daten", { recursive: true });
// Die Datei ist das einzige Argument. Gibt es sie noch nicht, entsteht
// sie. Den Ordner darum legt sie allerdings nicht an, deshalb die Zeile
// darueber.
const db = new DatabaseSync("daten/bibliothek.db");
db.exec(`CREATE TABLE IF NOT EXISTS buecher (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
jahr INTEGER,
ausgeliehen INTEGER NOT NULL DEFAULT 0
)`);
db.exec("INSERT INTO buecher (titel, jahr) VALUES ('Node in der Praxis', 2024)");
// Das Lesen ist der Stoff von 12.4, hier steht es nur, um die Zeile
// zeigen zu koennen, die eben entstanden ist.
const buch = db.prepare("SELECT id, titel, jahr, ausgeliehen FROM buecher").get();
db.close();
console.log("Datei:", statSync("daten/bibliothek.db").size, "Bytes");
console.log("Zeile:", JSON.stringify(buch)); DatabaseSync nimmt einen Dateipfad. Gibt es die Datei noch nicht, legt SQLite sie an. Was es
nicht anlegt, ist der Ordner darum: ein fehlendes daten/ quittiert es mit
unable to open database file, und das ist eine der Meldungen, bei denen man erst zehn Minuten die
falsche Stelle sucht. Deshalb steht das mkdirSync davor.
db.exec() schickt SQL zur Datenbank und gibt nichts zurück. Es ist die richtige Wahl für alles,
was etwas tut statt etwas zu liefern: Tabellen anlegen, Tabellen ändern, eine Transaktion
öffnen. Alles, wo Zeilen zurückkommen sollen, läuft ab 12.3 über db.prepare().
Dass die Datenbank wirklich nur eine Datei ist, kannst du dir ansehen. Lass das Beispiel laufen
und tipp danach im Terminal ls -l daten/. Dort steht bibliothek.db mit ihrer Größe. Und
head -c 16 daten/bibliothek.db gibt dir die ersten sechzehn Zeichen daraus, nämlich
SQLite format 3. So fängt jede SQLite-Datei an, und daran erkennt sie auch ein Programm, dem
niemand den Dateinamen verraten hat. Die ganze Datei mit cat auszugeben lohnt dagegen nicht,
dahinter kommen Bytes und keine Zeichen.
Was in einer Spalte stehen darf
SQLite kennt fünf Typen, und die Liste ist so kurz, dass man sie sich merken kann: INTEGER für
ganze Zahlen, REAL für Kommazahlen, TEXT für Zeichenketten, BLOB für rohe Bytes und NULL
für „hier steht nichts”.
Ein Datum gibt es nicht. Man speichert es als TEXT im Format 2026-08-09 oder als INTEGER mit
den Sekunden seit 1970. Beides ist üblich, das erste ist lesbar, das zweite rechnet sich leichter.
Und jetzt die ehrliche Bemerkung, die andere Anleitungen gern weglassen: SQLite nimmt diese Typen
nicht ganz so ernst wie andere Datenbanken. Wer einen Text in eine INTEGER-Spalte schreibt, darf
das in vielen Fällen. Die Spalte ist eher eine Absichtserklärung als eine Schranke. Für dich heißt
das nicht, dass du die Typen weglassen sollst, sondern dass du dich nicht auf sie verlassen darfst,
um Unsinn draußen zu halten. Dafür ist die Prüfung an der Tür da, die du in 11.5 gebaut hast.
Die drei Zusätze, die man von Anfang an benutzt
PRIMARY KEY macht eine Spalte zum Ausweis der Zeile. Schreibst du id INTEGER PRIMARY KEY,
bekommst du zwei Dinge geschenkt: Die Werte sind eindeutig, und wenn du beim Einfügen keinen
angibst, zählt SQLite selbst hoch. Genau den Zähler, den du in Lektion 11.3 von Hand geführt hast,
gibt es hier also fertig, und er überlebt den Neustart.
NOT NULL heißt, dass die Spalte nicht leer bleiben darf. Ein Buch ohne Titel ist kein Buch,
also gehört es dorthin. Ein Buch ohne Jahr ist dagegen denkbar, und deshalb steht bei jahr nichts.
DEFAULT liefert den Wert, wenn beim Einfügen keiner mitkommt. ausgeliehen INTEGER NOT NULL DEFAULT 0 heißt: Die Spalte ist Pflicht, aber du musst sie nie mitschicken. Ohne die Vorgabe würde
jedes Einfügen ohne ausgeliehen scheitern.
Der zweite Start
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
mkdirSync("daten", { recursive: true });
const db = new DatabaseSync("daten/bibliothek.db");
db.exec(`CREATE TABLE IF NOT EXISTS buecher (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL
)`);
db.exec("INSERT INTO buecher (titel) VALUES ('Ein Buch')");
const anzahl = db.prepare("SELECT count(*) AS n FROM buecher").get().n;
db.close();
console.log(`Zeilen in buecher: ${anzahl}`); import { execSync } from "node:child_process";
// Zweimal derselbe Prozess. Bei einem Array im Speicher waere der zweite
// Lauf wieder bei null.
for (const lauf of [1, 2]) {
console.log(`Lauf ${lauf}: ${execSync("node anlegen.js", { encoding: "utf8" }).trim()}`);
} Zwei Läufe, zwei Zeilen in der Tabelle. Das ist der ganze Unterschied zu Abschnitt 11: Dort hieß das Ergebnis beide Male „1”, weil das Array mit dem Prozess starb.
Beachte, dass das Anlegen der Tabelle in jedem Lauf steht. Genau so gehört es sich. Deine Anwendung weiß beim Start nicht, ob sie zum ersten oder zum tausendsten Mal hochfährt, und sie soll in beiden Fällen laufen.
Und ohne IF NOT EXISTS
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
mkdirSync("daten", { recursive: true });
const db = new DatabaseSync("daten/ohne-schutz.db");
// Kein IF NOT EXISTS. Beim ersten Start geht das gut.
db.exec(`CREATE TABLE buecher (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL
)`);
db.close();
console.log("Tabelle angelegt"); import { execSync } from "node:child_process";
// stdio: der Fehlerkanal des Kindes wird abgefangen statt durchgereicht,
// sonst stuende hier gleich der ganze Stacktrace.
const starte = () =>
execSync("node ohne-schutz.js", { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
for (const lauf of [1, 2]) {
try {
console.log(`Lauf ${lauf}: ${starte().trim()}`);
} catch (fehler) {
const zeile = fehler.stderr.split("\n").find((z) => z.startsWith("Error:"));
console.log(`Lauf ${lauf}: gescheitert, ${zeile}`);
}
} CREATE TABLE ohne den Zusatz heißt: „Leg diese Tabelle an, und wenn es sie schon gibt, ist das ein
Fehler.” Beim ersten Start merkst du davon nichts, und ab dem zweiten kommt deine Anwendung nicht
mehr hoch.
Das ist die häufigste Panne in dieser Lektion, und sie ist auf dem eigenen Rechner besonders leicht zu übersehen: Solange du zwischendurch die Datei löschst, geht immer alles gut.
Drei Wörter, ein Problem weniger. Sie gehören an jedes CREATE TABLE, das beim Start deiner
Anwendung läuft.
Alles hier ist synchron
Zum Schluss ein Punkt, der aus Lektion 6.5 folgt und den man kennen sollte, bevor man ihn im
Betrieb entdeckt: node:sqlite arbeitet synchron. Es gibt kein await. Jede Abfrage hält die
Ereignisschleife an, bis sie fertig ist.
Für kleine Abfragen ist das nicht nur in Ordnung, sondern sogar schnell: Es fällt keine Netzwerkrunde an und kein Wechsel in einen anderen Prozess, die Datei liegt direkt daneben. Eine Abfrage über eine Million Zeilen ist etwas anderes. Solange sie läuft, nimmt dein Server keine einzige andere Anfrage an, und das ist genau die Blockade, die du in 6.5 gemessen hast.
Der Name sagt es übrigens von sich aus: DatabaseSync heißt so, weil es auch ein asynchrones
Gegenstück geben könnte. Heute gibt es das noch nicht.
Zum Mitnehmen
Ein Dateiname genügt, und die Datenbank ist da. Der wichtigste Handgriff der Lektion sind drei Wörter: IF NOT EXISTS.
Jetzt du
Basis Konto, kostenlosZu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.