Aller au contenu
Sylvain Joffraud l4w
Retour

Prisma `not` et NULL : le filtre qui exclut presque tout

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 WHERE les 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) :

ÉcritureNULL compté ?Sur 5 billets
refund_status <> 'completed'non1
refund_status IS NULL OR refund_status <> 'completed'oui4
refund_status IS DISTINCT FROM 'completed'oui4
COALESCE(refund_status, '') <> 'completed'oui4

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


Partager cet article :

Article précédent
WireGuard full-tunnel : Docker 28 bloque le retour
Article suivant
mwan3 : le health-check d’usine qui coupe Internet