Skip to Content
React Native8. SQLite8.1 expo-sqlite

Chapitre 8.1 — expo-sqlite : une vraie base dans la poche

Où on en est : les questions viennent du réseau, les préférences d’AsyncStorage. Pour l’historique de jeu, on sort l’artillerie : SQLite, embarqué dans Android depuis toujours.

⏱️ TL;DRnpx expo install expo-sqlite, puis SQLiteProvider dans le layout et useSQLiteContext() dans les écrans. API asynchrone : runAsync (INSERT/UPDATE), getAllAsync/getFirstAsync (SELECT), withTransactionAsync (atomicité). Requêtes paramétrées (?) systématiques. La base est un fichier dans le sandbox de l’app — privée, rapide, sans réseau.

🎯 Objectifs

  • Installer et ouvrir la base via le provider (et comprendre où vit le fichier).
  • Exécuter les 4 opérations : run, getAll, getFirst, transaction.
  • Paramétrer TOUTES les requêtes (le réflexe anti-injection).
  • Poser la couche d’accès de Drill (lib/db.ts) — les écrans ne verront jamais de SQL.

Installer et ouvrir

npx expo install expo-sqlite
// app/_layout.tsx — le provider ouvre (ou crée) la base au démarrage import { SQLiteProvider } from 'expo-sqlite' import { migrate } from '../lib/db/migrations' export default function RootLayout() { return ( <SQLiteProvider databaseName="drill.db" onInit={migrate}> {/* PersistQueryClientProvider > Stack … */} </SQLiteProvider> ) }
  • databaseName="drill.db" : un fichier dans le sandbox de l’app (/data/data/com.alex.drill/…) — privé (aucune autre app n’y accède), persistant (survit aux mises à jour de l’app ; supprimé à la désinstallation).
  • onInit : exécuté à l’ouverture, AVANT que l’UI consomme la base — l’endroit exact des migrations (chapitre 8.2).
  • Expo Go suffit : expo-sqlite fait partie du SDK.

Les quatre gestes

import { useSQLiteContext } from 'expo-sqlite' const db = useSQLiteContext() // 1. Écrire — TOUJOURS paramétré (?) await db.runAsync( 'INSERT INTO sessions (theme, score, total, played_at) VALUES (?, ?, ?, ?)', theme, score, total, Date.now() ) // 2. Lire plusieurs lignes (typé par générique) type SessionRow = { id: number; theme: string; score: number; total: number; played_at: number } const rows = await db.getAllAsync<SessionRow>( 'SELECT * FROM sessions WHERE theme = ? ORDER BY played_at DESC LIMIT 20', theme ) // 3. Lire une ligne (ou null) const best = await db.getFirstAsync<{ max_score: number }>( 'SELECT MAX(score) AS max_score FROM sessions WHERE theme = ?', theme ) // 4. Transaction — tout ou rien await db.withTransactionAsync(async () => { const r = await db.runAsync('INSERT INTO sessions (…) VALUES (…)', …) for (const a of answers) { await db.runAsync('INSERT INTO answers (session_id, …) VALUES (?, …)', r.lastInsertRowId, …) } })

runAsync retourne lastInsertRowId et changes — les deux infos dont on a toujours besoin. La transaction du geste 4 est LE cas réel de Drill : une session et ses N réponses s’écrivent ensemble ou pas du tout (un crash au milieu ne laissera jamais une session sans réponses).

⚠️ Piège — Jamais, JAMAIS de concaténation dans le SQL : `WHERE theme = '${theme}'` casse sur PHP l'essentiel (apostrophe !) et ouvre l’injection. Les ? paramétrés règlent l’échappement ET la sécurité. Même dans une app locale sans attaquant plausible : c’est le même muscle que sur un serveur — entraînez le bon.

La couche d’accès : lib/db.ts

Règle d’architecture (cohérente avec tout le cours) : les écrans ne voient jamais de SQL. Une couche de fonctions nommées :

// lib/db/sessions.ts import type { SQLiteDatabase } from 'expo-sqlite' export type SessionRecord = { id: number theme: string score: number total: number correctCount: number bestStreak: number playedAt: number } export async function insertSession( db: SQLiteDatabase, s: Omit<SessionRecord, 'id'>, answers: Array<{ questionId: string; correct: boolean }> ): Promise<number> { let sessionId = 0 await db.withTransactionAsync(async () => { const r = await db.runAsync( `INSERT INTO sessions (theme, score, total, correct_count, best_streak, played_at) VALUES (?, ?, ?, ?, ?, ?)`, s.theme, s.score, s.total, s.correctCount, s.bestStreak, s.playedAt ) sessionId = r.lastInsertRowId for (const a of answers) { await db.runAsync( 'INSERT INTO answers (session_id, question_id, correct) VALUES (?, ?, ?)', sessionId, a.questionId, a.correct ? 1 : 0 ) } }) return sessionId } export function listSessions(db: SQLiteDatabase, limit = 50) { return db.getAllAsync<SessionRecord & { played_at: number }>( `SELECT id, theme, score, total, correct_count AS correctCount, best_streak AS bestStreak, played_at AS playedAt FROM sessions ORDER BY played_at DESC LIMIT ?`, limit ) }

Notez les alias SQL (correct_count AS correctCount) : la base parle snake_case, le TS camelCase — la traduction se fait dans la requête, pas dans des boucles de mapping. Et les booléens : SQLite n’en a pas → INTEGER 0/1, converti à la frontière.

Le branchement au jeu : quand la machine d’état passe en finished (Partie 6), l’écran résultat appelle insertSession(db, summary, answers). Une ligne — parce que la couche existe.

💡 Pour un dev Next.js — Vous connaissez ce SQL (PDO du cours PHP, ou Postgres via Prisma/Drizzle). La nouveauté est ailleurs : la base est dans l’app, à zéro latence, sans pool ni connexion réseau. Requêter à chaque render est banal ici. Et le pattern « couche d’accès nommée » est exactement votre repository côté serveur.

✏️ Exercices

1. Dans un écran de test, insérez 3 sessions factices puis listez-les. Vérifiez la persistance : tuez l’app, rouvrez, relistez.

✅ Solution

Bouton « Seed » → 3 × insertSession (thèmes variés) ; bouton « List » → console.log de listSessions. Après kill + réouverture, List renvoie toujours les 3 : le fichier drill.db a survécu. (Nettoyage : adb shell pm clear com.alex.drill remet tout à zéro — base comprise.)

2. Écrivez deleteSession(db, id) et clearHistory(db) — cette dernière doit supprimer sessions ET réponses de façon atomique.

✅ Solution

export function deleteSession(db: SQLiteDatabase, id: number) { return db.withTransactionAsync(async () => { await db.runAsync('DELETE FROM answers WHERE session_id = ?', id) await db.runAsync('DELETE FROM sessions WHERE id = ?', id) }) } export function clearHistory(db: SQLiteDatabase) { return db.withTransactionAsync(async () => { await db.runAsync('DELETE FROM answers') await db.runAsync('DELETE FROM sessions') }) }

(Le chapitre 8.2 ajoutera ON DELETE CASCADE — la transaction manuelle deviendra optionnelle, mais le réflexe atomique reste bon.) clearHistory se branche sur un bouton des Réglages, avec l’Alert de confirmation du ch. 5.4.

3. Chronométrez : 100 insertions une à une vs 100 insertions dans UNE transaction. Expliquez l’écart.

✅ Solution

const t0 = performance.now() // version A : 100 × runAsync isolés — chaque INSERT = sa propre transaction implicite (fsync !) // version B : withTransactionAsync autour des 100 — un seul commit console.log(performance.now() - t0)

B est typiquement 10-50× plus rapide : chaque transaction implicite force une écriture disque synchronisée ; la transaction groupée n’en paie qu’une. Règle : toute écriture en boucle se met en transaction. (Utile pour l’import des questions ratées de Drill v2.)

🧠 Quiz

1. Où vit physiquement drill.db, et qui peut y accéder ?

Réponse

Un fichier dans le sandbox privé de l’app (/data/data/com.alex.drill/). Seule l’app y accède ; il survit aux mises à jour et disparaît à la désinstallation.

2. Quelles méthodes pour : écrire, lire N lignes, lire 1 ligne ?

Réponse

runAsync (retourne lastInsertRowId/changes), getAllAsync<T>, getFirstAsync<T> (T | null).

3. Pourquoi les requêtes paramétrées même sans « attaquant » ?

Réponse

L’échappement correct (apostrophes des textes français !), la robustesse, et le bon réflexe transférable côté serveur. La concaténation SQL est un bug avant d’être une faille.

4. Quel est LE cas de transaction de Drill ?

Réponse

L’enregistrement d’une session + ses réponses : atomique (jamais de session orpheline de ses answers en cas de crash au milieu).

5. Pourquoi une couche lib/db plutôt que du SQL dans les écrans ?

Réponse

Écrans déclaratifs et testables, SQL centralisé (schéma modifiable en un lieu), conversions snake_case/camelCase et 0/1→boolean faites à la frontière — le pattern repository.


👉 Chapitre suivant : 8.2 — Le schéma de Drill — les tables, et l’art de les faire évoluer sans perdre les données des utilisateurs.