mitmario.dev

Synthese: die API mit Datenbank

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

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

Dieselben vier Funktionen, andere Innereien
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

Was ein Wert aus dem Netz alles sein kann
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

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