Persistance¶
La persistance est locale : une base SQLite fichier, sans serveur. La couche vit dans
commun.persistence
(infra technique) ; le SQL métier de chaque entité vit dans les */model/dao/ de sa feature.
Cette page = le mécanisme, pas le modèle
Pour quelles données sont stockées (entités, 19 tables, MCD du brief, correspondance concept → record → table), voir Modèle de données et domaine. Cette page-ci décrit comment on y accède : source de données, migrations, DAO, transactions.
Frontière
commun.persistence et tous les ..model.dao.. ignorent JavaFX (tests
persistance_sans_javafx et view_sans_jdbc). La couche données est réutilisable et testable
seule.
La source de données¶
SourceDeDonnees
est l'unique classe qui connaît l'URL JDBC (jdbc:sqlite:<workspace>/vigiechiro.db). Bindée en
singleton Guice, elle fournit des Connection ; DAO, unité de travail et migration la reçoivent
et ignorent tout du driver.
Intégrité référentielle activée explicitement
SQLite n'applique les clés étrangères que si on le demande. Chaque connexion active
PRAGMA foreign_keys = ON (objectif qualité O7). En test, le Workspace pointe un @TempDir :
base jetable par test.
Les migrations de schéma¶
MigrationSchema
applique des scripts versionnés
src/main/resources/db/migration/V0x__*.sql
et trace les versions dans une table schema_version. C'est idempotent : à la réouverture d'une
base existante, les versions déjà présentes sont ignorées (« base présente → réutilisée »).
État actuel : V01__schema.sql (toutes les tables) · V02__seed_taxons.sql (données de référence) ·
V03__perf_indexes.sql (index).
Ajouter une migration
- Créez
db/migration/V0n__xxx.sql(numéro suivant). - Ajoutez son nom au tableau
MIGRATIONSdeMigrationSchema— l'ordre fait foi.
App appelle MigrationSchema.migrer() au démarrage ; les tests le font sur leur base jetable.
Remplacer la base sous une application vivante¶
Trois gestes remplacent le fichier de base à chaud : la restauration (ServiceSauvegarde.restaurer,
148), la restauration complète (#1346) et la base neuve (BaseNeuve.repartirDeZero, #1419).¶
Ce n'est pas un pari. La source de données n'a aucun pool : chaque opération
ouvre puis ferme sa connexion, et SourceDeDonnees ne retient qu'une URL JDBC. Il n'y a donc aucune
connexion longue à fermer — la prochaine ouvrira simplement le fichier neuf.
Trois précautions, les mêmes pour les trois gestes :
- un filet est posé avant d'écraser (
vigiechiro.db.avant-restauration,…avant-reset) : le geste reste réversible ; - les journaux SQLite (
-wal,-shm,-journal) sont purgés : un journal périmé rejouerait l'ancienne base par-dessus la neuve ; - la migration est rejouée : la base obtenue est utilisable telle quelle, quel que soit l'âge de ce qu'on vient d'y mettre.
Ce que la base ne sait pas, c'est l'IHM qui doit le porter
Une application graphique déjà ouverte garde en mémoire des écrans peuplés par l'ancienne base : ils afficheraient des fantômes. Le socle ne connaît pas d'IHM — c'est à l'appelant d'exiger un redémarrage (ce que fait le reset : il ferme l'application après coup).
Le piège est plus subtil qu'il n'y paraît, et il a été trouvé par un test E2E : idUtilisateurCourant
est un singleton Guice déjà résolu. Après une base neuve, l'utilisateur local avait disparu de la
table, mais les rapprocheurs tenaient toujours son identifiant : tout ce qu'ils recréaient (sites,
points) pointait sur un propriétaire disparu. Clé étrangère morte, échec avalé par le contrat
best-effort, workspace muet. ServiceReset préserve donc l'observateur à travers le reset : c'est
la même personne qui repart d'une base neuve.
Le patron DAO¶
Pas d'ORM : des DAO en PreparedStatement. La base technique
DaoGenerique<T, ID>
offre findAll / findById / delete gratuitement dès qu'un DAO concret fournit son table(),
sa colonneCle() et son RowMapper. Seules les écritures dépendant des colonnes
(insert / update) restent à écrire, via les helpers executerMaj(...) et
insererEtRecupererCle(...).
Depuis #1193, la mécanique de lecture (connexion, liaison des paramètres, itération du
ResultSet vers un RowMapper) vit dans
ProjectionGenerique,
dont hérite DaoGenerique. Les DAO de projection en lecture seule (ProjectionsAnalyseDao,
ProjectionsAudioDao sur la table observation) étendent directement cette base : une projection
transverse ne porte ni table propre ni écriture, le contrat CRUD Dao ne s'applique pas à elle.
Les fragments SQL partagés entre DAO d'une même table (jointures de contexte, statut dérivé,
alias) sont factorisés dans une classe paquet-privée (FragmentsSqlObservation).
classDiagram
class Dao {
<<interface>>
+findAll()
+findById(id)
+delete(id)
}
class DaoGenerique {
<<abstract>>
#table() String
#colonneCle() String
#rowMapper() RowMapper
#executerMaj(sql, params)
#insererEtRecupererCle(sql, params)
}
class PassageDao {
+insert(Passage)
+update(Passage)
}
Dao <|.. DaoGenerique : implémente
DaoGenerique <|-- PassageDao : hérite
DaoGenerique ..> SourceDeDonnees : connexion
DaoGenerique ..> RowMapper : ResultSet vers entité
(Les classes sont génériques : Dao<T, ID>, DaoGenerique<T, ID>, RowMapper<T>.)
Le RowMapper<T>
transforme une ligne de ResultSet en entité (un record immuable).
Transactions¶
Par défaut, chaque appel DAO s'auto-commit. Quand plusieurs écritures doivent réussir ou échouer
ensemble (ex. créer un passage et sa session), on les regroupe dans une
UniteDeTravail :
uniteDeTravail.executer(connexion -> {
// plusieurs écritures sur la MÊME connexion...
}); // commit si tout passe, rollback sinon
Une exception dans le bloc déclenche un rollback : la base reste cohérente (objectif intégrité /
résilience O7). Les erreurs SQL sont remontées en
DataAccessException
(non vérifiée).
Les DAO et services sont assemblés par Guice : voir Injection (Guice).