Datenbanken
Kurz gesagt: Eine Datenbank speichert Daten in Tabellen und findet sie mit einer SQL-Abfrage schnell; in Node.js sprichst du über node:sqlite mit ihr, und ?-Parameter schützen vor SQL-Injection.
Problem
Ein Katalog in einer Datei
Der Katalog des Shops ist eine CSV-Datei, wie im Modul über Dateien. Solange es hundert Produkte sind, geht das. Dann werden es hunderttausend, und es gibt Probleme:
- um ein Produkt zu finden, liest du die ganze Datei;
- „alles unter 5 €, nach Preis sortiert“ ist eine Schleife, ein
ifund eine Sortierung, alles von Hand geschrieben; - zwei Kassen schreiben gleichzeitig in die Datei, und ein Eintrag geht verloren oder die Datei wird beschädigt;
- mitten beim Schreiben fällt der Strom aus, und die halbe Datei ist weg.
Deshalb liegen die Daten fast jedes echten Programms in einer Datenbank: einem Programm, dessen Aufgabe es ist, Daten aufzubewahren, sie schnell zu finden und nie zu verlieren.
Die Idee
Tabellen und SQL
Eine relationale Datenbank speichert Daten in Tabellen. Eine Tabelle hat Spalten (Name, Typ) und Zeilen (eine für jedes Produkt). Die Spalte id ist der Primärschlüssel: Ihr Wert ist in jeder Zeile anders, deshalb findet man mit ihm genau eine Zeile.
Mit der Datenbank sprichst du in SQL. Die fünf häufigsten Befehle:
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2));
INSERT INTO products VALUES (1, 'Kaffee', 7.50);SELECT name, price FROM products WHERE price < 5 ORDER BY price;UPDATE products SET price = 6.90 WHERE id = 1;DELETE FROM products WHERE id = 3;CREATE TABLElegt eine Tabelle an,INSERTfügt eine Zeile ein,UPDATEändert Zeilen undDELETElöscht sie.SELECTliest: welche Spalten, aus welcher Tabelle, unter welcher Bedingung (WHERE) und in welcher Reihenfolge (ORDER BY).
Du sagst, was du willst, und die Datenbank entscheidet, wie sie es schnell findet.
Genauer, für Neugierige
- Bekannte relationale Datenbanken sind PostgreSQL, MySQL, Oracle und SQL Server. Sie laufen als eigene Server. Hier benutzen wir SQLite: eine kleine Datenbank, die in einer Datei oder ganz im Speicher des Programms lebt. Das Modul
node:sqliteist in Node 22 eingebaut und braucht kein Flag, ist aber noch experimentell: Beim Ausführen schreibt esExperimentalWarning: SQLite is an experimental featurenach stderr. Die Adresse":memory:"ist eine Datenbank im Speicher: Jeder Lauf beginnt mit einer leeren. - Text steht in SQL in einfachen Anführungszeichen:
'Kaffee'. In PostgreSQL und MySQL istDECIMAL(8, 2)eine exakte Zahl mit 2 Nachkommastellen, das passt zu Geld. SQLite speichert keine exakten Dezimalzahlen: Dort ist7.50eine gewöhnliche Gleitkommazahl.
Sieh es dir an
node:sqlite: Verbindung, Abfrage, Ergebnis
node:sqlite spricht mit SQLite. Die Verbindung ist eine DatabaseSync, und eine Abfrage mit Ergebnis machst du mit prepare. Die Funktion setup legt die Tabelle an und fügt drei Produkte ein:function setup(db) { db.exec("CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2))"); db.exec("INSERT INTO products VALUES (1, 'Kaffee', 7.50), (2, 'Tee', 3.20), (3, 'Schokolade', 2.40)");}Product mit den Feldern id, name und price:class Product { constructor(id, name, price) { this.id = id; this.name = name; this.price = price; }}const db = new DatabaseSync(":memory:");setup(db);const products = [];for (const row of db.prepare("SELECT id, name, price FROM products ORDER BY price").all()) { products.push(new Product(row.id, row.name, row.price));}for (const p of products) { console.log(p);}db.close();Product { id: 3, name: 'Schokolade', price: 2.4 }Product { id: 2, name: 'Tee', price: 3.2 }Product { id: 1, name: 'Kaffee', price: 7.5 }new DatabaseSync(address)öffnet die Verbindung. Die Adresse sagt, welche Datenbank: hier":memory:", oder ein Dateiname wie"shop.db". „Sync“ bedeutet, dass die Methoden das Ergebnis sofort zurückgeben, ohneawait.db.exec(sql)schickt Befehle ohne Ergebnis (CREATE,INSERT,UPDATE,DELETE).db.prepare(sql)bereitet eine Abfrage vor, und.all()gibt alle Zeilen als Array zurück. Jede Zeile ist ein Objekt mit einem Feld für jede Spalte:row.name..get()gibt nur die erste Zeile zurück, und.run()ist für Änderungen und gibt zurück, wie viele Zeilen sich geändert haben (changes).- Standardmäßig wird jede Abfrage sofort gespeichert.
db.close()schließt die Verbindung. - Fehler werden als
Errormit demcode"ERR_SQLITE_ERROR"geworfen.
const db = new DatabaseSync(":memory:");setup(db);const products = [];for (const row of db.prepare("SELECT id, name, price FROM products ORDER BY price").all()) { products.push(new Product(row.id, row.name, row.price));}for (const p of products) { console.log(p);}db.close();Schritt für Schritt, in Worten
- Eine Verbindung zur Datenbank im Speicher. setup legt die Tabelle products an und fügt 3 Zeilen ein.
- Eine leere Liste, in die die Objekte kommen.
- SELECT mit ORDER BY price: all() gibt alle Zeilen auf einmal als Array zurück, die günstigste zuerst.
- Die Schleife nimmt Zeile 1: ein Objekt mit einem Feld für jede Spalte. new Product macht daraus ein Objekt: Schokolade.
- Die Schleife nimmt Zeile 2: ein Objekt mit einem Feld für jede Spalte. new Product macht daraus ein Objekt: Tee.
- Die Schleife nimmt Zeile 3: ein Objekt mit einem Feld für jede Spalte. new Product macht daraus ein Objekt: Kaffee.
- Das Array ist zu Ende, und die Schleife hört auf.
- Das Array enthält gewöhnliche Objekte. console.log zeigt die Klasse und ihre Felder.
Genauer, für Neugierige
- Bei einer echten Datenbank auf einem Server ist die Adresse anders, und es gibt einen Benutzer und ein Passwort. Außerdem brauchst du den Treiber der Datenbank als Abhängigkeit im Projekt (für MySQL: das Paket
mysql2):
import mysql from "mysql2/promise";
const conn = await mysql.createConnection({ host: "localhost", port: 3306, database: "shop", user: "shop_app", password: "geheim" });console.log("Mit der Datenbank verbunden!");await conn.end();- Das Passwort steht nicht im Code. Es wird aus den Einstellungen oder aus einer Umgebungsvariable gelesen (
process.env).
- Die Preise sind hier
number, damit es kurz bleibt. In einem echten Shop wird Geld exakt gespeichert: in ganzen Cent, weil JavaScript keinen exakten Dezimaltyp hat. console.log(row)zeigt[Object: null prototype] { … }: Die Zeilen sind Objekte ohne Prototyp, nur mit den Feldern der Spalten.
Probier es aus
Was wird ausgegeben?
Die Produkte sind Kaffee 7,50 €, Tee 3,20 € und Schokolade 2,40 €. Was gibt das Programm aus?
const db = new DatabaseSync(":memory:");setup(db);for (const row of db.prepare("SELECT name FROM products WHERE price < 5 ORDER BY price DESC").all()) { console.log(row.name);}db.close();WHERE price < 5 behält Tee und Schokolade. ORDER BY price DESC sortiert sie nach Preis von oben nach unten (DESC ist absteigend, ASC ist der Standard): zuerst Tee für 3,20 €, dann Schokolade für 2,40 €.
Problem
Die Suche, die alles zeigt
Die Website sucht ein Produkt nach dem Namen, den der Nutzer eintippt. Die Abfrage wird mit einem Template `…${name}…` zusammengesetzt:
function search(db, name) { const sql = `SELECT name, price FROM products WHERE name = '${name}'`; for (const row of db.prepare(sql).all()) { console.log(` ${row.name}: ${row.price} €`); }}const db = new DatabaseSync(":memory:");setup(db);console.log("Suche: Tee");search(db, "Tee");console.log("Suche: x' OR '1'='1");search(db, "x' OR '1'='1");db.close();Suche: Tee Tee: 3.2 €Suche: x' OR '1'='1 Kaffee: 7.5 € Tee: 3.2 € Schokolade: 2.4 €Mit „Tee“ funktioniert es. Mit x' OR '1'='1 wird die Abfrage zu ... WHERE name = 'x' OR '1'='1'. Die Bedingung ist immer wahr, und die ganze Tabelle kommt heraus. Die Eingabe des Nutzers ist Teil des SQL geworden. Das ist SQL-Injection, einer der häufigsten Angriffe auf Websites. Mit demselben Trick kann sich jemand ohne Passwort anmelden oder eine Tabelle löschen.
Die Idee
Parameter mit ?
prepare hat ? an den Stellen der Werte, und du übergibst sie als Argumente von all, get oder run, in derselben Reihenfolge:function search(db, name) { const sql = "SELECT name, price FROM products WHERE name = ?"; for (const row of db.prepare(sql).all(name)) { console.log(` ${row.name}: ${row.price} €`); }}Suche: Tee Tee: 3.2 €Suche: x' OR '1'='1Jetzt ist x' OR '1'='1 nur Text, der als Name gesucht wird. Ein solches Produkt gibt es nicht, und nichts kommt heraus. Was der Nutzer auch eintippt, es kann nicht zu SQL werden.
?.Probier es aus
Kaffee mit Apostroph
Kaffee 'Arabica' bricht das Programm mit Error: near "Arabica": syntax error ab. In welcher Zeile ist der Fehler?Wähle die Zeile mit dem Fehler.
'Arabica' schließt den Text zu früh, und der Rest ist sinnloses SQL (near "Arabica" zeigt auf die Stelle). Ein Angreifer würde dort seine eigene Abfrage hinschreiben. Die Korrektur sind Parameter: db.prepare("UPDATE products SET name = ? WHERE id = ?").run(newName, id). Dann wird der Name genau so gespeichert, wie er ist, mit den Apostrophen.Die Idee
Transaktion: alles oder nichts
Maria überweist 150 € an Jonas. Das sind zwei Abfragen: bei Jonas hinzufügen, bei Maria abziehen. Die Tabelle hat die Bedingung CHECK (balance >= 0), deshalb schlägt die zweite Abfrage fehl: Maria hat nur 100 €.
function openAccounts(db) { db.exec("CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT CONSTRAINT no_debt CHECK (balance >= 0))"); db.exec("INSERT INTO accounts VALUES (1, 'Maria', 100), (2, 'Jonas', 50)");}
function move(db, id, amount) { db.prepare("UPDATE accounts SET balance = balance + ? WHERE id = ?").run(amount, id);}
function printBalances(db) { for (const row of db.prepare("SELECT owner, balance FROM accounts ORDER BY id").all()) { console.log(` ${row.owner}: ${row.balance} €`); }}function transfer(db, from, to, amount) { move(db, to, amount); move(db, from, -amount);}
const db = new DatabaseSync(":memory:");openAccounts(db);try { transfer(db, 1, 2, 150);} catch { console.log("Die Überweisung von 150 € wurde abgelehnt.");}printBalances(db);db.close();Die Überweisung von 150 € wurde abgelehnt. Maria: 100 € Jonas: 200 €function transfer(db, from, to, amount) { db.exec("BEGIN"); try { move(db, to, amount); move(db, from, -amount); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; }}Die Überweisung von 150 € wurde abgelehnt. Maria: 100 € Jonas: 50 €db.exec("BEGIN")beginnt eine Transaktion: Die Änderungen sind noch nicht endgültig.db.exec("COMMIT")speichert sie alle auf einmal.db.exec("ROLLBACK")macht sie alle rückgängig, wenn etwas fehlschlägt. Die Exception geht mitthrowweiter nach oben, damit auch der Aufrufer davon erfährt.BEGIN,COMMITundROLLBACKsind gewöhnliche SQL-Befehle: So macht man Transaktionen auch in SQL selbst.
Genauer, für Neugierige
- Der Fehler für die fehlgeschlagene Abfrage hat den
codeERR_SQLITE_ERRORund die MeldungCHECK constraint failed: no_debt.no_debtist der Name der Bedingung: In der Tabelle stehtbalance INT CONSTRAINT no_debt CHECK (balance >= 0).
- Datenbanken versprechen bei Transaktionen vier Dinge, bekannt als ACID: alles oder nichts (Atomarität), die Daten bleiben nach den Regeln der Tabelle korrekt (Konsistenz), gleichzeitig laufende Transaktionen stören sich nicht gegenseitig (Isolation), und was gespeichert ist, geht nicht verloren, auch wenn der Strom ausfällt (Dauerhaftigkeit).
Die Idee
ORM: Objekte statt Zeilen
sequelize und sqlite3 aus npm). Andere sind Prisma, TypeORM und Drizzle.import { DataTypes, Op, Sequelize } from "sequelize";
const sequelize = new Sequelize({ dialect: "sqlite", storage: ":memory:", logging: false });
const Product = sequelize.define("Product", { name: { type: DataTypes.STRING(50), allowNull: false }, price: DataTypes.DOUBLE,}, { tableName: "products", timestamps: false });sequelize.definebeschreibt ein Model: eine Tabelle mit dem Namenproducts(tableName).- Jedes Feld ist eine Spalte mit einem Typ aus
DataTypes.allowNull: falseistNOT NULL. - Sequelize fügt von selbst eine Spalte
idals Primärschlüssel hinzu, und die Datenbank gibt ihr einen Wert. timestamps: falseschaltet die SpaltencreatedAtundupdatedAtab, die Sequelize sonst hinzufügt.
Speichern und Lesen gehen über das Model:
await sequelize.sync();await Product.create({ name: "Kaffee", price: 7.50 });await Product.create({ name: "Tee", price: 3.20 });
const cheap = await Product.findAll({ where: { price: { [Op.lt]: 5 } }, order: [["price", "ASC"]] });for (const p of cheap) { console.log(`${p.name}: ${p.price} €`);}Tee: 3.2 €new Sequelize({ dialect: "sqlite", storage: ":memory:" }) sagt, welche Datenbank: hier SQLite im Speicher. sync() legt die Tabellen an. Product.create speichert ein Objekt. findAll baut die Abfrage aus einem Objekt: { price: { [Op.lt]: 5 } } ist WHERE price < 5. Das Ergebnis ist ein Array fertiger Objekte. Alle Methoden geben Promises zurück, deshalb benutzt du await.
Sequelize ist ein Paket, und die Prüfung der Website hat es nicht, deshalb läuft dieser Code hier nicht. Die Ausgabe stammt aus einem Lauf mit installiertem sequelize 6 und sqlite3 5 (npm install sequelize sqlite3).
Genauer, für Neugierige
- Die Bedingungen von
findAllsind ein gewöhnliches Objekt, deshalb lassen sie sich leicht aus der Auswahl des Nutzers zusammensetzen. Eine Funktion, die nach dem Namen sucht:
function named(name) { return Product.findAll({ where: { name } });}- Eine Transaktion, die bei einem Fehler rückgängig gemacht wird:
sequelize.transactionmit einer Funktion speichert am Ende alles und macht alles rückgängig, wenn die Funktion wirft. Jede Abfrage darin bekommt{ transaction }:
await sequelize.transaction(async (transaction) => { await Product.create({ name: "Schokolade", price: 2.40 }, { transaction }); // weitere Änderungen: bei einer Exception wird alles rückgängig gemacht, sonst wird alles gespeichert});- Mit
logging: console.logstattfalsegibt Sequelize das SQL aus, das es schickt. So siehst du, dass darunter weiterhin SQL steckt.
Übung
Ein Shop mit Datenbank
node:sqlite und der Datenbank ":memory:".1. Katalog. Lege eine Tabelle products an und die Funktionen add, setPrice, remove und list, alle außer list mit ?. Füge Kaffee (7,50 €), Tee (3,20 €) und Schokolade (2,40 €) hinzu, gib sie aus, ändere den Preis des Kaffees auf 6,90 €, lösche die Schokolade und gib sie wieder aus:1. Kaffee: 7.5 €2. Tee: 3.2 €3. Schokolade: 2.4 €---1. Kaffee: 6.9 €2. Tee: 3.2 €find(db, text) findet alle Produkte, deren Name den Text enthält, ohne auf Groß- und Kleinschreibung zu achten. LOWER ändert in SQLite nur die Buchstaben A bis Z, deshalb bleibt É, wie es ist. Gib der Datenbank eine eigene Funktion: db.function("lower_text", (text) => text.toLowerCase()), dann WHERE lower_text(name) LIKE ? mit dem Wert `%${text.toLowerCase()}%`. Probiere „KA“, „éclair“ und einen Injection-Versuch:Suche nach „KA“: Kaffee: 7.5 € Kakao: 4.8 €Suche nach „éclair“: Éclair: 2.4 €Suche nach „' OR '1'='1“: keine Produkte gefundentransfer(db, from, to, amount) zieht zuerst von from ab und fügt dann bei to hinzu. Wenn run() changes 0 zurückgibt (kein solches Konto), wirf new Error(`Kein Konto ${id}`). Alles ist zwischen BEGIN und COMMIT. Überweise 30 € von 1 an 2, dann 40 € von 1 an das Konto 99, das es nicht gibt:Überwiesen: 30 € von 1 an 2Abgelehnt: Kein Konto 99 Maria: 70 € Jonas: 80 €Die zweite Überweisung hat Maria 40 € abgezogen, bevor sie fehlschlug, aber ROLLBACK hat die 40 € zurückgebucht.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Daten in Dateien · Arrays: push, splice und sort · Pfeilfunktionen · Daten, switch und Destrukturierung
- Geht weiter in: Injektionen: wenn Daten zum Befehl werden · Monolith, modularer Monolith, Services · Transactional Outbox · Eine Transaktion über Services hinweg (Saga)
- Aufgaben zum Thema: Programme in der echten Welt