Une extraction reproductible
- Leçon 9
- 55 min
- Module 3 : Des requêtes d'analyse utiles
Leçon 9 sur 18 · SQL et préparation des données pour l'IAUne extraction reproductible
1. Sources et structure des données
2. L'essentiel de SQL
3. Des requêtes d'analyse utiles
4. Nettoyer et transformer les données
5. Préparer la modélisation sans risque
6. Contrats de données et passation
Ce que vous saurez faire
Transformez vos requêtes en une extraction que tout le monde peut refaire. Un script Python exécute les requêtes enregistrées avec des paramètres et écrit des fichiers CSV. Il consigne exactement ce qu’il a sélectionné et dans quel instantané.
Carnet JupyterFaites cette leçon dans un carnet Jupyter
Le carnet (notebook) Jupyter contient toutes les requêtes, tâches et vérifications de cette leçon, avec les consignes. Vous écrivez les deux fichiers de requête et les trois versions de scripts/extract.py dans les cellules du carnet. Vous exécutez le script et lisez son manifeste. La première cellule télécharge les données d’exercice et construit la base de données. Le carnet fonctionne donc sans le projet local du module 1.
Le carnet est en anglais. Colab et Kaggle ouvrent votre propre copie. Il faut un compte Google ou Kaggle gratuit. Vous êtes bloqué ? Consultez le carnet de solution.
Avant de commencerCe qu’il faut savoir et avoir sous la main
Ce que vous devez savoir
- Les intervalles de dates semi-ouverts, dans Le temps et le classement : intervalles semi-ouverts.
- Une seule ligne la plus récente par billet de soutien, dans Le temps et le classement : le dernier résultat de chaque billet.
- Lire un fichier texte avec
pathlib, dans Lire et écrire des fichiers : chemins avec pathlib du cours Python pour l’ingénierie de l’IA en pratique. - Écrire un fichier CSV avec le module
csv, dans Lire et écrire des fichiers : CSV et JSON du cours de Python. - Les options de ligne de commande avec
argparse, dans L’interface en ligne de commande : argparse du cours de Python.
Ce qu’il vous faut sur votre ordinateur (seulement pour le travail local; le carnet et les encadrés de cette page n’en ont pas besoin)
- Le dossier
~/projects/ticket-dataaveclarkfield.dbetq.py. Vous l’avez créé dans Préparer une base de données d’exercice : créer la base de données. - Si le dossier ou la base de données manque ou a changé, refaites cette section. Vous pouvez aussi réinitialiser la base de données avec
python3 get_data.py --reset. L’extraction lit seulement la base de données, comme le faitq.py. - Le dossier
queries/, comme dans Exécuter une requête enregistrée. Cette leçon ajouteextract_tickets.sql,extract_outcomes.sql,35-et36-. - Un dossier
scripts/pourextract.py. S’il n’existe pas encore, créez-le dans le dossier du projet avecmkdir scripts. Si votre script ne fonctionne plus, copiez la version terminée du dossier C04/pipeline du cours. - Un terminal ouvert dans ce dossier. Le script utilise seulement la bibliothèque standard de Python : aucun environnement virtuel pour le moment. Cette leçon utilise
python3, comme sur macOS et Linux. Sur Windows, tapez plutôtpython, comme l’explique Quelle commande Python saisir.
Testé sur macOS avec Python 3.14.6 (SQLite 3.50.4). Les commandes Windows n’ont pas été testées. Le carnet a été testé dans Jupyter.
Un fichier que personne ne peut expliquer
Section intitulée « Un fichier que personne ne peut expliquer »Priya reçoit un fichier CSV de billets d’un collègue. Il contient 1 416 lignes. Elle a des questions avant d’entraîner quoi que ce soit :
- De quelle exportation vient-il? Le service de soutien exporte les données chaque mois.
- Quels billets ont été sélectionnés? Pourquoi 1 416 et non 1 417?
- Quelle requête l’a produit? Quelqu’un a-t-il modifié la requête depuis?
- Quand a-t-il été produit?
Le fichier ne peut pas répondre. Mei n’envoie donc jamais un fichier de données seul. Elle l’accompagne d’un manifeste : un petit fichier JSON qui consigne comment les données ont été produites. Son manifeste consigne cinq faits :
| Fait | Exemple | Pourquoi |
|---|---|---|
| Moment de l’extraction | 2026-10-07T03:08:46+00:00 |
Quand le script a été exécuté, en UTC. |
| Version de la source | La table snapshot : exportation du 2026-07-01 06:00:00, version 1.0 |
Quelles données ont servi. Un instantané est une copie fixe des données à un moment donné; il ne change pas après l’exportation. |
| Critères de sélection | created_from = 2026-01-01, created_before = 2026-07-01 |
Quelles lignes ont été choisies, et selon quelle règle. |
| Empreinte de la requête | Un SHA-256 de chaque fichier de requête | Quelle requête exacte a été exécutée. |
| Nombre de lignes | 1 416 lignes de billets; 1 ligne non sélectionnée | Ce qui a été produit et ce qui a été exclu. |
Un SHA-256 est une empreinte de 64 caractères d’un texte ou d’un fichier. Le même texte donne toujours la même empreinte. Modifier un seul caractère, même une espace, donne une empreinte complètement différente. Deux manifestes avec le même SHA-256 de requête ont donc utilisé la même requête.
Paramètres : des valeurs qui ne font pas partie de la requête
Section intitulée « Paramètres : des valeurs qui ne font pas partie de la requête »Les dates de sélection changent d’une extraction à l’autre. La requête ne devrait pas changer. Mei écrit donc la requête avec des paramètres nommés : :created_from et :created_before servent de substituts. Python fournit leurs valeurs séparément.
sql = "SELECT COUNT(*) FROM tickets WHERE team = :team"con.execute(sql, {"team": "returns"})sqlite3 envoie séparément le texte de la requête et les valeurs à SQLite. SQLite lit le texte comme du SQL et les valeurs seulement comme des valeurs. Une valeur ne peut jamais devenir une partie du SQL. Le texte de la requête reste aussi identique pour chaque sélection, donc son SHA-256 ne change pas.
L’autre méthode consiste à construire le texte SQL avec une chaîne formatée (f-string) : f"... WHERE team = '{team}'". Elle fonctionne pour returns. Le cas d’échec ci-dessous montre ce qui arrive avec d’autres valeurs. N’insérez jamais une valeur dans du texte SQL avec une f-string.
Vérifier la sélection dans SQL
Section intitulée « Vérifier la sélection dans SQL »Avant d’écrire le script, Mei vérifie ce que la sélection contiendra. Elle remplace les paramètres par les deux dates, seulement pour ce test :
Exécutez du SQL dans votre navigateur
Essayez : vérifier la sélection
Une comparaison vaut 1 quand elle est vraie et 0 quand elle est fausse. SUM compte donc les lignes où elle est vraie. Vous devriez voir 1 416 lignes sélectionnées, 1 non sélectionnée et 1 417 au total. Ajoutez une requête en dessous, SELECT ticket_id, created_at FROM tickets WHERE created_at >= '2026-07-01';, puis exécutez à nouveau. La ligne non sélectionnée est T-10147, le billet de 2036.
Tab ajoute quatre espaces. Appuyez sur Esc, puis sur Tab, pour quitter la zone. Ctrl+Enter exécute la requête. Chaque exécution part d’une nouvelle copie de la base de données d’exercice.
L’extraction garde les 14 lignes de billets répétées. Les supprimer est une décision de nettoyage; le module 4 prend cette décision et compte les suppressions. Une extraction ne fait que sélectionner : elle ne change pas les valeurs.
Les deux fichiers de requête
Section intitulée « Les deux fichiers de requête »Mei enregistre chaque requête dans son propre fichier. Le script peut ainsi calculer son SHA-256, et une personne peut la lire pour la réviser.
-- One row per ticket row in the export, with the customer's attributes.-- LEFT JOIN keeps tickets with no customer (guests) and tickets whose-- customer is no longer in the customer table. Duplicates are kept here:-- the cleaning step removes them and counts them.SELECT t.ticket_id, t.customer_id, t.created_at, t.channel, t.team, t.priority, t.order_value, t.word_count, t.first_reply_minutes, t.priority_now, t.closed_at, c.segment, c.region, c.joined_onFROM tickets AS tLEFT JOIN customers AS c ON c.customer_id = t.customer_idWHERE t.created_at >= :created_from AND t.created_at < :created_beforeORDER BY t.ticket_id;-- Every outcome event of the tickets in the selection. Events for tickets-- that are not in the ticket table are left out by the join.SELECT o.outcome_id, o.ticket_id, o.recorded_at, o.status, o.resolution_code, o.csatFROM outcomes AS oWHERE o.ticket_id IN ( SELECT t.ticket_id FROM tickets AS t WHERE t.created_at >= :created_from AND t.created_at < :created_before)ORDER BY o.outcome_id;Trois choix dans ces fichiers sont importants :
- La même règle dans les deux fichiers. Un événement de résultat est sélectionné quand son billet est sélectionné. Les deux fichiers décrivent donc toujours les mêmes billets.
ORDER BY. Sans cette clause, la base de données peut retourner les lignes dans n’importe quel ordre. Le fichier CSV peut alors différer entre deux exécutions sur les mêmes données.- L’extraction garde des colonnes comme
priority_now. Elles constituent une fuite de données : de l’information postérieure à la création du billet. L’extraction est une copie fidèle; le module 5 décide quelles colonnes un modèle peut utiliser.
La requête des résultats utilise IN (subquery), pas une jointure. IN vérifie seulement si le billet est dans la liste. Un billet présent deux fois dans tickets ne peut donc pas doubler ses événements.
Exécutez du SQL dans votre navigateur
À vous : combien d'événements l'extraction gardera-t-elle?
La requête compte les 1 874 événements. Ajoutez la règle de sélection de extract_outcomes.sql, avec les deux dates à la place des paramètres. Faites d’abord une prédiction : 4 événements appartiennent à des billets absents de tickets, et le billet de 2036 a 1 événement.
Tab ajoute quatre espaces. Appuyez sur Esc, puis sur Tab, pour quitter la zone. Ctrl+Enter exécute la requête. Chaque exécution part d’une nouvelle copie de la base de données d’exercice.
Afficher l’exemple de réponse
SELECT COUNT(*) AS eventsFROM outcomesWHERE ticket_id IN ( SELECT ticket_id FROM tickets WHERE created_at >= '2026-01-01' AND created_at < '2026-07-01');Le résultat est 1 869 : 1 874 − 4 événements des billets manquants T-9990 à T-9993 − 1 événement de T-10147.
Exercice guidé : construire extract.py
Section intitulée « Exercice guidé : construire extract.py »Construisez le script en trois versions. Exécutez chaque version avant d’ajouter la suite.
-
Créez les deux fichiers
queries/extract_tickets.sqletqueries/extract_outcomes.sql. Copiez-y le texte ci-dessus. -
Créez le fichier
scripts/extract.pyavec cette première version. Elle exécute une requête avec des paramètres et affiche le nombre de lignes.scripts/extract.py (version 1) """Extract tickets and outcomes from larkfield.db into CSV files, with a manifest."""import sqlite3from pathlib import Pathdef main():params = {"created_from": "2026-01-01", "created_before": "2026-07-01"}sql = Path("queries/extract_tickets.sql").read_text(encoding="utf-8")con = sqlite3.connect("file:larkfield.db?mode=ro", uri=True)rows = con.execute(sql, params).fetchall()con.close()print(f"{len(rows)} ticket rows")print(rows[0])if __name__ == "__main__":main()L’adresse
file:larkfield.db?mode=roavecuri=Trueouvre la base de données en lecture seule, comme le faitq.py. Les clés deparamssont les noms des paramètres sans le deux-points. -
Exécutez-le depuis le dossier du projet. Les chemins du script partent du dossier où vous l’exécutez.
Fenêtre de terminal python3 scripts/extract.pySortie 1416 ticket rows('T-10001', 'C-0228', '2026-01-05 07:30:24', 'email', 'returns', 2, 47.25, 134, 9, 2, '2026-01-06 17:58:45', 'home', 'north', '2025-10-11')Si vous voyez
sqlite3.ProgrammingError: You did not supply a value for binding parameter :created_before.(aucune valeur fournie pour le paramètre), une clé deparamsmanque ou s’écrit autrement. -
Remplacez tout le fichier par la version 2. La fonction
runexécute un fichier de requête et écrit un fichier CSV. Elle retourne les faits pour le manifeste : le fichier de requête, son SHA-256, le fichier de sortie et le nombre de lignes.scripts/extract.py (version 2) """Extract tickets and outcomes from larkfield.db into CSV files, with a manifest."""import csvimport hashlibimport sqlite3from pathlib import PathOUT = Path("data/extract")def run(con, query_file, params, out_file):sql = Path(query_file).read_text(encoding="utf-8")cursor = con.execute(sql, params)header = [d[0] for d in cursor.description]rows = cursor.fetchall()with open(out_file, "w", newline="", encoding="utf-8") as f:writer = csv.writer(f, lineterminator="\n")writer.writerow(header)writer.writerows(rows)return {"query": query_file, "query_sha256": hashlib.sha256(sql.encode()).hexdigest(),"file": str(out_file), "rows": len(rows)}def main():params = {"created_from": "2026-01-01", "created_before": "2026-07-01"}con = sqlite3.connect("file:larkfield.db?mode=ro", uri=True)OUT.mkdir(parents=True, exist_ok=True)files = [run(con, "queries/extract_tickets.sql", params, OUT / "tickets.csv"),run(con, "queries/extract_outcomes.sql", params, OUT / "outcomes.csv"),]con.close()for f in files:print(f)if __name__ == "__main__":main()Ligne Pourquoi cursor.descriptionLes noms des colonnes du résultat. Ils deviennent l’en-tête du CSV. lineterminator="\n"Les mêmes fins de ligne sur chaque ordinateur rendent le fichier identique, octet par octet, sur macOS et Windows. hashlib.sha256(sql.encode()).hexdigest()Le SHA-256 du texte de la requête, sous forme de 64 caractères hexadécimaux. OUT.mkdir(parents=True, exist_ok=True)Crée data/extract/s’il n’existe pas encore. -
Exécutez-le à nouveau :
Sortie {'query': 'queries/extract_tickets.sql', 'query_sha256': '28a0267e280062b0e02f9727cb5a95d2c8b60309e5fc992a578e44ba61b54338', 'file': 'data/extract/tickets.csv', 'rows': 1416}{'query': 'queries/extract_outcomes.sql', 'query_sha256': '533e9573c06975a463369aea503c4218c806640b99067d240607c1cea6fdc864', 'file': 'data/extract/outcomes.csv', 'rows': 1869}Vos SHA-256 sont identiques à ceux-ci seulement si vos fichiers de requête sont des copies exactes, commentaires et dernier saut de ligne compris. Si une valeur diffère, comparez les fichiers caractère par caractère. Le nombre de lignes doit être identique.
-
Remplacez tout le fichier par la version finale. Elle ajoute trois éléments : les dates comme options de ligne de commande, les faits de l’instantané et le manifeste. Les faits de l’instantané viennent de la base de données.
scripts/extract.py """Extract tickets and outcomes from larkfield.db into CSV files, with a manifest."""import argparseimport csvimport hashlibimport jsonimport sqlite3from datetime import datetime, timezonefrom pathlib import PathOUT = Path("data/extract")def run(con, query_file, params, out_file):sql = Path(query_file).read_text(encoding="utf-8")cursor = con.execute(sql, params)header = [d[0] for d in cursor.description]rows = cursor.fetchall()with open(out_file, "w", newline="", encoding="utf-8") as f:writer = csv.writer(f, lineterminator="\n")writer.writerow(header)writer.writerows(rows)return {"query": query_file, "query_sha256": hashlib.sha256(sql.encode()).hexdigest(),"file": str(out_file), "rows": len(rows)}def main():parser = argparse.ArgumentParser()parser.add_argument("--created-from", default="2026-01-01")parser.add_argument("--created-before", default="2026-07-01")args = parser.parse_args()params = {"created_from": args.created_from, "created_before": args.created_before}con = sqlite3.connect("file:larkfield.db?mode=ro", uri=True)snapshot = dict(con.execute("SELECT name, value FROM snapshot").fetchall())total = con.execute("SELECT COUNT(*) FROM tickets").fetchone()[0]OUT.mkdir(parents=True, exist_ok=True)files = [run(con, "queries/extract_tickets.sql", params, OUT / "tickets.csv"),run(con, "queries/extract_outcomes.sql", params, OUT / "outcomes.csv"),]con.close()manifest = {"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),"source": snapshot,"selection": {**params, "rule": "created_from <= created_at < created_before"},"ticket_rows_in_source": total,"ticket_rows_not_selected": total - files[0]["rows"],"files": files,}(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")for f in files:print(f"{f['file']}: {f['rows']} rows")print(f"Not selected: {manifest['ticket_rows_not_selected']} ticket row(s)")if __name__ == "__main__":main()Partie Pourquoi argparse--created-fromet--created-beforechangent la sélection sans modifier le code. Les valeurs par défaut couvrent tout le premier semestre de 2026.SELECT name, value FROM snapshotLa base de données consigne sa propre source et le moment de l’exportation. dict(...)transforme les trois lignes en dictionnaire.total - files[0]["rows"]Les lignes de billets exclues par la sélection. Un nombre différent de zéro doit avoir une raison. datetime.now(timezone.utc)Le moment de l’extraction en UTC, pour qu’il ait le même sens dans tous les pays. -
Exécutez la version finale :
Fenêtre de terminal python3 scripts/extract.pySortie data/extract/tickets.csv: 1416 rowsdata/extract/outcomes.csv: 1869 rowsNot selected: 1 ticket row(s) -
Ouvrez
data/extract/manifest.json. Votreextracted_atcorrespond à votre propre moment d’exécution. Tout le reste est identique à l’exemple, sauf les deux valeursquery_sha256si vos fichiers de requête diffèrent.data/extract/manifest.json {"extracted_at": "2026-10-07T03:08:46+00:00","source": {"source": "Larkfield help-desk export (synthetic, Nextia Learning C04)","exported_at": "2026-07-01 06:00:00","data_version": "1.0"},"selection": {"created_from": "2026-01-01","created_before": "2026-07-01","rule": "created_from <= created_at < created_before"},"ticket_rows_in_source": 1417,"ticket_rows_not_selected": 1,"files": [{"query": "queries/extract_tickets.sql","query_sha256": "28a0267e280062b0e02f9727cb5a95d2c8b60309e5fc992a578e44ba61b54338","file": "data/extract/tickets.csv","rows": 1416},{"query": "queries/extract_outcomes.sql","query_sha256": "533e9573c06975a463369aea503c4218c806640b99067d240607c1cea6fdc864","file": "data/extract/outcomes.csv","rows": 1869}]}
Le manifeste répond aux quatre questions de Priya. La source est l’exportation du 1er juillet 2026, version 1,0. La sélection a une règle et deux dates. Les empreintes des requêtes et le moment de l’extraction sont consignés. La ligne non sélectionnée est le billet de 2036. Les cellules vides des fichiers CSV sont des valeurs NULL dans la base de données. Le module 4 les charge avec soin.
À vous : extraire seulement mars
Section intitulée « À vous : extraire seulement mars »Changez une condition : extrayez seulement les billets de mars 2026. Prédisez d’abord le nombre de lignes de billets. Vous avez compté les billets de mars dans la dernière leçon.
-
Exécutez le script avec deux options :
Fenêtre de terminal python3 scripts/extract.py --created-from 2026-03-01 --created-before 2026-04-01 -
Lisez la sortie et les valeurs
selectionetticket_rows_not_selecteddans le manifeste. -
Exécutez à nouveau
python3 scripts/extract.pysans options pour rétablir l’extraction complète avant le prochain module.
Afficher le résultat attendu
data/extract/tickets.csv: 252 rowsdata/extract/outcomes.csv: 330 rowsNot selected: 1165 ticket row(s)252 lignes de billets, comme le compte de mars avec un intervalle semi-ouvert dans la dernière leçon. Le manifeste consigne "created_from": "2026-03-01" et "created_before": "2026-04-01", ainsi que 1 165 lignes non sélectionnées. Les deux valeurs query_sha256 ne changent pas, car les dates sont des paramètres, pas une partie du texte de la requête. Les fichiers de mars remplacent les fichiers précédents dans data/extract/. Sans le manifeste, personne ne pourrait savoir quelle sélection le dossier contient maintenant.
Erreur courante
Une requête avec une f-string échoue sur une apostrophe ou retourne toutes les lignes
Symptôme : Tomás construit une requête pour une équipe avec une f-string. Elle fonctionne pendant des semaines. Puis elle s’arrête avec une erreur pour une valeur. Pour une autre valeur, elle retourne les 1 417 lignes sans erreur.
Pour le voir, créez un fichier temporaire scripts/try_quote.py :
import sqlite3
con = sqlite3.connect("file:larkfield.db?mode=ro", uri=True)
for team in ["returns", "customer's account", "returns' OR '1'='1"]: print("value:", team) safe = con.execute("SELECT COUNT(*) FROM tickets WHERE team = :team", {"team": team}).fetchone()[0] print(" parameter:", safe) try: unsafe = con.execute(f"SELECT COUNT(*) FROM tickets WHERE team = '{team}'").fetchone()[0] print(" f-string: ", unsafe) except sqlite3.Error as error: print(" f-string: error:", type(error).__name__, error)
con.close()value: returns parameter: 327 f-string: 327value: customer's account parameter: 0 f-string: error: OperationalError near "s": syntax errorvalue: returns' OR '1'='1 parameter: 0 f-string: 1417Cause : la f-string insère la valeur dans le texte SQL. L’apostrophe dans customer's termine la chaîne SQL trop tôt. SQLite lit donc s account' comme du SQL et échoue. La troisième valeur est pire : ses apostrophes la transforment en code SQL, team = 'returns' OR '1'='1', vrai pour chaque ligne. Des attaquants utilisent cette méthode, appelée injection SQL, pour lire ou modifier des données auxquelles ils ne doivent pas accéder.
Correction : utilisez un paramètre pour chaque valeur : WHERE team = :team, et passez {"team": team}. Avec un paramètre, les deux valeurs inhabituelles restent du texte : aucune équipe ne porte ces noms, donc le compte est 0. Supprimez scripts/try_quote.py quand vous avez terminé.
Dans le travail en IA : les valeurs viennent souvent de l’extérieur. Par exemple, une date d’un planificateur, un nom de client, le texte d’un billet ou la sortie d’un modèle de langage. Chacune peut contenir une apostrophe. Un paramètre garde chacune comme une valeur.
Vérifiez votre compréhension
Vous exécutez extract.py pour janvier, puis pour juin. Les valeurs query_sha256 des deux manifestes diffèrent-elles? Pourquoi?
Afficher la réponse
Non. Le script calcule le SHA-256 à partir du texte du fichier de requête, et les dates ne figurent pas dans ce texte. Ce sont des paramètres, passés séparément. La partie selection du manifeste montre les différentes dates. Supposons qu’une f-string insère les dates dans le texte de la requête. Chaque sélection aurait alors une empreinte différente, et vous ne pourriez pas voir si la logique de la requête a changé.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Le manifeste indique ticket_rows_not_selected : 1. Pourquoi ce nombre figure-t-il dans le manifeste, et que devez-vous en faire?
Afficher la réponse
Il rend visibles les lignes exclues par la sélection. Une extraction qui exclut des lignes sans le signaler ressemble à une extraction qui n’en exclut aucune. Chaque ligne exclue doit avoir une raison : ici, T-10147 a un created_at en 2036, hors de l’intervalle. Si le nombre change lors d’une extraction ultérieure avec les mêmes dates, les données sources ont changé.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Exercice du module
Section intitulée « Exercice du module »La tâche prévue pour ce module : extraire un seul résultat le plus récent par billet, sans duplication accidentelle.
Exemple détaillé : d’où viennent les doublons
Section intitulée « Exemple détaillé : d’où viennent les doublons »Mei commence avec trois billets de la dernière leçon. L’étape de fenêtrage numérote les événements de chaque billet, et le filtre rn = 1 garde une ligne par billet :
| ticket_id | événements | dernier événement (rn = 1) | status |
|---|---|---|---|
| T-10120 | 2 | O-00246 | resolved |
| T-10135 | 1 | O-00182 | resolved |
| T-10136 | 3 (deux à la même seconde) | O-00229 | resolved |
Trois billets, trois lignes. Dans les vraies données, deux situations produisent plus de lignes que de billets, sans avertissement :
-
Les égalités. Un
ORDER BYsansoutcome_iddonne 1 432 lignes avecRANK(), pour 1 399 billets. -
Une jointure avec les lignes de billets répétées. Mei joint les derniers événements (1 399 lignes) à
ticketspour garder seulement les billets de l’exportation :WITH ranked AS (SELECT ticket_id, outcome_id, recorded_at, status,ROW_NUMBER() OVER (PARTITION BY ticket_idORDER BY recorded_at DESC, outcome_id DESC) AS rnFROM outcomes),latest AS (SELECT * FROM ranked WHERE rn = 1)SELECT COUNT(*) AS result_rows, COUNT(DISTINCT t.ticket_id) AS ticketsFROM tickets AS tJOIN latest AS lON l.ticket_id = t.ticket_id;result_rows tickets 1409 1395 Les 14 billets présents deux fois dans
ticketscorrespondent chacun deux fois à leur dernier événement. La jointure a aussi retiré les 4 événements de billets absents detickets(1 399 → 1 395 billets).
La correction consiste à joindre une liste qui contient une ligne par billet : SELECT DISTINCT ticket_id FROM tickets. La vérification devient alors : lignes du résultat = billets distincts = billets qui ont des événements. Dans cet instantané, ce nombre est 1 395 : 1 403 billets distincts, moins les 8 billets sans événement.
Votre tâche
Section intitulée « Votre tâche »Écrivez une requête qui retourne une ligne par billet de l’exportation ayant au moins un événement de résultat. Retournez quatre colonnes du dernier événement : ticket_id, outcome_id, recorded_at et status. Départagez les égalités avec outcome_id.
Exécutez du SQL dans votre navigateur
Exercice du module : un seul résultat le plus récent par billet
L’encadré affiche les 50 premières lignes et le nombre total. Le total doit être 1 395. La vérification compare chaque ligne à la nôtre. Un mauvais choix en cas d’égalité (par exemple, reopened (rouvert) pour T-10136) échoue donc même si le compte est correct.
Tab ajoute quatre espaces. Appuyez sur Esc, puis sur Tab, pour quitter la zone. Ctrl+Enter exécute la requête. Chaque exécution part d’une nouvelle copie de la base de données d’exercice.
Faites ensuite la même chose sur votre ordinateur et enregistrez aussi les vérifications :
- Enregistrez votre requête dans
queries/35-latest-outcome.sql, puis exécutezpython3 q.py queries/35-latest-outcome.sql. La dernière ligne doit être1395 rows (showing 20; add --all to see every row)(1 395 lignes, dont 20 affichées; ajoutez –all pour toutes les voir). - Créez
queries/36-latest-outcome-check.sql. Ce fichier affiche trois nombres : les lignes du résultat, les billets distincts du résultat et les billets de l’exportation ayant des événements. - Exécutez-le. Les trois nombres doivent être égaux.
Afficher la réponse modèle
-- One latest outcome event per ticket in the export.WITH unique_tickets AS ( SELECT DISTINCT ticket_id FROM tickets),ranked AS ( SELECT o.ticket_id, o.outcome_id, o.recorded_at, o.status, ROW_NUMBER() OVER ( PARTITION BY o.ticket_id ORDER BY o.recorded_at DESC, o.outcome_id DESC ) AS rn FROM outcomes AS o JOIN unique_tickets AS u ON u.ticket_id = o.ticket_id)SELECT ticket_id, outcome_id, recorded_at, statusFROM rankedWHERE rn = 1ORDER BY ticket_id;ticket_id outcome_id recorded_at status--------- ---------- ------------------- --------T-10001 O-00005 2026-01-06 17:58:45 resolvedT-10002 O-00001 2026-01-05 15:29:21 resolvedT-10003 O-00075 2026-01-13 18:56:26 resolved...
1395 rows (showing 20; add --all to see every row)-- Checks for 35-latest-outcome.sql: all three numbers must be equal.WITH unique_tickets AS ( SELECT DISTINCT ticket_id FROM tickets),ranked AS ( SELECT o.ticket_id, o.outcome_id, o.recorded_at, o.status, ROW_NUMBER() OVER ( PARTITION BY o.ticket_id ORDER BY o.recorded_at DESC, o.outcome_id DESC ) AS rn FROM outcomes AS o JOIN unique_tickets AS u ON u.ticket_id = o.ticket_id),latest AS ( SELECT ticket_id, outcome_id, recorded_at, status FROM ranked WHERE rn = 1)SELECT (SELECT COUNT(*) FROM latest) AS latest_rows, (SELECT COUNT(DISTINCT ticket_id) FROM latest) AS distinct_tickets, (SELECT COUNT(*) FROM unique_tickets WHERE ticket_id IN (SELECT ticket_id FROM outcomes)) AS tickets_with_events;latest_rows distinct_tickets tickets_with_events----------- ---------------- -------------------1395 1395 1395
1 rowSur les 1 395 derniers événements, 1 391 sont resolved (résolus), 3 sont escalated (transmis au niveau supérieur) et 1 est reopened. La jointure avec unique_tickets exclut les 4 événements de billets manquants. DISTINCT empêche les 14 lignes de billets répétées de doubler les événements. outcome_id DESC départage les 33 égalités : l’événement consigné en dernier l’emporte.
Vérification du module
Section intitulée « Vérification du module »Vérifiez votre compréhension
Rappel : quelle est la différence entre une CTE et une fonction de fenêtre?
Afficher la réponse
Une CTE (WITH name AS (...)) nomme un résultat intermédiaire pour permettre d’écrire et de vérifier une requête par étapes. Une fonction de fenêtre (... OVER (PARTITION BY ... ORDER BY ...)) ajoute une valeur à chaque ligne à partir d’un groupe de lignes liées. Elle conserve toutes les lignes. Les deux se combinent : une fonction de fenêtre calcule rn dans une CTE, puis l’étape suivante filtre avec rn = 1.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Prédiction : dans votre requête du dernier résultat, vous remplacez ROW_NUMBER() par RANK() et retirez outcome_id DESC. Combien de lignes la requête renvoie-t-elle, et quelle vérification détecte le problème?
Afficher la réponse
Plus de 1 395 lignes. Pour 33 billets de soutien, plusieurs événements partagent l’heure la plus récente. RANK() donne donc deux lignes de rang 1 pour chacun de ces billets. Pour les billets de l’exportation, cela donne 1 428 lignes. La vérification « lignes du résultat = billets distincts » détecte le problème : 1 428 lignes, mais 1 395 billets distincts.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Vérification finale : expliquez comment votre requête départage les égalités. Montrez ensuite que votre extraction donne la même sélection sur l'instantané fixe.
Afficher la réponse
Égalités. Quand deux événements d’un billet ont la même valeur recorded_at, la requête les trie selon outcome_id DESC. Le service de soutien attribue les identifiants de résultat dans l’ordre d’enregistrement. Le dernier événement enregistré l’emporte donc. Pour T-10136, resolved (résolu, O-00229) l’emporte sur reopened (rouvert, O-00228), tous deux enregistrés le 28 janvier 2026 à 19 h 46 min 52 s. Sans cette règle, RANK() conserve les deux lignes et ROW_NUMBER() conserve une ligne choisie par la base de données.
Reproduire. Exécutez python3 scripts/extract.py deux fois. Comparez les deux manifestes : extracted_at change, et tout le reste demeure identique. Comparez ensuite les empreintes SHA-256 des fichiers CSV avec les nôtres :
shasum -a 256 data/extract/tickets.csv data/extract/outcomes.csvsha256sum data/extract/tickets.csv data/extract/outcomes.csvGet-FileHash data\extract\tickets.csv, data\extract\outcomes.csv96786af9e2ebb2492219867ac302f57fabdefb4c53fb60a1071d9412e70e6f39 data/extract/tickets.csv60c828f37db48a01ac6f003f5879a2c3ed5254f2c0e12816f814ed661c4067b4 data/extract/outcomes.csvPowerShell affiche les mêmes valeurs en majuscules. Les comptes doivent être de 1 416 lignes de billets et de 1 869 événements de résultat. Ils proviennent de l’instantané exporté à 2026-07-01 06:00:00, version 1.0. Les empreintes des fichiers dépendent seulement de l’instantané, de la sélection et des résultats des requêtes. Elles correspondent donc aux nôtres même si les commentaires de vos requêtes diffèrent.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Récapitulatif
Dans ce module, vous avez rendu vos requêtes lisibles, correctes dans le temps et reproductibles :
- Les CTE divisent une requête en étapes nommées. Comptez les lignes à chaque étape et sachez quel compte ne doit pas changer.
- Dans SQLite, les heures sont du texte. Vérifiez d’abord
MINetMAX, puis filtrez avec des intervalles semi-ouverts. - Les fonctions de fenêtre numérotent, classent et comparent les lignes dans les partitions. Pour obtenir le « dernier résultat par billet », départagez les égalités avec
outcome_id. Vérifiez que le nombre de lignes égale le nombre de billets distincts. - Les paramètres nommés gardent les valeurs hors du texte SQL : pas de guillemets mal fermés, pas d’injection. L’empreinte de la requête reste identique pour chaque sélection.
scripts/extract.pyécrit l’extraction et un manifeste : l’instantané, la sélection, les valeurs SHA-256 des requêtes et les comptes de lignes. Exécutez-le deux fois : les fichiers sont identiques.
Ensuite, le module 4 profile les données extraites avant toute modification. Il examine les valeurs manquantes, les lignes répétées, les catégories mal orthographiées et les valeurs dans la mauvaise unité.
Leçon suivante : Profiler avant de modifier