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;DR —
npx expo install expo-sqlite, puisSQLiteProviderdans le layout etuseSQLiteContext()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 surPHP 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.