Qu’est-ce que Knex.js ?
Knex.js est un constructeur de requêtes SQL pour Node.js. Il n’est délibérément pas un ORM. Il ne mappe pas les lignes à des classes, ne suit pas l’état des objets et ne charge pas les relations de lui-même. Son rôle est de vous permettre de construire des instructions SQL sous forme d’objets JavaScript composables, de lier chaque valeur de manière sécurisée et de les exécuter contre Postgres, MySQL, SQLite, MSSQL ou Oracle.
C’est précisément cette sobriété qui explique sa longévité. Knex est apparu en 2013 et est devenu le socle sur lequel s’appuient Objection.js et plusieurs autres bibliothèques. Si vous recherchez l’ergonomie d’un ORM, vous pouvez en ajouter un par-dessus ; si vous souhaitez rester proche du SQL, Knex offre déjà le niveau d’abstraction idéal.
Le modèle mental est simple : commencez par le nom d’une table, chaînez des méthodes pour décrire l’instruction, puis await pour l’exécuter. Chaque chaîne devient finalement une requête paramétrée.
Pourquoi utiliser un query builder ?
Écrire du SQL sous forme de chaînes de caractères (template strings) semble pratique, jusqu’à ce qu’une requête doive changer de structure. Un endpoint de recherche avec des filtres optionnels, un tri dépendant de l’entrée utilisateur et une clause de pagination sont pénibles à assembler par concaténation, et dangereux si une valeur est interpolée directement.
Un builder résout ces deux problèmes. Les conditions s’ajoutent proprement, les valeurs deviennent des paramètres liés (bound parameters) et les identifiants sont échappés selon le dialecte cible.
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);
Remarquez que search est passé en tant qu’argument à where, et non inséré dans une chaîne. Knex l’envoie au driver comme un placeholder, ce qui empêche toute modification de la structure de l’instruction.
L’autre avantage est la portabilité. La même chaîne de builder s’exécute sur Postgres et MySQL avec un simple changement de configuration, ce qui est très utile pour le développement local et les tests avec SQLite.
Installation et le knexfile
Installez Knex ainsi que le driver correspondant à votre base de données. Le driver est un package séparé, car Knex n’embarque pas les clients de base de données.
pnpm add knex pg
pnpm add -D @types/pg
La configuration se trouve dans un knexfile, avec une entrée par environnement. La CLI le lit automatiquement, et votre application importe ce même objet.
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;
Créez une instance unique partagée et importez-la partout, plutôt que d’appeler knex() dans chaque module. Une seule instance gère un pool de connexions ; multiplier les instances revient à multiplier les connexions vers la base de données.
import knex from "knex";
import config from "../knexfile";
export const db = knex(config.development);
Utilisez le satisfies de TypeScript ou un objet de configuration typé pour que l’éditeur détecte les options mal orthographiées, et conservez vos secrets dans les variables d’environnement plutôt que dans le fichier.
Migrations avec le schema builder
Les migrations permettent de faire évoluer le schéma au fil du temps. Chaque fichier possède une fonction up qui applique le changement et une fonction down qui l’annule.
npx knex migrate:make create_users
npx knex migrate:latest
npx knex migrate:rollback
npx knex migrate:list
Le fichier généré est du TypeScript classique. Le schema builder décrit les tables via un callback qui reçoit un objet table.
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 enregistre les migrations appliquées dans une table knex_migrations, afin que chaque environnement sache exactement quels fichiers ont été exécutés. Rédigez toujours une fonction down concrète ; même si vous effectuez rarement des rollbacks, cela documente la manière d’annuler le changement et permet de tester les migrations.
alterTable modifie une table existante. Certains changements, comme l’ajout d’une colonne notNullable à une table contenant déjà des lignes, nécessitent une valeur par défaut ou une étape de backfill. Divisez ces opérations en deux migrations : une pour ajouter la colonne et effectuer le backfill, et une autre pour ajouter la contrainte.
Seeds pour les données locales
Les seeds permettent de peupler une base de données avec des lignes connues pour le développement et les tests. Ils sont distincts des migrations car ils représentent des données, et non une structure.
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" },
]);
}
Exécutez-les avec npx knex seed:run. Les seeds doivent être idempotents dans la mesure du possible, ce qui signifie généralement qu’il faut vider la table ou utiliser un upsert avant l’insertion, afin que leur exécution répétée n’entraîne ni erreur ni duplication de données.
Construire des requêtes
Une requête commence par le nom d’une table. À partir de là, les méthodes s’enchaînent jusqu’à ce que l’instruction soit attendue avec await.
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 accepte plusieurs formes : une colonne et une valeur, une colonne, un opérateur et une valeur, ou un objet d’égalités. whereIn, whereNot, whereNull, whereBetween et whereExists couvrent le reste des prédicats courants. Les conditions groupées utilisent un callback afin que les parenthèses soient placées au bon endroit.
db("posts")
.where("published", true)
.andWhere((qb) => {
qb.where("title", "ilike", `%${term}%`).orWhere("body", "ilike", `%${term}%`);
});
Utilisez .first() lorsque vous attendez une seule ligne, et pluck lorsque vous souhaitez un tableau plat d’une seule colonne.
const user = await db("users").where({ email }).first();
const emails = await db("users").pluck("email");
Joins, agrégations et group by
Les joins se lisent de la même manière qu’en SQL : une table, puis la paire de colonnes qui les lie.
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 est un inner join, leftJoin conserve les lignes non appariées de la première table, et rightJoin ainsi que fullOuterJoin sont disponibles sur les dialectes qui les supportent. La condition de join peut également être un callback lorsqu’elle nécessite plus d’une clause.
Les agrégations utilisent .count(), .sum(), .avg(), .min() et .max(). Deux détails piègent souvent les développeurs. Premièrement, toute colonne non agrégée dans le select doit apparaître dans groupBy. Deuxièmement, sur Postgres, les valeurs count et sum sont retournées sous forme de chaînes de caractères pour éviter le dépassement d’entier (integer overflow) ; effectuez donc un cast ou un parse lorsque vous avez besoin de nombres.
const { count } = await db("users").count("* as count").first();
const total = Number(count);
La clause returning
L’insertion d’une ligne implique généralement le besoin de récupérer la clé primaire générée ou les valeurs par défaut définies côté serveur. Sur Postgres et MSSQL, .returning() demande à la base de données de renvoyer la ligne.
const [user] = await db("users")
.insert({ email, display_name: displayName })
.returning(["id", "email", "created_at"]);
Les mises à jour (updates) et les suppressions (deletes) peuvent également renvoyer des lignes :
const updated = await db("posts")
.where({ id })
.update({ title, updated_at: db.fn.now() })
.returning("*");
MySQL et les anciennes versions de SQLite ne supportent pas RETURNING. Dans ce cas, l’appel d’insertion renvoie le nouvel id, et vous devez effectuer un select complémentaire. Il est important de savoir quel dialecte vous ciblez, car c’est l’un des points où la promesse de portabilité a ses limites.
Upserts et gestion des conflits
Un pattern courant consiste à « insérer cette ligne, mais à la mettre à jour si elle existe déjà ». Knex exprime cela avec onConflict, ce qui correspond à ON CONFLICT sur Postgres et SQLite, et à ON DUPLICATE KEY UPDATE sur MySQL.
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 prend en argument la ou les colonnes uniques qui définissent le conflit. merge met à jour les colonnes listées à partir de la nouvelle ligne, tandis que ignore ignore complètement l’insertion. C’est la méthode sécurisée pour rendre un processus de synchronisation ou un gestionnaire de webhook idempotent, car la base de données résout la race condition entre deux écritures concurrentes plutôt que votre code applicatif.
Transactions
Une transaction regroupe des instructions afin qu’elles soient validées (commit) ou annulées (roll back) ensemble. db.transaction passe un objet de transaction à la fonction de rappel (callback), et chaque instruction à l’intérieur doit l’utiliser.
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);
});
Lancer une exception annule la transaction et rejette la promesse. Retourner une valeur la valide. L’erreur la plus courante consiste à mélanger trx et db à l’intérieur d’un même bloc : les requêtes exécutées sur db utilisent une connexion différente du pool et s’exécutent en dehors de la transaction, elles ne sont donc pas annulées.
Pour un contrôle manuel, db.transaction() sans callback retourne un objet de transaction avec les méthodes commit et rollback. Privilégiez la forme avec callback ; il est plus difficile de provoquer une fuite de connexion en oubliant de valider la transaction.
Des requêtes brutes quand c’est nécessaire
Le builder ne peut pas tout exprimer, et il ne cherche pas à le faire. knex.raw exécute une chaîne SQL avec des valeurs liées.
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;
Utilisez toujours des placeholders ? et passez les valeurs dans le tableau. Les interpoler directement dans la chaîne réintroduit précisément le risque d’injection que le builder est censé éliminer. Vous pouvez intégrer un fragment brut à l’intérieur d’une chaîne de builder avec whereRaw ou select(db.raw(...)) lorsque seule une partie de la requête nécessite du SQL écrit à la main.
Les fonctions de fenêtrage (window functions), les CTE récursives, COPY et les opérateurs spécifiques à un dialecte sont autant de bonnes raisons de passer en mode brut. Une requête de classement, par exemple, est plus claire lorsqu’elle est écrite explicitement que lorsqu’elle est assemblée à partir de fragments de builder :
const { rows } = await db.raw(
`select email, score,
row_number() over (order by score desc) as rank
from leaderboard
where season = ?`,
[season],
);
Gardez ces fragments courts et commentés, et privilégiez le builder pour tout le reste.
Pool de connexions
Chaque requête s’exécute via un pool de connexions géré par tarn.js. Par défaut, le minimum est fixé à deux et le maximum à dix, ce qui est raisonnable pour un processus unique, mais nécessite un ajustement en production.
const db = knex({
client: "pg",
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10, acquireTimeoutMillis: 30_000 },
});
Déterminez la taille du pool en fonction de la valeur max_connections de la base de données, répartie sur toutes les instances de l’application. Un pool trop large est tout aussi préjudiciable qu’un pool trop petit : un nombre excessif de connexions épuise la mémoire du serveur et sa table des processus. Utilisez un pooler tel que PgBouncer en amont lorsque vous déployez de nombreuses instances ou des fonctions serverless.
Appelez await db.destroy() lors de l’arrêt du processus. Sans cela, les connexions ouvertes maintiennent l’event loop active et empêchent l’arrêt progressif (graceful shutdown) de s’effectuer.
Associer Knex à Objection.js
Knex retourne des lignes brutes ; si vous souhaitez utiliser des modèles, des relations et des hooks de cycle de vie, ajoutez Objection.js. Il est construit directement sur Knex et réutilise la même connexion.
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 });
Cette superposition est la solution pragmatique pour les équipes qui veulent un ORM mais n’aiment pas les abstractions lourdes : Knex gère le SQL et les migrations, Objection ajoute le modèle d’objet, et vous pouvez repasser à Knex à tout moment pour une requête que la couche modèle ne permet pas de gérer facilement.
Vous écrivez toujours du SQL
La chose la plus importante à intégrer concernant Knex est qu’il ne cache pas le SQL. Il le réorganise, le paramètre et gère les guillemets, mais l’instruction qu’il produit est exactement ce que vous auriez écrit.
Cela a deux conséquences. La première est positive : la lecture d’une chaîne Knex vous indique la requête, et le débogage consiste à afficher .toSQL() et à lire le plan, plutôt qu’à essayer de deviner le SQL généré.
La seconde est une responsabilité : un builder ne vous sauvera pas d’un index manquant, d’un SELECT * sur une table volumineuse ou d’une jointure croisée accidentelle. C’est toujours à vous de concevoir le schéma, d’ajouter les index et de vérifier EXPLAIN ANALYZE. Knex élimine la plomberie des chaînes de caractères, pas la nécessité de comprendre la base de données.
Bonnes pratiques
- Créez une seule instance Knex et importez-la ; n’appelez jamais
knex()par module. - Liez chaque valeur en tant que paramètre et utilisez des placeholders
?dans les requêtes raw. - Versionnez chaque modification de schéma via une migration avec une méthode
downconcrète. - Divisez les changements risqués en migrations distinctes : ajoutez et effectuez le backfill d’abord, puis appliquez les contraintes plus tard.
- Utilisez
.returning()lorsque le dialecte le supporte, sinon effectuez un select complémentaire. - Utilisez toujours l’objet de transaction à l’intérieur de
db.transaction, et jamais l’instance de niveau supérieur. - Dimensionnez le pool de connexions en fonction de la limite de la base de données et appelez
db.destroy()lors de l’arrêt. - Convertissez les résultats Postgres
countetsumen nombres avant d’effectuer des opérations arithmétiques. - Affichez
.toSQL()lorsqu’une requête vous surprend, et confirmez les performances avecEXPLAIN ANALYZE.
Erreurs courantes
- Mélanger
trxetdbdans une seule transaction, ce qui fait qu’une partie du travail échappe au rollback. - Interpoler des valeurs dans des chaînes
knex.rawau lieu d’utiliser des placeholders. - Oublier
groupBypour les colonnes non agrégées et provoquer une erreur SQL. - Traiter la chaîne retournée par
countcomme un nombre, ce qui produit"10" + 1. - Supposer que
.returning()fonctionne de manière identique sur MySQL et SQLite. - Laisser un pool sans limite ou plus grand que ce que la base de données peut supporter.
- Créer une nouvelle instance Knex par requête et épuiser les connexions.
- Modifier manuellement un schéma de production et laisser les environnements diverger.
- Ignorer la méthode
downjusqu’à ce qu’un rollback soit réellement nécessaire.
Et après ?
Knex est un excellent choix si vous aimez travailler au plus près du SQL, et constitue une base solide si vous souhaitez ajouter un ORM par la suite. Consultez le guide SQL pour perfectionner les requêtes générées par le builder, et le guide PostgreSQL pour en savoir plus sur les index, les transactions et les plans d’exécution. Si vous préférez un client typé basé sur un schéma, Prisma en génère un pour vous, tandis que Drizzle ORM reste plus proche du style builder avec une inférence TypeScript complète.