mitmario.dev

Daten einfügen

Node.js Sandbox 4 Min Lesezeit 3 BeispieleLektion 3 von 9

Die Tabelle steht, jetzt kommen Zeilen hinein. Dabei lernst du drei Dinge, die zusammengehören: den Platzhalter, den Rückgabewert und die Transaktion.

Der Platzhalter und das Vorbereiten

Eine Zeile einfügen
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE buecher (
  id INTEGER PRIMARY KEY,
  titel TEXT NOT NULL,
  jahr INTEGER
)`);

// Die Fragezeichen sind Platzhalter. Was hineinkommt, sagt run().
const einfuegen = db.prepare("INSERT INTO buecher (titel, jahr) VALUES (?, ?)");

const ergebnis = einfuegen.run("Node in der Praxis", 2024);

console.log("geschriebene Zeilen:", ergebnis.changes);
console.log("vergebene Kennung:  ", ergebnis.lastInsertRowid);

// Kein id angegeben, und trotzdem steht eine drin.
const zweites = einfuegen.run("HTTP verstehen", 2023);
console.log("naechste Kennung:   ", zweites.lastInsertRowid);

db.prepare() nimmt eine Anweisung mit Fragezeichen darin und gibt dir etwas zurück, das du ausführen kannst. Die Werte kommen erst bei run() dazu, in der Reihenfolge der Fragezeichen.

Warum man das so macht und nicht die Werte einfach in den Text schreibt, ist die wichtigste Sicherheitsregel des ganzen Kurses und bekommt in Lektion 12.5 eine eigene Lektion. Für heute reicht die mechanische Seite: Anweisung und Werte gehen getrennt zur Datenbank.

Was run() zurückgibt

Zwei Felder, und beide braucht man ständig.

changes sagt, wie viele Zeilen betroffen waren. Beim Einfügen ist das langweilig, da steht immer 1. Beim Ändern und Löschen in Lektion 12.6 wird es zu der Angabe, an der du erkennst, ob es die Zeile überhaupt gab, und damit zur Grundlage für den 404 aus Lektion 11.4.

lastInsertRowid ist die Kennung, die die Tabelle gerade vergeben hat. Genau die brauchst du für die Location-Kopfzeile aus Lektion 11.3, und du bekommst sie geschenkt, statt sie zu zählen.

Die Kennung kommt von der Tabelle

Im Beispiel steht in keinem INSERT eine id, und trotzdem haben beide Zeilen eine. Das liegt an id INTEGER PRIMARY KEY aus der letzten Lektion: Lässt man den Wert weg, setzt SQLite die nächste freie Zahl ein.

Genau diese Regel aus Lektion 11.3 ist damit erfüllt, und zwar ohne dein Zutun: Die Kennung vergibt der Server, nicht der Aufrufer. Der Zähler, den du dort als naechsteId im Speicher gehalten hast, kann jetzt weg, und er hat den zusätzlichen Vorteil, dass er einen Neustart übersteht.

Nebenbei: Du wirst in anderen Anleitungen AUTOINCREMENT sehen. Das braucht man in SQLite fast nie. Der Unterschied ist, dass AUTOINCREMENT verhindert, dass eine gelöschte Kennung später noch einmal vergeben wird. Dafür legt SQLite eine zusätzliche Verwaltungstabelle an, und Arbeit kostet es auch. Ohne guten Grund lässt man es weg.

Eine Anweisung, viele Werte

Eine Anweisung, viele Werte
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE buecher (id INTEGER PRIMARY KEY, titel TEXT NOT NULL)");

const titel = ["Erstes", "Zweites", "Drittes", "Viertes", "Fuenftes"];

// Einmal vorbereitet, fuenfmal ausgefuehrt. Die Anweisung wird dabei nur
// einmal zerlegt und geprueft, nicht bei jedem Durchlauf neu.
const einfuegen = db.prepare("INSERT INTO buecher (titel) VALUES (?)");

db.exec("BEGIN");
for (const eintrag of titel) einfuegen.run(eintrag);
db.exec("COMMIT");

console.log("Zeilen:", db.prepare("SELECT count(*) AS n FROM buecher").get().n);
console.log("letzte Kennung:", db.prepare("SELECT max(id) AS m FROM buecher").get().m);

prepare() einmal, run() fünfmal. Das ist nicht nur kürzer, sondern auch schneller: Die Datenbank zerlegt und prüft die Anweisung einmal und führt danach nur noch aus. Bei fünf Zeilen ist das egal, bei fünftausend nicht mehr.

Die Regel dazu ist einfach: prepare() gehört vor die Schleife, run() hinein. Wer beides in die Schleife schreibt, bezahlt das Vorbereiten bei jedem Durchlauf noch einmal.

Warum das in eine Transaktion gehört

Um das BEGIN und COMMIT herum steht der eigentliche Grund, und der ist drastisch.

Ohne Transaktion behandelt SQLite jedes einzelne INSERT als abgeschlossenen Vorgang und wartet jedes Mal darauf, dass die Platte das Geschriebene bestätigt. Dieses Warten ist der ganze Aufwand. Gemessen auf einem gewöhnlichen Laptop: tausend Zeilen einzeln eingefügt brauchen rund 6300 Millisekunden, dieselben tausend Zeilen zwischen BEGIN und COMMIT rund 6. Das ist kein Feinschliff, das ist Faktor tausend.

Das musst du mir nicht glauben. In der Aufgabe dieser Lektion liegt messen.js daneben, eine Datei, die zum Kurs gehört und die du nicht anfasst. Sie fügt tausend Zeilen einmal einzeln und einmal in einer Transaktion ein und misst beide Male die Zeit. Gestartet wird sie im Terminal mit node messen.js, und der erste Durchgang dauert dann wirklich die paar Sekunden, die dort stehen. Die Zahlen fallen auf jeder Maschine anders aus, das Verhältnis bleibt.

Es lohnt sich, dieses Verhältnis einmal gesehen zu haben, weil es die Erklärung für eine sehr verbreitete Beobachtung ist: „SQLite ist langsam” heißt in aller Regel „hier fehlt eine Transaktion”.

Ganz oder gar nicht

Der zweite Zweck einer Transaktion ist der, wegen dem sie so heißt.

Ganz oder gar nicht
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE konten (name TEXT PRIMARY KEY, stand INTEGER NOT NULL)");
db.exec("INSERT INTO konten VALUES ('Anna', 100), ('Bert', 100)");

const abbuchen = db.prepare("UPDATE konten SET stand = stand - ? WHERE name = ?");
const gutschreiben = db.prepare("UPDATE konten SET stand = stand + ? WHERE name = 'gibtsnicht'");

const staende = () =>
  db
    .prepare("SELECT name, stand FROM konten ORDER BY name")
    .all()
    .map((zeile) => `${zeile.name} ${zeile.stand}`)
    .join(", ");

console.log("vorher: ", staende());

db.exec("BEGIN");
try {
  abbuchen.run(30, "Anna");
  // Die Gutschrift trifft niemanden. Wir merken es und drehen alles zurueck.
  const gebucht = gutschreiben.run(30);
  if (gebucht.changes === 0) throw new Error("Empfaenger gibt es nicht");
  db.exec("COMMIT");
} catch (fehler) {
  db.exec("ROLLBACK");
  console.log("abgebrochen:", fehler.message);
}

console.log("nachher:", staende());

Anna wird abgebucht, die Gutschrift geht ins Leere, und am Ende hat Anna trotzdem ihre hundert. Das ist die Zusage: Alles zwischen BEGIN und COMMIT passiert entweder vollständig oder gar nicht.

Drei Wörter, mehr braucht man nicht:

BEGIN öffnet die Klammer. COMMIT schließt sie und macht alles gültig. ROLLBACK verwirft alles seit dem BEGIN, so als wäre nichts gewesen.

Der Fall im Beispiel ist bewusst einer, den kein Absturz auslöst: Die Gutschrift läuft technisch fehlerfrei, sie trifft nur niemanden. Deshalb steht dort die Prüfung auf changes === 0. Eine Transaktion dreht nicht von selbst zurück, wenn dein Programm etwas Sinnloses tut. Sie gibt dir nur die Möglichkeit dazu, und du musst sie ergreifen.

Zum Mitnehmen

Tausend Zeilen einzeln geschrieben brauchen gemessene sechs Sekunden. Dieselben tausend Zeilen in einer Transaktion brauchen sechs Millisekunden.

Jetzt du

Basis Konto, kostenlos

Zu 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.