Abschnitt 12 · Lektion 8
Synthese: die API mit Datenbank
Die Bücher-API aus Abschnitt 11 bekommt ein Gedächtnis. Und der Anspruch dabei ist, dass von außen niemand etwas davon merkt: dieselben Pfade, dieselben Statuscodes, dieselben Antwortkörper.
Der Umbau ist genau so klein, wie er aussehen sollte
// Der Stand aus Lektion 11.7: die Daten liegen in einem Array.
const buecher = [
{ id: 1, titel: "Node in der Praxis", jahr: 2020 },
{ id: 2, titel: "JavaScript von vorn", jahr: 2019 },
{ id: 3, titel: "HTTP verstehen", jahr: 2017 },
];
let naechsteId = 4;
export const laden = {
alle: () => buecher,
eines: (id) => buecher.find((buch) => buch.id === id),
anlegen: (titel, jahr) => {
const buch = { id: naechsteId++, titel, jahr };
buecher.push(buch);
return buch;
},
loeschen: (id) => {
const platz = buecher.findIndex((buch) => buch.id === id);
if (platz === -1) return false;
buecher.splice(platz, 1);
return true;
},
}; 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);
INSERT INTO buecher (titel, jahr) VALUES
('Node in der Praxis', 2020), ('JavaScript von vorn', 2019), ('HTTP verstehen', 2017);`);
// Dieselben vier Funktionen, dieselben Rueckgaben, andere Innereien.
export const laden = {
alle: () => db.prepare("SELECT id, titel, jahr FROM buecher ORDER BY id").all(),
eines: (id) => db.prepare("SELECT id, titel, jahr FROM buecher WHERE id = ?").get(id),
anlegen: (titel, jahr) => {
const kennung = db.prepare("INSERT INTO buecher (titel, jahr) VALUES (?, ?)").run(titel, jahr).lastInsertRowid;
return db.prepare("SELECT id, titel, jahr FROM buecher WHERE id = ?").get(kennung);
},
loeschen: (id) => db.prepare("DELETE FROM buecher WHERE id = ?").run(id).changes > 0,
}; import { laden as imSpeicher } from "./imspeicher.js";
import { laden as mitTabelle } from "./mittabelle.js";
// Derselbe Ablauf gegen beide Umsetzungen. Wenn die Schnittstelle
// wirklich gleich geblieben ist, muss zweimal dasselbe herauskommen.
function ablauf(laden) {
const zeilen = [];
zeilen.push(`alle: ${laden.alle().length}`);
zeilen.push(`eines(2): ${laden.eines(2).titel}`);
zeilen.push(`eines(99): ${laden.eines(99)}`);
const neu = laden.anlegen("Nachtschicht mit Node", 2023);
zeilen.push(`anlegen: id ${neu.id}`);
zeilen.push(`loeschen(1): ${laden.loeschen(1)}`);
zeilen.push(`loeschen(99): ${laden.loeschen(99)}`);
zeilen.push(`alle danach: ${laden.alle().length}`);
return zeilen;
}
const links = ablauf(imSpeicher);
const rechts = ablauf(mitTabelle);
for (let i = 0; i < links.length; i++) {
const gleich = links[i] === rechts[i] ? "gleich" : "ABWEICHUNG";
console.log(`${links[i].padEnd(24)} | ${rechts[i].padEnd(24)} | ${gleich}`);
} Links das Array aus Lektion 11.7, rechts dieselben vier Funktionen mit SQLite darunter. Derselbe Ablauf gegen beide, und siebenmal steht „gleich” in der letzten Spalte.
Das ist die eigentliche Aussage dieser Lektion. Der Datenzugriff lag in Abschnitt 11 schon in eigenen Funktionen, und deshalb ist der Wechsel des Speichers ein Austausch dieser Funktionen und kein Umbau der Anwendung. Hätten die Routen direkt in das Array gegriffen, müsste jetzt jede einzeln angefasst werden.
Zwei Kleinigkeiten sind dabei bemerkenswert. Das undefined bei eines(99) kommt bei beiden
heraus, weil find und get sich zufällig gleich verhalten. Und anlegen gibt bei SQLite den
Eintrag zurück, den es danach noch einmal liest. Das sieht nach einem überflüssigen Schritt aus, ist
aber der ehrlichere: Was der Aufrufer bekommt, ist dann wirklich das, was in der Tabelle steht,
inklusive aller Spalten mit Vorgabewerten, die er gar nicht mitgeschickt hat.
Wo die Verbindung hingehört
Einmal beim Start, nicht je Anfrage. Eine Verbindung ist bei SQLite ein geöffneter Dateizeiger, und den je Anfrage neu aufzumachen und wieder zu schließen kostet nur Zeit.
Praktisch heißt das: ein eigenes Modul, das die Datenbank öffnet und exportiert. Weil ein Modul in Node genau einmal ausgewertet wird, egal wie oft es importiert wird, bekommen alle Teile deiner Anwendung damit automatisch dieselbe Verbindung. Das ist derselbe Mechanismus wie in Lektion 2.3, nur diesmal mit einem Zweck, der sich lohnt.
Die Tabellen entstehen an derselben Stelle, und zwar mit CREATE TABLE IF NOT EXISTS aus
Lektion 12.2. Deine Anwendung soll auf einer leeren Festplatte hochfahren können, ohne dass vorher
jemand von Hand etwas anlegt. Und sie soll beim zweiten Start genauso hochfahren.
Der Wert aus der Adresszeile
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, gelesen INTEGER);
INSERT INTO buecher (titel, jahr, gelesen) VALUES ('Node in der Praxis', 2020, 0);`);
const zeig = (was, tu) => {
try {
console.log(`${was} ${tu()}`);
} catch (fehler) {
console.log(`${was} ${fehler.constructor.name}: ${fehler.message}`);
}
};
// Eine Kennung aus der Adresszeile ist Text, kein Zahlenwert.
const suche = db.prepare("SELECT titel FROM buecher WHERE id = ?");
zeig('id als Text "1": ', () => JSON.stringify(suche.get("1")));
zeig("id als Zahl 1: ", () => JSON.stringify(suche.get(1)));
// Ein Wahrheitswert aus einem JSON-Koerper.
const setze = db.prepare("UPDATE buecher SET gelesen = ? WHERE id = 1");
zeig("gelesen = true: ", () => `changes ${setze.run(true).changes}`);
zeig("gelesen = 1: ", () => `changes ${setze.run(1).changes}`);
// Und ein Feld, das gar nicht mitgeschickt wurde.
const jahr = db.prepare("UPDATE buecher SET jahr = ? WHERE id = 1");
zeig("jahr = undefined: ", () => `changes ${jahr.run(undefined).changes}`);
zeig("jahr = null: ", () => `changes ${jahr.run(null).changes}`); Seit Lektion 8.6 gilt: Was aus der Adresszeile kommt, ist Text. req.params.id ist der String
"1", nicht die Zahl 1.
Der erste Teil des Beispiels ist trotzdem eine Entwarnung, und zwar eine nachgemessene.
WHERE id = ? findet die Zeile auch mit dem Text "1". SQLite rechnet den Wert in den Typ der Spalte um, wenn die
Spalte einen hat, und INTEGER PRIMARY KEY hat einen. Man liest oft das Gegenteil. Es stimmt für
eine Spalte ohne Typ, und in einer Tabelle, die du selbst angelegt hast, kommt das nicht vor.
Trotzdem gehört ein Number(req.params.id) in den Code, und zwar aus einem anderen Grund als dem
befürchteten: Sobald du den Wert nicht nur weiterreichst, sondern damit rechnest, vergleichst oder
ihn in eine Antwort schreibst, willst du eine Zahl. Ein id === 1 gegen den String "1" ist falsch,
und dieser Fehler entsteht später und weiter weg.
Die echten Fallen stehen in den vier Zeilen darunter, und die sind hart. Ein Wahrheitswert lässt
sich nicht binden, undefined auch nicht, beide werfen einen TypeError. Das ist kein Randfall: Ein
JSON-Körper mit "gelesen": true ist das Natürlichste der Welt, und wer ihn ungeprüft an die
Datenbank durchreicht, bringt damit die Route zum Absturz und antwortet mit 500 statt mit 400.
Zwei Dinge folgen daraus. Wahrheitswerte werden vor dem Binden in 0 und 1 übersetzt, denn SQLite hat
keinen eigenen Typ dafür. Und ein Feld, das nicht mitgeschickt wurde, ist undefined und darf gar
nicht erst in die Anweisung geraten. Womit wir beim letzten Stück wären.
Nur die geschickten Felder ändern
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);
INSERT INTO buecher (titel, jahr) VALUES ('Node in der Praxis', 2020);`);
// Die Spaltennamen kommen aus dieser Liste und nie aus dem Koerper.
const AENDERBAR = ["titel", "jahr"];
function aendere(id, koerper) {
const felder = AENDERBAR.filter((feld) => koerper[feld] !== undefined);
if (felder.length === 0) return "nichts zu tun";
const satz = felder.map((feld) => `${feld} = ?`).join(", ");
const werte = felder.map((feld) => koerper[feld]);
console.log(` SQL: UPDATE buecher SET ${satz} WHERE id = ?`);
db.prepare(`UPDATE buecher SET ${satz} WHERE id = ?`).run(...werte, id);
return JSON.stringify(db.prepare("SELECT id, titel, jahr FROM buecher WHERE id = ?").get(id));
}
console.log("nur das Jahr:");
console.log(` ${aendere(1, { jahr: 1999 })}`);
console.log("nur der Titel:");
console.log(` ${aendere(1, { titel: "Node in der Praxis, 2. Auflage" })}`);
console.log("ein Feld, das nicht in der Liste steht:");
console.log(` ${aendere(1, { id: 42, geheim: "weg damit" })}`); Das ist PATCH aus Lektion 11.4, jetzt in SQL. Die SET-Liste entsteht aus den Feldern, die
wirklich da sind, und die Werte gehen als Parameter mit.
Sieh dir an, welche Anweisung dabei jeweils entsteht: einmal SET jahr = ?, einmal
SET titel = ?. Nie beides, wenn nur eines geschickt wurde. Hätte man stattdessen immer beide
Spalten gesetzt, müsste man den fehlenden Wert vorher lesen, und zwischen dem Lesen und dem
Schreiben liegt die Lücke aus Lektion 12.1.
Der wichtigste Teil ist die Zeile const AENDERBAR = ["titel", "jahr"]. Die Spaltennamen kommen
aus dieser Liste, niemals aus den Schlüsseln des empfangenen Objekts. Ein Spaltenname ist kein Wert
und lässt sich nicht als Platzhalter übergeben, er landet also als Text in der Anweisung. Genau
davor warnt Lektion 12.5, und die Liste ist die Antwort darauf: In der Anweisung steht am Ende nur,
was du selbst hingeschrieben hast.
Der letzte Aufruf zeigt es. Da kommen id und geheim mit, und beide fallen einfach heraus. Der
Aufrufer kann sich also weder eine neue Kennung geben noch eine Spalte erfinden.
Zum Mitnehmen
Die Schnittstelle bleibt Zeile für Zeile dieselbe, nur die Datenhaltung wird ausgetauscht. Wenn das gelingt, war der Entwurf aus Abschnitt 11 richtig.
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.