JDBC — Bonus : métadonnées de résultats et transactions
TP — ResultSetMetaData et pilotage du commit
Ce TP est autonome : il installe lui-même les deux tables qu’il utilise (section 0). Il suppose un PostgreSQL accessible sur localhost:5432, base university, utilisateur et mot de passe postgres — la section 0 donne la commande pour en démarrer un jetable. Exécutez d’abord la cellule d’initialisation lst-jdbc-bonus-setup de la section 0 : elle installe la table students sans contrainte de clé primaire (4 lignes : Alice, Bob, Carol, Dave) et la table wallet.
Les conventions du cours sont rappelées dans 01_java_jdbc_L_IntroductionJDBC.qmd :
- le rôle du paragraphe (
contexte/question/réponse/check/bonus/sources) ; - le profil de sortie :
text-output: rawpour une sortie console brute, blocstextsans exécution pour les sorties attendues.
//| label: lst-java-jdbc-bonus-init
//| classes: cell-source
//| output: false
import java.sql.*;1 0. Installation des données
Contrairement aux leçons précédentes, celle-ci ne réutilise aucune base existante : elle a besoin d’un PostgreSQL frais et de deux tables précises — dont une sans clé primaire, parce que tout l’enjeu de la section 1 est là.
Démarrez un serveur PostgreSQL jetable — le port 5432 doit être libre, sinon adaptez la carte de ports et les URL de connexion du TP :
#| label: lst-jdbc-bonus-db
#| classes: cell-console
docker run --rm --name jdbc-bonus-pg \
-e POSTGRES_DB=university -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
-p 127.0.0.1:5432:5432 postgres:16Puis initialisez les données. Comme tout ce TP, ce bloc est une capture d’atelier : il est exécuté dans votre environnement, pas par le rendu.
//| label: lst-jdbc-bonus-setup
//| classes: cell-source
//| output: false
import java.sql.*;
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
Statement statement = connection.createStatement()) {
statement.executeUpdate("DROP TABLE IF EXISTS wallet");
statement.executeUpdate("DROP TABLE IF EXISTS students");
// Volontairement SANS clé primaire : c'est le point de départ du TP.
statement.executeUpdate(
"CREATE TABLE students (id serial, name varchar(80), email varchar(120))");
statement.executeUpdate(
"INSERT INTO students (name, email) VALUES "
+ "('Alice Martin', 'alice@example.com'),"
+ "('Bob Dupont', 'bob@example.com'),"
+ "('Carol Bernard', 'carol@example.com'),"
+ "('Dave Petit', 'dave@example.com')");
statement.executeUpdate(
"CREATE TABLE wallet (student_id integer, balance integer)");
statement.executeUpdate(
"INSERT INTO wallet (student_id, balance) VALUES (1, 500), (2, 500), (3, 500), (4, 500)");
}2 Contexte
La leçon 02_java_jdbc_L_Avance.qmd a montré que les métadonnées de base de données — le schéma déclaré dans le serveur — se lisent via DatabaseMetaData. Ce TP explore deux mécanismes complémentaires, déclenchés par des situations que cette lecture du schéma laisse ouvertes :
- Section 1 — La table
studentsinstallée en section 0 n’a pas de clé primaire : pour cibler une ligne, il faudra s’appuyer sur une colonne qui en tient lieu, iciemail. Peut-on lire le schéma d’un résultat — le nom et le type des colonnes réellement produites par une requête — sans rien connaître de la table ? C’est le rôle deResultSetMetaData. - Section 2 — Quand une opération exécute plusieurs requêtes à la suite, que se passe-t-il si l’une échoue au milieu de l’autre ? C’est l’occasion de passer d’une cohérence assurée par le code Java à une garantie demandée à la base elle-même : la transaction SQL.
3 1. Métadonnées de résultats
Les métadonnées de la base décrivent ce qui est déclaré (tables, colonnes, contraintes). Celles d’un résultat décrivent ce qui est produit par la requête que l’on vient d’exécuter.
3.1 1.1 Le problème : un ResultSet sans notice d’emploi
Dans les TPs précédents, chaque lecture de ResultSet suppose que le codeur connaît déjà les colonnes de la requête : SELECT name, email s’accompagne d’un rs.getString("name") écrit à la main, colonne par colonne.
Comment écrire une méthode afficher(ResultSet rs) générique — qui affiche n’importe quel résultat, sans savoir à l’avance quelles colonnes la requête produit ?
Difficile de deviner : le ResultSet ne fournit qu’un accès par nom ou par index, encore faut-il connaître ce nom, ce nombre et ce type. La réponse existe pourtant, et c’est une brique utilisée en permanence — sans le savoir — par tous les outils qui affichent un résultat SQL de façon dynamique.
3.2 1.2 ResultSetMetaData : la description du résultat
ResultSetMetaData est l’équivalent d’une notice d’emploi attachée à chaque ResultSet : rs.getMetaData() renvoie, pour les colonnes de ce résultat, le nom, le nombre, le type SQL — exactement les informations qui manquent à la méthode générique.
Ces métadonnées décrivent le résultat, pas la table : deux requêtes différentes sur une même table produisent deux notices différentes (SELECT * et SELECT name, COUNT(*) GROUP BY name n’ont rien de comparable).
Inspectons le résultat de SELECT id, name, email FROM students :
//| label: lst-jdbc-bonus-resultsetmetadata
//| classes: cell-source
//| output: true
//| maxOutput: 25
import java.sql.Types;
String sql = "SELECT id, name, email FROM students";
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
ResultSetMetaData metaData = resultSet.getMetaData();
int columnCount = metaData.getColumnCount();
System.out.println("La requête \"" + sql + "\" contient " + columnCount + " colonnes.");
for (int i = 1; i <= columnCount; i++) {
String columnName = metaData.getColumnLabel(i);
int columnType = metaData.getColumnType(i);
String columnTypeName = metaData.getColumnTypeName(i);
System.out.println("Colonne " + i + " : " + columnName + " (" + columnTypeName + ")");
if (columnType == Types.INTEGER) {
System.out.println(" Type : INTEGER");
} else if (columnType == Types.VARCHAR) {
System.out.println(" Type : VARCHAR");
}
}
}3.3 1.3 Résultat observé
//| label: lst-jdbc-bonus-observed-result
//| classes: cell-console
//| echo: false
//| output: true
La requête "SELECT id, name, email FROM students" contient 3 colonnes.
Colonne 1 : id (int4)
Type : INTEGER
Colonne 2 : name (varchar)
Type : VARCHAR
Colonne 3 : email (varchar)
Type : VARCHAR
Sur ce résultat :
getColumnCountrenvoie3, le nombre de colonnes sélectionnées.getColumnLabelrenvoie le nom de la colonne (ou son aliasASs’il existe).getColumnTyperenvoie une constante dejava.sql.Types(INTEGER,VARCHAR, etc.).getColumnTypeNamerenvoie le nom natif du type côté PostgreSQL (int4,varchar).
3.4 1.4 Affichage générique d’un ResultSet
Passons à la pratique avec une méthode printResultSet qui affiche n’importe quel ResultSet, quelle que soit sa structure.
//| label: lst-jdbc-bonus-generic-print
//| classes: cell-source
//| output: true
void printResultSet(ResultSet resultSet) throws SQLException {
ResultSetMetaData metaData = resultSet.getMetaData();
int columnCount = metaData.getColumnCount();
// En-tête
for (int i = 1; i <= columnCount; i++) {
if (i > 1) {
System.out.print(" | ");
}
System.out.print(metaData.getColumnLabel(i));
}
System.out.println();
// Séparateur
StringBuilder separator = new StringBuilder();
for (int i = 1; i <= columnCount; i++) {
if (i > 1) {
separator.append("-+-");
}
separator.append("-".repeat(Math.max(10, metaData.getColumnLabel(i).length())));
}
System.out.println(separator);
// Lignes
int rowCount = 0;
while (resultSet.next()) {
for (int i = 1; i <= columnCount; i++) {
if (i > 1) {
System.out.print(" | ");
}
System.out.print(resultSet.getString(i));
}
System.out.println();
rowCount++;
}
System.out.println("(" + rowCount + " lignes)");
}Dans un outil d’exploration de base — un client SQL, un visualiseur de données — on ne connaît pas la structure du résultat à l’avance : printResultSet découvre les colonnes à l’exécution et s’adapte, sans aucun getString("...") codé à la main.
Testez printResultSet sur le résultat de SELECT id, name, email FROM students. Votre sortie devrait afficher l’en-tête, le séparateur, les 4 lignes du jeu de données, puis le compteur :
//| label: lst-jdbc-bonus-test-printresultset
//| classes: cell-console
//| echo: false
//| output: true
id | name | email
-----------+-----------+----------
1 | Alice Martin | alice@example.com
2 | Bob Dupont | bob@example.com
3 | Carol Bernard | carol@example.com
4 | Dave Petit | dave@example.com
(4 lignes)
//| label: lst-jdbc-bonus-test-run
//| classes: cell-source
//| output: true
//| eval: false
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("SELECT id, name, email FROM students")) {
printResultSet(resultSet);
}3.5 1.5 Découvrir les colonnes d’une table inconnue
Puisqu’un SELECT * produit un résultat dont les colonnes sont auto-décrites, on peut interroger une table sans en connaître le schéma — une requête qui ne rapporte aucune ligne suffit, ce qui la rend gratuite :
//| label: lst-jdbc-bonus-unknown-cols
//| classes: cell-source
//| output: true
//| maxOutput: 25
void afficherColonnesTable(Connection connection, String tableName) throws SQLException {
// Une requête exécutée, même vide, porte ses métadonnées de colonnes.
try (PreparedStatement ps = connection.prepareStatement("SELECT * FROM " + tableName + " LIMIT 0");
ResultSet rs = ps.executeQuery()) {
ResultSetMetaData meta = rs.getMetaData();
System.out.println("Colonnes de la table " + tableName + " :");
for (int i = 1; i <= meta.getColumnCount(); i++) {
System.out.println(" - " + meta.getColumnLabel(i)
+ " (" + meta.getColumnTypeName(i) + ")");
}
}
}
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres")) {
afficherColonnesTable(connection, "students");
}//| label: lst-jdbc-bonus-unknown-cols-output
//| classes: cell-console
//| echo: false
//| output: true
Colonnes de la table students :
- id (int4)
- name (varchar)
- email (varchar)
Sur un projet réel, on écrit de nombreux objets d’accès aux données dont les méthodes se déduisent des colonnes de la table : les colonnes lues par ResultSetMetaData fournissent exactement l’information nécessaire pour générer ce code automatiquement.
3.6 1.6 Manipuler le résultat comme une table générique
Reprenons la requête de la section 1.2, cette fois sans rien connaître de ses colonnes : une fonction charger(ResultSet) construit une liste de lignes, chaque ligne étant un tableau de chaînes.
//| label: lst-jdbc-bonus-result-as-table
//| classes: cell-source
//| output: true
//| maxOutput: 25
import java.util.ArrayList;
import java.util.List;
List<String[]> charger(ResultSet resultSet) throws SQLException {
ResultSetMetaData meta = resultSet.getMetaData();
int colonnes = meta.getColumnCount();
List<String[]> lignes = new ArrayList<>();
while (resultSet.next()) {
String[] ligne = new String[colonnes];
for (int i = 0; i < colonnes; i++) {
ligne[i] = resultSet.getString(i + 1);
}
lignes.add(ligne);
}
return lignes;
}
List<String[]> lignes;
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("SELECT id, name, email FROM students")) {
lignes = charger(resultSet);
}
System.out.println("Lignes récupérées : " + lignes.size());
System.out.println("Colonnes par ligne : " + lignes.getFirst().length);//| label: lst-jdbc-bonus-result-as-table-output
//| classes: cell-console
//| echo: false
//| output: true
Lignes récupérées : 4
Colonnes par ligne : 3
La liste est transposable dans n’importe quel code générique — ici un affichage ligne par ligne, dans un autre contexte une exportation CSV :
//| label: lst-jdbc-bonus-print-rows
//| classes: cell-source
//| output: true
lignes.forEach(ligne -> System.out.println(" " + String.join(" | ", ligne)));//| label: lst-jdbc-bonus-print-rows-output
//| classes: cell-console
//| echo: false
//| output: true
1 | Alice Martin | alice@example.com
2 | Bob Dupont | bob@example.com
3 | Carol Bernard | carol@example.com
4 | Dave Petit | dave@example.com
À aucun moment le code n’a été modifié pour la requête : charger fonctionne pour n’importe quel résultat, mais elle ne produit que des chaînes, indexées par position. Impossible de savoir si la colonne 0 est un identifiant ou un nom — ResultSetMetaData décrit la forme d’une colonne (nom, type, taille), jamais sa signification métier. Le code métier — celui qui interprète les colonnes — reste à écrire, ou à générer.
3.7 1.7 Bonus : schéma d’un PreparedStatement
PreparedStatement expose ses métadonnées de résultat sans exécution : getMetaData() renvoie la description des colonnes que la requête produirait. Cet usage est hors programme : la méthode exige que le serveur puisse typer les colonnes sans exécuter la requête, ce qui n’est pas toujours possible. Retenez simplement que la description d’un résultat est accessible avant même de l’avoir produit.
4 2. Transactions SQL
Imaginons une opération enrollStudent qui insère une inscription puis met à jour le solde du portefeuille. Si la seconde requête échoue, que reste-t-il en base ?
La première requête a déjà été confirmée (commit) : l’inscription est en base alors que le solde n’a pas été débité. Le portefeuille et les inscriptions ne décrivent plus la même réalité.
Comment faire pour que tout soit annulé si une seule des requêtes échoue ?
4.1 2.1 setAutoCommit(false) : le début de la transaction
Par défaut, en JDBC, chaque instruction SQL est exécutée dans sa propre transaction (mode auto-commit) : elle est confirmée dès sa fin. Pour mettre plusieurs instructions dans une transaction unique, on désactive ce mode, on exécute les instructions, puis on confirme ou on annule selon le résultat.
//| label: lst-jdbc-bonus-tx-rollback
//| classes: cell-source
//| output: true
//| maxOutput: 25
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres")) {
connection.setAutoCommit(false); // Début de la transaction
try (Statement statement = connection.createStatement()) {
// Opération 1 : créer un étudiant
statement.executeUpdate(
"INSERT INTO students (name, email) " +
"VALUES ('Frank Lee', 'frank@example.com')");
System.out.println("Opération 1 : étudiant créé.");
// Opération 2 : une erreur volontaire
statement.executeUpdate("INSERT INTO table_inexistante VALUES (1)");
connection.commit(); // jamais atteint
} catch (SQLException e) {
connection.rollback();
System.out.println("Transaction annulée : " + e.getMessage());
}
}
// Vérification : l'étudiant n'est pas apparu
try (Connection checkConnection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres");
PreparedStatement check = checkConnection.prepareStatement(
"SELECT COUNT(*) FROM students WHERE email = ?")) {
check.setString(1, "frank@example.com");
try (ResultSet rs = check.executeQuery()) {
rs.next();
System.out.println("Étudiants avec cet email : " + rs.getInt(1));
}
}Sortie console :
//| label: lst-jdbc-bonus-tx-output
//| classes: cell-console
//| echo: false
//| output: true
//| text-output: raw
Opération 1 : étudiant créé.
Transaction annulée : ERROR: relation "table_inexistante" does not exist
Étudiants avec cet email : 0
Ce qui s’est passé :
- l’erreur SQL a interrompu le flot normal de la séquence, et le
commit()n’a jamais été atteint ; - le
catcha détecté l’erreur et déclenché l’annulation : l’insertion de Frank Lee est effacée, aucune trace ne reste de l’opération partielle ; - sans le
rollback(), l’insertion du point 1 serait définitive à la fermeture de la connexion.
System.out.println sont-elles annulées aussi ?
Non : le rollback annule les effets en base, pas les sorties déjà émises côté JVM. « Opération 1 : étudiant créé. » a été affiché puis annulé — c’est exactement ce qui rend les traces de débogage trompeuses en présence de transactions : elles racontent l’intention du code, pas l’état final de la base.
4.2 2.2 La transaction correcte
Voici le patron général d’une transaction JDBC.
//| label: lst-jdbc-bonus-tx-correct
//| classes: cell-source
//| output: true
//| maxOutput: 25
void transfererEtInscrire() throws SQLException {
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/university", "postgres", "postgres")) {
connection.setAutoCommit(false);
try (Statement statement = connection.createStatement()) {
// Opération 1
statement.executeUpdate(
"INSERT INTO students (name, email) " +
"VALUES ('Erin Meyer', 'erin@example.com')");
// Opération 2
statement.executeUpdate(
"UPDATE wallet SET balance = balance - 100 WHERE student_id = 1");
connection.commit();
System.out.println("Transaction confirmée.");
} catch (SQLException e) {
connection.rollback();
System.out.println("Transaction annulée : " + e.getMessage());
throw e; // Important : propager l'erreur pour que l'appelant le sache.
}
}
}
transfererEtInscrire();//| label: lst-jdbc-bonus-tx-output2
//| classes: cell-console
//| echo: false
//| output: true
Transaction confirmée.
throw e : ne jamais avaler une exception dans une transaction
Le rollback annule les effets en base, mais côté Java le code appelant doit être prévenu de l’échec pour ne pas considérer l’opération comme réussie. Sans le throw e final, le programme continue avec des données qui n’existent plus.
5 3. Checkpoint
ResultSetMetaData :
rs.getMetaData()renvoie la description des colonnes du résultat courant ;getColumnCount(): le nombre de colonnes ;getColumnLabel(i): le nom (ou l’alias) de la colonnei;getColumnType(i): un code dejava.sql.Types(INTEGER,VARCHAR, etc.) ;getColumnTypeName(i): le nom natif du type côté SGBD (int4,varchar).
Transactions SQL :
connection.setAutoCommit(false)place toutes les requêtes suivantes dans une seule transaction ;connection.commit()valide tout ;connection.rollback()annule tout.
rollback() :
- annule les deux opérations en cas d’échec de la seconde : sans lui, l’erreur SQL interrompt la méthode, le
commit()n’est jamais atteint, et la première requête reste confirmée de façon irrévocable ; - avec lui, le rattrapage est explicite : après le rollback, la base est revenue à son état d’avant la transaction.
6 Récapitulatif JDBC
Les API vues dans ce notebook se recoupent ainsi : connexions et instructions dans 01_java_jdbc_L_IntroductionJDBC.qmd, pilotage avancé et métadonnées de base dans 02_java_jdbc_L_Avance.qmd, transactions appliquées dans 03_java_jdbc_P_Exemple.qmd (E3 « Tout ou rien ») et dans ce TP.