Was ist Knex.js?
Knex.js ist ein SQL query builder für Node.js. Es ist bewusst kein ORM. Es bildet Zeilen nicht auf Klassen ab, verfolgt keinen Objektzustand und lädt keine Relationen eigenständig. Stattdessen ermöglicht es Ihnen, SQL-Statements als kombinierbare JavaScript-Objekte aufzubauen, jeden Wert sicher zu binden und diese gegen Postgres, MySQL, SQLite, MSSQL oder Oracle auszuführen.
Genau diese Zurückhaltung ist der Grund für seinen langfristigen Erfolg. Knex erschien 2013 und wurde zum Fundament, auf dem Objection.js und mehrere andere Bibliotheken aufbauen. Wenn Sie die Ergonomie eines ORM wünschen, können Sie eines darauf aufsetzen; wenn Sie nah am SQL bleiben wollen, bietet Knex bereits die richtige Abstraktionsebene.
Das mentale Modell ist simpel: Beginnen Sie mit einem Tabellennamen, verketten Sie Methoden, um das Statement zu beschreiben, und await es schließlich, um es auszuführen. Jede Kette wird letztendlich zu einer parametrisierten Query.
Warum überhaupt einen Query Builder verwenden?
SQL als Template-Strings zu schreiben scheint völlig in Ordnung, bis eine Abfrage ihre Struktur ändern muss. Ein Such-Endpunkt mit optionalen Filtern, eine Sortierung, die von der Benutzereingabe abhängt, und eine Pagination-Klausel sind mühsam per Konkatenation zusammenzusetzen – und gefährlich, wenn Werte direkt interpoliert werden.
Ein Builder löst beide Probleme. Bedingungen werden sauber angehängt, Werte werden zu gebundenen Parametern und Bezeichner werden für den jeweiligen Dialekt korrekt in Anführungszeichen gesetzt.
const query = db("users").select("id", "email");
if (search) {
query.where("email", "ilike", `%${search}%`);
}
if (verifiedOnly) {
query.whereNotNull("email_verified_at");
}
const users = await query.orderBy("created_at", "desc").limit(20);
Beachten Sie, dass search als Argument an where übergeben wird und nicht in einen String eingefügt wird. Knex sendet diesen Wert als Platzhalter an den Treiber, sodass er niemals die Struktur des Statements verändern kann.
Ein weiterer Vorteil ist die Portabilität. Dieselbe Builder-Kette läuft auf Postgres und MySQL mit nur einer Konfigurationsänderung, was besonders für die lokale Entwicklung und Tests mit SQLite nützlich ist.
Installation und die knexfile
Installiere Knex und den Treiber für deine Datenbank. Der Treiber ist ein separates Paket, da Knex keine Datenbank-Clients bündelt.
pnpm add knex pg
pnpm add -D @types/pg
Die Konfiguration befindet sich in einer knexfile, mit einem Eintrag pro Umgebung. Die CLI liest diese automatisch aus, und deine Anwendung importiert dasselbe Objekt.
import type { Knex } from "knex";
const config: { [key: string]: Knex.Config } = {
development: {
client: "pg",
connection: process.env.DATABASE_URL,
migrations: { directory: "./migrations" },
seeds: { directory: "./seeds" },
pool: { min: 2, max: 10 },
},
production: {
client: "pg",
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10 },
},
};
export default config;
Erstelle eine einzige gemeinsame Instanz und importiere sie überall, anstatt knex() in jedem Modul aufzurufen. Eine Instanz besitzt einen Connection Pool; zusätzliche Instanzen vervielfachen die Verbindungen zur Datenbank.
import knex from "knex";
import config from "../knexfile";
export const db = knex(config.development);
Nutze TypeScript’s satisfies oder ein typisiertes Config-Objekt, damit der Editor falsch geschriebene Optionen erkennt, und speichere Secrets in der Umgebung anstatt in der Datei.
Migrationen mit dem Schema Builder
Migrationen steuern, wie sich das Schema im Laufe der Zeit verändert. Jede Datei enthält eine up, die die Änderung anwendet, und eine down, die diese rückgängig macht.
npx knex migrate:make create_users
npx knex migrate:latest
npx knex migrate:rollback
npx knex migrate:list
Die generierte Datei ist gewöhnliches TypeScript. Der Schema Builder beschreibt Tabellen mithilfe eines Callbacks, der ein table-Objekt erhält.
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("users", (table) => {
table.increments("id").primary();
table.string("email", 255).notNullable().unique();
table.string("display_name", 120).notNullable();
table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("users");
}
Knex protokolliert angewendete Migrationen in einer knex_migrations-Tabelle, sodass jede Umgebung genau weiß, welche Dateien bereits ausgeführt wurden. Schreiben Sie immer eine echte down; auch wenn Sie selten Rollbacks durchführen, dokumentiert dies, wie die Änderung rückgängig gemacht werden kann, und ermöglicht das Testen von Migrationen.
alterTable ändert eine bestehende Tabelle. Einige Änderungen, wie das Hinzufügen einer notNullable-Spalte zu einer Tabelle mit vorhandenen Zeilen, erfordern einen Standardwert oder einen Backfill-Schritt. Teilen Sie diese in zwei Migrationen auf: eine zum Hinzufügen der Spalte und zum Backfill, und eine weitere zum Hinzufügen des Constraints.
Seeds für lokale Daten
Seeds befüllen eine Datenbank mit bekannten Datensätzen für die Entwicklung und für Tests. Sie sind von Migrationen getrennt, da sie Daten und nicht die Struktur repräsentieren.
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
await knex("users").del();
await knex("users").insert([
{ email: "[email protected]", display_name: "Ada Lovelace" },
{ email: "[email protected]", display_name: "Linus Torvalds" },
]);
}
Führen Sie diese mit npx knex seed:run aus. Seeds sollten nach Möglichkeit idempotent sein. Das bedeutet in der Regel, dass die Tabelle geleert oder ein Upsert vor dem Einfügen verwendet wird, damit ein mehrfacher Durchlauf nicht zu Fehlern oder Duplikaten führt.
Queries erstellen
Eine Query beginnt mit einem Tabellennamen. Von dort aus werden Methoden verkettet, bis das Statement mit await aufgerufen wird.
const rows = await db("users")
.select("id", "email", "display_name")
.where("created_at", ">", since)
.whereIn("role", ["admin", "editor"])
.orderBy("created_at", "desc")
.limit(50)
.offset(0);
where akzeptiert verschiedene Formen: eine Spalte und einen Wert, eine Spalte, einen Operator und einen Wert oder ein Objekt mit Gleichheitsprüfungen. whereIn, whereNot, whereNull, whereBetween und whereExists decken die restlichen gängigen Prädikate ab. Gruppierte Bedingungen verwenden einen Callback, damit die Klammern an der richtigen Stelle gesetzt werden.
db("posts")
.where("published", true)
.andWhere((qb) => {
qb.where("title", "ilike", `%${term}%`).orWhere("body", "ilike", `%${term}%`);
});
Verwenden Sie .first(), wenn Sie eine einzelne Zeile erwarten, und pluck, wenn Sie ein flaches Array einer einzelnen Spalte benötigen.
const user = await db("users").where({ email }).first();
const emails = await db("users").pluck("email");
Joins, Aggregates und Group By
Joins werden genauso gelesen wie in SQL: zuerst eine Tabelle, dann das Spaltenpaar, das sie verknüpft.
const rows = await db("users as u")
.join("posts as p", "p.author_id", "u.id")
.whereNot("p.status", "draft")
.groupBy("u.id", "u.email")
.select("u.email")
.count("p.id as post_count")
.max("p.created_at as last_post_at")
.orderBy("post_count", "desc")
.limit(10);
join ist ein Inner Join, leftJoin behält nicht zugeordnete Zeilen aus der ersten Tabelle bei, und rightJoin sowie fullOuterJoin sind in Dialekten verfügbar, die diese unterstützen. Die Join-Bedingung kann auch ein Callback sein, wenn mehr als eine Klausel benötigt wird.
Aggregates nutzen .count(), .sum(), .avg(), .min() und .max(). Zwei Details führen oft zu Fehlern. Erstens muss jede nicht aggregierte Spalte im Select in groupBy erscheinen. Zweitens werden in Postgres die Werte für count und sum als Strings zurückgegeben, um einen Integer-Overflow zu vermeiden; casten oder parsen Sie diese daher, wenn Sie Zahlen benötigen.
const { count } = await db("users").count("* as count").first();
const total = Number(count);
Die returning-Klausel
Beim Einfügen einer Zeile möchte man in der Regel den generierten Primary Key oder die serverseitigen Standardwerte erhalten. In Postgres und MSSQL fordert .returning() die Datenbank auf, die Zeile zurückzusenden.
const [user] = await db("users")
.insert({ email, display_name: displayName })
.returning(["id", "email", "created_at"]);
Auch Updates und Deletes können Zeilen zurückgeben:
const updated = await db("posts")
.where({ id })
.update({ title, updated_at: db.fn.now() })
.returning("*");
MySQL und ältere SQLite-Versionen unterstützen RETURNING nicht. Dort löst sich der Insert-Aufruf zur neuen ID auf, und man muss einen anschließenden Select ausführen. Es ist wichtig zu wissen, welchen Dialekt man anspricht, da dies einer der Punkte ist, an denen das Versprechen der Portabilität an seine Grenzen stößt.
Upserts und Conflict Handling
Ein gängiges Muster ist: „Füge diese Zeile ein, aber aktualisiere sie, falls sie bereits existiert“. Knex drückt dies mit onConflict aus, was bei Postgres und SQLite zu ON CONFLICT und bei MySQL zu ON DUPLICATE KEY UPDATE gemappt wird.
await db("users")
.insert({ email, display_name: displayName, last_seen_at: db.fn.now() })
.onConflict("email")
.merge({
display_name: displayName,
last_seen_at: db.fn.now(),
});
onConflict erwartet die eindeutige Spalte oder die Spalten, die den Konflikt definieren. merge aktualisiert die aufgelisteten Spalten mit den Werten der neuen Zeile, während ignore den Insert komplett überspringt. Dies ist der sichere Weg, um einen Sync- oder Webhook-Handler idempotent zu gestalten, da die Datenbank die Race Condition zwischen zwei gleichzeitigen Schreibvorgängen auflöst und nicht Ihr Anwendungscode.
Transaktionen
Eine Transaktion gruppiert Statements, sodass diese gemeinsam committet oder zurückgerollt (rollback) werden. db.transaction übergibt ein Transaktionsobjekt an den Callback, und jedes Statement innerhalb dieses Blocks muss dieses Objekt verwenden.
await db.transaction(async (trx) => {
const account = await trx("accounts").where({ id: fromId }).first();
if (!account || account.balance_cents < 5000) {
throw new Error("insufficient_funds");
}
await trx("accounts").where({ id: fromId }).decrement("balance_cents", 5000);
await trx("accounts").where({ id: toId }).increment("balance_cents", 5000);
});
Das Werfen eines Fehlers (Throwing) führt zum Rollback der Transaktion und lehnt das Promise ab. Ein Return-Statement committet die Transaktion. Der häufigste Fehler ist die Vermischung von trx und db innerhalb desselben Blocks: Abfragen, die über db ausgeführt werden, nutzen eine andere Connection aus dem Pool und laufen außerhalb der Transaktion, weshalb sie nicht zurückgerollt werden.
Für die manuelle Steuerung gibt db.transaction() ohne Callback ein Transaktionsobjekt mit den Methoden commit und rollback zurück. Bevorzugen Sie die Callback-Form; dabei ist es schwieriger, eine Connection zu leaken, weil man vergessen hat, den Commit auszuführen.
Raw Queries, wenn sie benötigt werden
Der Builder kann nicht alles ausdrücken, und das versucht er auch gar nicht. knex.raw führt einen SQL-String mit gebundenen Werten aus.
const result = await db.raw(
`select date_trunc('day', created_at) as day, count(*) as signups
from users
where created_at >= ?
group by 1
order by 1`,
[since],
);
const rows = result.rows;
Verwenden Sie immer ?-Platzhalter und übergeben Sie die Werte im Array. Wenn Sie diese direkt in den String interpolieren, führen Sie genau das Injection-Risiko wieder ein, das der Builder eigentlich verhindern soll. Sie können mit whereRaw oder select(db.raw(...)) ein Raw-Fragment in eine Builder-Kette einbetten, wenn nur ein Teil der Query handgeschriebenes SQL benötigt.
Window-Funktionen, rekursive CTEs, COPY und dialektspezifische Operatoren sind gute Gründe, auf Raw-Queries auszuweichen. Eine Ranking-Query beispielsweise ist klarer geschrieben als mühsam aus Builder-Fragmenten zusammengesetzt:
const { rows } = await db.raw(
`select email, score,
row_number() over (order by score desc) as rank
from leaderboard
where season = ?`,
[season],
);
Halten Sie solche Fragmente klein und kommentieren Sie diese; verwenden Sie für alles andere drumherum bevorzugt den Builder.
Connection Pooling
Jede Abfrage läuft über einen Pool von Verbindungen, der von tarn.js verwaltet wird. Standardmäßig sind ein Minimum von zwei und ein Maximum von zehn Verbindungen eingestellt, was für einen einzelnen Prozess angemessen ist, in der Produktion jedoch optimiert werden muss.
const db = knex({
client: "pg",
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10, acquireTimeoutMillis: 30_000 },
});
Die Größe des Pools sollte basierend auf dem max_connections der Datenbank festgelegt und auf alle Applikations-Instanzen verteilt werden. Ein zu großer Pool ist ebenso schädlich wie ein zu kleiner: Zu viele Verbindungen erschöpfen den Arbeitsspeicher und die Prozesstabelle des Servers. Setzen Sie einen Pooler wie PgBouncer davor, wenn Sie viele Instanzen oder serverless functions betreiben.
Rufen Sie await db.destroy() auf, wenn der Prozess heruntergefahren wird. Ohne diesen Aufruf halten offene Verbindungen den Event Loop aktiv, wodurch ein Graceful Shutdown hängen bleibt.
Knex mit Objection.js kombinieren
Knex gibt einfache Zeilen zurück. Wenn Sie hingegen Models, Relationen und Lifecycle-Hooks benötigen, ist Objection.js die richtige Ergänzung. Es baut direkt auf Knex auf und nutzt dieselbe Verbindung.
import { Model } from "objection";
import { db } from "./db";
Model.knex(db);
class User extends Model {
static tableName = "users";
static relationMappings = {
posts: {
relation: Model.HasManyRelation,
modelClass: Post,
join: { from: "users.id", to: "posts.author_id" },
},
};
}
const user = await User.query()
.withGraphFetched("posts")
.findOne({ email });
Diese Schichtung ist die pragmatische Lösung für Teams, die ein ORM wünschen, aber auf zu starke Abstraktionen verzichten möchten: Knex kümmert sich um SQL und Migrationen, Objection fügt das Objektmodell hinzu, und Sie können jederzeit zu Knex zurückkehren, wenn eine Abfrage nicht in das Model-Layer passt.
Du schreibst immer noch SQL
Das Wichtigste, das man über Knex verstehen muss, ist, dass es SQL nicht versteckt. Es ordnet es neu, parametrisiert es und setzt Anführungszeichen, aber das resultierende Statement ist genau das, was du selbst geschrieben hättest.
Das hat zwei Konsequenzen. Die erste ist positiv: Wenn du eine Knex-Chain liest, erkennst du sofort die Query. Debugging bedeutet hier, .toSQL() auszugeben und den Plan zu lesen, anstatt zu raten, welches SQL generiert wurde.
Die zweite Konsequenz ist eine Verantwortung: Ein Builder bewahrt dich nicht vor einem fehlenden Index, einem SELECT * über einer breiten Tabelle oder einem versehentlichen Cross Join. Du entwirfst weiterhin das Schema, fügst die Indexe hinzu und prüfst EXPLAIN ANALYZE. Knex nimmt dir das mühsame Zusammenbauen von Strings ab, aber nicht die Notwendigkeit, die Datenbank zu verstehen.
Best Practices
- Erstellen Sie eine einzige Knex-Instanz und importieren Sie diese; rufen Sie
knex()niemals pro Modul auf. - Binden Sie jeden Wert als Parameter und verwenden Sie
?-Platzhalter in Raw-Queries. - Versionieren Sie jede Schema-Änderung als Migration mit einer echten
down-Methode. - Teilen Sie riskante Änderungen in separate Migrationen auf: Zuerst hinzufügen und Backfill durchführen, Constraints erst später setzen.
- Verwenden Sie
.returning(), sofern der Dialekt dies unterstützt, andernfalls weichen Sie auf ein nachfolgendes Select aus. - Verwenden Sie innerhalb von
db.transactionimmer das Transaction-Objekt und niemals die Top-Level-Instanz. - Dimensionieren Sie den Connection Pool basierend auf dem Datenbank-Limit und rufen Sie
db.destroy()beim Shutdown auf. - Casten Sie Postgres
count- undsum-Ergebnisse in Zahlen, bevor Sie arithmetische Operationen durchführen. - Geben Sie
.toSQL()aus, wenn eine Query Sie überrascht, und bestätigen Sie die Performance mitEXPLAIN ANALYZE.
Häufige Fehler
- Vermischung von
trxunddbin einer einzigen Transaction, wodurch Teile der Arbeit nicht durch den Rollback rückgängig gemacht werden. - Interpolation von Werten in
knex.raw-Strings anstatt Platzhalter zu verwenden. - Vergessen von
groupByfür nicht-aggregierte Spalten, was zu einem SQL-Fehler führt. - Behandeln des von
countzurückgegebenen Strings als Zahl, was zu"10" + 1führt. - Die Annahme, dass
.returning()auf MySQL und SQLite identisch funktioniert. - Ein Pool wird unbegrenzt oder größer konfiguriert, als die Datenbank verarbeiten kann.
- Erstellen einer neuen Knex-Instanz pro Request, wodurch die Verbindungen erschöpft werden.
- Manuelles Bearbeiten eines Production-Schemas, was zu Divergenzen zwischen den Umgebungen führt.
- Ignorieren der
down-Methode, bis ein Rollback tatsächlich benötigt wird.
Wie geht es weiter?
Knex ist ein idealer Anlaufpunkt, wenn Sie gerne nah an SQL arbeiten, und bietet eine solide Grundlage, falls Sie später ein ORM darauf aufsetzen möchten. Lesen Sie den SQL-Guide, um die Statements, die der Builder generiert, zu perfektionieren, und den PostgreSQL-Guide für Informationen zu Indizes, Transaktionen und Query-Plänen. Wenn Sie stattdessen einen typisierten Schema-First-Client bevorzugen, generiert Prisma einen solchen für Sie, während Drizzle ORM näher am Builder-Stil bleibt und eine vollständige TypeScript-Inferenz bietet.