Leçon 9/12 15 min

Projet 9 : explorer une base SQL avec l'IA

TD corrigé : une vraie base SQLite dans le navigateur (sql.js), construite avec l'IA. Les vrais prompts, et la relecture qui bloque l'injection SQL avec des requêtes paramétrées.

FR EN

Le projet : du vrai SQL, sans serveur

Neuvième projet, et une surprise : on va faire tourner une vraie base de données SQL… directement dans le navigateur, sans aucun serveur. La magie s'appelle sql.js : c'est SQLite (le moteur de base le plus répandu au monde) compilé en WebAssembly. On crée des tables, on écrit des requêtes, on a des résultats — comme une vraie base. Notre mini-app : une recherche de clients qui affiche, pour chacun, son nombre de commandes (une jointure SQL).

C'est aussi le projet idéal pour le réflexe de sécurité le plus connu du métier : l'injection SQL. Dès qu'une saisie utilisateur entre dans une requête, l'IA prend le chemin dangereux par défaut. On code vite, on relit lentement.

Rappel honnête sur la méthode : on montre l'échange en 2 prompts pour rester clair. En vrai, sur un projet comme celui-ci, tu ferais bien plus d'allers-retours (charger la lib, tester une requête, corriger un message…). C'est normal. Ici on garde la version au propre.

Prompt 1 : poser le cadre

On cadre : charger sql.js, créer une petite base, et une recherche par nom de client.

Crée une mini-app web autonome (un fichier HTML) qui charge sql.js depuis un CDN, crée une base SQLite avec une table clients et une table commandes (quelques lignes), et propose un champ pour chercher un client par son nom. Affiche les résultats (nom, ville, nombre de commandes) dans un tableau. Bilingue FR/EN. Code simple et lisible.

L'IA met la base en place et code la recherche de la façon la plus naturelle… et la plus dangereuse :

function search(q) {
  // ⚠️ on colle la saisie directement dans la requête
  var sql = "SELECT * FROM clients WHERE nom LIKE '%" + q + "%'";
  var res = db.exec(sql);
  panel.innerHTML = renderRows(res);     // ⚠️ innerHTML avec des données de la base
}

Ça marche pour une recherche normale. Mais tape ' OR '1'='1 dans le champ… et la requête part en vrille : la condition devient toujours vraie. C'est l'injection SQL, la faille n°1 des applis qui parlent à une base.

Ma relecture humaine : 3 trucs que l'IA a laissés passer

Une base de données, c'est puissant et donc dangereux : une requête mal construite peut tout lire, tout casser. Voici les trois points.

1. Injection SQL : ne jamais coller la saisie dans la requête

Construire une requête en collant du texte saisi ("... '%" + q + "%'"), c'est laisser l'utilisateur écrire une partie du SQL. Avec la bonne saisie, il contourne la condition, lit des données qu'il ne devrait pas, voire supprime tout. La parade universelle, c'est la requête paramétrée : on met un emplacement (:q) dans la requête, et on confie la valeur au moteur séparément, via bind(). La saisie est alors traitée comme une donnée, jamais comme du code. Injection impossible.

2. Les résultats viennent de la base : on les échappe

Les valeurs renvoyées par la base s'affichent dans le tableau. Les injecter avec innerHTML, c'est le même risque XSS que d'habitude (une donnée stockée peut contenir du HTML). On construit les cellules avec createElement et textContent. Le réflexe « données qu'on ne contrôle pas » s'applique aussi à sa propre base.

3. Charger une dépendance lourde, proprement

sql.js, ce n'est pas un petit fichier : c'est ~1 Mo de WebAssembly téléchargé depuis un CDN. Donc on affiche un état « chargement… » pendant ce temps, et on gère le cas où le CDN ne répond pas (un message clair, pas un écran mort). Et une vérité à garder en tête : chaque dépendance ajoutée est une nouvelle surface à relire (poids, disponibilité, sécurité). Ici elle est justifiée — sans elle, pas de SQL dans le navigateur — mais on l'ajoute en connaissance de cause.

Prompt 2 : durcir après relecture

Corrige : (1) utilise une requête paramétrée (db.prepare + bind avec un emplacement :q) au lieu de coller la saisie dans le SQL. (2) Construis le tableau de résultats avec createElement + textContent, pas innerHTML. (3) Affiche un état « chargement » pendant le chargement de sql.js et un message d'erreur clair si la librairie ne se charge pas.
// requête PARAMÉTRÉE : :q est un emplacement, jamais la valeur elle-même
var stmt = db.prepare(
  "SELECT c.nom, c.ville, COUNT(o.id) AS commandes " +
  "FROM clients c LEFT JOIN commandes o ON o.client_id = c.id " +
  "WHERE c.nom LIKE :q GROUP BY c.id ORDER BY c.nom"
);
stmt.bind({ ':q': '%' + q + '%' });   // la saisie est fournie à part, traitée comme une donnée

var rows = [];
while (stmt.step()) { rows.push(stmt.getAsObject()); }
stmt.free();

Le fil rouge de toute la série atteint ici sa forme la plus pure : la frontière entre « code » et « donnée » est sacrée. Une injection (SQL, HTML…), c'est toujours une donnée qu'on a laissée se faire passer pour du code. Les requêtes paramétrées et textContent tracent cette frontière. Garde-la nette, partout.

Héberger et tester

  • Fichier statique, en ligne d'un dépôt (sql.js est chargé depuis le CDN par le navigateur).
  • Recherche un nom existant, puis tape ' OR '1'='1 : avec la requête paramétrée, ça doit être traité comme un simple texte (et ne rien renvoyer), pas contourner la recherche.
  • Laisse le champ vide : tous les clients s'affichent, avec leur nombre de commandes (la jointure marche).
  • Coupe ta connexion et recharge : un message clair doit apparaître, pas un écran figé.
  • Bascule FR / EN, console à zéro.

Le rendu final

Ouvrir le projet en plein écran

Le code complet (et téléchargeable)

Le fichier entier, exactement celui qui tourne au-dessus.

Télécharger le code (.html · 237 lignes)

Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Explorer une base SQL</title>
<meta name="description" content="Une vraie base SQLite dans le navigateur. Cherche un client, vois ses commandes. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  EXPLORER UNE BASE SQL — un fichier HTML + une librairie chargée du CDN (sql.js).
    1. <style>  : l'apparence (champ de recherche, tableau de résultats, états).
    2. <body>   : la structure (recherche + zone de résultats).
    3. <script> : charger sql.js (SQLite en WebAssembly), créer une base, chercher.
  Particularité : SQL côté navigateur, SANS serveur. On charge UNE librairie
  externe (sql.js) depuis un CDN — donc on gère son chargement (et son échec).
  Point clé du projet : la recherche utilise une REQUÊTE PARAMÉTRÉE, jamais une
  requête bricolée par collage de texte — c'est ce qui empêche l'injection SQL.
  ============================================================================
-->

<!-- La librairie sql.js (SQLite compilé en WebAssembly), chargée depuis un CDN. -->
<script src="https://cdn.jsdelivr.net/npm/sql.js@1.10.3/dist/sql-wasm.js"></script>

<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
  * { box-sizing:border-box; }
  html,body { margin:0; padding:0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:620px; }
  header { text-align:center; margin-bottom:18px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
  .lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  .search { display:flex; gap:10px; margin-bottom:8px; }
  .search input { flex:1; min-height:50px; padding:0 16px; border:1.5px solid var(--border); border-radius:12px; font:inherit; font-size:1rem; color:var(--ink); }
  .search input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
  .btn { min-height:50px; padding:0 22px; border-radius:12px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
  .btn-primary { background:var(--accent); color:#fff; }
  .btn-primary:hover { background:#1f6a37; }
  .btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
  .hint { color:var(--muted); font-size:0.82rem; margin:0 0 18px; }

  .panel { background:#fff; border:1px solid var(--border); border-radius:14px; padding:8px; min-height:120px; }
  table { width:100%; border-collapse:collapse; }
  th, td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--border); font-size:0.92rem; }
  th { font-size:0.72rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--muted); }
  tr:last-child td { border-bottom:0; }
  td.num { text-align:right; font-variant-numeric:tabular-nums; }
  .state { text-align:center; color:var(--muted); padding:34px 12px; }
  .state.error { color:#a8341f; }

  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
  .credit a { color:var(--accent); }
  @media (prefers-reduced-motion: reduce){ * { transition:none; } }
</style>
</head>
<body>

<main class="app">
  <header>
    <h1 data-i18n="title">Explorer une base SQL</h1>
    <p class="sub" data-i18n="sub">Une vraie base SQLite, dans ton navigateur. Cherche un client par son nom.</p>
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <form class="search" id="form">
    <input id="q" type="text" maxlength="40" data-i18n-ph="ph" placeholder="Nom du client (essaie « a », ou laisse vide)" aria-label="Nom du client">
    <button class="btn btn-primary" type="submit" data-i18n="go">Chercher</button>
  </form>
  <p class="hint" data-i18n="hint">Recherche dans la table « clients » et compte leurs commandes (jointure SQL).</p>

  <!-- aria-live : un lecteur d'écran annonce les résultats quand ils changent. -->
  <div class="panel" id="panel" aria-live="polite"></div>

  <p class="credit" data-i18n="credit">Base de démo (SQLite via sql.js) · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a></p>
</main>

<script>
(function () {
  'use strict';

  /* ---------------------------------------------------------------------------
     1. LES TEXTES (FR / EN).
     --------------------------------------------------------------------------- */
  var UI = {
    fr: { title:"Explorer une base SQL", sub:"Une vraie base SQLite, dans ton navigateur. Cherche un client par son nom.",
      go:"Chercher", ph:"Nom du client (essaie « a », ou laisse vide)",
      hint:"Recherche dans la table « clients » et compte leurs commandes (jointure SQL).",
      loading:"Chargement de la base…", loaderr:"Impossible de charger la base. Vérifie ta connexion et recharge.",
      none:"Aucun client trouvé.", col_nom:"Nom", col_ville:"Ville", col_cmd:"Commandes",
      credit:'Base de démo (SQLite via sql.js) · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>' },
    en: { title:"Explore a SQL database", sub:"A real SQLite database, in your browser. Search a client by name.",
      go:"Search", ph:"Client name (try \"a\", or leave empty)",
      hint:"Searches the \"clients\" table and counts their orders (SQL join).",
      loading:"Loading the database…", loaderr:"Couldn't load the database. Check your connection and reload.",
      none:"No client found.", col_nom:"Name", col_ville:"City", col_cmd:"Orders",
      credit:'Demo database (SQLite via sql.js) · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course' }
  };

  var panel = document.getElementById('panel');
  var form = document.getElementById('form');
  var qInput = document.getElementById('q');

  var lang = 'fr';
  try { lang = localStorage.getItem('sql-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';

  var db = null;   // la base sera prête après le chargement de sql.js

  function showState(msg, isError) {
    panel.innerHTML = '';
    var d = document.createElement('div');
    d.className = 'state' + (isError ? ' error' : '');
    d.textContent = msg;
    panel.appendChild(d);
  }

  /* ---------------------------------------------------------------------------
     2. CHARGER LA LIBRAIRIE + CRÉER LA BASE.
        sql.js est une dépendance externe : on gère son chargement ET son échec.
     --------------------------------------------------------------------------- */
  showState(UI[lang].loading, false);

  // Si le script du CDN n'a pas pu se charger, initSqlJs n'existe pas.
  if (typeof initSqlJs === 'undefined') {
    showState(UI[lang].loaderr, true);
  } else {
    initSqlJs({ locateFile: function (f) { return 'https://cdn.jsdelivr.net/npm/sql.js@1.10.3/dist/' + f; } })
      .then(function (SQL) {
        db = new SQL.Database();
        // On crée deux tables et quelques lignes de démonstration.
        db.run(
          "CREATE TABLE clients (id INTEGER PRIMARY KEY, nom TEXT, ville TEXT);" +
          "INSERT INTO clients (id, nom, ville) VALUES " +
            "(1,'Alice Durand','Besançon'),(2,'Karim Benali','Lyon')," +
            "(3,'Marie Lefévre','Besançon'),(4,'Tom Garnier','Genève'),(5,'Ana Costa','Lyon');" +
          "CREATE TABLE commandes (id INTEGER PRIMARY KEY, client_id INTEGER, montant REAL);" +
          "INSERT INTO commandes (id, client_id, montant) VALUES " +
            "(1,1,50),(2,1,30),(3,2,80),(4,3,20),(5,3,45),(6,3,15),(7,5,90);"
        );
        search('');   // on affiche tout au départ
      })
      .catch(function () { showState(UI[lang].loaderr, true); });
  }

  /* ---------------------------------------------------------------------------
     3. CHERCHER — avec une REQUÊTE PARAMÉTRÉE (le point clé du projet).
        On n'écrit JAMAIS la valeur tapée directement dans la requête. On met un
        emplacement « :q » et on confie la valeur à SQLite via bind(). La saisie est
        traitée comme une DONNÉE, jamais comme du code SQL : pas d'injection possible.
     --------------------------------------------------------------------------- */
  function search(q) {
    if (!db) return;
    var sql =
      "SELECT c.nom, c.ville, COUNT(o.id) AS commandes " +
      "FROM clients c LEFT JOIN commandes o ON o.client_id = c.id " +
      "WHERE c.nom LIKE :q " +          // :q est un emplacement, pas la valeur
      "GROUP BY c.id ORDER BY c.nom";

    var stmt = db.prepare(sql);
    stmt.bind({ ':q': '%' + q + '%' });   // la valeur est fournie à part, en toute sécurité

    var rows = [];
    while (stmt.step()) { rows.push(stmt.getAsObject()); }
    stmt.free();

    render(rows);
  }

  /* ---------------------------------------------------------------------------
     4. AFFICHER LES RÉSULTATS — en construisant un tableau, valeurs échappées.
     --------------------------------------------------------------------------- */
  function render(rows) {
    panel.innerHTML = '';
    if (!rows.length) { showState(UI[lang].none, false); return; }

    var t = UI[lang];
    var table = document.createElement('table');
    var thead = document.createElement('thead');
    thead.innerHTML = '<tr><th>' + t.col_nom + '</th><th>' + t.col_ville + '</th><th>' + t.col_cmd + '</th></tr>';
    table.appendChild(thead);

    var tbody = document.createElement('tbody');
    rows.forEach(function (r) {
      var tr = document.createElement('tr');
      // textContent : les valeurs viennent de la base ; on les insère comme du texte, jamais comme du HTML.
      var c1 = document.createElement('td'); c1.textContent = r.nom;
      var c2 = document.createElement('td'); c2.textContent = r.ville;
      var c3 = document.createElement('td'); c3.className = 'num'; c3.textContent = r.commandes;
      tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3);
      tbody.appendChild(tr);
    });
    table.appendChild(tbody);
    panel.appendChild(table);
  }

  /* ---------------------------------------------------------------------------
     5. LES ACTIONS + LE DÉMARRAGE.
     --------------------------------------------------------------------------- */
  form.addEventListener('submit', function (e) { e.preventDefault(); search(qInput.value.trim()); });

  function setLang(next) {
    lang = next;
    try { localStorage.setItem('sql-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var k = el.getAttribute('data-i18n'); if (!t[k]) return;
      if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
    });
    var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });
    if (db) search(qInput.value.trim()); else if (typeof initSqlJs === 'undefined') showState(UI[lang].loaderr, true);
  }

  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);
})();
</script>
</body>
</html>

À toi de jouer

  • Ajoute un tri par nombre de commandes (clic sur l'en-tête de colonne).
  • Ajoute une recherche par ville (toujours en requête paramétrée !).
  • Affiche le détail des commandes d'un client quand on clique sur sa ligne.

À chaque ajout : la saisie passe-t-elle par un paramètre (jamais collée) ? les valeurs affichées sont-elles échappées ?

Dernier projet
Un formulaire validé en PHP →

The project: real SQL, no server

Ninth project, and a surprise: we'll run a real SQL database… right in the browser, with no server at all. The magic is called sql.js: it's SQLite (the most widespread database engine in the world) compiled to WebAssembly. We create tables, write queries, get results — like a real database. Our mini-app: a client search that shows, for each one, their number of orders (a SQL join).

It's also the ideal project for the most famous security reflex in the field: SQL injection. As soon as a user input enters a query, the AI takes the dangerous path by default. Code fast, review slowly.

Honest reminder about the method: we show the exchange in 2 prompts to keep it clear. In reality, on a project like this, you'd do many more round-trips (load the lib, test a query, fix a message…). That's normal. Here we keep the tidied-up version.

Prompt 1: set the frame

We frame it: load sql.js, create a small database, and a search by client name.

Create a standalone web mini-app (one HTML file) that loads sql.js from a CDN, creates a SQLite database with a clients table and an orders table (a few rows), and offers a field to search a client by name. Show the results (name, city, number of orders) in a table. Bilingual FR/EN. Simple, readable code.

The AI sets up the database and codes the search the most natural way… and the most dangerous:

function search(q) {
  // ⚠️ we paste the input straight into the query
  var sql = "SELECT * FROM clients WHERE nom LIKE '%" + q + "%'";
  var res = db.exec(sql);
  panel.innerHTML = renderRows(res);     // ⚠️ innerHTML with database data
}

It works for a normal search. But type ' OR '1'='1 in the field… and the query goes haywire: the condition becomes always true. That's SQL injection, the #1 flaw of apps that talk to a database.

My human review: 3 things the AI let slip

A database is powerful and therefore dangerous: a badly built query can read everything, break everything. Here are the three points.

1. SQL injection: never paste the input into the query

Building a query by pasting typed text ("... '%" + q + "%'") lets the user write part of the SQL. With the right input, they bypass the condition, read data they shouldn't, or even delete everything. The universal fix is the parameterized query: you put a placeholder (:q) in the query and hand the value to the engine separately, via bind(). The input is then treated as data, never as code. Injection impossible.

2. Results come from the database: escape them

The values returned by the database show up in the table. Injecting them with innerHTML is the same XSS risk as always (stored data can contain HTML). We build the cells with createElement and textContent. The "data you don't control" reflex applies to your own database too.

3. Loading a heavy dependency, cleanly

sql.js isn't a small file: it's ~1 MB of WebAssembly downloaded from a CDN. So we show a "loading…" state meanwhile, and handle the case where the CDN doesn't respond (a clear message, not a dead screen). And a truth to keep in mind: every added dependency is a new surface to review (weight, availability, security). Here it's justified — without it, no SQL in the browser — but we add it knowingly.

Prompt 2: harden after review

Fix: (1) use a parameterized query (db.prepare + bind with a :q placeholder) instead of pasting the input into the SQL. (2) Build the results table with createElement + textContent, not innerHTML. (3) Show a "loading" state while sql.js loads, and a clear error message if the library fails to load.
// PARAMETERIZED query: :q is a placeholder, never the value itself
var stmt = db.prepare(
  "SELECT c.nom, c.ville, COUNT(o.id) AS commandes " +
  "FROM clients c LEFT JOIN commandes o ON o.client_id = c.id " +
  "WHERE c.nom LIKE :q GROUP BY c.id ORDER BY c.nom"
);
stmt.bind({ ':q': '%' + q + '%' });   // the input is provided separately, treated as data

var rows = [];
while (stmt.step()) { rows.push(stmt.getAsObject()); }
stmt.free();

The through-line of the whole series reaches its purest form here: the border between "code" and "data" is sacred. An injection (SQL, HTML…) is always data that was allowed to pass itself off as code. Parameterized queries and textContent draw that border. Keep it sharp, everywhere.

Host and test

  • Static file, online in one drop (sql.js is loaded from the CDN by the browser).
  • Search an existing name, then type ' OR '1'='1: with the parameterized query, it must be treated as plain text (and return nothing), not bypass the search.
  • Leave the field empty: all clients show up, with their order count (the join works).
  • Cut your connection and reload: a clear message must appear, not a frozen screen.
  • Toggle FR / EN, console at zero.

The finished result

Open the project full screen

The full code (and downloadable)

The entire file, exactly the one running above.

Download the code (.html · 237 lines)

View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Explorer une base SQL</title>
<meta name="description" content="Une vraie base SQLite dans le navigateur. Cherche un client, vois ses commandes. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  EXPLORER UNE BASE SQL — un fichier HTML + une librairie chargée du CDN (sql.js).
    1. <style>  : l'apparence (champ de recherche, tableau de résultats, états).
    2. <body>   : la structure (recherche + zone de résultats).
    3. <script> : charger sql.js (SQLite en WebAssembly), créer une base, chercher.
  Particularité : SQL côté navigateur, SANS serveur. On charge UNE librairie
  externe (sql.js) depuis un CDN — donc on gère son chargement (et son échec).
  Point clé du projet : la recherche utilise une REQUÊTE PARAMÉTRÉE, jamais une
  requête bricolée par collage de texte — c'est ce qui empêche l'injection SQL.
  ============================================================================
-->

<!-- La librairie sql.js (SQLite compilé en WebAssembly), chargée depuis un CDN. -->
<script src="https://cdn.jsdelivr.net/npm/sql.js@1.10.3/dist/sql-wasm.js"></script>

<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
  * { box-sizing:border-box; }
  html,body { margin:0; padding:0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:620px; }
  header { text-align:center; margin-bottom:18px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
  .lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  .search { display:flex; gap:10px; margin-bottom:8px; }
  .search input { flex:1; min-height:50px; padding:0 16px; border:1.5px solid var(--border); border-radius:12px; font:inherit; font-size:1rem; color:var(--ink); }
  .search input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
  .btn { min-height:50px; padding:0 22px; border-radius:12px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
  .btn-primary { background:var(--accent); color:#fff; }
  .btn-primary:hover { background:#1f6a37; }
  .btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
  .hint { color:var(--muted); font-size:0.82rem; margin:0 0 18px; }

  .panel { background:#fff; border:1px solid var(--border); border-radius:14px; padding:8px; min-height:120px; }
  table { width:100%; border-collapse:collapse; }
  th, td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--border); font-size:0.92rem; }
  th { font-size:0.72rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--muted); }
  tr:last-child td { border-bottom:0; }
  td.num { text-align:right; font-variant-numeric:tabular-nums; }
  .state { text-align:center; color:var(--muted); padding:34px 12px; }
  .state.error { color:#a8341f; }

  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
  .credit a { color:var(--accent); }
  @media (prefers-reduced-motion: reduce){ * { transition:none; } }
</style>
</head>
<body>

<main class="app">
  <header>
    <h1 data-i18n="title">Explorer une base SQL</h1>
    <p class="sub" data-i18n="sub">Une vraie base SQLite, dans ton navigateur. Cherche un client par son nom.</p>
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <form class="search" id="form">
    <input id="q" type="text" maxlength="40" data-i18n-ph="ph" placeholder="Nom du client (essaie « a », ou laisse vide)" aria-label="Nom du client">
    <button class="btn btn-primary" type="submit" data-i18n="go">Chercher</button>
  </form>
  <p class="hint" data-i18n="hint">Recherche dans la table « clients » et compte leurs commandes (jointure SQL).</p>

  <!-- aria-live : un lecteur d'écran annonce les résultats quand ils changent. -->
  <div class="panel" id="panel" aria-live="polite"></div>

  <p class="credit" data-i18n="credit">Base de démo (SQLite via sql.js) · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a></p>
</main>

<script>
(function () {
  'use strict';

  /* ---------------------------------------------------------------------------
     1. LES TEXTES (FR / EN).
     --------------------------------------------------------------------------- */
  var UI = {
    fr: { title:"Explorer une base SQL", sub:"Une vraie base SQLite, dans ton navigateur. Cherche un client par son nom.",
      go:"Chercher", ph:"Nom du client (essaie « a », ou laisse vide)",
      hint:"Recherche dans la table « clients » et compte leurs commandes (jointure SQL).",
      loading:"Chargement de la base…", loaderr:"Impossible de charger la base. Vérifie ta connexion et recharge.",
      none:"Aucun client trouvé.", col_nom:"Nom", col_ville:"Ville", col_cmd:"Commandes",
      credit:'Base de démo (SQLite via sql.js) · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>' },
    en: { title:"Explore a SQL database", sub:"A real SQLite database, in your browser. Search a client by name.",
      go:"Search", ph:"Client name (try \"a\", or leave empty)",
      hint:"Searches the \"clients\" table and counts their orders (SQL join).",
      loading:"Loading the database…", loaderr:"Couldn't load the database. Check your connection and reload.",
      none:"No client found.", col_nom:"Name", col_ville:"City", col_cmd:"Orders",
      credit:'Demo database (SQLite via sql.js) · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course' }
  };

  var panel = document.getElementById('panel');
  var form = document.getElementById('form');
  var qInput = document.getElementById('q');

  var lang = 'fr';
  try { lang = localStorage.getItem('sql-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';

  var db = null;   // la base sera prête après le chargement de sql.js

  function showState(msg, isError) {
    panel.innerHTML = '';
    var d = document.createElement('div');
    d.className = 'state' + (isError ? ' error' : '');
    d.textContent = msg;
    panel.appendChild(d);
  }

  /* ---------------------------------------------------------------------------
     2. CHARGER LA LIBRAIRIE + CRÉER LA BASE.
        sql.js est une dépendance externe : on gère son chargement ET son échec.
     --------------------------------------------------------------------------- */
  showState(UI[lang].loading, false);

  // Si le script du CDN n'a pas pu se charger, initSqlJs n'existe pas.
  if (typeof initSqlJs === 'undefined') {
    showState(UI[lang].loaderr, true);
  } else {
    initSqlJs({ locateFile: function (f) { return 'https://cdn.jsdelivr.net/npm/sql.js@1.10.3/dist/' + f; } })
      .then(function (SQL) {
        db = new SQL.Database();
        // On crée deux tables et quelques lignes de démonstration.
        db.run(
          "CREATE TABLE clients (id INTEGER PRIMARY KEY, nom TEXT, ville TEXT);" +
          "INSERT INTO clients (id, nom, ville) VALUES " +
            "(1,'Alice Durand','Besançon'),(2,'Karim Benali','Lyon')," +
            "(3,'Marie Lefévre','Besançon'),(4,'Tom Garnier','Genève'),(5,'Ana Costa','Lyon');" +
          "CREATE TABLE commandes (id INTEGER PRIMARY KEY, client_id INTEGER, montant REAL);" +
          "INSERT INTO commandes (id, client_id, montant) VALUES " +
            "(1,1,50),(2,1,30),(3,2,80),(4,3,20),(5,3,45),(6,3,15),(7,5,90);"
        );
        search('');   // on affiche tout au départ
      })
      .catch(function () { showState(UI[lang].loaderr, true); });
  }

  /* ---------------------------------------------------------------------------
     3. CHERCHER — avec une REQUÊTE PARAMÉTRÉE (le point clé du projet).
        On n'écrit JAMAIS la valeur tapée directement dans la requête. On met un
        emplacement « :q » et on confie la valeur à SQLite via bind(). La saisie est
        traitée comme une DONNÉE, jamais comme du code SQL : pas d'injection possible.
     --------------------------------------------------------------------------- */
  function search(q) {
    if (!db) return;
    var sql =
      "SELECT c.nom, c.ville, COUNT(o.id) AS commandes " +
      "FROM clients c LEFT JOIN commandes o ON o.client_id = c.id " +
      "WHERE c.nom LIKE :q " +          // :q est un emplacement, pas la valeur
      "GROUP BY c.id ORDER BY c.nom";

    var stmt = db.prepare(sql);
    stmt.bind({ ':q': '%' + q + '%' });   // la valeur est fournie à part, en toute sécurité

    var rows = [];
    while (stmt.step()) { rows.push(stmt.getAsObject()); }
    stmt.free();

    render(rows);
  }

  /* ---------------------------------------------------------------------------
     4. AFFICHER LES RÉSULTATS — en construisant un tableau, valeurs échappées.
     --------------------------------------------------------------------------- */
  function render(rows) {
    panel.innerHTML = '';
    if (!rows.length) { showState(UI[lang].none, false); return; }

    var t = UI[lang];
    var table = document.createElement('table');
    var thead = document.createElement('thead');
    thead.innerHTML = '<tr><th>' + t.col_nom + '</th><th>' + t.col_ville + '</th><th>' + t.col_cmd + '</th></tr>';
    table.appendChild(thead);

    var tbody = document.createElement('tbody');
    rows.forEach(function (r) {
      var tr = document.createElement('tr');
      // textContent : les valeurs viennent de la base ; on les insère comme du texte, jamais comme du HTML.
      var c1 = document.createElement('td'); c1.textContent = r.nom;
      var c2 = document.createElement('td'); c2.textContent = r.ville;
      var c3 = document.createElement('td'); c3.className = 'num'; c3.textContent = r.commandes;
      tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3);
      tbody.appendChild(tr);
    });
    table.appendChild(tbody);
    panel.appendChild(table);
  }

  /* ---------------------------------------------------------------------------
     5. LES ACTIONS + LE DÉMARRAGE.
     --------------------------------------------------------------------------- */
  form.addEventListener('submit', function (e) { e.preventDefault(); search(qInput.value.trim()); });

  function setLang(next) {
    lang = next;
    try { localStorage.setItem('sql-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var k = el.getAttribute('data-i18n'); if (!t[k]) return;
      if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
    });
    var ph = document.querySelector('[data-i18n-ph]'); if (ph) ph.placeholder = t.ph;
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });
    if (db) search(qInput.value.trim()); else if (typeof initSqlJs === 'undefined') showState(UI[lang].loaderr, true);
  }

  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);
})();
</script>
</body>
</html>

Your turn

  • Add sorting by order count (click the column header).
  • Add a search by city (still a parameterized query!).
  • Show a client's order details when you click their row.

On every addition: does the input go through a parameter (never pasted)? are the displayed values escaped?

Last project
A server-validated PHP form →
Accepter ou rejeter le code de l'IA

Après le prompt 2, l'IA renvoie ce bout de code pour la recherche. Tu relis : on accepte ou on rejette ? La requête est paramétrée, mais l'affichage des résultats utilise innerHTML.

var stmt = db.prepare("SELECT nom, ville FROM clients WHERE nom LIKE :q");
stmt.bind({ ':q': '%' + q + '%' });

var html = "";
while (stmt.step()) {
  var r = stmt.getAsObject();
  html += "<tr><td>" + r.nom + "</td><td>" + r.ville + "</td></tr>";
}
stmt.free();
panel.innerHTML = html;   // affichage des résultats
Rejeter. L'IA a corrigé l'injection SQL (la requête est bien paramétrée avec :q et bind()), mais elle a oublié la deuxième moitié de la relecture : panel.innerHTML recolle r.nom et r.ville dans du HTML. Si un client s'appelle <img src=x onerror=alert(1)>, c'est du XSS stocké. La frontière code/donnée est respectée côté SQL mais brisée côté affichage. Correction : createElement + textContent pour chaque cellule. Une faille bouchée n'autorise pas à oublier l'autre.
Rappel libre

Sans remonter dans la leçon : pourquoi coller la saisie dans "... LIKE '%" + q + "%'" est dangereux, et qu'est-ce qu'une requête paramétrée (:q + bind()) change exactement ?

En concaténant, la saisie devient une partie du SQL : avec ' OR '1'='1 la condition devient toujours vraie, et l'utilisateur peut lire (ou casser) ce qu'il veut. Une requête paramétrée met un emplacement :q dans la requête et confie la valeur au moteur à part via bind() : la saisie est alors traitée comme une donnée, jamais comme du code. ' OR '1'='1 devient un simple texte recherché, l'injection est impossible. C'est la frontière code/donnée appliquée au SQL.
Prochaine étape

Tu sais maintenant interroger une base SQL et en tirer les réponses que tu cherches. Au projet suivant, tu passes côté serveur : un formulaire PHP qui valide vraiment les données, et la vraie boucle de travail avec l'IA.

Leçon 10 : Formulaire validé en PHP →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement