Pourquoi le SQL d’un modèle doit seulement lire
Lancer le SQL d’un modèle sans danger, c’est lui permettre de lire sans jamais rien changer. Un modèle peut écrire DELETE ou DROP par erreur, ou parce que la question d’un utilisateur l’a piégé.
Le module 4 enseigne à changer des données exprès. Cette leçon place trois verrous devant la requête d’un modèle : une garde, des limites et une connexion en lecture seule.
À la fin de cette leçon
Remarque
Je peux lancer la requête d’un modèle pour qu’elle ne fasse que lire, un peu à la fois.
Mots clés
| Mot | Ce que ça veut dire | Exemple |
|---|---|---|
| Garde | du code qui refuse une requête avant qu’elle tourne | |
| Instruction | une commande SQL complète | SELECT titre FROM livres |
| Limite de lignes | le plus grand nombre de lignes qu’une requête peut renvoyer | |
| Délai maximal | le plus long temps qu’une requête peut tourner | |
| Lecture seule | pouvoir lire des lignes sans jamais les changer |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
N’accepter qu’un SELECT, et rien d’autre
La garde range la requête et retire un point-virgule final. S’il reste un point-virgule, il y a une seconde instruction : elle refuse.
Le premier mot doit être SELECT ou WITH. Tout mot d’écriture, comme DELETE, DROP ou UPDATE, est refusé où qu’il soit. Un commentaire aussi, car il peut cacher du texte.
Exemple détaillé : ce que dit la garde
| Requête | Verdict | Raison |
|---|---|---|
| SELECT titre FROM livres WHERE annee > 2020 | Acceptée | Un seul SELECT |
| SELECT titre FROM livres; DROP TABLE prets | Refusée | Plus d’une instruction |
| DELETE FROM prets WHERE rendu = 1 | Refusée | Pas un SELECT |
| SELECT titre FROM livres -- note | Refusée | Un commentaire |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Une limite de lignes et de temps
Une requête juste peut quand même renvoyer un million de lignes ou tourner sans fin. Entourez-la ainsi : SELECT * FROM (la requête) LIMIT 5. Au plus cinq lignes reviennent.
Lancez-la dans un processus à part, avec un délai maximal. Quand le temps est écoulé, le processus est arrêté et la page répond au lieu de rester bloquée.
La lecture seule est le dernier verrou
Une garde contrôle des mots, et des mots peuvent passer à travers. Ouvrez donc la base en lecture seule, ou connectez-vous avec un compte qui n’a droit qu’à SELECT.
Ne lancez jamais le SQL d’un modèle sur des données réelles avec un droit d’écriture. Utilisez une copie en lecture seule : le pire qu’une mauvaise requête puisse faire, c’est être lente.
Une erreur courante
Attention
Beaucoup pensent qu’une garde qui contrôle les mots suffit. Une garde a des trous, car le SQL a mille façons de dire une chose. Gardez aussi la connexion en lecture seule : une requête ratée ne pourra toujours pas écrire.
Écrire l’assistant du modèle, partie 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '// Les jetons d’une réponse, ou null si elle n’en donne pas.' 'function compter(donnees, nom) {' ' return donnees.usage && typeof donnees.usage[nom] === \"number\" ? donnees.usage[nom] : null;' '}' '// Une réponse peut entourer la requête d’un bloc délimité : on garde l’intérieur.' 'function sqlDe(texte) {' ' const morceaux = texte.split(String.fromCharCode(96).repeat(3));' ' const dedans = (morceaux.length > 2 ? morceaux[1] : texte).trim();' ' return dedans.toLowerCase().startsWith(\"sql\") ? dedans.slice(3).trim() : dedans;' '}' > model.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire l’assistant du modèle, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function demander(messages, echantillon, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", {' ' method: \"POST\",' ' headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' ' body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 100 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"statut \" + res.status);' ' const d = await res.json();' ' return { texte: d.choices[0].message.content, entree: compter(d, \"prompt_tokens\"), sortie: compter(d, \"completion_tokens\"), enregistre: false };' ' } catch (erreur) {' ' console.log(\"Échantillon enregistré : aucun modèle n’a répondu (\" + erreur.message + \").\");' ' return { texte: echantillon, entree: null, sortie: null, enregistre: true };' ' }' '}' 'module.exports = { demander, sqlDe };' >> model.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire la base de la médiathèque, partie 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { DatabaseSync } = require(\"node:sqlite\");' '// Une médiathèque inventée : six livres, quatre membres, neuf prêts.' 'const LIVRES = [[1, \"Le Phare des oranges\", 2019], [2, \"Port de verre\", 2021], [3, \"Carte de la pluie\", 2015],' ' [4, \"Moteurs tranquilles\", 2023], [5, \"Sept Bouilloires\", 2018], [6, \"Au nord de midi\", 2022]];' 'const MEMBRES = [[1, \"Amina\"], [2, \"Bruno\"], [3, \"Chloé\"], [4, \"Driss\"]];' '// id, livre_id, membre_id, mois, rendu (1 rendu, 0 encore dehors)' 'const PRETS = [[1, 1, 1, 3, 1], [2, 2, 1, 3, 1], [3, 3, 2, 3, 0], [4, 1, 3, 3, 1], [5, 1, 1, 4, 0],' ' [6, 4, 2, 4, 0], [7, 2, 3, 4, 1], [8, 5, 2, 5, 0], [9, 2, 4, 5, 0]];' > mediatheque.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire la base de la médiathèque, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function remplir(db, table, lignes) {' ' const marques = lignes[0].map(function () { return \"?\"; }).join(\", \");' ' const inserer = db.prepare(\"INSERT INTO \" + table + \" VALUES (\" + marques + \")\");' ' lignes.forEach(function (ligne) { inserer.run(...ligne); });' '}' 'function ouvrir(fichier) {' ' const db = new DatabaseSync(fichier || \":memory:\");' ' db.exec(\"CREATE TABLE livres (id INTEGER PRIMARY KEY, titre TEXT, annee INTEGER)\");' ' db.exec(\"CREATE TABLE membres (id INTEGER PRIMARY KEY, nom TEXT)\");' ' db.exec(\"CREATE TABLE prets (id INTEGER PRIMARY KEY, livre_id INTEGER REFERENCES livres(id), \"' ' + \"membre_id INTEGER REFERENCES membres(id), mois INTEGER, rendu INTEGER)\");' ' remplir(db, \"livres\", LIVRES);' ' remplir(db, \"membres\", MEMBRES);' ' remplir(db, \"prets\", PRETS);' ' return db;' '}' 'module.exports = { ouvrir };' >> mediatheque.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le lanceur en lecture seule
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { DatabaseSync } = require(\"node:sqlite\");' '// Ouverte en lecture seule : même une requête ratée par la garde ne peut changer aucune ligne.' 'const db = new DatabaseSync(\"mediatheque.db\", { readOnly: true });' 'try {' ' const lignes = db.prepare(process.argv[2]).all().map(function (r) { return Object.values(r); });' ' console.log(\"lignes \" + lignes.length + \" : \" + JSON.stringify(lignes));' '} catch (erreur) { console.log(\"erreur : \" + erreur.message); }' > lancer.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le script de la garde, partie 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { demander, sqlDe } = require(\"./model\");' 'const { ouvrir } = require(\"./mediatheque\");' 'const fs = require(\"node:fs\");' 'const { spawnSync } = require(\"node:child_process\");' 'fs.rmSync(\"mediatheque.db\", { force: true });' 'ouvrir(\"mediatheque.db\").close();' 'const INTERDITS = [\"INSERT\", \"UPDATE\", \"DELETE\", \"DROP\", \"ALTER\", \"CREATE\", \"ATTACH\", \"PRAGMA\", \"REPLACE\"];' 'const LIMITE = 5;' > garde.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le script de la garde, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function garde(sql) {' ' let texte = sql.trim();' ' if (texte.endsWith(\";\")) texte = texte.slice(0, -1).trim();' ' if (texte.indexOf(\";\") >= 0) return { ok: false, raison: \"plus d’une instruction\" };' ' if (texte.indexOf(\"--\") >= 0 || texte.indexOf(\"/*\") >= 0) return { ok: false, raison: \"un commentaire\" };' ' const mots = texte.toUpperCase().split(/[^A-Z_]+/).filter(function (m) { return m.length > 0; });' ' if ([\"SELECT\", \"WITH\"].indexOf(mots[0]) < 0) return { ok: false, raison: \"pas un SELECT\" };' ' const mauvais = mots.filter(function (m) { return INTERDITS.indexOf(m) >= 0; });' ' if (mauvais.length) return { ok: false, raison: \"mot interdit \" + mauvais[0] };' ' return { ok: true, sql: \"SELECT * FROM (\" + texte + \") LIMIT \" + LIMITE };' '}' >> garde.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le script de la garde, partie 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function lancerUne(sql) {' ' const res = spawnSync(process.execPath, [\"--experimental-sqlite\", \"--no-warnings\", \"lancer.js\", sql],' ' { encoding: \"utf8\", timeout: 2000 });' ' return res.error ? \"arrêtée : plus de 2 secondes\" : res.stdout.trim();' '}' 'function lancer(sql) {' ' const g = garde(sql);' ' return g.ok ? lancerUne(g.sql) : \"refusée : \" + g.raison;' '}' 'const N = \"WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n\";' >> garde.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le script de la garde, partie 4
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' const essais = [\"SELECT titre FROM livres WHERE annee > 2020\", \"SELECT titre FROM livres; DROP TABLE prets\",' ' \"DELETE FROM prets WHERE rendu = 1\", N + \") SELECT COUNT(*) FROM n\",' ' N + \" WHERE i < 1000) SELECT i FROM n\"];' ' essais.forEach(function (sql, i) { console.log((i + 1) + \" \" + lancer(sql)); });' ' console.log(\"6 sans garde, fichier en lecture seule : \" + lancerUne(\"DELETE FROM prets\"));' ' await direct();' '}' >> garde.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le script de la garde, partie 5
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function direct() {' ' const q = \"livres(id, titre, annee)\nQuestion : Quels livres sont parus après 2020 ?\";' ' const echantillon = \"SELECT titre FROM livres WHERE annee > 2020;\";' ' const reponse = await demander([{ role: \"system\", content: \"Répondez par une seule requête SELECT pour SQLite, et rien d’autre.\" },' ' { role: \"user\", content: q }], echantillon, 120);' ' const sql = sqlDe(reponse.texte);' ' console.log(\"le modèle a écrit : \" + sql);' ' console.log(\" passée par la garde : \" + lancer(sql));' '}' 'main();' >> garde.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Faire passer six requêtes par les verrous
docker exec -w /home/node/project agent node --experimental-sqlite --no-warnings garde.jsAffichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Ce que le Run affiche
| Ligne | Ce qu’elle montre |
|---|---|
| 1 lignes 3 : [["Port de verre"],["Moteurs tranquilles"],["Au nord de midi"]] | Une requête acceptée |
| 2 refusée : plus d’une instruction | Un DROP caché |
| 3 refusée : pas un SELECT | Une écriture |
| 4 arrêtée : plus de 2 secondes | Une requête qui ne finit jamais |
| 5 lignes 5 : [[1],[2],[3],[4],[5]] | 1 000 lignes ramenées à cinq |
| 6 sans garde, fichier en lecture seule : erreur : attempt to write a readonly database | Le dernier verrou tient |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.