↑
CHAPITRE 5.3

Base de données locale avec SQLite

Gérer des données structurées et complexes avec une base de données relationnelle dans votre application Flutter
Dans cette section, vous allez découvrir SQLite, une base de données relationnelle légère et embarquée, parfaite pour stocker des données structurées dans vos applications Flutter. Contrairement à SharedPreferences qui ne gère que des paires clé-valeur, SQLite permet de créer des tables, d'établir des relations entre données, et d'effectuer des requêtes complexes. 📊

5.3Base de données locale avec SQLite

5.3.1 – Introduction à SQLite dans Flutter

🎯 Qu'est-ce que SQLite ?

SQLite est un système de gestion de base de données relationnelle (SGBDR) léger et embarqué. Contrairement aux bases de données serveur comme MySQL ou PostgreSQL, SQLite fonctionne directement dans votre application sans nécessiter de serveur séparé.

Base de données embarquée :
SQLite stocke toutes les données dans un seul fichier sur l'appareil. Ce fichier contient toutes les tables, les relations, et les données. C'est simple, efficace, et parfait pour les applications mobiles.

📊 Pourquoi utiliser SQLite dans Flutter ?

SQLite est idéal lorsque vous avez besoin de :

  • DonnĂ©es structurĂ©es : Stocker des informations organisĂ©es en tables (utilisateurs, produits, commandes, etc.)
  • Relations entre donnĂ©es : Établir des liens entre diffĂ©rentes entitĂ©s (un utilisateur a plusieurs commandes, une commande contient plusieurs produits)
  • RequĂŞtes complexes : Rechercher, filtrer, trier, et agrĂ©ger des donnĂ©es avec le langage SQL
  • Grandes quantitĂ©s de donnĂ©es : GĂ©rer des milliers ou millions d'enregistrements efficacement
  • Performance : Accès rapide aux donnĂ©es avec indexation et optimisations

đź“‹ Cas d'utilisation typiques

Application
Données stockées
Application de notes
Notes, catégories, tags, favoris
Application de tâches
Tâches, projets, sous-tâches, dates
Application de contacts
Contacts, groupes, numéros, emails
Application e-commerce
Produits, commandes, panier, historique
Application de messagerie
Messages, conversations, pièces jointes

🔍 SQLite vs autres solutions de stockage

Critère
SharedPreferences
Secure Storage
SQLite
Structure
Clé-valeur
Clé-valeur
Tables relationnelles
RequĂŞtes
Non
Non
Oui (SQL)
Relations
Non
Non
Oui
Volume
Petit (< 200 clés)
Petit (données sensibles)
Grand (illimité)
Complexité
✓✓✓ Simple
✓✓ Simple
âś“ Moyenne

âś… Avantages de SQLite

  • Puissant : Support complet du langage SQL (SELECT, INSERT, UPDATE, DELETE, JOIN, etc.)
  • Rapide : Performances excellentes pour les opĂ©rations locales
  • Fiable : Base de donnĂ©es mature et largement utilisĂ©e
  • LĂ©ger : Pas de serveur, tout est dans un fichier
  • Cross-platform : Fonctionne sur Android, iOS, Web, Desktop
  • ACID : Garantit l'intĂ©gritĂ© des donnĂ©es (AtomicitĂ©, CohĂ©rence, Isolation, DurabilitĂ©)

❌ Limites de SQLite

  • Pas de rĂ©seau : Base de donnĂ©es locale uniquement, pas de partage entre appareils
  • Pas de concurrence Ă©levĂ©e : OptimisĂ© pour un seul utilisateur/application
  • Courbe d'apprentissage : NĂ©cessite de connaĂ®tre SQL
  • Configuration initiale : Plus complexe que SharedPreferences
Quand utiliser SQLite ?
Utilisez SQLite si vous avez besoin de relations entre données, de requêtes complexes, ou de gérer de grandes quantités de données structurées. Pour des préférences simples, préférez SharedPreferences. Pour des données sensibles, utilisez Secure Storage en complément.

5.3.2 – Installation du package sqflite

📦 Qu'est-ce que sqflite ?

sqflite est le package Flutter officiel pour utiliser SQLite. Il fournit une API Dart simple et intuitive pour interagir avec une base de données SQLite dans votre application.

Package sqflite :
Le package sqflite est un wrapper autour de SQLite qui permet d'utiliser SQLite de manière native dans Flutter. Il gère automatiquement les différences entre Android et iOS, vous offrant une API unifiée.

📝 Installation

Pour installer sqflite, ajoutez-le Ă  votre fichier pubspec.yaml :

dependencies:
  flutter:
    sdk: flutter
  sqflite: ^2.3.0
  path: ^1.8.3
Pourquoi path ?
Le package path est nécessaire pour gérer les chemins de fichiers de manière cross-platform. Il permet de créer le chemin vers le fichier de base de données de manière compatible avec Android, iOS, et les autres plateformes.

🔧 Installation des dépendances

Après avoir ajouté les dépendances, exécutez :

flutter pub get

📚 Import dans votre code

Pour utiliser sqflite dans votre code Dart, importez les packages nécessaires :

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
Note importante :
Sur certaines plateformes (notamment Web), sqflite peut nécessiter des configurations supplémentaires. Pour Web, vous devrez peut-être utiliser sqflite_common_ffi ou une alternative comme drift. Pour Android et iOS, sqflite fonctionne nativement sans configuration supplémentaire.

✅ Vérification de l'installation

Pour vérifier que sqflite est correctement installé, vous pouvez créer un simple test :

import 'package:sqflite/sqflite.dart';

void main() async {
  // Vérifier la version de SQLite
  print('Version SQLite: ${await getDatabasesPath()}');
}

Si cette commande s'exécute sans erreur, sqflite est correctement installé ! ✅

5.3.3 – Créer et initialiser une base de données

📝 Créer une base de données

Pour créer une base de données SQLite dans Flutter, vous devez :

  1. Obtenir le chemin où stocker la base de données
  2. Ouvrir ou créer la base de données
  3. Initialiser les tables

🧪 Exemple : Créer et initialiser une base de données

Voici un exemple complet de création et d'initialisation d'une base de données :

import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

void main() {
  runApp(const MonApp());
}

class MonApp extends StatelessWidget {
  const MonApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const MaPage(),
    );
  }
}

class MaPage extends StatefulWidget {
  const MaPage({super.key});

  @override
  State<MaPage> createState() => _MaPageState();
}

class _MaPageState extends State<MaPage> {
  Database? _database;
  String _status = 'Initialisation...';

  @override
  void initState() {
    super.initState();
    _initialiserBaseDeDonnees();
  }

  // Initialiser la base de données
  Future<void> _initialiserBaseDeDonnees() async {
    try {
      // 1. Obtenir le chemin de la base de données
      String databasesPath = await getDatabasesPath();
      String path = join(databasesPath, 'ma_base_de_donnees.db');

      // 2. Ouvrir ou créer la base de données
      _database = await openDatabase(
        path,
        version: 1,
        onCreate: (db, version) {
          // 3. Créer les tables lors de la première création
          db.execute('''
            CREATE TABLE utilisateurs (
              id INTEGER PRIMARY KEY AUTOINCREMENT,
              nom TEXT NOT NULL,
              email TEXT NOT NULL UNIQUE,
              age INTEGER
            )
          ''');
        },
      );

      setState(() {
        _status = 'Base de données créée avec succès !';
      });
    } catch (e) {
      setState(() {
        _status = 'Erreur: $e';
      });
    }
  }

  @override
  void dispose() {
    _database?.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Initialisation SQLite'),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                _status.contains('succès') ? Icons.check_circle : Icons.info,
                size: 64,
                color: _status.contains('succès') ? Colors.green : Colors.blue,
              ),
              const SizedBox(height: 24),
              Text(
                _status,
                style: const TextStyle(fontSize: 18),
                textAlign: TextAlign.center,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

🔍 Explication du code

Analysons les éléments clés de cet exemple :

  • getDatabasesPath() : Retourne le chemin par dĂ©faut pour les bases de donnĂ©es sur la plateforme actuelle
  • join(databasesPath, 'ma_base_de_donnees.db') : CrĂ©e le chemin complet vers le fichier de base de donnĂ©es
  • openDatabase() : Ouvre une base de donnĂ©es existante ou en crĂ©e une nouvelle si elle n'existe pas
  • version: 1 : DĂ©finit la version de la base de donnĂ©es (important pour les migrations)
  • onCreate : Callback exĂ©cutĂ© uniquement lors de la première crĂ©ation de la base de donnĂ©es
  • db.execute() : ExĂ©cute une commande SQL pour crĂ©er les tables
Chemin de la base de données :
Sur Android, la base de données est stockée dans /data/data/com.votre.app/databases/. Sur iOS, elle est dans le répertoire Documents de l'application. Le package sqflite gère automatiquement ces différences.

đź“‹ Structure d'une table SQL

Dans l'exemple ci-dessus, nous avons créé une table utilisateurs avec la structure suivante :

CREATE TABLE utilisateurs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  nom TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  age INTEGER
)

Explication des types et contraintes :

  • INTEGER PRIMARY KEY AUTOINCREMENT : Identifiant unique qui s'incrĂ©mente automatiquement
  • TEXT : ChaĂ®ne de caractères
  • NOT NULL : Le champ ne peut pas ĂŞtre vide
  • UNIQUE : La valeur doit ĂŞtre unique dans la table
  • INTEGER : Nombre entier

đź”§ Gestion des erreurs

Il est important de gérer les erreurs lors de la création de la base de données :

try {
  _database = await openDatabase(
    path,
    version: 1,
    onCreate: (db, version) {
      // Création des tables
    },
  );
} catch (e) {
  print('Erreur lors de la création de la base de données: $e');
  // Gérer l'erreur (afficher un message, logger, etc.)
}
Bonnes pratiques :
  • Toujours fermer la base de donnĂ©es dans dispose() pour libĂ©rer les ressources
  • GĂ©rer les erreurs avec try-catch
  • Utiliser des transactions pour les opĂ©rations multiples
  • Ne pas ouvrir plusieurs instances de la mĂŞme base de donnĂ©es

5.3.4 – Définir des tables et des modèles

📊 Modèle de données

Avant de créer vos tables, il est important de définir un modèle de données en Dart. Cela permet de structurer votre code et de faciliter la conversion entre les objets Dart et les enregistrements de la base de données.

🧪 Exemple : Modèle Utilisateur

Voici un exemple complet avec un modèle Utilisateur :

import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

// Modèle de données
class Utilisateur {
  final int? id;
  final String nom;
  final String email;
  final int? age;

  Utilisateur({
    this.id,
    required this.nom,
    required this.email,
    this.age,
  });

  // Convertir un Map (de la base de données) en Utilisateur
  factory Utilisateur.fromMap(Map<String, dynamic> map) {
    return Utilisateur(
      id: map['id'] as int?,
      nom: map['nom'] as String,
      email: map['email'] as String,
      age: map['age'] as int?,
    );
  }

  // Convertir un Utilisateur en Map (pour la base de données)
  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'nom': nom,
      'email': email,
      'age': age,
    };
  }

  @override
  String toString() {
    return 'Utilisateur{id: $id, nom: $nom, email: $email, age: $age}';
  }
}

// Classe helper pour gérer la base de données
class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._init();
  static Database? _database;

  DatabaseHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB('ma_base.db');
    return _database!;
  }

  Future<Database> _initDB(String filePath) async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, filePath);

    return await openDatabase(
      path,
      version: 1,
      onCreate: _createDB,
    );
  }

  Future<void> _createDB(Database db, int version) async {
    await db.execute('''
      CREATE TABLE utilisateurs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        nom TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE,
        age INTEGER
      )
    ''');
  }

  Future<void> close() async {
    final db = await database;
    db.close();
  }
}

void main() {
  runApp(const MonApp());
}

class MonApp extends StatelessWidget {
  const MonApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const MaPage(),
    );
  }
}

class MaPage extends StatefulWidget {
  const MaPage({super.key});

  @override
  State<MaPage> createState() => _MaPageState();
}

class _MaPageState extends State<MaPage> {
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;

  @override
  void dispose() {
    _dbHelper.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Modèles et Tables'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'Base de données initialisée avec modèle Utilisateur',
              style: TextStyle(fontSize: 18),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                // Exemple d'utilisation du modèle
                final utilisateur = Utilisateur(
                  nom: 'Jean Dupont',
                  email: 'jean@example.com',
                  age: 30,
                );
                print(utilisateur.toMap());
              },
              child: const Text('Tester le modèle'),
            ),
          ],
        ),
      ),
    );
  }
}

🔍 Explication du modèle

Le modèle Utilisateur contient :

  • PropriĂ©tĂ©s : Les champs de donnĂ©es (id, nom, email, age)
  • fromMap() : Constructeur factory qui convertit un Map (rĂ©sultat d'une requĂŞte SQL) en objet Utilisateur
  • toMap() : MĂ©thode qui convertit un Utilisateur en Map pour l'insĂ©rer dans la base de donnĂ©es

📋 Types de données SQLite

SQLite supporte les types suivants :

Type SQLite
Type Dart
Description
INTEGER
int
Nombre entier (1, 2, 3, 4, 6, ou 8 octets)
REAL
double
Nombre Ă  virgule flottante (8 octets)
TEXT
String
Chaîne de caractères (UTF-8, UTF-16BE, UTF-16LE)
BLOB
Uint8List
Données binaires (images, fichiers)
NULL
null
Valeur nulle

đź”— Relations entre tables

Pour créer des relations entre tables, utilisez des clés étrangères (FOREIGN KEY) :

-- Table utilisateurs
CREATE TABLE utilisateurs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  nom TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE
);

-- Table commandes (avec relation vers utilisateurs)
CREATE TABLE commandes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  utilisateur_id INTEGER NOT NULL,
  montant REAL NOT NULL,
  date TEXT NOT NULL,
  FOREIGN KEY (utilisateur_id) REFERENCES utilisateurs(id)
);
Clé étrangère (FOREIGN KEY) :
Une clé étrangère établit une relation entre deux tables. Dans l'exemple ci-dessus, chaque commande est liée à un utilisateur via utilisateur_id. Cela garantit l'intégrité référentielle : on ne peut pas créer une commande pour un utilisateur qui n'existe pas.

âś… Bonnes pratiques

  • Utiliser des modèles Dart : Facilite la manipulation des donnĂ©es
  • CrĂ©er une classe helper : Centralise la gestion de la base de donnĂ©es
  • Utiliser des noms cohĂ©rents : Nommez vos tables et colonnes de manière claire
  • DĂ©finir des contraintes : Utilisez NOT NULL, UNIQUE, PRIMARY KEY pour garantir l'intĂ©gritĂ©
  • Documenter votre schĂ©ma : Commentez vos tables et relations

5.3.5 – Opérations CRUD (Create, Read, Update, Delete)

📝 Qu'est-ce que CRUD ?

CRUD est un acronyme pour les quatre opérations de base sur les données :

  • Create (CrĂ©er) : InsĂ©rer de nouvelles donnĂ©es
  • Read (Lire) : RĂ©cupĂ©rer des donnĂ©es
  • Update (Mettre Ă  jour) : Modifier des donnĂ©es existantes
  • Delete (Supprimer) : Supprimer des donnĂ©es

➕ CREATE - Insérer des données

Pour insérer des données dans une table, utilisez la méthode insert() :

// Méthode dans DatabaseHelper
Future<int> insertUtilisateur(Utilisateur utilisateur) async {
  final db = await database;
  return await db.insert(
    'utilisateurs',
    utilisateur.toMap(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}

🧪 Exemple : Application CRUD complète

Voici un exemple complet avec toutes les opérations CRUD :

import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

// Modèle Utilisateur (identique à la section précédente)
class Utilisateur {
  final int? id;
  final String nom;
  final String email;
  final int? age;

  Utilisateur({
    this.id,
    required this.nom,
    required this.email,
    this.age,
  });

  factory Utilisateur.fromMap(Map<String, dynamic> map) {
    return Utilisateur(
      id: map['id'] as int?,
      nom: map['nom'] as String,
      email: map['email'] as String,
      age: map['age'] as int?,
    );
  }

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'nom': nom,
      'email': email,
      'age': age,
    };
  }
}

// DatabaseHelper avec toutes les opérations CRUD
class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._init();
  static Database? _database;

  DatabaseHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB('crud_example.db');
    return _database!;
  }

  Future<Database> _initDB(String filePath) async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, filePath);

    return await openDatabase(
      path,
      version: 1,
      onCreate: _createDB,
    );
  }

  Future<void> _createDB(Database db, int version) async {
    await db.execute('''
      CREATE TABLE utilisateurs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        nom TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE,
        age INTEGER
      )
    ''');
  }

  // CREATE - Insérer un utilisateur
  Future<int> insertUtilisateur(Utilisateur utilisateur) async {
    final db = await database;
    return await db.insert(
      'utilisateurs',
      utilisateur.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  // READ - Lire tous les utilisateurs
  Future<List<Utilisateur>> getAllUtilisateurs() async {
    final db = await database;
    final result = await db.query('utilisateurs', orderBy: 'nom');
    return result.map((map) => Utilisateur.fromMap(map)).toList();
  }

  // READ - Lire un utilisateur par ID
  Future<Utilisateur?> getUtilisateurById(int id) async {
    final db = await database;
    final result = await db.query(
      'utilisateurs',
      where: 'id = ?',
      whereArgs: [id],
    );
    if (result.isNotEmpty) {
      return Utilisateur.fromMap(result.first);
    }
    return null;
  }

  // UPDATE - Mettre Ă  jour un utilisateur
  Future<int> updateUtilisateur(Utilisateur utilisateur) async {
    final db = await database;
    return await db.update(
      'utilisateurs',
      utilisateur.toMap(),
      where: 'id = ?',
      whereArgs: [utilisateur.id],
    );
  }

  // DELETE - Supprimer un utilisateur
  Future<int> deleteUtilisateur(int id) async {
    final db = await database;
    return await db.delete(
      'utilisateurs',
      where: 'id = ?',
      whereArgs: [id],
    );
  }

  Future<void> close() async {
    final db = await database;
    db.close();
  }
}

void main() {
  runApp(const MonApp());
}

class MonApp extends StatelessWidget {
  const MonApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const MaPage(),
    );
  }
}

class MaPage extends StatefulWidget {
  const MaPage({super.key});

  @override
  State<MaPage> createState() => _MaPageState();
}

class _MaPageState extends State<MaPage> {
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;
  final _nomController = TextEditingController();
  final _emailController = TextEditingController();
  final _ageController = TextEditingController();
  List<Utilisateur> _utilisateurs = [];
  Utilisateur? _utilisateurSelectionne;

  @override
  void initState() {
    super.initState();
    _chargerUtilisateurs();
  }

  @override
  void dispose() {
    _nomController.dispose();
    _emailController.dispose();
    _ageController.dispose();
    _dbHelper.close();
    super.dispose();
  }

  // Charger tous les utilisateurs
  Future<void> _chargerUtilisateurs() async {
    final utilisateurs = await _dbHelper.getAllUtilisateurs();
    setState(() {
      _utilisateurs = utilisateurs;
    });
  }

  // CREATE - Ajouter un utilisateur
  Future<void> _ajouterUtilisateur() async {
    if (_nomController.text.isEmpty || _emailController.text.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Veuillez remplir tous les champs')),
      );
      return;
    }

    final utilisateur = Utilisateur(
      nom: _nomController.text,
      email: _emailController.text,
      age: _ageController.text.isNotEmpty ? int.tryParse(_ageController.text) : null,
    );

    await _dbHelper.insertUtilisateur(utilisateur);
    _chargerUtilisateurs();
    _viderChamps();
  }

  // UPDATE - Modifier un utilisateur
  Future<void> _modifierUtilisateur() async {
    if (_utilisateurSelectionne == null) return;

    final utilisateur = Utilisateur(
      id: _utilisateurSelectionne!.id,
      nom: _nomController.text,
      email: _emailController.text,
      age: _ageController.text.isNotEmpty ? int.tryParse(_ageController.text) : null,
    );

    await _dbHelper.updateUtilisateur(utilisateur);
    _chargerUtilisateurs();
    _viderChamps();
    setState(() {
      _utilisateurSelectionne = null;
    });
  }

  // DELETE - Supprimer un utilisateur
  Future<void> _supprimerUtilisateur(int id) async {
    await _dbHelper.deleteUtilisateur(id);
    _chargerUtilisateurs();
  }

  void _selectionnerUtilisateur(Utilisateur utilisateur) {
    setState(() {
      _utilisateurSelectionne = utilisateur;
      _nomController.text = utilisateur.nom;
      _emailController.text = utilisateur.email;
      _ageController.text = utilisateur.age?.toString() ?? '';
    });
  }

  void _viderChamps() {
    _nomController.clear();
    _emailController.clear();
    _ageController.clear();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('CRUD SQLite'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // Formulaire
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  children: [
                    TextField(
                      controller: _nomController,
                      decoration: const InputDecoration(
                        labelText: 'Nom',
                        border: OutlineInputBorder(),
                      ),
                    ),
                    const SizedBox(height: 12),
                    TextField(
                      controller: _emailController,
                      decoration: const InputDecoration(
                        labelText: 'Email',
                        border: OutlineInputBorder(),
                      ),
                    ),
                    const SizedBox(height: 12),
                    TextField(
                      controller: _ageController,
                      decoration: const InputDecoration(
                        labelText: 'Âge',
                        border: OutlineInputBorder(),
                      ),
                      keyboardType: TextInputType.number,
                    ),
                    const SizedBox(height: 16),
                    Row(
                      children: [
                        Expanded(
                          child: ElevatedButton(
                            onPressed: _utilisateurSelectionne == null
                                ? _ajouterUtilisateur
                                : _modifierUtilisateur,
                            child: Text(_utilisateurSelectionne == null
                                ? 'Ajouter'
                                : 'Modifier'),
                          ),
                        ),
                        if (_utilisateurSelectionne != null) ...[
                          const SizedBox(width: 8),
                          Expanded(
                            child: OutlinedButton(
                              onPressed: () {
                                _viderChamps();
                                setState(() {
                                  _utilisateurSelectionne = null;
                                });
                              },
                              child: const Text('Annuler'),
                            ),
                          ),
                        ],
                      ],
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),
            // Liste des utilisateurs
            Expanded(
              child: ListView.builder(
                itemCount: _utilisateurs.length,
                itemBuilder: (context, index) {
                  final utilisateur = _utilisateurs[index];
                  return Card(
                    margin: const EdgeInsets.only(bottom: 8),
                    child: ListTile(
                      title: Text(utilisateur.nom),
                      subtitle: Text('${utilisateur.email} - ${utilisateur.age ?? "N/A"} ans'),
                      trailing: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          IconButton(
                            icon: const Icon(Icons.edit),
                            onPressed: () => _selectionnerUtilisateur(utilisateur),
                          ),
                          IconButton(
                            icon: const Icon(Icons.delete, color: Colors.red),
                            onPressed: () => _supprimerUtilisateur(utilisateur.id!),
                          ),
                        ],
                      ),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

🔍 Explication des opérations CRUD

1. CREATE - Insert

await db.insert(
  'utilisateurs',
  utilisateur.toMap(),
  conflictAlgorithm: ConflictAlgorithm.replace,
);
  • insert() : Insère une nouvelle ligne dans la table
  • conflictAlgorithm : DĂ©finit le comportement en cas de conflit (doublon, etc.)
  • Retourne l'ID de la ligne insĂ©rĂ©e

2. READ - Query

await db.query(
  'utilisateurs',
  where: 'id = ?',
  whereArgs: [id],
  orderBy: 'nom',
);
  • query() : RĂ©cupère des donnĂ©es avec des conditions
  • where : Condition SQL (utilisez ? pour Ă©viter les injections SQL)
  • whereArgs : Valeurs Ă  substituer aux ?
  • orderBy : Tri des rĂ©sultats

3. UPDATE - Update

await db.update(
  'utilisateurs',
  utilisateur.toMap(),
  where: 'id = ?',
  whereArgs: [utilisateur.id],
);
  • update() : Met Ă  jour des lignes existantes
  • Retourne le nombre de lignes modifiĂ©es

4. DELETE - Delete

await db.delete(
  'utilisateurs',
  where: 'id = ?',
  whereArgs: [id],
);
  • delete() : Supprime des lignes
  • Retourne le nombre de lignes supprimĂ©es
⚠️ Protection contre les injections SQL :
Toujours utiliser ? dans les requêtes WHERE et passer les valeurs via whereArgs. Ne jamais concaténer directement des valeurs dans les requêtes SQL !

5.3.6 – Requêtes et filtres

🔍 Requêtes simples

La méthode query() permet de faire des requêtes avec filtres, tri, et limites :

// Récupérer tous les utilisateurs
final tous = await db.query('utilisateurs');

// Récupérer avec une condition
final majeurs = await db.query(
  'utilisateurs',
  where: 'age >= ?',
  whereArgs: [18],
);

// Récupérer avec tri
final tries = await db.query(
  'utilisateurs',
  orderBy: 'nom ASC',
);

// Récupérer avec limite
final premiers = await db.query(
  'utilisateurs',
  limit: 10,
);

🧪 Exemple : Recherche et filtres avancés

import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

// Modèle Utilisateur (identique aux sections précédentes)
class Utilisateur {
  final int? id;
  final String nom;
  final String email;
  final int? age;

  Utilisateur({
    this.id,
    required this.nom,
    required this.email,
    this.age,
  });

  factory Utilisateur.fromMap(Map<String, dynamic> map) {
    return Utilisateur(
      id: map['id'] as int?,
      nom: map['nom'] as String,
      email: map['email'] as String,
      age: map['age'] as int?,
    );
  }

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'nom': nom,
      'email': email,
      'age': age,
    };
  }
}

class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._init();
  static Database? _database;

  DatabaseHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB('requetes.db');
    return _database!;
  }

  Future<Database> _initDB(String filePath) async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, filePath);

    return await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE utilisateurs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            nom TEXT NOT NULL,
            email TEXT NOT NULL UNIQUE,
            age INTEGER
          )
        ''');
        // Données de test
        await db.insert('utilisateurs', {'nom': 'Alice', 'email': 'alice@example.com', 'age': 25});
        await db.insert('utilisateurs', {'nom': 'Bob', 'email': 'bob@example.com', 'age': 30});
        await db.insert('utilisateurs', {'nom': 'Charlie', 'email': 'charlie@example.com', 'age': 35});
        await db.insert('utilisateurs', {'nom': 'David', 'email': 'david@example.com', 'age': 20});
      },
    );
  }

  // Recherche par nom (LIKE)
  Future<List<Utilisateur>> rechercherParNom(String recherche) async {
    final db = await database;
    final result = await db.query(
      'utilisateurs',
      where: 'nom LIKE ?',
      whereArgs: ['%$recherche%'],
    );
    return result.map((map) => Utilisateur.fromMap(map)).toList();
  }

  // Filtrer par âge minimum
  Future<List<Utilisateur>> filtrerParAge(int ageMin) async {
    final db = await database;
    final result = await db.query(
      'utilisateurs',
      where: 'age >= ?',
      whereArgs: [ageMin],
      orderBy: 'age ASC',
    );
    return result.map((map) => Utilisateur.fromMap(map)).toList();
  }

  // Trier par nom
  Future<List<Utilisateur>> trierParNom({bool asc = true}) async {
    final db = await database;
    final result = await db.query(
      'utilisateurs',
      orderBy: 'nom ${asc ? "ASC" : "DESC"}',
    );
    return result.map((map) => Utilisateur.fromMap(map)).toList();
  }

  // RequĂŞte avec plusieurs conditions
  Future<List<Utilisateur>> rechercherAvancee({
    String? nom,
    int? ageMin,
    int? ageMax,
  }) async {
    final db = await database;
    List<String> conditions = [];
    List<dynamic> args = [];

    if (nom != null && nom.isNotEmpty) {
      conditions.add('nom LIKE ?');
      args.add('%$nom%');
    }
    if (ageMin != null) {
      conditions.add('age >= ?');
      args.add(ageMin);
    }
    if (ageMax != null) {
      conditions.add('age <= ?');
      args.add(ageMax);
    }

    String whereClause = conditions.isNotEmpty
        ? conditions.join(' AND ')
        : '1=1';

    final result = await db.query(
      'utilisateurs',
      where: whereClause,
      whereArgs: args,
      orderBy: 'nom ASC',
    );
    return result.map((map) => Utilisateur.fromMap(map)).toList();
  }

  // Compter les enregistrements
  Future<int> compterUtilisateurs() async {
    final db = await database;
    final result = await db.rawQuery('SELECT COUNT(*) as count FROM utilisateurs');
    return Sqflite.firstIntValue(result) ?? 0;
  }

  // RequĂŞte SQL brute
  Future<List<Utilisateur>> requeteBrute(String sql, [List<dynamic>? args]) async {
    final db = await database;
    final result = await db.rawQuery(sql, args);
    return result.map((map) => Utilisateur.fromMap(map)).toList();
  }

  Future<void> close() async {
    final db = await database;
    db.close();
  }
}

void main() {
  runApp(const MonApp());
}

class MonApp extends StatelessWidget {
  const MonApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const MaPage(),
    );
  }
}

class MaPage extends StatefulWidget {
  const MaPage({super.key});

  @override
  State<MaPage> createState() => _MaPageState();
}

class _MaPageState extends State<MaPage> {
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;
  final _rechercheController = TextEditingController();
  List<Utilisateur> _utilisateurs = [];
  int _nombreTotal = 0;

  @override
  void initState() {
    super.initState();
    _chargerDonnees();
  }

  @override
  void dispose() {
    _rechercheController.dispose();
    _dbHelper.close();
    super.dispose();
  }

  Future<void> _chargerDonnees() async {
    final utilisateurs = await _dbHelper.trierParNom();
    final nombre = await _dbHelper.compterUtilisateurs();
    setState(() {
      _utilisateurs = utilisateurs;
      _nombreTotal = nombre;
    });
  }

  Future<void> _rechercher() async {
    if (_rechercheController.text.isEmpty) {
      _chargerDonnees();
      return;
    }

    final resultats = await _dbHelper.rechercherParNom(_rechercheController.text);
    setState(() {
      _utilisateurs = resultats;
    });
  }

  Future<void> _filtrerParAge(int ageMin) async {
    final resultats = await _dbHelper.filtrerParAge(ageMin);
    setState(() {
      _utilisateurs = resultats;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('RequĂŞtes et Filtres'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // Barre de recherche
            TextField(
              controller: _rechercheController,
              decoration: InputDecoration(
                labelText: 'Rechercher par nom',
                border: const OutlineInputBorder(),
                suffixIcon: IconButton(
                  icon: const Icon(Icons.search),
                  onPressed: _rechercher,
                ),
              ),
              onSubmitted: (_) => _rechercher(),
            ),
            const SizedBox(height: 16),
            // Filtres rapides
            Row(
              children: [
                Expanded(
                  child: ElevatedButton(
                    onPressed: () => _filtrerParAge(25),
                    child: const Text('Âge >= 25'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: ElevatedButton(
                    onPressed: () => _filtrerParAge(30),
                    child: const Text('Âge >= 30'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: ElevatedButton(
                    onPressed: _chargerDonnees,
                    child: const Text('Tous'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            // Compteur
            Text(
              'Total: $_nombreTotal utilisateurs',
              style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 16),
            // Liste des résultats
            Expanded(
              child: ListView.builder(
                itemCount: _utilisateurs.length,
                itemBuilder: (context, index) {
                  final utilisateur = _utilisateurs[index];
                  return Card(
                    margin: const EdgeInsets.only(bottom: 8),
                    child: ListTile(
                      title: Text(utilisateur.nom),
                      subtitle: Text('${utilisateur.email} - ${utilisateur.age ?? "N/A"} ans'),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

🔍 Opérateurs SQL courants

Opérateur
Description
Exemple
=
Égal à
where: 'age = ?'
!= ou <>
Différent de
where: 'age != ?'
>
Supérieur à
where: 'age > ?'
>=
Supérieur ou égal
where: 'age >= ?'
<
Inférieur à
where: 'age < ?'
<=
Inférieur ou égal
where: 'age <= ?'
LIKE
Correspond Ă  un motif
where: 'nom LIKE ?' avec '%texte%'
IN
Dans une liste
where: 'id IN (?,?)'
BETWEEN
Entre deux valeurs
where: 'age BETWEEN ? AND ?'

📊 Requêtes SQL brutes

Pour des requĂŞtes complexes, vous pouvez utiliser rawQuery() :

// RequĂŞte avec JOIN
final result = await db.rawQuery('''
  SELECT u.*, COUNT(c.id) as nb_commandes
  FROM utilisateurs u
  LEFT JOIN commandes c ON u.id = c.utilisateur_id
  GROUP BY u.id
''');

// Requête avec agrégation
final stats = await db.rawQuery('''
  SELECT 
    AVG(age) as age_moyen,
    MIN(age) as age_min,
    MAX(age) as age_max,
    COUNT(*) as total
  FROM utilisateurs
''');
⚠️ Sécurité avec rawQuery :
Même avec rawQuery(), utilisez toujours des paramètres ? et arguments pour éviter les injections SQL. Ne concaténez jamais directement des valeurs utilisateur dans les requêtes SQL !

5.3.7 – Gestion des versions et migrations

🔄 Pourquoi gérer les versions ?

Lorsque vous publiez une mise Ă  jour de votre application, vous pouvez avoir besoin de :

  • Ajouter de nouvelles tables : Nouvelle fonctionnalitĂ© nĂ©cessitant de nouvelles donnĂ©es
  • Modifier des tables existantes : Ajouter des colonnes, modifier des types
  • Supprimer des tables : Nettoyer des donnĂ©es obsolètes
  • Migrer des donnĂ©es : Transformer des donnĂ©es existantes vers un nouveau format
⚠️ Problème sans migrations :
Si vous modifiez le schéma de votre base de données sans gérer les versions, les utilisateurs qui ont déjà installé votre application verront des erreurs car leur base de données ne correspond plus au nouveau schéma.

📝 Comment fonctionnent les migrations ?

SQLite utilise un système de versions pour gérer les migrations :

  1. Chaque base de données a un numéro de version
  2. Lors de l'ouverture, sqflite compare la version actuelle avec la version demandée
  3. Si la version demandée est supérieure, onUpgrade est appelé
  4. Si la version demandée est inférieure, onDowngrade est appelé (rare)

đź§Ş Exemple : Gestion des versions et migrations

import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._init();
  static Database? _database;

  // Version actuelle de la base de données
  static const int _version = 3;

  DatabaseHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB('migrations.db');
    return _database!;
  }

  Future<Database> _initDB(String filePath) async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, filePath);

    return await openDatabase(
      path,
      version: _version,
      onCreate: _onCreate,
      onUpgrade: _onUpgrade,
      onDowngrade: _onDowngrade,
    );
  }

  // Création initiale (version 1)
  Future<void> _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE utilisateurs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        nom TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE
      )
    ''');
  }

  // Migration entre versions
  Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
    print('Migration de la version $oldVersion vers $newVersion');

    // Migration de la version 1 vers 2 : ajouter la colonne age
    if (oldVersion < 2) {
      await db.execute('ALTER TABLE utilisateurs ADD COLUMN age INTEGER');
      print('✓ Colonne age ajoutée');
    }

    // Migration de la version 2 vers 3 : ajouter la colonne telephone
    if (oldVersion < 3) {
      await db.execute('ALTER TABLE utilisateurs ADD COLUMN telephone TEXT');
      print('✓ Colonne telephone ajoutée');
    }

    // Vous pouvez aussi créer de nouvelles tables
    if (oldVersion < 3) {
      await db.execute('''
        CREATE TABLE IF NOT EXISTS commandes (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          utilisateur_id INTEGER NOT NULL,
          montant REAL NOT NULL,
          date TEXT NOT NULL,
          FOREIGN KEY (utilisateur_id) REFERENCES utilisateurs(id)
        )
      ''');
      print('✓ Table commandes créée');
    }
  }

  // Downgrade (rare, mais possible)
  Future<void> _onDowngrade(Database db, int oldVersion, int newVersion) async {
    print('Downgrade de la version $oldVersion vers $newVersion');
    // Gérer le downgrade si nécessaire
    // Attention : SQLite ne supporte pas DROP COLUMN directement
    // Il faut recréer la table sans la colonne
  }

  Future<void> close() async {
    final db = await database;
    db.close();
  }
}

void main() {
  runApp(const MonApp());
}

class MonApp extends StatelessWidget {
  const MonApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const MaPage(),
    );
  }
}

class MaPage extends StatefulWidget {
  const MaPage({super.key});

  @override
  State<MaPage> createState() => _MaPageState();
}

class _MaPageState extends State<MaPage> {
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;
  String _status = 'Initialisation...';

  @override
  void initState() {
    super.initState();
    _initialiser();
  }

  @override
  void dispose() {
    _dbHelper.close();
    super.dispose();
  }

  Future<void> _initialiser() async {
    try {
      final db = await _dbHelper.database;
      setState(() {
        _status = 'Base de données initialisée avec succès !\nVersion: ${DatabaseHelper._version}';
      });
    } catch (e) {
      setState(() {
        _status = 'Erreur: $e';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Migrations SQLite'),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(
                Icons.storage,
                size: 64,
                color: Colors.blue,
              ),
              const SizedBox(height: 24),
              Text(
                _status,
                style: const TextStyle(fontSize: 18),
                textAlign: TextAlign.center,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

🔍 Explication des migrations

1. Version de la base de données

static const int _version = 3;

Définissez la version actuelle de votre schéma de base de données. Incrémentez ce numéro à chaque modification du schéma.

2. Callback onCreate

onCreate: _onCreate,

Exécuté uniquement lors de la première création de la base de données. Créez ici le schéma initial (version 1).

3. Callback onUpgrade

onUpgrade: _onUpgrade,

Exécuté lorsque la version demandée est supérieure à la version actuelle. Utilisez des conditions if (oldVersion < X) pour appliquer les migrations progressivement.

đź“‹ Bonnes pratiques pour les migrations

  • IncĂ©menter la version progressivement : Ne sautez pas de versions (1 → 2 → 3, pas 1 → 5)
  • Tester les migrations : Testez toujours vos migrations avec des donnĂ©es existantes
  • Sauvegarder les donnĂ©es : Avant de supprimer des colonnes/tables, sauvegardez les donnĂ©es importantes
  • Documenter les changements : Commentez chaque migration pour comprendre l'historique
  • GĂ©rer les erreurs : Entourez les migrations de try-catch pour gĂ©rer les erreurs

⚠️ Limitations de SQLite

Opérations non supportées directement :
  • DROP COLUMN : SQLite ne supporte pas la suppression directe de colonnes
  • RENAME COLUMN : NĂ©cessite une recrĂ©ation de table (anciennes versions)
  • MODIFY COLUMN TYPE : NĂ©cessite une recrĂ©ation de table

Solution : Pour ces opérations, vous devez :

  1. Créer une nouvelle table avec le nouveau schéma
  2. Copier les données de l'ancienne table vers la nouvelle
  3. Supprimer l'ancienne table
  4. Renommer la nouvelle table

âś… Exemple de migration complexe

// Migration complexe : renommer une colonne
if (oldVersion < 4) {
  // 1. Créer une nouvelle table avec le bon schéma
  await db.execute('''
    CREATE TABLE utilisateurs_new (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      nom_complet TEXT NOT NULL,  -- anciennement "nom"
      email TEXT NOT NULL UNIQUE,
      age INTEGER,
      telephone TEXT
    )
  ''');

  // 2. Copier les données
  await db.execute('''
    INSERT INTO utilisateurs_new (id, nom_complet, email, age, telephone)
    SELECT id, nom, email, age, telephone FROM utilisateurs
  ''');

  // 3. Supprimer l'ancienne table
  await db.execute('DROP TABLE utilisateurs');

  // 4. Renommer la nouvelle table
  await db.execute('ALTER TABLE utilisateurs_new RENAME TO utilisateurs');
}
Transactions :
Pour les migrations complexes, utilisez des transactions pour garantir que toutes les opérations réussissent ou échouent ensemble :
await db.transaction((txn) async {
  // Toutes les opérations de migration ici
});