02Bases de données5 min

J'ai réécrit le même modèle de contrats plus de dix fois

Un modèle de domaine compliqué ne sort pas bien du premier coup. On converge vers lui en le réécrivant avec les vraies données sous les yeux, et ça inclut de revenir sur des décisions qui semblaient correctes.

Par Salva Castells

Image de couverture : Ernst Alban, Domaine public.
Dans cet article

Le premier modèle de contrats que j'ai écrit pour un outil de BI d'une grande entreprise faisait quelque quatre-vingts lignes de SQL et me semblait bien. Un contrat, une date de début, une date de fin, des conditions accrochées dessous. Ça se lisait proprement. Un an plus tard, ce même modèle, je l'avais réécrit plus de dix fois, dont une où je lui ai changé son nom parce que le nom mentait.

Ce n'était pas de la mauvaise planification. C'est que le domaine ne se voit que quand les vraies données arrivent.

Pourquoi le premier modèle paraît toujours correct

Parce que tu l'écris contre le domaine qu'on t'a raconté, pas contre celui qui existe. On te dit « un contrat a une date de fin ». Tu le modélises. Ça marche avec les vingt lignes que tu regardes.

Le premier avertissement est venu du nom. Le modèle s'appelait kpi_02_20_revenue_contracts et je l'ai renommé kpi_02_20_rent_contracts. Ce n'était pas du revenue, c'était du rent : des contrats de location où l'entreprise paie, pas où elle encaisse. Signe inverse. Le nom venait du ticket, pas de la table. Un mois à travailler avec un modèle qui s'appelait le contraire de ce qu'il contenait.

Les noms mal posés ne sont pas cosmétiques. Quelqu'un les lit et en déduit des choses.

Ce qui n'apparaît qu'avec les vraies données

La date de fin a été le puits sans fond. La table avait deux colonnes : DT_FIRST_CONTRACT_END et DT_CONTRACT_END_NEXT. La première version de la date unifiée était ceci :

case
    when c.DT_FIRST_CONTRACT_END is null then c.DT_CONTRACT_END_NEXT
    when c.DT_CONTRACT_END_NEXT is null then c.DT_FIRST_CONTRACT_END
    when c.DT_FIRST_CONTRACT_END < CURRENT_DATE() then c.DT_CONTRACT_END_NEXT
    else c.DT_FIRST_CONTRACT_END
end as dt_end_merged

Ça a l'air exhaustif. Ça ne l'est pas. Il manque le cas où les deux sont nulles, et celui où la première est dans le passé et la seconde n'existe pas. Dans les données, ces deux cas signifiaient la même chose : contrat à durée indéterminée. Le case les envoyait vers un null accidentel qui était ensuite compté comme « expiré ». La version finale ajoute ces deux branches explicites et un when dt_end_merged is null then True dans le flag is_unlimited, parce que null à cet endroit est une information, pas une absence d'information.

Et ce même flag traînait déjà une autre perle du monde réel :

when year_end_merged in (8909, 8899, 9999) then True

Trois années sentinelles différentes pour dire « sans fin ». Aucune documentée. Les trois en production.

Les trois fois où nous sommes revenus sur une décision

La plus parlante est l'année 99. Dans les Excel d'origine, il y avait des dates de fin dont l'année arrivait sous la forme 99, et quelqu'un dans l'équipe disait que ça voulait dire « indéterminé ». J'ai ajouté le traitement :

case
    when {{ normalize_date('ew.WARUNEK_WA_NY_DO') }} like '%99%' then '2099-12-31'::date
    else {{ normalize_date('ew.WARUNEK_WA_NY_DO') }}::date
end

Et le filtre correspondant, un or ... like '%99%' pour que ces lignes ne tombent pas hors de la fenêtre de dates. Je l'ai retiré trois jours plus tard. Le like '%99%' matche 1999, matche 2099 et matche n'importe quelle date qui porte un 99 dans le jour ou dans le mois. J'étais en train de faire entrer des contrats clos depuis vingt ans dans le calcul du revenue actif. La règle métier était peut-être vraie ; son implémentation par like sur une chaîne de date, non.

Deux mois plus tard, le même problème est revenu par une autre porte, dans le modèle de renégociations : des années à deux chiffres. Cette fois, je l'ai fait sur l'année déjà extraite, pas sur le texte :

case
  when year(normalized_next_possible_termination_old_contract) < 100
    then year(...) + 2000
  else year(...)
end

Ça non plus n'a pas survécu intact : deux semaines plus tard, c'était commenté dans le fichier avec un -- ! devant. Troisième tour sur la même colonne.

L'autre gros retour en arrière a été l'inner join entre contrats et conditions. Un contrat sans condition n'apparaissait pas. Le passer en left join a changé les chiffres du tableau de bord, et c'est exactement la conversation à avoir avec celui qui commande le KPI : est-ce qu'un contrat sans condition compte ou non. La première fois, je l'ai décidé tout seul, avec un inner, sans me rendre compte que j'étais en train de le décider.

Comment je sais qu'un modèle est stable

Au type de commit. Pendant la première année, les commits étaient structurels : des joins qui changent, des filtres qui entrent et sortent, des colonnes qu'on renomme. À la fin du projet, les commits étaient d'une autre nature : retirer un multiplicateur par 100, fusionner deux case en un, écrire la documentation des colonnes. Ça, c'est un modèle qui ne bouge plus par en dessous, seulement par en haut.

L'autre signal, c'est que le .yml de documentation passe de quatre puces vagues à la liste exacte des critères de sélection : entités, types de contrat, devise, ce qui est exclu et pourquoi. Quand tu peux écrire cette liste sans regarder le SQL, le modèle existe vraiment. Avant, ce que tu as est une requête.

Ce que je fais aujourd'hui avant le premier CREATE TABLE

Avant, j'écrivais le modèle puis je regardais les données. Maintenant c'est l'inverse, et dans cet ordre :

  • Compter les nulls de chaque colonne qui entrera dans une condition. Une colonne avec 40 % de nulls n'est pas une date, ce sont deux concepts mélangés.
  • Chercher les valeurs sentinelles. select distinct year(...) order by 1 desc. Si 9999, 8899 ou 2099 sortent, il y a des règles métier non documentées cachées là.
  • Écrire le filtre en prose avant de l'écrire en SQL. Si je ne sais pas dire en une phrase quels contrats entrent, le where finira avec six conditions contradictoires et deux commentées.
  • Normaliser sur des types, jamais sur des chaînes. Tout like sur une date est une bombe à retardement.

Un modèle de domaine ne se conçoit pas : il se négocie avec les données, et les données ne cèdent pas. Chaque fois que je suis revenu sur quelque chose, c'est parce que j'avais décidé moi-même ce que j'aurais dû demander.