Avec Prisma, un filtre not exclut les NULL : { refundStatus: { not: 'completed' } } ne renvoie pas « tous les billets sauf les remboursés », il renvoie seulement les billets dont le statut est renseigné et différent de completed. Dans une billetterie événementielle où presque aucun billet n’a de statut de remboursement, le comptage des places vendues serait tombé à presque rien. La cause n’est pas Prisma mais SQL : la logique à trois valeurs, où NULL <> 'completed' ne vaut ni vrai ni faux.
Le filtre intuitif pour compter les places vendues
Le contexte : vérifier la jauge d’un événement avant de vendre un billet, comme décrit dans l’article sur la survente et le SELECT … FOR UPDATE. Un billet occupe une place tant qu’il n’est pas remboursé. La colonne refund_status est nullable : NULL tant que personne n’a demandé de remboursement, puis pending, processing, completed ou denied.
Le filtre qui vient naturellement :
const taken = await tx.ticket.count({
where: { eventId, refundStatus: { not: RefundStatus.COMPLETED } },
});
J’ai reproduit le cas avec Prisma 5.22 et PostgreSQL 16 : cinq billets, trois à NULL, un pending, un completed. Résultat attendu : 4. Résultat obtenu : 1.
Pourquoi Prisma not exclut les NULL ?
Le log des requêtes montre le SQL généré par Prisma 5.22 :
WHERE "tickets"."refund_status" <> $1 -- { not: 'completed' }
WHERE (NOT "tickets"."refund_status" = $1) -- { NOT: { refundStatus: 'completed' } }
Les deux formes donnent 1. En SQL, NULL signifie « valeur inconnue » : toute comparaison avec NULL (=, <>) renvoie NULL, pas false. NOT NULL reste NULL. Et une clause WHERE ne garde que les lignes où la condition vaut vrai : NULL est écarté comme false.
SELECT NULL = 'completed', NULL <> 'completed', NOT (NULL = 'completed');
-- NULL | NULL | NULL
En SQL, « différent de X » ne veut pas dire « tout sauf X » : les lignes à NULL ne sont ni égales ni différentes, et
WHEREles jette.
La page officielle de Prisma sur null et undefined ne détaille pas ce cas ; il est discuté dans le dépôt Prisma, par exemple dans l’issue « not filters should not skip over records with NULL field ».
Le filtre correct : un OR explicite sur NULL
Côté Prisma, il faut demander les NULL explicitement :
export function seatHoldingWhere(): Prisma.TicketWhereInput {
return {
status: { notIn: [TicketStatus.REJECTED, TicketStatus.CANCELLED] },
// `refundStatus <> completed` exclurait les billets à NULL : presque tous.
OR: [
{ refundStatus: null },
{ refundStatus: { not: RefundStatus.COMPLETED } },
],
};
}
SQL généré : ("refund_status" IS NULL OR "refund_status" <> $1). Résultat : 4. En SQL brut, deux alternatives donnent le même compte (doc PostgreSQL sur les comparaisons) :
| Écriture | NULL compté ? | Sur 5 billets |
|---|---|---|
refund_status <> 'completed' | non | 1 |
refund_status IS NULL OR refund_status <> 'completed' | oui | 4 |
refund_status IS DISTINCT FROM 'completed' | oui | 4 |
COALESCE(refund_status, '') <> 'completed' | oui | 4 |
IS DISTINCT FROM est un opérateur de comparaison qui traite NULL comme une valeur ordinaire : NULL IS DISTINCT FROM 'completed' vaut true. COALESCE marche aussi, mais repose sur une valeur sentinelle ('') qui ne doit jamais être un statut valide.
Comment détecter le piège avant la prod ?
Un test avec uniquement des lignes renseignées passe au vert avec le filtre faux. Le piège ne se voit qu’avec un jeu de données qui contient des NULL, et c’est justement le cas majoritaire en prod. Dans l’API, j’ai aussi figé la forme du where dans un test unitaire, avec le commentaire qui explique le OR : sans lui, un refactoring « simplifiant » le filtre réintroduirait le bug.
Lessons learned
- Prisma
{ not: x }exclut les lignes où la colonne est NULL. Le SQL généré est un<>, etNULL <> xn’est pas vrai. - La logique à trois valeurs s’applique aussi à
NOT.NOT (NULL = x)reste NULL, donc la ligne est écartée parWHERE. - Sur une colonne nullable, « tout sauf X » s’écrit avec un OR explicite.
OR: [{ col: null }, { col: { not: x } }]en Prisma,IS DISTINCT FROMen SQL brut. - Un test de filtre sans NULL ne prouve rien. Les fixtures doivent contenir le cas majoritaire de la prod, souvent la valeur absente.